Merge pull request #366 from guarzo/guarzo/undo

feat: improve signature undo process
This commit is contained in:
Dmitry Popov
2025-05-19 22:44:13 +04:00
committed by GitHub
26 changed files with 1246 additions and 572 deletions
@@ -10,7 +10,7 @@ export interface SignatureViewProps {
export const SignatureView = ({ signature, showCharacterPortrait = false }: SignatureViewProps) => {
const isWormhole = signature?.group === SignatureGroup.Wormhole;
const hasCharacterInfo = showCharacterPortrait && signature.character_eve_id;
const groupDisplay = isWormhole ? SignatureGroup.Wormhole : (signature?.group ?? SignatureGroup.CosmicSignature);
const groupDisplay = isWormhole ? SignatureGroup.Wormhole : signature?.group ?? SignatureGroup.CosmicSignature;
const characterName = signature.character_name || 'Unknown character';
return (
@@ -19,7 +19,7 @@ export type HeaderProps = {
lazyDeleteValue: boolean;
onLazyDeleteChange: (checked: boolean) => void;
pendingCount: number;
pendingTimeRemaining?: number; // Time remaining in ms
undoCountdown?: number;
onUndoClick: () => void;
onSettingsClick: () => void;
};
@@ -29,7 +29,7 @@ export const SystemSignaturesHeader = ({
lazyDeleteValue,
onLazyDeleteChange,
pendingCount,
pendingTimeRemaining,
undoCountdown,
onUndoClick,
onSettingsClick,
}: HeaderProps) => {
@@ -43,13 +43,6 @@ export const SystemSignaturesHeader = ({
const containerRef = useRef<HTMLDivElement>(null);
const isCompact = useMaxWidth(containerRef, COMPACT_MAX_WIDTH);
// Format time remaining as seconds
const formatTimeRemaining = () => {
if (!pendingTimeRemaining) return '';
const seconds = Math.ceil(pendingTimeRemaining / 1000);
return ` (${seconds}s remaining)`;
};
return (
<div ref={containerRef} className="w-full">
<div className="flex justify-between items-center text-xs w-full h-full">
@@ -78,7 +71,9 @@ export const SystemSignaturesHeader = ({
<WdImgButton
className={PrimeIcons.UNDO}
style={{ color: 'red' }}
tooltip={{ content: `Undo pending changes (${pendingCount})${formatTimeRemaining()}` }}
tooltip={{
content: `Undo pending deletions (${pendingCount})${undoCountdown && undoCountdown > 0 ? `${undoCountdown}s left` : ''}`,
}}
onClick={onUndoClick}
/>
)}
@@ -1,99 +1,156 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useState, useEffect, useRef, useMemo } from 'react';
import { Widget } from '@/hooks/Mapper/components/mapInterface/components';
import { SystemSignaturesContent } from './SystemSignaturesContent';
import { SystemSignatureSettingsDialog } from './SystemSignatureSettingsDialog';
import { ExtendedSystemSignature, SystemSignature } from '@/hooks/Mapper/types';
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import { useHotkey } from '@/hooks/Mapper/hooks';
import { SystemSignaturesHeader } from './SystemSignatureHeader';
import useLocalStorageState from 'use-local-storage-state';
import { useHotkey } from '@/hooks/Mapper/hooks/useHotkey';
import {
SETTINGS_KEYS,
SETTINGS_VALUES,
SIGNATURE_DELETION_TIMEOUTS,
SIGNATURE_SETTING_STORE_KEY,
SIGNATURE_WINDOW_ID,
SIGNATURES_DELETION_TIMING,
SignatureSettingsType,
SIGNATURES_DELETION_TIMING,
SIGNATURE_DELETION_TIMEOUTS,
} from '@/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/constants.ts';
import { calculateTimeRemaining } from './helpers';
import { OutCommand, OutCommandHandler } from '@/hooks/Mapper/types/mapHandlers';
export const SystemSignatures = () => {
const [visible, setVisible] = useState(false);
const [sigCount, setSigCount] = useState<number>(0);
const [pendingSigs, setPendingSigs] = useState<SystemSignature[]>([]);
const [pendingTimeRemaining, setPendingTimeRemaining] = useState<number | undefined>();
const undoPendingFnRef = useRef<() => void>(() => {});
/**
* Custom hook for managing pending signature deletions and undo countdown.
*/
function useSignatureUndo(
systemId: string | undefined,
settings: SignatureSettingsType,
outCommand: OutCommandHandler,
) {
const [countdown, setCountdown] = useState<number>(0);
const [pendingIds, setPendingIds] = useState<Set<string>>(new Set());
const intervalRef = useRef<number | null>(null);
const {
data: { selectedSystems },
} = useMapRootState();
const [currentSettings, setCurrentSettings] = useLocalStorageState(SIGNATURE_SETTING_STORE_KEY, {
defaultValue: SETTINGS_VALUES,
});
const handleSigCountChange = useCallback((count: number) => {
setSigCount(count);
const addDeleted = useCallback((ids: string[]) => {
setPendingIds(prev => {
const next = new Set(prev);
ids.forEach(id => next.add(id));
return next;
});
}, []);
const [systemId] = selectedSystems;
const isNotSelectedSystem = selectedSystems.length !== 1;
const handleSettingsChange = useCallback((newSettings: SignatureSettingsType) => {
setCurrentSettings(newSettings);
setVisible(false);
}, []);
const handleLazyDeleteChange = useCallback((value: boolean) => {
setCurrentSettings(prev => ({ ...prev, [SETTINGS_KEYS.LAZY_DELETE_SIGNATURES]: value }));
}, []);
useHotkey(true, ['z'], event => {
if (pendingSigs.length > 0) {
event.preventDefault();
event.stopPropagation();
undoPendingFnRef.current();
setPendingSigs([]);
setPendingTimeRemaining(undefined);
}
});
const handleUndoClick = useCallback(() => {
undoPendingFnRef.current();
setPendingSigs([]);
setPendingTimeRemaining(undefined);
}, []);
const handleSettingsButtonClick = useCallback(() => {
setVisible(true);
}, []);
const handlePendingChange = useCallback(
(pending: React.MutableRefObject<Record<string, ExtendedSystemSignature>>, newUndo: () => void) => {
setPendingSigs(() => {
return Object.values(pending.current).filter(sig => sig.pendingDeletion);
});
undoPendingFnRef.current = newUndo;
},
[],
);
// Calculate the minimum time remaining for any pending signature
// kick off or clear countdown whenever pendingIds changes
useEffect(() => {
if (pendingSigs.length === 0) {
setPendingTimeRemaining(undefined);
// clear any existing timer
if (intervalRef.current != null) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
if (pendingIds.size === 0) {
setCountdown(0);
return;
}
const calculate = () => {
setPendingTimeRemaining(() => calculateTimeRemaining(pendingSigs));
};
// determine timeout from settings
const timingKey = Number(settings[SETTINGS_KEYS.DELETION_TIMING] ?? SIGNATURES_DELETION_TIMING.DEFAULT);
const timeoutMs =
Number(SIGNATURE_DELETION_TIMEOUTS[timingKey as keyof typeof SIGNATURE_DELETION_TIMEOUTS]) || 10000;
setCountdown(Math.ceil(timeoutMs / 1000));
calculate();
const interval = setInterval(calculate, 1000);
return () => clearInterval(interval);
}, [pendingSigs]);
// start new interval
intervalRef.current = window.setInterval(() => {
setCountdown(prev => {
if (prev <= 1) {
clearInterval(intervalRef.current!);
intervalRef.current = null;
setPendingIds(new Set());
return 0;
}
return prev - 1;
});
}, 1000);
return () => {
if (intervalRef.current != null) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [pendingIds, settings[SETTINGS_KEYS.DELETION_TIMING]]);
// undo handler
const handleUndo = useCallback(async () => {
if (!systemId || pendingIds.size === 0) return;
await outCommand({
type: OutCommand.undoDeleteSignatures,
data: { system_id: systemId, eve_ids: Array.from(pendingIds) },
});
setPendingIds(new Set());
setCountdown(0);
if (intervalRef.current != null) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}, [systemId, pendingIds, outCommand]);
return {
pendingIds,
countdown,
addDeleted,
handleUndo,
};
}
export const SystemSignatures = () => {
const [visible, setVisible] = useState(false);
const [sigCount, setSigCount] = useState(0);
const {
data: { selectedSystems },
outCommand,
} = useMapRootState();
const [currentSettings, setCurrentSettings] = useLocalStorageState<SignatureSettingsType>(
SIGNATURE_SETTING_STORE_KEY,
{
defaultValue: SETTINGS_VALUES,
},
);
const [systemId] = selectedSystems;
const isSystemSelected = useMemo(() => selectedSystems.length === 1, [selectedSystems.length]);
const { pendingIds, countdown, addDeleted, handleUndo } = useSignatureUndo(systemId, currentSettings, outCommand);
useHotkey(true, ['z', 'Z'], (event: KeyboardEvent) => {
if (pendingIds.size > 0 && countdown > 0) {
event.preventDefault();
event.stopPropagation();
handleUndo();
}
});
const handleCountChange = useCallback((count: number) => {
setSigCount(count);
}, []);
const handleSettingsSave = useCallback(
(newSettings: SignatureSettingsType) => {
setCurrentSettings(newSettings);
setVisible(false);
},
[setCurrentSettings],
);
const handleLazyDeleteToggle = useCallback(
(value: boolean) => {
setCurrentSettings(prev => ({
...prev,
[SETTINGS_KEYS.LAZY_DELETE_SIGNATURES]: value,
}));
},
[setCurrentSettings],
);
const openSettings = useCallback(() => setVisible(true), []);
return (
<Widget
@@ -101,16 +158,16 @@ export const SystemSignatures = () => {
<SystemSignaturesHeader
sigCount={sigCount}
lazyDeleteValue={currentSettings[SETTINGS_KEYS.LAZY_DELETE_SIGNATURES] as boolean}
pendingCount={pendingSigs.length}
pendingTimeRemaining={pendingTimeRemaining}
onLazyDeleteChange={handleLazyDeleteChange}
onUndoClick={handleUndoClick}
onSettingsClick={handleSettingsButtonClick}
pendingCount={pendingIds.size}
undoCountdown={countdown}
onLazyDeleteChange={handleLazyDeleteToggle}
onUndoClick={handleUndo}
onSettingsClick={openSettings}
/>
}
windowId={SIGNATURE_WINDOW_ID}
>
{isNotSelectedSystem ? (
{!isSystemSelected ? (
<div className="w-full h-full flex justify-center items-center select-none text-center text-stone-400/80 text-sm">
System is not selected
</div>
@@ -118,22 +175,17 @@ export const SystemSignatures = () => {
<SystemSignaturesContent
systemId={systemId}
settings={currentSettings}
deletionTiming={
SIGNATURE_DELETION_TIMEOUTS[
(currentSettings[SETTINGS_KEYS.DELETION_TIMING] as keyof typeof SIGNATURE_DELETION_TIMEOUTS) ||
SIGNATURES_DELETION_TIMING.DEFAULT
] as number
}
onLazyDeleteChange={handleLazyDeleteChange}
onCountChange={handleSigCountChange}
onPendingChange={handlePendingChange}
onLazyDeleteChange={handleLazyDeleteToggle}
onCountChange={handleCountChange}
onSignatureDeleted={addDeleted}
/>
)}
{visible && (
<SystemSignatureSettingsDialog
settings={currentSettings}
onCancel={() => setVisible(false)}
onSave={handleSettingsChange}
onSave={handleSettingsSave}
/>
)}
</Widget>
@@ -57,12 +57,8 @@ interface SystemSignaturesContentProps {
onSelect?: (signature: SystemSignature) => void;
onLazyDeleteChange?: (value: boolean) => void;
onCountChange?: (count: number) => void;
onPendingChange?: (
pending: React.MutableRefObject<Record<string, ExtendedSystemSignature>>,
undo: () => void,
) => void;
deletionTiming?: number;
filterSignature?: (signature: SystemSignature) => boolean;
onSignatureDeleted?: (deletedIds: string[]) => void;
}
export const SystemSignaturesContent = ({
@@ -73,9 +69,8 @@ export const SystemSignaturesContent = ({
onSelect,
onLazyDeleteChange,
onCountChange,
onPendingChange,
deletionTiming,
filterSignature,
onSignatureDeleted,
}: SystemSignaturesContentProps) => {
const [selectedSignatureForDialog, setSelectedSignatureForDialog] = useState<SystemSignature | null>(null);
const [showSignatureSettings, setShowSignatureSettings] = useState(false);
@@ -95,15 +90,21 @@ export const SystemSignaturesContent = ({
{ defaultValue: SORT_DEFAULT_VALUES },
);
const { signatures, selectedSignatures, setSelectedSignatures, handleDeleteSelected, handleSelectAll, handlePaste } =
useSystemSignaturesData({
systemId,
settings,
onCountChange,
onPendingChange,
onLazyDeleteChange,
deletionTiming,
});
const {
signatures,
selectedSignatures,
setSelectedSignatures,
handleDeleteSelected,
handleSelectAll,
handlePaste,
hasUnsupportedLanguage,
} = useSystemSignaturesData({
systemId,
settings,
onCountChange,
onLazyDeleteChange,
onSignatureDeleted,
});
useEffect(() => {
if (selectable) return;
@@ -125,6 +126,10 @@ export const SystemSignaturesContent = ({
event.preventDefault();
event.stopPropagation();
if (onSignatureDeleted && selectedSignatures.length > 0) {
const deletedIds = selectedSignatures.map(s => s.eve_id);
onSignatureDeleted(deletedIds);
}
handleDeleteSelected();
});
@@ -155,7 +160,7 @@ export const SystemSignaturesContent = ({
(e: { value: SystemSignature[] }) => {
selectable ? onSelect?.(e.value[0]) : setSelectedSignatures(e.value as ExtendedSystemSignature[]);
},
[selectable],
[onSelect, selectable, setSelectedSignatures],
);
const { showDescriptionColumn, showUpdatedColumn, showCharacterColumn, showCharacterPortrait } = useMemo(
@@ -188,7 +193,11 @@ export const SystemSignaturesContent = ({
x => GROUPS_LIST.includes(x as SignatureGroup) && settings[x as SETTINGS_KEYS],
);
return enabledGroups.includes(getGroupIdByRawGroup(sig.group));
const mappedGroup = getGroupIdByRawGroup(sig.group);
if (!mappedGroup) {
return true; // If we can't determine the group, still show it
}
return enabledGroups.includes(mappedGroup);
}
return true;
@@ -236,113 +245,121 @@ export const SystemSignaturesContent = ({
No signatures
</div>
) : (
<DataTable
value={filteredSignatures}
size="small"
selectionMode="multiple"
selection={selectedSignatures}
metaKeySelection
onSelectionChange={handleSelectSignatures}
dataKey="eve_id"
className="w-full select-none"
resizableColumns={false}
rowHover
selectAll
onRowDoubleClick={handleRowClick}
sortField={sortSettings.sortField}
sortOrder={sortSettings.sortOrder}
onSort={handleSortSettings}
onRowMouseEnter={onRowMouseEnter}
onRowMouseLeave={onRowMouseLeave}
// @ts-ignore
rowClassName={getRowClassName}
>
<Column
field="icon"
header=""
body={renderColIcon}
bodyClassName="p-0 px-1"
style={{ maxWidth: 26, minWidth: 26, width: 26 }}
/>
<Column
field="eve_id"
header="Id"
bodyClassName="text-ellipsis overflow-hidden whitespace-nowrap"
style={{ maxWidth: 72, minWidth: 72, width: 72 }}
sortable
/>
<Column
field="group"
header="Group"
bodyClassName="text-ellipsis overflow-hidden whitespace-nowrap"
style={{ maxWidth: 110, minWidth: 110, width: 110 }}
body={sig => sig.group ?? ''}
hidden={isCompact}
sortable
/>
<Column
field="info"
header="Info"
bodyClassName="text-ellipsis overflow-hidden whitespace-nowrap"
style={{ maxWidth: nameColumnWidth }}
hidden={isCompact || isMedium}
body={renderInfoColumn}
/>
{showDescriptionColumn && (
<Column
field="description"
header="Description"
bodyClassName="text-ellipsis overflow-hidden whitespace-nowrap"
hidden={isCompact}
body={renderDescription}
sortable
/>
<>
{hasUnsupportedLanguage && (
<div className="w-full flex justify-center items-center text-amber-500 text-xs p-1 bg-amber-950/20 border-b border-amber-800/30">
<i className={PrimeIcons.EXCLAMATION_TRIANGLE + ' mr-1'} />
Non-English signatures detected. Some signatures may not display correctly. Double-click to edit signature details.
</div>
)}
<Column
field="inserted_at"
header="Added"
dataType="date"
body={renderAddedTimeLeft}
style={{ minWidth: 70, maxWidth: 80 }}
bodyClassName="ssc-header text-ellipsis overflow-hidden whitespace-nowrap"
sortable
/>
{showUpdatedColumn && (
<Column
field="updated_at"
header="Updated"
dataType="date"
body={renderUpdatedTimeLeft}
style={{ minWidth: 70, maxWidth: 80 }}
bodyClassName="text-ellipsis overflow-hidden whitespace-nowrap"
sortable
/>
)}
{showCharacterColumn && (
<Column
field="character_name"
header="Character"
bodyClassName="w-[70px] text-ellipsis overflow-hidden whitespace-nowrap"
sortable
></Column>
)}
{!selectable && (
<DataTable
value={filteredSignatures}
size="small"
selectionMode="multiple"
selection={selectedSignatures}
metaKeySelection
onSelectionChange={handleSelectSignatures}
dataKey="eve_id"
className="w-full select-none"
resizableColumns={false}
rowHover
selectAll
onRowDoubleClick={handleRowClick}
sortField={sortSettings.sortField}
sortOrder={sortSettings.sortOrder}
onSort={handleSortSettings}
onRowMouseEnter={onRowMouseEnter}
onRowMouseLeave={onRowMouseLeave}
// @ts-ignore
rowClassName={getRowClassName}
>
<Column
field="icon"
header=""
body={() => (
<div className="flex justify-end items-center gap-2 mr-[4px]">
<WdTooltipWrapper content="Double-click a row to edit signature">
<span className={PrimeIcons.PENCIL + ' text-[10px]'} />
</WdTooltipWrapper>
</div>
)}
body={renderColIcon}
bodyClassName="p-0 px-1"
style={{ maxWidth: 26, minWidth: 26, width: 26 }}
bodyClassName="p-0 pl-1 pr-2"
/>
)}
</DataTable>
<Column
field="eve_id"
header="Id"
bodyClassName="text-ellipsis overflow-hidden whitespace-nowrap"
style={{ maxWidth: 72, minWidth: 72, width: 72 }}
sortable
/>
<Column
field="group"
header="Group"
bodyClassName="text-ellipsis overflow-hidden whitespace-nowrap"
style={{ maxWidth: 110, minWidth: 110, width: 110 }}
body={sig => sig.group ?? ''}
hidden={isCompact}
sortable
/>
<Column
field="info"
header="Info"
bodyClassName="text-ellipsis overflow-hidden whitespace-nowrap"
style={{ maxWidth: nameColumnWidth }}
hidden={isCompact || isMedium}
body={renderInfoColumn}
/>
{showDescriptionColumn && (
<Column
field="description"
header="Description"
bodyClassName="text-ellipsis overflow-hidden whitespace-nowrap"
hidden={isCompact}
body={renderDescription}
sortable
/>
)}
<Column
field="inserted_at"
header="Added"
dataType="date"
body={renderAddedTimeLeft}
style={{ minWidth: 70, maxWidth: 80 }}
bodyClassName="ssc-header text-ellipsis overflow-hidden whitespace-nowrap"
sortable
/>
{showUpdatedColumn && (
<Column
field="updated_at"
header="Updated"
dataType="date"
body={renderUpdatedTimeLeft}
style={{ minWidth: 70, maxWidth: 80 }}
bodyClassName="text-ellipsis overflow-hidden whitespace-nowrap"
sortable
/>
)}
{showCharacterColumn && (
<Column
field="character_name"
header="Character"
bodyClassName="w-[70px] text-ellipsis overflow-hidden whitespace-nowrap"
sortable
></Column>
)}
{!selectable && (
<Column
header=""
body={() => (
<div className="flex justify-end items-center gap-2 mr-[4px]">
<WdTooltipWrapper content="Double-click a row to edit signature">
<span className={PrimeIcons.PENCIL + ' text-[10px]'} />
</WdTooltipWrapper>
</div>
)}
style={{ maxWidth: 26, minWidth: 26, width: 26 }}
bodyClassName="p-0 pl-1 pr-2"
/>
)}
</DataTable>
</>
)}
<WdTooltip
@@ -1,10 +1,14 @@
import {
GroupType,
SignatureGroup,
SignatureGroupDE,
SignatureGroupENG,
SignatureGroupFR,
SignatureGroupRU,
SignatureKind,
SignatureKindDE,
SignatureKindENG,
SignatureKindFR,
SignatureKindRU,
} from '@/hooks/Mapper/types';
@@ -40,46 +44,58 @@ export const GROUPS: Record<SignatureGroup, GroupType> = {
[SignatureGroup.CosmicSignature]: { id: SignatureGroup.CosmicSignature, icon: '/icons/x_close14.png', w: 9, h: 9 },
};
export const MAPPING_GROUP_TO_ENG = {
// ENGLISH
[SignatureGroupENG.GasSite]: SignatureGroup.GasSite,
[SignatureGroupENG.RelicSite]: SignatureGroup.RelicSite,
[SignatureGroupENG.DataSite]: SignatureGroup.DataSite,
[SignatureGroupENG.OreSite]: SignatureGroup.OreSite,
[SignatureGroupENG.CombatSite]: SignatureGroup.CombatSite,
[SignatureGroupENG.Wormhole]: SignatureGroup.Wormhole,
[SignatureGroupENG.CosmicSignature]: SignatureGroup.CosmicSignature,
// RUSSIAN
[SignatureGroupRU.GasSite]: SignatureGroup.GasSite,
[SignatureGroupRU.RelicSite]: SignatureGroup.RelicSite,
[SignatureGroupRU.DataSite]: SignatureGroup.DataSite,
[SignatureGroupRU.OreSite]: SignatureGroup.OreSite,
[SignatureGroupRU.CombatSite]: SignatureGroup.CombatSite,
[SignatureGroupRU.Wormhole]: SignatureGroup.Wormhole,
[SignatureGroupRU.CosmicSignature]: SignatureGroup.CosmicSignature,
export const LANGUAGE_GROUP_MAPPINGS = {
EN: {
[SignatureGroupENG.GasSite]: SignatureGroup.GasSite,
[SignatureGroupENG.RelicSite]: SignatureGroup.RelicSite,
[SignatureGroupENG.DataSite]: SignatureGroup.DataSite,
[SignatureGroupENG.OreSite]: SignatureGroup.OreSite,
[SignatureGroupENG.CombatSite]: SignatureGroup.CombatSite,
[SignatureGroupENG.Wormhole]: SignatureGroup.Wormhole,
[SignatureGroupENG.CosmicSignature]: SignatureGroup.CosmicSignature,
},
RU: {
[SignatureGroupRU.GasSite]: SignatureGroup.GasSite,
[SignatureGroupRU.RelicSite]: SignatureGroup.RelicSite,
[SignatureGroupRU.DataSite]: SignatureGroup.DataSite,
[SignatureGroupRU.OreSite]: SignatureGroup.OreSite,
[SignatureGroupRU.CombatSite]: SignatureGroup.CombatSite,
[SignatureGroupRU.Wormhole]: SignatureGroup.Wormhole,
[SignatureGroupRU.CosmicSignature]: SignatureGroup.CosmicSignature,
},
FR: {
[SignatureGroupFR.GasSite]: SignatureGroup.GasSite,
[SignatureGroupFR.RelicSite]: SignatureGroup.RelicSite,
[SignatureGroupFR.DataSite]: SignatureGroup.DataSite,
[SignatureGroupFR.OreSite]: SignatureGroup.OreSite,
[SignatureGroupFR.CombatSite]: SignatureGroup.CombatSite,
[SignatureGroupFR.Wormhole]: SignatureGroup.Wormhole,
[SignatureGroupFR.CosmicSignature]: SignatureGroup.CosmicSignature,
},
DE: {
[SignatureGroupDE.GasSite]: SignatureGroup.GasSite,
[SignatureGroupDE.RelicSite]: SignatureGroup.RelicSite,
[SignatureGroupDE.DataSite]: SignatureGroup.DataSite,
[SignatureGroupDE.OreSite]: SignatureGroup.OreSite,
[SignatureGroupDE.CombatSite]: SignatureGroup.CombatSite,
[SignatureGroupDE.Wormhole]: SignatureGroup.Wormhole,
[SignatureGroupDE.CosmicSignature]: SignatureGroup.CosmicSignature,
},
};
export const MAPPING_TYPE_TO_ENG = {
// ENGLISH
[SignatureKindENG.CosmicSignature]: SignatureKind.CosmicSignature,
[SignatureKindENG.CosmicAnomaly]: SignatureKind.CosmicAnomaly,
[SignatureKindENG.Structure]: SignatureKind.Structure,
[SignatureKindENG.Ship]: SignatureKind.Ship,
[SignatureKindENG.Deployable]: SignatureKind.Deployable,
[SignatureKindENG.Drone]: SignatureKind.Drone,
// Flatten the structure for backward compatibility
export const MAPPING_GROUP_TO_ENG: Record<string, SignatureGroup> = (() => {
const flattened: Record<string, SignatureGroup> = {};
for (const [, mappings] of Object.entries(LANGUAGE_GROUP_MAPPINGS)) {
Object.assign(flattened, mappings);
}
return flattened;
})();
// RUSSIAN
[SignatureKindRU.CosmicSignature]: SignatureKind.CosmicSignature,
[SignatureKindRU.CosmicAnomaly]: SignatureKind.CosmicAnomaly,
[SignatureKindRU.Structure]: SignatureKind.Structure,
[SignatureKindRU.Ship]: SignatureKind.Ship,
[SignatureKindRU.Deployable]: SignatureKind.Deployable,
[SignatureKindRU.Drone]: SignatureKind.Drone,
export const getGroupIdByRawGroup = (val: string): SignatureGroup | undefined => {
return MAPPING_GROUP_TO_ENG[val] || undefined;
};
export const getGroupIdByRawGroup = (val: string) => MAPPING_GROUP_TO_ENG[val as SignatureGroup];
export const SIGNATURE_WINDOW_ID = 'system_signatures_window';
export const SIGNATURE_SETTING_STORE_KEY = 'wanderer_system_signature_settings_v6_5';
@@ -123,7 +139,7 @@ export type Setting = {
name: string;
type: SettingsTypes;
isSeparator?: boolean;
options?: { label: string; value: any }[];
options?: { label: string; value: number | string | boolean }[];
};
export enum SIGNATURES_DELETION_TIMING {
@@ -208,3 +224,52 @@ export const SIGNATURE_DELETION_TIMEOUTS: SignatureDeletionTimingType = {
[SIGNATURES_DELETION_TIMING.IMMEDIATE]: 0,
[SIGNATURES_DELETION_TIMING.EXTENDED]: 30_000,
};
// Replace the flat structure with a nested structure by language
export const LANGUAGE_TYPE_MAPPINGS = {
EN: {
[SignatureKindENG.CosmicSignature]: SignatureKind.CosmicSignature,
[SignatureKindENG.CosmicAnomaly]: SignatureKind.CosmicAnomaly,
[SignatureKindENG.Structure]: SignatureKind.Structure,
[SignatureKindENG.Ship]: SignatureKind.Ship,
[SignatureKindENG.Deployable]: SignatureKind.Deployable,
[SignatureKindENG.Drone]: SignatureKind.Drone,
[SignatureKindENG.Starbase]: SignatureKind.Starbase,
},
RU: {
[SignatureKindRU.CosmicSignature]: SignatureKind.CosmicSignature,
[SignatureKindRU.CosmicAnomaly]: SignatureKind.CosmicAnomaly,
[SignatureKindRU.Structure]: SignatureKind.Structure,
[SignatureKindRU.Ship]: SignatureKind.Ship,
[SignatureKindRU.Deployable]: SignatureKind.Deployable,
[SignatureKindRU.Drone]: SignatureKind.Drone,
[SignatureKindRU.Starbase]: SignatureKind.Starbase,
},
FR: {
[SignatureKindFR.CosmicSignature]: SignatureKind.CosmicSignature,
[SignatureKindFR.CosmicAnomaly]: SignatureKind.CosmicAnomaly,
[SignatureKindFR.Structure]: SignatureKind.Structure,
[SignatureKindFR.Ship]: SignatureKind.Ship,
[SignatureKindFR.Deployable]: SignatureKind.Deployable,
[SignatureKindFR.Drone]: SignatureKind.Drone,
[SignatureKindFR.Starbase]: SignatureKind.Starbase,
},
DE: {
[SignatureKindDE.CosmicSignature]: SignatureKind.CosmicSignature,
[SignatureKindDE.CosmicAnomaly]: SignatureKind.CosmicAnomaly,
[SignatureKindDE.Structure]: SignatureKind.Structure,
[SignatureKindDE.Ship]: SignatureKind.Ship,
[SignatureKindDE.Deployable]: SignatureKind.Deployable,
[SignatureKindDE.Drone]: SignatureKind.Drone,
[SignatureKindDE.Starbase]: SignatureKind.Starbase,
},
};
// Flatten the structure for backward compatibility
export const MAPPING_TYPE_TO_ENG: Record<string, SignatureKind> = (() => {
const flattened: Record<string, SignatureKind> = {};
for (const [, mappings] of Object.entries(LANGUAGE_TYPE_MAPPINGS)) {
Object.assign(flattened, mappings);
}
return flattened;
})();
@@ -1,23 +1,18 @@
import { useCallback, useRef, useEffect } from 'react';
import { useCallback, useRef } from 'react';
import { OutCommand } from '@/hooks/Mapper/types/mapHandlers';
import { prepareUpdatePayload, scheduleLazyTimers } from '../helpers';
import { prepareUpdatePayload } from '../helpers';
import { UsePendingDeletionParams } from './types';
import { FINAL_DURATION_MS } from '../constants';
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import { ExtendedSystemSignature } from '@/hooks/Mapper/types';
export function usePendingDeletions({
systemId,
setSignatures,
deletionTiming,
onPendingChange,
}: UsePendingDeletionParams) {
}: Omit<UsePendingDeletionParams, 'deletionTiming'>) {
const { outCommand } = useMapRootState();
const pendingDeletionMapRef = useRef<Record<string, ExtendedSystemSignature>>({});
// Use the provided deletion timing or fall back to the default
const finalDuration = deletionTiming !== undefined ? deletionTiming : FINAL_DURATION_MS;
const processRemovedSignatures = useCallback(
async (
removed: ExtendedSystemSignature[],
@@ -25,63 +20,15 @@ export function usePendingDeletions({
updated: ExtendedSystemSignature[],
) => {
if (!removed.length) return;
// If deletion timing is 0, immediately delete without pending state
if (finalDuration === 0) {
await outCommand({
type: OutCommand.updateSignatures,
data: prepareUpdatePayload(systemId, added, updated, removed),
});
return;
}
const now = Date.now();
const processedRemoved = removed.map(r => ({
...r,
pendingDeletion: true,
pendingUntil: now + finalDuration,
}));
pendingDeletionMapRef.current = {
...pendingDeletionMapRef.current,
...processedRemoved.reduce((acc: any, sig) => {
acc[sig.eve_id] = sig;
return acc;
}, {}),
};
onPendingChange?.(pendingDeletionMapRef, clearPendingDeletions);
setSignatures(prev =>
prev.map(sig => {
if (processedRemoved.find(r => r.eve_id === sig.eve_id)) {
return { ...sig, pendingDeletion: true, pendingUntil: now + finalDuration };
}
return sig;
}),
);
scheduleLazyTimers(
processedRemoved,
pendingDeletionMapRef,
async sig => {
await outCommand({
type: OutCommand.updateSignatures,
data: prepareUpdatePayload(systemId, [], [], [sig]),
});
delete pendingDeletionMapRef.current[sig.eve_id];
setSignatures(prev => prev.filter(x => x.eve_id !== sig.eve_id));
onPendingChange?.(pendingDeletionMapRef, clearPendingDeletions);
},
finalDuration,
);
await outCommand({
type: OutCommand.updateSignatures,
data: prepareUpdatePayload(systemId, added, updated, removed),
});
},
[systemId, outCommand, finalDuration],
[systemId, outCommand],
);
const clearPendingDeletions = useCallback(() => {
Object.values(pendingDeletionMapRef.current).forEach(({ finalTimeoutId }) => {
clearTimeout(finalTimeoutId);
});
pendingDeletionMapRef.current = {};
setSignatures(prev => prev.map(x => (x.pendingDeletion ? { ...x, pendingDeletion: false } : x)));
onPendingChange?.(pendingDeletionMapRef, clearPendingDeletions);
@@ -18,16 +18,18 @@ export const useSystemSignaturesData = ({
onCountChange,
onPendingChange,
onLazyDeleteChange,
deletionTiming,
}: UseSystemSignaturesDataProps) => {
onSignatureDeleted,
}: Omit<UseSystemSignaturesDataProps, 'deletionTiming'> & {
onSignatureDeleted?: (deletedIds: string[]) => void;
}) => {
const { outCommand } = useMapRootState();
const [signatures, setSignatures, signaturesRef] = useRefState<ExtendedSystemSignature[]>([]);
const [selectedSignatures, setSelectedSignatures] = useState<ExtendedSystemSignature[]>([]);
const [hasUnsupportedLanguage, setHasUnsupportedLanguage] = useState<boolean>(false);
const { pendingDeletionMapRef, processRemovedSignatures, clearPendingDeletions } = usePendingDeletions({
systemId,
setSignatures,
deletionTiming,
onPendingChange,
});
@@ -42,6 +44,7 @@ export const useSystemSignaturesData = ({
async (clipboardString: string) => {
const lazyDeleteValue = settings[SETTINGS_KEYS.LAZY_DELETE_SIGNATURES] as boolean;
// Parse the incoming signatures
const incomingSignatures = parseSignatures(
clipboardString,
Object.keys(settings).filter(skey => skey in SignatureKind),
@@ -51,6 +54,18 @@ export const useSystemSignaturesData = ({
return;
}
// Check if any signatures might be using unsupported languages
// This is a basic heuristic: if we have signatures where the original group wasn't mapped
const clipboardRows = clipboardString.split('\n').filter(row => row.trim() !== '');
const detectedSignatureCount = clipboardRows.filter(row => row.match(/^[A-Z]{3}-\d{3}/)).length;
// If we detected valid IDs but got fewer parsed signatures, we might have language issues
if (detectedSignatureCount > 0 && incomingSignatures.length < detectedSignatureCount) {
setHasUnsupportedLanguage(true);
} else {
setHasUnsupportedLanguage(false);
}
const currentNonPending = lazyDeleteValue
? signaturesRef.current.filter(sig => !sig.pendingDeletion)
: signaturesRef.current.filter(sig => !sig.pendingDeletion || !sig.pendingAddition);
@@ -59,6 +74,10 @@ export const useSystemSignaturesData = ({
if (removed.length > 0) {
await processRemovedSignatures(removed, added, updated);
if (onSignatureDeleted) {
const deletedIds = removed.map(sig => sig.eve_id);
onSignatureDeleted(deletedIds);
}
}
if (updated.length !== 0 || added.length !== 0) {
@@ -78,17 +97,16 @@ export const useSystemSignaturesData = ({
onLazyDeleteChange?.(false);
}
},
[settings, signaturesRef, processRemovedSignatures, outCommand, systemId, onLazyDeleteChange],
[settings, signaturesRef, processRemovedSignatures, outCommand, systemId, onLazyDeleteChange, onSignatureDeleted],
);
const handleDeleteSelected = useCallback(async () => {
if (!selectedSignatures.length) return;
const selectedIds = selectedSignatures.map(s => s.eve_id);
const finalList = signatures.filter(s => !selectedIds.includes(s.eve_id));
await handleUpdateSignatures(finalList, false, true);
setSelectedSignatures([]);
}, [selectedSignatures, signatures]);
}, [handleUpdateSignatures, selectedSignatures, signatures]);
const handleSelectAll = useCallback(() => {
setSelectedSignatures(signatures);
@@ -119,11 +137,12 @@ export const useSystemSignaturesData = ({
}, [signatures]);
return {
signatures,
signatures: signatures.filter(sig => !sig.deleted),
selectedSignatures,
setSelectedSignatures,
handleDeleteSelected,
handleSelectAll,
handlePaste,
hasUnsupportedLanguage,
};
};
@@ -1,5 +1,8 @@
import { SignatureGroup, SignatureKind, SystemSignature } from '@/hooks/Mapper/types';
import { MAPPING_TYPE_TO_ENG } from '@/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/constants.ts';
import {
MAPPING_GROUP_TO_ENG,
MAPPING_TYPE_TO_ENG,
} from '@/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/constants.ts';
export const parseSignatures = (value: string, availableKeys: string[]): SystemSignature[] => {
const outArr: SystemSignature[] = [];
@@ -14,13 +17,37 @@ export const parseSignatures = (value: string, availableKeys: string[]): SystemS
continue;
}
const kind = MAPPING_TYPE_TO_ENG[sigArrInfo[1] as SignatureKind];
// Extract the signature ID and check if it's valid (XXX-XXX format)
const sigId = sigArrInfo[0];
if (!sigId || !sigId.match(/^[A-Z]{3}-\d{3}$/)) {
continue;
}
// Try to map the kind, or fall back to CosmicSignature if unknown
const typeString = sigArrInfo[1];
let kind = SignatureKind.CosmicSignature;
// Try to map the kind using the flattened mapping
const mappedKind = MAPPING_TYPE_TO_ENG[typeString];
if (mappedKind && availableKeys.includes(mappedKind)) {
kind = mappedKind;
}
// Try to map the group, or fall back to CosmicSignature if unknown
const rawGroup = sigArrInfo[2];
let group = SignatureGroup.CosmicSignature;
// Try to map the group using the flattened mapping
const mappedGroup = MAPPING_GROUP_TO_ENG[rawGroup];
if (mappedGroup) {
group = mappedGroup;
}
const signature: SystemSignature = {
eve_id: sigArrInfo[0],
kind: availableKeys.includes(kind) ? kind : SignatureKind.CosmicSignature,
group: sigArrInfo[2] as SignatureGroup,
name: sigArrInfo[3],
eve_id: sigId,
kind,
group,
name: sigArrInfo[3] || 'Unknown',
type: '',
};
+2 -5
View File
@@ -242,15 +242,12 @@ export enum OutCommand {
addSystemComment = 'addSystemComment',
deleteSystemComment = 'deleteSystemComment',
getSystemComments = 'getSystemComments',
// toggleTrack = 'toggle_track',
toggleFollow = 'toggle_follow',
getCharacterInfo = 'getCharacterInfo',
getCharactersTrackingInfo = 'getCharactersTrackingInfo',
updateCharacterTracking = 'updateCharacterTracking',
updateFollowingCharacter = 'updateFollowingCharacter',
updateMainCharacter = 'updateMainCharacter',
// Only UI commands
openSettings = 'open_settings',
showActivity = 'show_activity',
showTracking = 'show_tracking',
@@ -258,7 +255,7 @@ export enum OutCommand {
updateUserSettings = 'update_user_settings',
unlinkSignature = 'unlink_signature',
searchSystems = 'search_systems',
undoDeleteSignatures = 'undo_delete_signatures',
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type OutCommandHandler = <T = any>(event: { type: OutCommand; data: any }) => Promise<T>;
export type OutCommandHandler = <T = unknown>(event: { type: OutCommand; data: unknown }) => Promise<T>;
@@ -30,6 +30,7 @@ export type GroupType = {
export type SignatureCustomInfo = {
k162Type?: string;
isEOL?: boolean;
isCrit?: boolean;
};
export type SystemSignature = {
@@ -46,6 +47,7 @@ export type SystemSignature = {
linked_system?: SolarSystemStaticInfoRaw;
inserted_at?: string;
updated_at?: string;
deleted?: boolean;
};
export interface ExtendedSystemSignature extends SystemSignature {
@@ -53,6 +55,7 @@ export interface ExtendedSystemSignature extends SystemSignature {
pendingAddition?: boolean;
pendingUntil?: number;
finalTimeoutId?: number;
deleted?: boolean;
}
export enum SignatureKindENG {
@@ -75,6 +78,26 @@ export enum SignatureKindRU {
Starbase = 'Starbase',
}
export enum SignatureKindFR {
CosmicSignature = 'Signature cosmique (type)',
CosmicAnomaly = 'Anomalie cosmique',
Structure = 'Structure',
Ship = 'Vaisseau',
Deployable = 'Déployable',
Drone = 'Drone',
Starbase = 'Base stellaire',
}
export enum SignatureKindDE {
CosmicSignature = 'Kosmische Signatur (typ)',
CosmicAnomaly = 'Kosmische Anomalie',
Structure = 'Struktur',
Ship = 'Schiff',
Deployable = 'Mobile Struktur',
Drone = 'Drohne',
Starbase = 'Sternenbasis',
}
export enum SignatureGroupENG {
CosmicSignature = 'Cosmic Signature',
Wormhole = 'Wormhole',
@@ -94,3 +117,23 @@ export enum SignatureGroupRU {
OreSite = 'Астероидный район',
CombatSite = 'Боевой район',
}
export enum SignatureGroupFR {
CosmicSignature = 'Signature cosmique (groupe)',
Wormhole = 'Trou de ver',
GasSite = 'Site de gaz',
RelicSite = 'Site de reliques',
DataSite = 'Site de données',
OreSite = 'Site de minerai',
CombatSite = 'Site de combat',
}
export enum SignatureGroupDE {
CosmicSignature = 'Kosmische Signatur (gruppe)',
Wormhole = 'Wurmloch',
GasSite = 'Gasgebiet',
RelicSite = 'Reliktgebiet',
DataSite = 'Datengebiet',
OreSite = 'Mineraliengebiet',
CombatSite = 'Kampfgebiet',
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

+31 -5
View File
@@ -24,7 +24,10 @@ defmodule WandererApp.Api.MapSystemSignature do
)
define(:by_system_id, action: :by_system_id, args: [:system_id])
define(:by_system_id_all, action: :by_system_id_all, args: [:system_id])
define(:by_system_id_and_eve_ids, action: :by_system_id_and_eve_ids, args: [:system_id, :eve_ids])
define(:by_linked_system_id, action: :by_linked_system_id, args: [:linked_system_id])
define(:by_deleted_and_updated_before!, action: :by_deleted_and_updated_before, args: [:deleted, :updated_before])
end
actions do
@@ -36,7 +39,8 @@ defmodule WandererApp.Api.MapSystemSignature do
:description,
:kind,
:group,
:type
:type,
:deleted
]
defaults [:read, :destroy]
@@ -64,7 +68,8 @@ defmodule WandererApp.Api.MapSystemSignature do
:kind,
:group,
:type,
:custom_info
:custom_info,
:deleted
]
argument :system_id, :uuid, allow_nil?: false
@@ -83,7 +88,7 @@ defmodule WandererApp.Api.MapSystemSignature do
:group,
:type,
:custom_info,
:updated
:deleted
]
primary? true
@@ -105,14 +110,32 @@ defmodule WandererApp.Api.MapSystemSignature do
read :by_system_id do
argument(:system_id, :string, allow_nil?: false)
filter(expr(system_id == ^arg(:system_id) and deleted == false))
end
read :by_system_id_all do
argument(:system_id, :string, allow_nil?: false)
filter(expr(system_id == ^arg(:system_id)))
end
read :by_system_id_and_eve_ids do
argument(:system_id, :string, allow_nil?: false)
argument(:eve_ids, {:array, :string}, allow_nil?: false)
filter(expr(system_id == ^arg(:system_id) and eve_id in ^arg(:eve_ids)))
end
read :by_linked_system_id do
argument(:linked_system_id, :integer, allow_nil?: false)
filter(expr(linked_system_id == ^arg(:linked_system_id)))
end
read :by_deleted_and_updated_before do
argument(:deleted, :boolean, allow_nil?: false)
argument(:updated_before, :utc_datetime, allow_nil?: false)
filter(expr(deleted == ^arg(:deleted) and updated_at < ^arg(:updated_before)))
end
end
attributes do
@@ -149,7 +172,10 @@ defmodule WandererApp.Api.MapSystemSignature do
allow_nil? true
end
attribute :updated, :integer
attribute :deleted, :boolean do
allow_nil? false
default false
end
create_timestamp(:inserted_at)
update_timestamp(:updated_at)
@@ -178,7 +204,7 @@ defmodule WandererApp.Api.MapSystemSignature do
:kind,
:group,
:custom_info,
:updated,
:deleted,
:inserted_at,
:updated_at
]
+38 -1
View File
@@ -9,12 +9,15 @@ defmodule WandererApp.Map.Manager do
alias WandererApp.Map.Server
alias WandererApp.Map.ServerSupervisor
alias WandererApp.Api.MapSystemSignature
@maps_start_per_second 5
@maps_start_interval 1000
@maps_queue :maps_queue
@garbage_collection_interval :timer.hours(1)
@check_maps_queue_interval :timer.seconds(1)
@signatures_cleanup_interval :timer.minutes(30)
@delete_after_minutes 30
def start_map(map_id) when is_binary(map_id),
do: WandererApp.Queue.push_uniq(@maps_queue, map_id)
@@ -44,6 +47,9 @@ defmodule WandererApp.Map.Manager do
{:ok, garbage_collector_timer} =
:timer.send_interval(@garbage_collection_interval, :garbage_collect)
{:ok, signatures_cleanup_timer} =
:timer.send_interval(@signatures_cleanup_interval, :cleanup_signatures)
try do
Task.async(fn ->
start_last_active_maps()
@@ -56,7 +62,8 @@ defmodule WandererApp.Map.Manager do
{:ok,
%{
garbage_collector_timer: garbage_collector_timer,
check_maps_queue_timer: check_maps_queue_timer
check_maps_queue_timer: check_maps_queue_timer,
signatures_cleanup_timer: signatures_cleanup_timer
}}
end
@@ -118,6 +125,36 @@ defmodule WandererApp.Map.Manager do
end
end
@impl true
def handle_info(:cleanup_signatures, state) do
try do
cleanup_deleted_signatures()
{:noreply, state}
rescue
e ->
Logger.error("Failed to cleanup signatures: #{inspect(e)}")
{:noreply, state}
end
end
def cleanup_deleted_signatures() do
delete_after_date = DateTime.utc_now() |> DateTime.add(-1 * @delete_after_minutes, :minute)
case MapSystemSignature.by_deleted_and_updated_before!(true, delete_after_date) do
{:ok, deleted_signatures} ->
Enum.each(deleted_signatures, fn sig ->
Ash.destroy!(sig)
end)
:ok
{:error, error} ->
Logger.error("Failed to fetch deleted signatures: #{inspect(error)}")
{:error, error}
end
end
defp start_last_active_maps() do
{:ok, last_map_states} =
WandererApp.Api.MapState.get_last_active(
@@ -93,6 +93,7 @@ defmodule WandererApp.Map.Server.Impl do
Process.send_after(self(), :cleanup_connections, 5_000)
Process.send_after(self(), :cleanup_systems, 10_000)
Process.send_after(self(), :cleanup_characters, :timer.minutes(5))
Process.send_after(self(), :cleanup_signatures, :timer.minutes(30))
Process.send_after(self(), :backup_state, @backup_state_timeout)
WandererApp.Cache.insert("map_#{map_id}:started", true)
@@ -311,6 +312,11 @@ defmodule WandererApp.Map.Server.Impl do
state
end
def handle_event(:cleanup_signatures, state) do
Process.send_after(self(), :cleanup_signatures, :timer.minutes(30))
state |> SignaturesImpl.cleanup_signatures()
end
def handle_event(msg, state) do
Logger.warning("Unhandled event: #{inspect(msg)}")
@@ -3,147 +3,187 @@ defmodule WandererApp.Map.Server.SignaturesImpl do
require Logger
alias WandererApp.Api.{MapSystem, MapSystemSignature}
alias WandererApp.Character
alias WandererApp.User.ActivityTracker
alias WandererApp.Map.Server.{Impl, ConnectionsImpl, SystemsImpl}
@doc """
Public entrypoint for updating signatures on a map system.
"""
def update_signatures(
%{map_id: map_id} = state,
%{
solar_system_id: solar_system_id,
character_id: character_id,
solar_system_id: system_solar_id,
character_id: char_id,
user_id: user_id,
delete_connection_with_sigs: delete_connection_with_sigs,
added_signatures: added_signatures,
updated_signatures: updated_signatures,
removed_signatures: removed_signatures
} =
_signatures_update
delete_connection_with_sigs: delete_conn?,
added_signatures: added_params,
updated_signatures: updated_params,
removed_signatures: removed_params
}
)
when not is_nil(character_id) do
WandererApp.Api.MapSystem.read_by_map_and_solar_system(%{
map_id: map_id,
solar_system_id: solar_system_id
})
|> case do
{:ok, system} ->
{:ok, %{eve_id: character_eve_id}} = WandererApp.Character.get_character(character_id)
added_signatures =
added_signatures
|> parse_signatures(character_eve_id, system.id)
updated_signatures =
updated_signatures
|> parse_signatures(character_eve_id, system.id)
updated_signatures_eve_ids =
updated_signatures
|> Enum.map(fn s -> s.eve_id end)
removed_signatures_eve_ids =
removed_signatures
|> parse_signatures(character_eve_id, system.id)
|> Enum.map(fn s -> s.eve_id end)
WandererApp.Api.MapSystemSignature.by_system_id!(system.id)
|> Enum.filter(fn s -> s.eve_id in removed_signatures_eve_ids end)
|> Enum.each(fn s ->
if delete_connection_with_sigs && not is_nil(s.linked_system_id) do
state
|> ConnectionsImpl.delete_connection(%{
solar_system_source_id: system.solar_system_id,
solar_system_target_id: s.linked_system_id
})
end
if not is_nil(s.linked_system_id) do
state
|> SystemsImpl.update_system_linked_sig_eve_id(%{
solar_system_id: s.linked_system_id,
linked_sig_eve_id: nil
})
end
s
|> Ash.destroy!()
end)
WandererApp.Api.MapSystemSignature.by_system_id!(system.id)
|> Enum.filter(fn s -> s.eve_id in updated_signatures_eve_ids end)
|> Enum.each(fn s ->
updated = updated_signatures |> Enum.find(fn u -> u.eve_id == s.eve_id end)
if not is_nil(updated) do
s
|> WandererApp.Api.MapSystemSignature.update(
updated
|> Map.put(:updated, System.os_time())
)
end
end)
added_signatures
|> Enum.each(fn s ->
s |> WandererApp.Api.MapSystemSignature.create!()
end)
added_signatures_eve_ids =
added_signatures
|> Enum.map(fn s -> s.eve_id end)
if not (added_signatures_eve_ids |> Enum.empty?()) do
WandererApp.User.ActivityTracker.track_map_event(:signatures_added, %{
character_id: character_id,
user_id: user_id,
map_id: map_id,
solar_system_id: system.solar_system_id,
signatures: added_signatures_eve_ids
})
end
if not (removed_signatures_eve_ids |> Enum.empty?()) do
WandererApp.User.ActivityTracker.track_map_event(:signatures_removed, %{
character_id: character_id,
user_id: user_id,
map_id: map_id,
solar_system_id: system.solar_system_id,
signatures: removed_signatures_eve_ids
})
end
Impl.broadcast!(map_id, :signatures_updated, system.solar_system_id)
state
_ ->
when not is_nil(char_id) do
with {:ok, system} <-
MapSystem.read_by_map_and_solar_system(%{map_id: map_id, solar_system_id: system_solar_id}),
{:ok, %{eve_id: char_eve_id}} <- Character.get_character(char_id) do
do_update_signatures(
state,
system,
char_eve_id,
user_id,
delete_conn?,
added_params,
updated_params,
removed_params
)
else
error ->
Logger.warning("Skipping signature update: #{inspect(error)}")
state
end
end
def update_signatures(
state,
_signatures_update
),
do: state
def update_signatures(state, _), do: state
defp parse_signatures(signatures, character_eve_id, system_id),
do:
signatures
|> Enum.map(fn %{
"eve_id" => eve_id,
"name" => name,
"kind" => kind,
"group" => group
} = signature ->
%{
system_id: system_id,
eve_id: eve_id,
name: name,
description: Map.get(signature, "description"),
kind: kind,
group: group,
type: Map.get(signature, "type"),
custom_info: Map.get(signature, "custom_info"),
character_eve_id: character_eve_id
}
end)
defp do_update_signatures(
state,
system,
character_eve_id,
user_id,
delete_conn?,
added_params,
updated_params,
removed_params
) do
# parse incoming DTOs
added_sigs = parse_signatures(added_params, character_eve_id, system.id)
updated_sigs = parse_signatures(updated_params, character_eve_id, system.id)
removed_sigs = parse_signatures(removed_params, character_eve_id, system.id)
# fetch both current & all (including deleted) signatures once
existing_current = MapSystemSignature.by_system_id!(system.id)
existing_all = MapSystemSignature.by_system_id_all!(system.id)
removed_ids = Enum.map(removed_sigs, & &1.eve_id)
updated_ids = Enum.map(updated_sigs, & &1.eve_id)
added_ids = Enum.map(added_sigs, & &1.eve_id)
# 1. Removals
existing_current
|> Enum.filter(&(&1.eve_id in removed_ids))
|> Enum.each(&remove_signature(&1, state, system, delete_conn?))
# 2. Updates
existing_current
|> Enum.filter(&(&1.eve_id in updated_ids))
|> Enum.each(fn existing ->
update = Enum.find(updated_sigs, &(&1.eve_id == existing.eve_id))
apply_update_signature(existing, update)
end)
# 3. Additions & restorations
added_eve_ids = Enum.map(added_sigs, & &1.eve_id)
existing_index = MapSystemSignature.by_system_id_all!(system.id)
|> Enum.filter(&(&1.eve_id in added_eve_ids))
|> Map.new(&{&1.eve_id, &1})
added_sigs
|> Enum.each(fn sig ->
case existing_index[sig.eve_id] do
nil ->
MapSystemSignature.create!(sig)
%MapSystemSignature{deleted: true} = deleted_sig ->
MapSystemSignature.update!(
deleted_sig,
Map.take(sig, [:name, :description, :kind, :group, :type, :custom_info, :deleted])
)
_ ->
:noop
end
end)
# 4. Activity tracking
if added_ids != [] do
track_activity(:signatures_added, state.map_id, system.solar_system_id, user_id, character_eve_id,
added_ids
)
end
if removed_ids != [] do
track_activity(
:signatures_removed,
state.map_id,
system.solar_system_id,
user_id,
character_eve_id,
removed_ids
)
end
# 5. Broadcast to any live subscribers
Impl.broadcast!(state.map_id, :signatures_updated, system.solar_system_id)
state
end
defp remove_signature(sig, state, system, delete_conn?) do
# optionally remove the linked connection
if delete_conn? && sig.linked_system_id do
ConnectionsImpl.delete_connection(state, %{
solar_system_source_id: system.solar_system_id,
solar_system_target_id: sig.linked_system_id
})
end
# clear any linked_sig_eve_id on the target system
if sig.linked_system_id do
SystemsImpl.update_system_linked_sig_eve_id(state, %{
solar_system_id: sig.linked_system_id,
linked_sig_eve_id: nil
})
end
# mark as deleted
MapSystemSignature.update!(sig, %{deleted: true})
end
defp apply_update_signature(%MapSystemSignature{} = existing, update_params)
when not is_nil(update_params) do
case MapSystemSignature.update(existing, update_params) do
{:ok, _} -> :ok
{:error, reason} ->
Logger.error("Failed to update signature #{existing.id}: #{inspect(reason)}")
end
end
defp track_activity(event, map_id, solar_system_id, user_id, character_id, signatures) do
ActivityTracker.track_map_event(event, %{
map_id: map_id,
solar_system_id: solar_system_id,
user_id: user_id,
character_id: character_id,
signatures: signatures
})
end
@doc false
defp parse_signatures(signatures, character_eve_id, system_id) do
Enum.map(signatures, fn sig ->
%{
system_id: system_id,
eve_id: sig["eve_id"],
name: sig["name"],
description: Map.get(sig, "description"),
kind: sig["kind"],
group: sig["group"],
type: Map.get(sig, "type"),
custom_info: Map.get(sig, "custom_info"),
character_eve_id: character_eve_id,
deleted: false
}
end)
end
end
@@ -200,48 +200,28 @@ defmodule WandererAppWeb.MapAccessListAPIController do
@spec index(Plug.Conn.t(), map()) :: Plug.Conn.t()
operation :index,
summary: "List ACLs for a Map",
description: "Lists the ACLs for a given map. Requires either 'map_id' or 'slug' as a query parameter to identify the map.",
description: "Lists the ACLs for a given map. Provide only one of map_id or slug as a query parameter. If both are provided, the request will fail.",
parameters: [
map_id: [
in: :query,
description: "Map identifier (UUID) - Either map_id or slug must be provided",
description: "Map identifier (UUID) - Provide only one of map_id or slug.",
type: :string,
required: false,
example: "00000000-0000-0000-0000-000000000000"
required: false
],
slug: [
in: :query,
description: "Map slug - Either map_id or slug must be provided",
description: "Map slug - Provide only one of map_id or slug.",
type: :string,
required: false,
example: "map-name"
required: false
]
],
responses: [
ok: {
"List of ACLs",
"application/json",
@acl_index_response_schema
},
ok: {"List of ACLs", "application/json", @acl_index_response_schema},
bad_request: {"Error", "application/json", %OpenApiSpex.Schema{
type: :object,
properties: %{
error: %OpenApiSpex.Schema{type: :string}
},
properties: %{error: %OpenApiSpex.Schema{type: :string}},
required: ["error"],
example: %{
"error" => "Must provide either ?map_id=UUID or ?slug=SLUG as a query parameter"
}
}},
not_found: {"Error", "application/json", %OpenApiSpex.Schema{
type: :object,
properties: %{
error: %OpenApiSpex.Schema{type: :string}
},
required: ["error"],
example: %{
"error" => "Map not found. Please provide a valid map_id or slug as a query parameter."
}
example: %{"error" => "Must provide only one of map_id or slug as a query parameter"}
}}
]
def index(conn, params) do
@@ -277,46 +257,30 @@ defmodule WandererAppWeb.MapAccessListAPIController do
"""
@spec create(Plug.Conn.t(), map()) :: Plug.Conn.t()
operation :create,
summary: "Create a new ACL",
description: "Creates a new ACL for a map. Requires either 'map_id' or 'slug' as a query parameter to identify the map.",
summary: "Create ACL for a Map",
description: "Creates a new ACL for a given map. Provide only one of map_id or slug as a query parameter. If both are provided, the request will fail.",
parameters: [
map_id: [
in: :query,
description: "Map identifier (UUID) - Either map_id or slug must be provided",
description: "Map identifier (UUID) - Provide only one of map_id or slug.",
type: :string,
required: false,
example: "00000000-0000-0000-0000-000000000000"
required: false
],
slug: [
in: :query,
description: "Map slug - Either map_id or slug must be provided",
description: "Map slug - Provide only one of map_id or slug.",
type: :string,
required: false,
example: "map-name"
required: false
]
],
request_body: {"Access List parameters", "application/json", @acl_create_request_schema},
request_body: {"ACL parameters", "application/json", @acl_create_request_schema},
responses: [
ok: {"Access List", "application/json", @acl_create_response_schema},
created: {"Created ACL", "application/json", @acl_create_response_schema},
bad_request: {"Error", "application/json", %OpenApiSpex.Schema{
type: :object,
properties: %{
error: %OpenApiSpex.Schema{type: :string}
},
properties: %{error: %OpenApiSpex.Schema{type: :string}},
required: ["error"],
example: %{
"error" => "Must provide either ?map_id=UUID or ?slug=SLUG as a query parameter"
}
}},
not_found: {"Error", "application/json", %OpenApiSpex.Schema{
type: :object,
properties: %{
error: %OpenApiSpex.Schema{type: :string}
},
required: ["error"],
example: %{
"error" => "Map not found. Please provide a valid map_id or slug as a query parameter."
}
example: %{"error" => "Must provide only one of map_id or slug as a query parameter"}
}}
]
def create(conn, params) do
@@ -246,7 +246,6 @@ defmodule WandererAppWeb.MapAPIController do
in: :query,
description: "Map slug",
type: :string,
example: "my-map",
required: false
],
map_id: [
@@ -319,7 +318,7 @@ defmodule WandererAppWeb.MapAPIController do
end
@doc """
GET /api/map/structure_timers
GET /api/map/structure-timers
Returns structure timers for visible systems on the map or for a specific system.
"""
@@ -327,6 +326,7 @@ defmodule WandererAppWeb.MapAPIController do
operation :show_structure_timers,
summary: "Show Structure Timers",
description: "Retrieves structure timers for a map.",
deprecated: true,
parameters: [
map_id: [
in: :query,
@@ -342,7 +342,7 @@ defmodule WandererAppWeb.MapAPIController do
],
system_id: [
in: :query,
description: "System ID",
description: "Optional: System ID to filter timers for a specific system",
type: :string,
required: false
]
@@ -790,15 +790,13 @@ defmodule WandererAppWeb.MapAPIController do
in: :query,
description: "Map identifier (UUID) - Either map_id or slug must be provided",
type: :string,
required: false,
example: ""
required: false
],
slug: [
in: :query,
description: "Map slug - Either map_id or slug must be provided",
type: :string,
required: false,
example: "map-name"
required: false
]
],
responses: [
@@ -129,23 +129,46 @@ defmodule WandererAppWeb.MapConnectionAPIController do
operation :index,
summary: "List Map Connections",
description: "Lists all connections for a map.",
parameters: [
map_identifier: [
in: :path,
description: "Map identifier (UUID or slug). Provide either a UUID or a slug.",
description: "Map identifier (UUID or slug)",
type: :string,
required: true,
example: "map-slug or map UUID"
],
solar_system_source: [in: :query, type: :integer, required: false],
solar_system_target: [in: :query, type: :integer, required: false]
solar_system_source: [
in: :query,
description: "Filter connections by source system ID",
type: :integer,
required: false,
example: 30000142
],
solar_system_target: [
in: :query,
description: "Filter connections by target system ID",
type: :integer,
required: false,
example: 30000144
]
],
responses: [
ok: {
"List Map Connections",
"List of Map Connections",
"application/json",
@list_response_schema
}
},
not_found: {"Error", "application/json", %OpenApiSpex.Schema{
type: :object,
properties: %{
error: %OpenApiSpex.Schema{type: :string}
},
required: ["error"],
example: %{
"error" => "Map not found"
}
}}
]
def index(%{assigns: %{map_id: map_id}} = conn, params) do
with {:ok, src_filter} <- parse_optional(params, "solar_system_source"),
@@ -187,7 +210,7 @@ defmodule WandererAppWeb.MapConnectionAPIController do
parameters: [
map_identifier: [
in: :path,
description: "Map identifier (UUID or slug). Provide either a UUID or a slug.",
description: "Map identifier (UUID or slug)",
type: :string,
required: true,
example: "map-slug or map UUID"
@@ -218,12 +241,11 @@ defmodule WandererAppWeb.MapConnectionAPIController do
parameters: [
map_identifier: [
in: :path,
description: "Map identifier (UUID or slug). Provide either a UUID or a slug.",
description: "Map identifier (UUID or slug)",
type: :string,
required: true,
example: "map-slug or map UUID"
],
system_id: [in: :path, type: :string, required: false]
]
],
request_body: {"Connection create", "application/json", @connection_request_schema},
responses: ResponseSchemas.create_responses(@detail_response_schema)
@@ -256,7 +278,7 @@ defmodule WandererAppWeb.MapConnectionAPIController do
parameters: [
map_identifier: [
in: :path,
description: "Map identifier (UUID or slug). Provide either a UUID or a slug.",
description: "Map identifier (UUID or slug)",
type: :string,
required: true,
example: "map-slug or map UUID"
@@ -344,7 +366,7 @@ defmodule WandererAppWeb.MapConnectionAPIController do
parameters: [
map_identifier: [
in: :path,
description: "Map identifier (UUID or slug). Provide either a UUID or a slug.",
description: "Map identifier (UUID or slug)",
type: :string,
required: true,
example: "map-slug or map UUID"
@@ -442,9 +464,49 @@ defmodule WandererAppWeb.MapConnectionAPIController do
@deprecated "Use GET /api/maps/:map_identifier/systems instead"
operation :list_all_connections,
summary: "List All Connections (Legacy)",
description: "Legacy endpoint for listing connections. Use GET /api/maps/:map_identifier/connections instead. Requires exactly one of map_id or slug as a query parameter. If both are provided, a 400 Bad Request will be returned.",
deprecated: true,
parameters: [map_id: [in: :query]],
responses: ResponseSchemas.standard_responses(@list_response_schema)
parameters: [
map_id: [
in: :query,
description: "Map identifier (UUID) - Exactly one of map_id or slug must be provided",
type: :string,
required: false
],
slug: [
in: :query,
description: "Map slug - Exactly one of map_id or slug must be provided",
type: :string,
required: false
]
],
responses: [
ok: {
"List of Map Connections",
"application/json",
@list_response_schema
},
bad_request: {"Error", "application/json", %OpenApiSpex.Schema{
type: :object,
properties: %{
error: %OpenApiSpex.Schema{type: :string}
},
required: ["error"],
example: %{
"error" => "Must provide exactly one of map_id or slug as a query parameter"
}
}},
not_found: {"Error", "application/json", %OpenApiSpex.Schema{
type: :object,
properties: %{
error: %OpenApiSpex.Schema{type: :string}
},
required: ["error"],
example: %{
"error" => "Map not found. Please provide a valid map_id or slug as a query parameter."
}
}}
]
def list_all_connections(%{assigns: %{map_id: map_id}} = conn, _params) do
connections = Operations.list_connections(map_id)
data = Enum.map(connections, &APIUtils.connection_to_json/1)
@@ -290,10 +290,10 @@ defmodule WandererAppWeb.MapSystemAPIController do
parameters: [
map_identifier: [
in: :path,
description: "Map identifier (UUID or slug). Provide either a UUID or a slug.",
description: "Map identifier (UUID or slug)",
type: :string,
required: true,
example: "my-map-slug or map UUID"
example: "map-slug or map UUID"
]
],
responses: [
@@ -314,12 +314,17 @@ defmodule WandererAppWeb.MapSystemAPIController do
parameters: [
map_identifier: [
in: :path,
description: "Map identifier (UUID or slug). Provide either a UUID or a slug.",
description: "Map identifier (UUID or slug)",
type: :string,
required: true,
example: "my-map-slug or map UUID"
example: "map-slug or map UUID"
],
id: [in: :path, type: :string, required: true]
id: [
in: :path,
description: "System ID",
type: :string,
required: true
]
],
responses: ResponseSchemas.standard_responses(@detail_response_schema)
def show(%{assigns: %{map_id: map_id}} = conn, %{"id" => id}) do
@@ -334,10 +339,10 @@ defmodule WandererAppWeb.MapSystemAPIController do
parameters: [
map_identifier: [
in: :path,
description: "Map identifier (UUID or slug). Provide either a UUID or a slug.",
description: "Map identifier (UUID or slug)",
type: :string,
required: true,
example: "my-map-slug or map UUID"
example: "map-slug or map UUID"
]
],
request_body: {"Systems+Connections upsert", "application/json", @batch_request_schema},
@@ -358,12 +363,17 @@ defmodule WandererAppWeb.MapSystemAPIController do
parameters: [
map_identifier: [
in: :path,
description: "Map identifier (UUID or slug). Provide either a UUID or a slug.",
description: "Map identifier (UUID or slug)",
type: :string,
required: true,
example: "my-map-slug or map UUID"
example: "map-slug or map UUID"
],
id: [in: :path, type: :string, required: true]
id: [
in: :path,
description: "System ID",
type: :string,
required: true
]
],
request_body: {"System update request", "application/json", @system_update_schema},
responses: ResponseSchemas.update_responses(@detail_response_schema)
@@ -381,10 +391,10 @@ defmodule WandererAppWeb.MapSystemAPIController do
parameters: [
map_identifier: [
in: :path,
description: "Map identifier (UUID or slug). Provide either a UUID or a slug.",
description: "Map identifier (UUID or slug)",
type: :string,
required: true,
example: "my-map-slug or map UUID"
example: "map-slug or map UUID"
]
],
request_body: {"Batch delete", "application/json", @batch_delete_schema},
@@ -428,12 +438,17 @@ defmodule WandererAppWeb.MapSystemAPIController do
parameters: [
map_identifier: [
in: :path,
description: "Map identifier (UUID or slug). Provide either a UUID or a slug.",
description: "Map identifier (UUID or slug)",
type: :string,
required: true,
example: "my-map-slug or map UUID"
example: "map-slug or map UUID"
],
id: [in: :path, type: :string, required: true]
id: [
in: :path,
description: "System ID",
type: :string,
required: true
]
],
responses: ResponseSchemas.standard_responses(@delete_response_schema)
def delete_single(conn, %{"id" => id}) do
@@ -462,7 +477,20 @@ defmodule WandererAppWeb.MapSystemAPIController do
summary: "List Map Systems (Legacy)",
deprecated: true,
description: "Deprecated, use GET /api/maps/:map_identifier/systems instead",
parameters: [map_id: [in: :query]],
parameters: [
map_id: [
in: :query,
description: "Map identifier (UUID) - Either map_id or slug must be provided, but not both",
type: :string,
required: false,
],
slug: [
in: :query,
description: "Map slug - Either map_id or slug must be provided, but not both",
type: :string,
required: false,
]
],
responses: ResponseSchemas.standard_responses(@list_response_schema)
defdelegate list_systems(conn, params), to: __MODULE__, as: :index
@@ -470,7 +498,26 @@ defmodule WandererAppWeb.MapSystemAPIController do
summary: "Show Map System (Legacy)",
deprecated: true,
description: "Deprecated, use GET /api/maps/:map_identifier/systems/:id instead",
parameters: [map_id: [in: :query], id: [in: :query]],
parameters: [
map_id: [
in: :query,
description: "Map identifier (UUID) - Either map_id or slug must be provided, but not both",
type: :string,
required: false,
],
slug: [
in: :query,
description: "Map slug - Either map_id or slug must be provided, but not both",
type: :string,
required: false,
],
id: [
in: :query,
description: "System ID",
type: :string,
required: true
]
],
responses: ResponseSchemas.standard_responses(@detail_response_schema)
defdelegate show_system(conn, params), to: __MODULE__, as: :show
@@ -8,7 +8,6 @@ defmodule WandererAppWeb.MapSystemStructureAPIController do
@moduledoc """
API controller for managing map system structures.
Includes legacy structure-timers endpoint (deprecated).
"""
# Inlined OpenAPI schema for a map system structure
@@ -174,16 +173,25 @@ defmodule WandererAppWeb.MapSystemStructureAPIController do
end
@doc """
@deprecated "Use /structures instead. This endpoint will be removed in a future release."
Legacy: Get structure timers for a map.
Get structure timers for a map.
"""
operation :structure_timers,
summary: "Get structure timers for a map (Legacy)",
deprecated: true,
summary: "Get structure timers for a map",
parameters: [
map_identifier: [in: :path, description: "Map identifier (UUID or slug)", type: :string, required: true]
],
responses: [ok: {"Structure timers", "application/json", %Schema{type: :array, items: %Schema{type: :object}}}]
responses: [ok: {"Structure timers", "application/json", %Schema{
type: :object,
properties: %{
data: %Schema{
type: :array,
items: @structure_schema
}
},
example: %{
data: [@structure_schema.example]
}
}}]
def structure_timers(conn, _params) do
map_id = conn.assigns.map_id
structures = MapOperations.list_structures(map_id)
@@ -273,6 +273,39 @@ defmodule WandererAppWeb.MapSignaturesEventHandler do
end
end
def handle_ui_event(
"undo_delete_signatures",
%{"system_id" => solar_system_id, "eve_ids" => eve_ids} = payload,
%{
assigns: %{
map_id: map_id,
main_character_id: main_character_id,
user_permissions: %{update_system: true}
}
} = socket
)
when not is_nil(main_character_id) do
case WandererApp.Api.MapSystem.read_by_map_and_solar_system(%{
map_id: map_id,
solar_system_id: get_integer(solar_system_id)
}) do
{:ok, system} ->
restored =
WandererApp.Api.MapSystemSignature.by_system_id_all!(system.id)
|> Enum.filter(fn s -> s.eve_id in eve_ids end)
|> Enum.map(fn s ->
s |> WandererApp.Api.MapSystemSignature.update!(%{deleted: false})
end)
Phoenix.PubSub.broadcast!(WandererApp.PubSub, map_id, %{
event: :signatures_updated,
payload: system.solar_system_id
})
{:noreply, socket}
_ ->
{:noreply, socket}
end
end
def handle_ui_event(event, body, socket),
do: MapCoreEventHandler.handle_ui_event(event, body, socket)
@@ -121,7 +121,8 @@ defmodule WandererAppWeb.MapEventHandler do
"update_signatures",
"get_signatures",
"link_signature_to_system",
"unlink_signature"
"unlink_signature",
"undo_delete_signatures"
]
@map_structures_events [
+1 -1
View File
@@ -212,7 +212,7 @@ defmodule WandererAppWeb.Router do
get "/system", MapSystemAPIController, :show_system
get "/connections", MapConnectionAPIController, :list_all_connections
get "/characters", MapAPIController, :list_tracked_characters
get "/structure-timers", MapSystemStructureAPIController, :structure_timers
get "/structure-timers", MapAPIController, :show_structure_timers
get "/character-activity", MapAPIController, :character_activity
get "/user_characters", MapAPIController, :user_characters
@@ -0,0 +1,70 @@
%{
title: "Instant Signature Deletion & Undo: A New Flow for Map Signatures",
author: "Wanderer Team",
cover_image_uri: "/images/news/05-08-undo/undo.png",
tags: ~w(signatures deletion undo map realtime guide),
description: "Learn about the new instant signature deletion flow, real-time updates, and the ability to undo removals in Wanderer maps."
}
---
### Introduction
Managing cosmic signatures is a core part of mapping and navigation in EVE Online. With our latest update, signature deletion is now **instant, real-time, and reversible**—making it easier than ever to keep your map up to date and error-free.
This guide covers the new signature deletion flow, how to use the undo feature, and what happens behind the scenes to keep your map clean and synchronized for all users.
---
### 1. The New User Flow: Instant, Real-Time, Reversible
- **Delete a signature:** When you remove a signature, it disappears from your map (and all other users' maps) instantly after a server roundtrip.
- **Undo:** If you make a mistake, you have a window of up to 30s to undo the deletion
---
### 2. How to Use the New Signature Deletion Flow
1. **Select and Delete:**
- Open the system signatures widget.
- Select one or more signatures and click delete (or paste and use lazy delete).
- The signatures will disappear for all users viewing the same system.
2. **Undo a Deletion:**
- After deleting, an **Undo** button appears for you (the user who deleted the signature) and remains visible based on your timeout settings.
- Click **Undo** to restore the removed signatures instantly for all users.
- If you don't click Undo in time, the deletion becomes permanent
3. **Real-Time Updates:**
- All users see signature changes (add, update, remove, undo) in real time—no need to refresh.
---
### 4. FAQ & Troubleshooting
**Q: Who sees the Undo button?**
- Only the user who deleted the signature sees the Undo button
**Q: Do all users see the same signature list in real time?**
- Yes! All changes are broadcast instantly to everyone viewing the same map.
**Q: Can I configure the undo timeout?**
- Yes, in the user inteface settings for the signatures widget
**Q: What about performance?**
- The new flow is optimized for real-time collaboration and efficient cleanup, ensuring your map stays fast and accurate.
---
### 5. Summary
The new signature deletion flow brings instant, real-time updates and a safety net for accidental removals. Enjoy a more collaborative, error-resistant mapping experience—now live for all Wanderer users!
---
Fly safe,
**The Wanderer Team**
---
@@ -0,0 +1,23 @@
defmodule WandererApp.Repo.Migrations.AddDeletedSignature do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
alter table(:map_system_signatures_v1) do
remove :updated
add :deleted, :boolean, null: false, default: false
end
end
def down do
alter table(:map_system_signatures_v1) do
remove :deleted
add :updated, :bigint
end
end
end
@@ -0,0 +1,197 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"primary_key?": true,
"references": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "eve_id",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "character_eve_id",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "name",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "description",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "type",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "linked_system_id",
"type": "bigint"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "kind",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "group",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "custom_info",
"type": "text"
},
{
"allow_nil?": false,
"default": "false",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "deleted",
"type": "boolean"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "inserted_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "updated_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "map_system_signatures_v1_system_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "map_system_v1"
},
"size": null,
"source": "system_id",
"type": "uuid"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "63D26445C9E67459C4D41CF31D61C3EE2356BE664F0D44AB5BC04C2100B701F3",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "map_system_signatures_v1_uniq_system_eve_id_index",
"keys": [
{
"type": "atom",
"value": "system_id"
},
{
"type": "atom",
"value": "eve_id"
}
],
"name": "uniq_system_eve_id",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.WandererApp.Repo",
"schema": null,
"table": "map_system_signatures_v1"
}