mirror of
https://github.com/wanderer-industries/wanderer
synced 2025-11-30 13:03:53 +00:00
Compare commits
45 Commits
v1.52.5
...
audit-pagi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cee545cfd9 | ||
|
|
9612cda72b | ||
|
|
d55f03b63c | ||
|
|
aa4fd2fe90 | ||
|
|
6abf628a38 | ||
|
|
ad46002c85 | ||
|
|
2f21bd0f44 | ||
|
|
993608f911 | ||
|
|
c6c6adb7d8 | ||
|
|
3937330ce4 | ||
|
|
1590c848c9 | ||
|
|
2bb45b312c | ||
|
|
1fc95c96eb | ||
|
|
ee7a453a72 | ||
|
|
4b79afbac0 | ||
|
|
c8fc31257b | ||
|
|
8e0b8fd7f9 | ||
|
|
ee8f9e4d24 | ||
|
|
994e03945d | ||
|
|
aff00a18b5 | ||
|
|
6c22e6554d | ||
|
|
2a0d7654e7 | ||
|
|
4eb1f641ae | ||
|
|
2da5a243ec | ||
|
|
5ac8ccbe5c | ||
|
|
0568533550 | ||
|
|
178abc2af2 | ||
|
|
adb2a5f459 | ||
|
|
ada1571e1e | ||
|
|
5931c00ff3 | ||
|
|
a6e7c1bf74 | ||
|
|
1a5374f2f6 | ||
|
|
c9e3683b8e | ||
|
|
aba93b342a | ||
|
|
dee78b77a9 | ||
|
|
d21705f355 | ||
|
|
9abcd4bd0b | ||
|
|
b052943e34 | ||
|
|
e1e9b4c2e8 | ||
|
|
42c30e0741 | ||
|
|
3b45e77e65 | ||
|
|
dcb2b6b912 | ||
|
|
638a4e2535 | ||
|
|
489fde16d1 | ||
|
|
35e1c363e5 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -26,6 +26,7 @@
|
||||
|
||||
.env
|
||||
*.local.env
|
||||
test/manual/.auto*
|
||||
|
||||
.direnv/
|
||||
.cache/
|
||||
|
||||
96
CHANGELOG.md
96
CHANGELOG.md
@@ -2,6 +2,102 @@
|
||||
|
||||
<!-- changelog -->
|
||||
|
||||
## [v1.54.1](https://github.com/wanderer-industries/wanderer/compare/v1.54.0...v1.54.1) (2025-03-06)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* fix scroll and size issues with kills widget (#219)
|
||||
|
||||
* fix scroll and size issues with kills widget
|
||||
|
||||
## [v1.54.0](https://github.com/wanderer-industries/wanderer/compare/v1.53.4...v1.54.0) (2025-03-05)
|
||||
|
||||
|
||||
|
||||
|
||||
### Features:
|
||||
|
||||
* added auto-refresh timeout for cloud new version updates
|
||||
|
||||
* add selectable sig deletion timing, and color options (#208)
|
||||
|
||||
## [v1.53.4](https://github.com/wanderer-industries/wanderer/compare/v1.53.3...v1.53.4) (2025-03-04)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* add retry on kills retrieval (#207)
|
||||
|
||||
* add missing masses to wh sizes const (#215)
|
||||
|
||||
## [v1.53.3](https://github.com/wanderer-industries/wanderer/compare/v1.53.2...v1.53.3) (2025-02-27)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* Map: little bit up performance for windows manager
|
||||
|
||||
## [v1.53.2](https://github.com/wanderer-industries/wanderer/compare/v1.53.1...v1.53.2) (2025-02-27)
|
||||
|
||||
|
||||
|
||||
|
||||
## [v1.53.1](https://github.com/wanderer-industries/wanderer/compare/v1.53.0...v1.53.1) (2025-02-26)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* Core: Fixed map ACLs add/remove behaviour
|
||||
|
||||
## [v1.53.0](https://github.com/wanderer-industries/wanderer/compare/v1.52.8...v1.53.0) (2025-02-26)
|
||||
|
||||
|
||||
|
||||
|
||||
### Features:
|
||||
|
||||
* Auto-set connection EOL status and ship size when linking/editing signatures (#194)
|
||||
|
||||
* Automatically set connection EOL status and ship size type when linking/updating signatures
|
||||
|
||||
## [v1.52.8](https://github.com/wanderer-industries/wanderer/compare/v1.52.7...v1.52.8) (2025-02-26)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* Map: Added delete systems hotkey
|
||||
|
||||
## [v1.52.7](https://github.com/wanderer-industries/wanderer/compare/v1.52.6...v1.52.7) (2025-02-24)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* update news image link (#204)
|
||||
|
||||
* Map: Block map events for old client versions
|
||||
|
||||
## [v1.52.6](https://github.com/wanderer-industries/wanderer/compare/v1.52.5...v1.52.6) (2025-02-23)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* Map: Fixed delete systems on map changes
|
||||
|
||||
## [v1.52.5](https://github.com/wanderer-industries/wanderer/compare/v1.52.4...v1.52.5) (2025-02-22)
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { ForwardedRef, forwardRef, MouseEvent, useCallback, useEffect, useMemo }
|
||||
import ReactFlow, {
|
||||
Background,
|
||||
Edge,
|
||||
EdgeChange,
|
||||
MiniMap,
|
||||
Node,
|
||||
NodeChange,
|
||||
@@ -79,11 +78,12 @@ const edgeTypes = {
|
||||
floating: SolarSystemEdge,
|
||||
};
|
||||
|
||||
export const MAP_ROOT_ID = 'MAP_ROOT_ID';
|
||||
|
||||
interface MapCompProps {
|
||||
refn: ForwardedRef<MapHandlers>;
|
||||
onCommand: OutCommandHandler;
|
||||
onSelectionChange: OnMapSelectionChange;
|
||||
onManualDelete(systems: string[]): void;
|
||||
onConnectionInfoClick?(e: SolarSystemConnection): void;
|
||||
onAddSystem?: OnMapAddSystemCallback;
|
||||
onSelectionContextMenu?: NodeSelectionMouseHandler;
|
||||
@@ -105,7 +105,6 @@ const MapComp = ({
|
||||
onSystemContextMenu,
|
||||
onConnectionInfoClick,
|
||||
onSelectionContextMenu,
|
||||
onManualDelete,
|
||||
isShowMinimap,
|
||||
showKSpaceBG,
|
||||
isThickConnections,
|
||||
@@ -114,7 +113,7 @@ const MapComp = ({
|
||||
theme,
|
||||
onAddSystem,
|
||||
}: MapCompProps) => {
|
||||
const { getNode, getNodes } = useReactFlow();
|
||||
const { getNodes } = useReactFlow();
|
||||
const [nodes, , onNodesChange] = useNodesState<Node<SolarSystemRawType>>(initialNodes);
|
||||
const [edges, , onEdgesChange] = useEdgesState<Edge<SolarSystemConnection>>(initialEdges);
|
||||
|
||||
@@ -187,8 +186,6 @@ const MapComp = ({
|
||||
|
||||
const handleNodesChange = useCallback(
|
||||
(changes: NodeChange[]) => {
|
||||
const systemsIdsToRemove: string[] = [];
|
||||
|
||||
// prevents single node deselection on background / same node click
|
||||
// allows deseletion of all nodes if multiple are currently selected
|
||||
if (changes.length === 1 && changes[0].type == 'select' && changes[0].selected === false) {
|
||||
@@ -196,30 +193,12 @@ const MapComp = ({
|
||||
}
|
||||
|
||||
const nextChanges = changes.reduce((acc, change) => {
|
||||
if (change.type !== 'remove') {
|
||||
return [...acc, change];
|
||||
}
|
||||
|
||||
const node = getNode(change.id);
|
||||
if (!node) {
|
||||
return [...acc, change];
|
||||
}
|
||||
|
||||
if (node.data.locked) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
systemsIdsToRemove.push(node.data.id);
|
||||
return [...acc, change];
|
||||
}, [] as NodeChange[]);
|
||||
|
||||
if (systemsIdsToRemove.length > 0) {
|
||||
onManualDelete(systemsIdsToRemove);
|
||||
}
|
||||
|
||||
onNodesChange(nextChanges);
|
||||
},
|
||||
[getNode, getNodes, onManualDelete, onNodesChange],
|
||||
[getNodes, onNodesChange],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -232,7 +211,10 @@ const MapComp = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={clsx(classes.MapRoot, { [classes.BackgroundAlternateColor]: isSoftBackground })}>
|
||||
<div
|
||||
data-window-id={MAP_ROOT_ID}
|
||||
className={clsx(classes.MapRoot, { [classes.BackgroundAlternateColor]: isSoftBackground })}
|
||||
>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
@@ -276,7 +258,7 @@ const MapComp = ({
|
||||
minZoom={0.2}
|
||||
maxZoom={1.5}
|
||||
elevateNodesOnSelect
|
||||
deleteKeyCode={['Delete']}
|
||||
deleteKeyCode={['']}
|
||||
{...(isPanAndDrag
|
||||
? {
|
||||
selectionOnDrag: true,
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { SystemKillsContent } from '../../../mapInterface/widgets/SystemKills/SystemKillsContent/SystemKillsContent';
|
||||
import { useKillsCounter } from '../../hooks/useKillsCounter';
|
||||
import { WdTooltipWrapper } from '@/hooks/Mapper/components/ui-kit/WdTooltipWrapper';
|
||||
import { WithChildren, WithClassName } from '@/hooks/Mapper/types/common';
|
||||
|
||||
const ITEM_HEIGHT = 35;
|
||||
const MIN_TOOLTIP_HEIGHT = 40;
|
||||
|
||||
type TooltipSize = 'xs' | 'sm' | 'md' | 'lg';
|
||||
|
||||
type KillsBookmarkTooltipProps = {
|
||||
@@ -15,19 +19,41 @@ type KillsBookmarkTooltipProps = {
|
||||
WithClassName;
|
||||
|
||||
export const KillsCounter = ({ killsCount, systemId, className, children, size = 'xs' }: KillsBookmarkTooltipProps) => {
|
||||
const { isLoading, kills: detailedKills, systemNameMap } = useKillsCounter({ realSystemId: systemId });
|
||||
const {
|
||||
isLoading,
|
||||
kills: detailedKills,
|
||||
systemNameMap,
|
||||
} = useKillsCounter({
|
||||
realSystemId: systemId,
|
||||
});
|
||||
|
||||
if (!killsCount || detailedKills.length === 0 || !systemId || isLoading) return null;
|
||||
const limitedKills = useMemo(() => {
|
||||
if (!detailedKills || detailedKills.length === 0) return [];
|
||||
return detailedKills.slice(0, killsCount);
|
||||
}, [detailedKills, killsCount]);
|
||||
|
||||
if (!killsCount || limitedKills.length === 0 || !systemId || isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate height based on number of kills, but ensure a minimum height
|
||||
const killsNeededHeight = limitedKills.length * ITEM_HEIGHT;
|
||||
// Add a small buffer (10px) to prevent scrollbar from appearing unnecessarily
|
||||
const tooltipHeight = Math.max(MIN_TOOLTIP_HEIGHT, Math.min(killsNeededHeight + 10, 500));
|
||||
|
||||
const tooltipContent = (
|
||||
<div style={{ width: '100%', minWidth: '300px', overflow: 'hidden' }}>
|
||||
<SystemKillsContent
|
||||
kills={detailedKills}
|
||||
systemNameMap={systemNameMap}
|
||||
onlyOneSystem={true}
|
||||
autoSize={true}
|
||||
limit={killsCount}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
width: '400px',
|
||||
height: `${tooltipHeight}px`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="flex-1 h-full">
|
||||
<SystemKillsContent kills={limitedKills} systemNameMap={systemNameMap} onlyOneSystem />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Handle, NodeProps, Position } from 'reactflow';
|
||||
import clsx from 'clsx';
|
||||
import classes from './SolarSystemNodeDefault.module.scss';
|
||||
import { PrimeIcons } from 'primereact/api';
|
||||
import { useLocalCounter, useSolarSystemNode, useNodeKillsCount } from '../../hooks/useSolarSystemLogic';
|
||||
import { useLocalCounter, useSolarSystemNode, useNodeKillsCount } from '../../hooks';
|
||||
import {
|
||||
EFFECT_BACKGROUND_STYLES,
|
||||
MARKER_BOOKMARK_BG_STYLES,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Handle, NodeProps, Position } from 'reactflow';
|
||||
import clsx from 'clsx';
|
||||
import classes from './SolarSystemNodeTheme.module.scss';
|
||||
import { PrimeIcons } from 'primereact/api';
|
||||
import { useLocalCounter, useNodeKillsCount, useSolarSystemNode } from '../../hooks/useSolarSystemLogic';
|
||||
import { useLocalCounter, useNodeKillsCount, useSolarSystemNode } from '../../hooks';
|
||||
import {
|
||||
EFFECT_BACKGROUND_STYLES,
|
||||
MARKER_BOOKMARK_BG_STYLES,
|
||||
|
||||
@@ -322,6 +322,9 @@ export const WORMHOLES_ADDITIONAL_INFO: Record<string, WormholesAdditionalInfoTy
|
||||
export const WORMHOLES_ADDITIONAL_INFO_BY_CLASS_ID: Record<string, WormholesAdditionalInfoType> =
|
||||
WORMHOLES_ADDITIONAL_INFO_RAW.reduce((acc, x) => ({ ...acc, [x.wormholeClassID]: x }), {});
|
||||
|
||||
export const WORMHOLES_ADDITIONAL_INFO_BY_SHORT_NAME: Record<string, WormholesAdditionalInfoType> =
|
||||
WORMHOLES_ADDITIONAL_INFO_RAW.reduce((acc, x) => ({ ...acc, [x.shortName]: x }), {});
|
||||
|
||||
// export const SOLAR_SYSTEM_CLASS_NAMES = {
|
||||
// ccp1 = ,
|
||||
// c1 = ,
|
||||
@@ -650,6 +653,7 @@ export enum LABELS {
|
||||
l3 = '3',
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const LABELS_INFO: Record<string, any> = {
|
||||
[LABELS.clear]: { id: 'clear', name: 'Clear', shortName: '', icon: '' },
|
||||
[LABELS.la]: { id: 'la', name: 'Label A', shortName: 'A', icon: '' },
|
||||
@@ -750,6 +754,17 @@ export const SHIP_SIZES_SIZE = {
|
||||
[ShipSizeStatus.capital]: '2M',
|
||||
};
|
||||
|
||||
export const SHIP_MASSES_SIZE: Record<number, ShipSizeStatus> = {
|
||||
5_000_000: ShipSizeStatus.small,
|
||||
62_000_000: ShipSizeStatus.medium,
|
||||
300_000_000: ShipSizeStatus.large,
|
||||
375_000_000: ShipSizeStatus.large,
|
||||
1_000_000_000: ShipSizeStatus.freight,
|
||||
1_350_000_000: ShipSizeStatus.capital,
|
||||
1_800_000_000: ShipSizeStatus.capital,
|
||||
2_000_000_000: ShipSizeStatus.capital,
|
||||
};
|
||||
|
||||
export const SHIP_SIZES_DESCRIPTION = {
|
||||
[ShipSizeStatus.small]: 'Frigate wormhole - up to Destroyer | 5K t.',
|
||||
[ShipSizeStatus.medium]: 'Cruise wormhole - up to Battlecruiser | 62K t.',
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
export * from './useMapHandlers';
|
||||
export * from './useUpdateNodes';
|
||||
export * from './useNodesEdgesState';
|
||||
export * from './useBackgroundVars';
|
||||
export * from './useKillsCounter';
|
||||
export * from './useSystemName';
|
||||
export * from './useNodesEdgesState';
|
||||
export * from './useSolarSystemNode';
|
||||
export * from './useUnsplashedSignatures';
|
||||
export * from './useUpdateNodes';
|
||||
export * from './useNodeKillsCount';
|
||||
|
||||
@@ -27,13 +27,12 @@ export function useKillsCounter({ realSystemId }: UseKillsCounterProps) {
|
||||
const filteredKills = useMemo(() => {
|
||||
if (!allKills || allKills.length === 0) return [];
|
||||
|
||||
return [...allKills]
|
||||
.sort((a, b) => {
|
||||
const aTime = a.kill_time ? new Date(a.kill_time).getTime() : 0;
|
||||
const bTime = b.kill_time ? new Date(b.kill_time).getTime() : 0;
|
||||
return bTime - aTime;
|
||||
})
|
||||
.slice(0, 10);
|
||||
// Sort kills by time, most recent first, but don't limit the number of kills
|
||||
return [...allKills].sort((a, b) => {
|
||||
const aTime = a.kill_time ? new Date(a.kill_time).getTime() : 0;
|
||||
const bTime = b.kill_time ? new Date(b.kill_time).getTime() : 0;
|
||||
return bTime - aTime;
|
||||
});
|
||||
}, [allKills]);
|
||||
|
||||
return {
|
||||
|
||||
31
assets/js/hooks/Mapper/components/map/hooks/useLabelsInfo.ts
Normal file
31
assets/js/hooks/Mapper/components/map/hooks/useLabelsInfo.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useMemo } from 'react';
|
||||
import { LabelsManager } from '@/hooks/Mapper/utils/labelsManager';
|
||||
import { LABELS_INFO, LABELS_ORDER } from '@/hooks/Mapper/components/map/constants';
|
||||
interface UseLabelsInfoParams {
|
||||
labels: string | null;
|
||||
linkedSigPrefix: string | null;
|
||||
isShowLinkedSigId: boolean;
|
||||
}
|
||||
|
||||
export type LabelInfo = {
|
||||
id: string;
|
||||
shortName: string;
|
||||
};
|
||||
|
||||
function sortedLabels(labels: string[]): LabelInfo[] {
|
||||
if (!labels) return [];
|
||||
return LABELS_ORDER.filter(x => labels.includes(x)).map(x => LABELS_INFO[x] as LabelInfo);
|
||||
}
|
||||
|
||||
export function useLabelsInfo({ labels, linkedSigPrefix, isShowLinkedSigId }: UseLabelsInfoParams) {
|
||||
const labelsManager = useMemo(() => new LabelsManager(labels ?? ''), [labels]);
|
||||
const labelsInfo = useMemo(() => sortedLabels(labelsManager.list), [labelsManager]);
|
||||
const labelCustom = useMemo(() => {
|
||||
if (isShowLinkedSigId && linkedSigPrefix) {
|
||||
return labelsManager.customLabel ? `${linkedSigPrefix}・${labelsManager.customLabel}` : linkedSigPrefix;
|
||||
}
|
||||
return labelsManager.customLabel;
|
||||
}, [linkedSigPrefix, isShowLinkedSigId, labelsManager]);
|
||||
|
||||
return { labelsInfo, labelCustom };
|
||||
}
|
||||
@@ -131,6 +131,21 @@ export const useMapHandlers = (ref: ForwardedRef<MapHandlers>, onSelectionChange
|
||||
// do nothing here
|
||||
break;
|
||||
|
||||
case Commands.characterActivityData:
|
||||
break;
|
||||
|
||||
case Commands.trackingCharactersData:
|
||||
break;
|
||||
|
||||
case Commands.updateActivity:
|
||||
break;
|
||||
|
||||
case Commands.updateTracking:
|
||||
break;
|
||||
|
||||
case Commands.userSettingsUpdated:
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(`Map handlers: Unknown command: ${type}`, data);
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useMapEventListener } from '@/hooks/Mapper/events';
|
||||
import { Commands } from '@/hooks/Mapper/types';
|
||||
|
||||
interface Kill {
|
||||
solar_system_id: number | string;
|
||||
kills: number;
|
||||
}
|
||||
|
||||
interface MapEvent {
|
||||
name: Commands;
|
||||
data?: any;
|
||||
payload?: Kill[];
|
||||
}
|
||||
|
||||
export function useNodeKillsCount(
|
||||
systemId: number | string,
|
||||
initialKillsCount: number | null
|
||||
): number | null {
|
||||
const [killsCount, setKillsCount] = useState<number | null>(initialKillsCount);
|
||||
|
||||
useEffect(() => {
|
||||
setKillsCount(initialKillsCount);
|
||||
}, [initialKillsCount]);
|
||||
|
||||
const handleEvent = useCallback((event: MapEvent): boolean => {
|
||||
if (event.name === Commands.killsUpdated && Array.isArray(event.payload)) {
|
||||
const killForSystem = event.payload.find(
|
||||
kill => kill.solar_system_id.toString() === systemId.toString()
|
||||
);
|
||||
if (killForSystem && typeof killForSystem.kills === 'number') {
|
||||
setKillsCount(killForSystem.kills);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, [systemId]);
|
||||
|
||||
useMapEventListener(handleEvent);
|
||||
|
||||
return killsCount;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { MapSolarSystemType } from '../map.types';
|
||||
import { NodeProps } from 'reactflow';
|
||||
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
|
||||
@@ -7,19 +7,12 @@ import { useMapState } from '@/hooks/Mapper/components/map/MapProvider';
|
||||
import { useDoubleClick } from '@/hooks/Mapper/hooks/useDoubleClick';
|
||||
import { REGIONS_MAP, Spaces } from '@/hooks/Mapper/constants';
|
||||
import { isWormholeSpace } from '@/hooks/Mapper/components/map/helpers/isWormholeSpace';
|
||||
import { getSystemClassStyles, prepareUnsplashedChunks } from '@/hooks/Mapper/components/map/helpers';
|
||||
import { getSystemClassStyles } from '@/hooks/Mapper/components/map/helpers';
|
||||
import { sortWHClasses } from '@/hooks/Mapper/helpers';
|
||||
import { LabelsManager } from '@/hooks/Mapper/utils/labelsManager';
|
||||
import { CharacterTypeRaw, Commands, OutCommand, SystemSignature } from '@/hooks/Mapper/types';
|
||||
import { LABELS_INFO, LABELS_ORDER } from '@/hooks/Mapper/components/map/constants';
|
||||
import { useMapEventListener } from '@/hooks/Mapper/events';
|
||||
|
||||
export type LabelInfo = {
|
||||
id: string;
|
||||
shortName: string;
|
||||
};
|
||||
|
||||
export type UnsplashedSignatureType = SystemSignature & { sig_id: string };
|
||||
import { CharacterTypeRaw, OutCommand, SystemSignature } from '@/hooks/Mapper/types';
|
||||
import { useUnsplashedSignatures } from './useUnsplashedSignatures';
|
||||
import { useSystemName } from './useSystemName';
|
||||
import { LabelInfo, useLabelsInfo } from './useLabelsInfo';
|
||||
|
||||
function getActivityType(count: number): string {
|
||||
if (count <= 5) return 'activityNormal';
|
||||
@@ -34,11 +27,6 @@ const SpaceToClass: Record<string, string> = {
|
||||
[Spaces.Gallente]: 'Gallente',
|
||||
};
|
||||
|
||||
function sortedLabels(labels: string[]): LabelInfo[] {
|
||||
if (!labels) return [];
|
||||
return LABELS_ORDER.filter(x => labels.includes(x)).map(x => LABELS_INFO[x] as LabelInfo);
|
||||
}
|
||||
|
||||
export function useLocalCounter(nodeVars: SolarSystemNodeVars) {
|
||||
const localCounterCharacters = useMemo(() => {
|
||||
return nodeVars.charactersInSystem
|
||||
@@ -127,21 +115,19 @@ export function useSolarSystemNode(props: NodeProps<MapSolarSystemType>): SolarS
|
||||
|
||||
const linkedSigPrefix = useMemo(() => (linkedSigEveId ? linkedSigEveId.split('-')[0] : null), [linkedSigEveId]);
|
||||
|
||||
const labelsManager = useMemo(() => new LabelsManager(labels ?? ''), [labels]);
|
||||
const labelsInfo = useMemo(() => sortedLabels(labelsManager.list), [labelsManager]);
|
||||
const labelCustom = useMemo(() => {
|
||||
if (isShowLinkedSigId && linkedSigPrefix) {
|
||||
return labelsManager.customLabel ? `${linkedSigPrefix}・${labelsManager.customLabel}` : linkedSigPrefix;
|
||||
}
|
||||
return labelsManager.customLabel;
|
||||
}, [linkedSigPrefix, isShowLinkedSigId, labelsManager]);
|
||||
const { labelsInfo, labelCustom } = useLabelsInfo({
|
||||
labels,
|
||||
linkedSigPrefix,
|
||||
isShowLinkedSigId,
|
||||
});
|
||||
|
||||
const killsCount = useMemo(() => kills[solar_system_id] ?? null, [kills, solar_system_id]);
|
||||
const killsActivityType = killsCount ? getActivityType(killsCount) : null;
|
||||
|
||||
const hasUserCharacters = useMemo(() => {
|
||||
return charactersInSystem.some(x => userCharacters.includes(x.eve_id));
|
||||
}, [charactersInSystem, userCharacters]);
|
||||
const hasUserCharacters = useMemo(
|
||||
() => charactersInSystem.some(x => userCharacters.includes(x.eve_id)),
|
||||
[charactersInSystem, userCharacters],
|
||||
);
|
||||
|
||||
const dbClick = useDoubleClick(() => {
|
||||
outCommand({
|
||||
@@ -153,54 +139,19 @@ export function useSolarSystemNode(props: NodeProps<MapSolarSystemType>): SolarS
|
||||
const showHandlers = isConnecting || hoverNodeId === id;
|
||||
|
||||
const space = showKSpaceBG ? REGIONS_MAP[region_id] : '';
|
||||
const regionClass = showKSpaceBG ? SpaceToClass[space] : null;
|
||||
const regionClass = showKSpaceBG ? SpaceToClass[space] || null : null;
|
||||
|
||||
const computedTemporaryName = useMemo(() => {
|
||||
if (!isTempSystemNameEnabled) {
|
||||
return '';
|
||||
}
|
||||
if (isShowLinkedSigIdTempName && linkedSigPrefix) {
|
||||
return temporary_name ? `${linkedSigPrefix}・${temporary_name}` : `${linkedSigPrefix}・${solar_system_name}`;
|
||||
}
|
||||
return temporary_name;
|
||||
}, [isShowLinkedSigIdTempName, isTempSystemNameEnabled, linkedSigPrefix, solar_system_name, temporary_name]);
|
||||
const { systemName, computedTemporaryName, customName } = useSystemName({
|
||||
isTempSystemNameEnabled,
|
||||
temporary_name,
|
||||
solar_system_name: solar_system_name || '',
|
||||
isShowLinkedSigIdTempName,
|
||||
linkedSigPrefix,
|
||||
name,
|
||||
});
|
||||
|
||||
const systemName = useMemo(() => {
|
||||
if (isTempSystemNameEnabled && computedTemporaryName) {
|
||||
return computedTemporaryName;
|
||||
}
|
||||
return solar_system_name;
|
||||
}, [isTempSystemNameEnabled, solar_system_name, computedTemporaryName]);
|
||||
const { unsplashedLeft, unsplashedRight } = useUnsplashedSignatures(systemSigs, isShowUnsplashedSignatures);
|
||||
|
||||
const customName = useMemo(() => {
|
||||
if (isTempSystemNameEnabled && computedTemporaryName && name) {
|
||||
return name;
|
||||
}
|
||||
if (solar_system_name !== name && name) {
|
||||
return name;
|
||||
}
|
||||
return null;
|
||||
}, [isTempSystemNameEnabled, computedTemporaryName, name, solar_system_name]);
|
||||
|
||||
const [unsplashedLeft, unsplashedRight] = useMemo(() => {
|
||||
if (!isShowUnsplashedSignatures) {
|
||||
return [[], []];
|
||||
}
|
||||
return prepareUnsplashedChunks(
|
||||
systemSigs
|
||||
.filter(s => s.group === 'Wormhole' && !s.linked_system)
|
||||
.map(s => ({
|
||||
eve_id: s.eve_id,
|
||||
type: s.type,
|
||||
custom_info: s.custom_info,
|
||||
kind: s.kind,
|
||||
name: s.name,
|
||||
group: s.group,
|
||||
})) as UnsplashedSignatureType[],
|
||||
);
|
||||
}, [isShowUnsplashedSignatures, systemSigs]);
|
||||
|
||||
// Ensure hubs are always strings.
|
||||
const hubsAsStrings = useMemo(() => hubs.map(item => item.toString()), [hubs]);
|
||||
|
||||
const nodeVars: SolarSystemNodeVars = {
|
||||
@@ -225,12 +176,10 @@ export function useSolarSystemNode(props: NodeProps<MapSolarSystemType>): SolarS
|
||||
dbClick,
|
||||
sortedStatics,
|
||||
effectName: effect_name,
|
||||
regionName: region_name,
|
||||
solarSystemId: solar_system_id.toString(),
|
||||
solarSystemName: solar_system_name,
|
||||
locked,
|
||||
hubs: hubsAsStrings,
|
||||
name: name,
|
||||
name,
|
||||
isConnecting,
|
||||
hoverNodeId,
|
||||
charactersInSystem,
|
||||
@@ -239,6 +188,8 @@ export function useSolarSystemNode(props: NodeProps<MapSolarSystemType>): SolarS
|
||||
isThickConnections,
|
||||
classTitle: class_title,
|
||||
temporaryName: computedTemporaryName,
|
||||
regionName: region_name,
|
||||
solarSystemName: solar_system_name,
|
||||
};
|
||||
|
||||
return nodeVars;
|
||||
@@ -281,25 +232,3 @@ export interface SolarSystemNodeVars {
|
||||
classTitle: string | null;
|
||||
temporaryName?: string | null;
|
||||
}
|
||||
|
||||
export function useNodeKillsCount(systemId: number | string, initialKillsCount: number | null): number | null {
|
||||
const [killsCount, setKillsCount] = useState<number | null>(initialKillsCount);
|
||||
|
||||
useEffect(() => {
|
||||
setKillsCount(initialKillsCount);
|
||||
}, [initialKillsCount]);
|
||||
|
||||
useMapEventListener(event => {
|
||||
if (event.name === Commands.killsUpdated && event.data?.toString() === systemId.toString()) {
|
||||
//@ts-ignore
|
||||
if (event.payload && typeof event.payload.kills === 'number') {
|
||||
// @ts-ignore
|
||||
setKillsCount(event.payload.kills);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return killsCount;
|
||||
}
|
||||
49
assets/js/hooks/Mapper/components/map/hooks/useSystemName.ts
Normal file
49
assets/js/hooks/Mapper/components/map/hooks/useSystemName.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// useSystemName.ts
|
||||
import { useMemo } from 'react';
|
||||
|
||||
interface UseSystemNameParams {
|
||||
isTempSystemNameEnabled: boolean;
|
||||
temporary_name?: string | null;
|
||||
solar_system_name: string;
|
||||
isShowLinkedSigIdTempName: boolean;
|
||||
linkedSigPrefix: string | null;
|
||||
name?: string | null;
|
||||
}
|
||||
|
||||
export function useSystemName({
|
||||
isTempSystemNameEnabled,
|
||||
temporary_name,
|
||||
solar_system_name,
|
||||
isShowLinkedSigIdTempName,
|
||||
linkedSigPrefix,
|
||||
name,
|
||||
}: UseSystemNameParams) {
|
||||
const computedTemporaryName = useMemo(() => {
|
||||
if (!isTempSystemNameEnabled) {
|
||||
return '';
|
||||
}
|
||||
if (isShowLinkedSigIdTempName && linkedSigPrefix) {
|
||||
return temporary_name ? `${linkedSigPrefix}・${temporary_name}` : `${linkedSigPrefix}・${solar_system_name}`;
|
||||
}
|
||||
return temporary_name ?? '';
|
||||
}, [isTempSystemNameEnabled, temporary_name, solar_system_name, isShowLinkedSigIdTempName, linkedSigPrefix]);
|
||||
|
||||
const systemName = useMemo(() => {
|
||||
if (isTempSystemNameEnabled && computedTemporaryName) {
|
||||
return computedTemporaryName;
|
||||
}
|
||||
return solar_system_name;
|
||||
}, [isTempSystemNameEnabled, computedTemporaryName, solar_system_name]);
|
||||
|
||||
const customName = useMemo(() => {
|
||||
if (isTempSystemNameEnabled && computedTemporaryName && name) {
|
||||
return name;
|
||||
}
|
||||
if (solar_system_name !== name && name) {
|
||||
return name;
|
||||
}
|
||||
return null;
|
||||
}, [isTempSystemNameEnabled, computedTemporaryName, name, solar_system_name]);
|
||||
|
||||
return { systemName, computedTemporaryName, customName };
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useMemo } from 'react';
|
||||
import { SystemSignature } from '@/hooks/Mapper/types';
|
||||
import { prepareUnsplashedChunks } from '@/hooks/Mapper/components/map/helpers';
|
||||
|
||||
export type UnsplashedSignatureType = SystemSignature & { sig_id: string };
|
||||
|
||||
export function useUnsplashedSignatures(systemSigs: SystemSignature[], isShowUnsplashedSignatures: boolean) {
|
||||
return useMemo(() => {
|
||||
if (!isShowUnsplashedSignatures) {
|
||||
return {
|
||||
unsplashedLeft: [] as SystemSignature[],
|
||||
unsplashedRight: [] as SystemSignature[],
|
||||
};
|
||||
}
|
||||
const chunks = prepareUnsplashedChunks(
|
||||
systemSigs
|
||||
.filter(s => s.group === 'Wormhole' && !s.linked_system)
|
||||
.map(s => ({
|
||||
eve_id: s.eve_id,
|
||||
type: s.type,
|
||||
custom_info: s.custom_info,
|
||||
kind: s.kind,
|
||||
name: s.name,
|
||||
group: s.group,
|
||||
})) as UnsplashedSignatureType[],
|
||||
);
|
||||
const [unsplashedLeft, unsplashedRight] = chunks;
|
||||
return { unsplashedLeft, unsplashedRight };
|
||||
}, [isShowUnsplashedSignatures, systemSigs]);
|
||||
}
|
||||
@@ -6,9 +6,9 @@ import { SolarSystemRawType } from '@/hooks/Mapper/types';
|
||||
const useThrottle = () => {
|
||||
const throttleSeed = useRef<number | null>(null);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const throttleFunction = useRef((func: any, delay = 200) => {
|
||||
if (!throttleSeed.current) {
|
||||
// Call the callback immediately for the first time
|
||||
func();
|
||||
throttleSeed.current = setTimeout(() => {
|
||||
throttleSeed.current = null;
|
||||
@@ -75,7 +75,7 @@ export const useUpdateNodes = (nodes: Node<SolarSystemRawType>[]) => {
|
||||
|
||||
const visibleNodes = new Set(nodes.filter(x => isNodeVisible(x, viewport)).map(x => x.id));
|
||||
update({ visibleNodes });
|
||||
}, [nodes]);
|
||||
}, [getViewport, nodes, update]);
|
||||
|
||||
useOnViewportChange({
|
||||
onChange: () => throttle(updateNodesVisibility.bind(this)),
|
||||
@@ -84,5 +84,5 @@ export const useUpdateNodes = (nodes: Node<SolarSystemRawType>[]) => {
|
||||
|
||||
useEffect(() => {
|
||||
updateNodesVisibility();
|
||||
}, [nodes]);
|
||||
}, [nodes, updateNodesVisibility]);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useCallback, useRef, useMemo } from 'react';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
|
||||
import { OutCommand } from '@/hooks/Mapper/types/mapHandlers.ts';
|
||||
import { SystemSignature } from '@/hooks/Mapper/types';
|
||||
import { SystemSignature, TimeStatus } from '@/hooks/Mapper/types';
|
||||
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
|
||||
import { CommandLinkSignatureToSystem } from '@/hooks/Mapper/types';
|
||||
import { SystemSignaturesContent } from '@/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/SystemSignaturesContent';
|
||||
@@ -12,6 +12,17 @@ import {
|
||||
COSMIC_SIGNATURE,
|
||||
} from '@/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/SystemSignatureSettingsDialog';
|
||||
import { SignatureGroup } from '@/hooks/Mapper/types';
|
||||
import { parseSignatureCustomInfo } from '@/hooks/Mapper/helpers/parseSignatureCustomInfo';
|
||||
import { getWhSize } from '@/hooks/Mapper/helpers/getWhSize';
|
||||
import { useSystemInfo } from '@/hooks/Mapper/components/hooks';
|
||||
import {
|
||||
SOLAR_SYSTEM_CLASS_IDS,
|
||||
SOLAR_SYSTEM_CLASSES_TO_CLASS_GROUPS,
|
||||
WORMHOLES_ADDITIONAL_INFO_BY_SHORT_NAME,
|
||||
} from '@/hooks/Mapper/components/map/constants.ts';
|
||||
import { K162_TYPES_MAP } from '@/hooks/Mapper/constants.ts';
|
||||
|
||||
const K162_SIGNATURE_TYPE = WORMHOLES_ADDITIONAL_INFO_BY_SHORT_NAME['K162'].shortName;
|
||||
|
||||
interface SystemLinkSignatureDialogProps {
|
||||
data: CommandLinkSignatureToSystem;
|
||||
@@ -24,34 +35,132 @@ const signatureSettings: Setting[] = [
|
||||
{ key: SHOW_DESCRIPTION_COLUMN_SETTING, name: 'Show Description Column', value: true, isFilter: false },
|
||||
];
|
||||
|
||||
// Extend the SignatureCustomInfo type to include k162Type
|
||||
interface ExtendedSignatureCustomInfo {
|
||||
k162Type?: string;
|
||||
isEOL?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export const SystemLinkSignatureDialog = ({ data, setVisible }: SystemLinkSignatureDialogProps) => {
|
||||
const { outCommand } = useMapRootState();
|
||||
const {
|
||||
outCommand,
|
||||
data: { wormholes },
|
||||
} = useMapRootState();
|
||||
|
||||
const ref = useRef({ outCommand });
|
||||
ref.current = { outCommand };
|
||||
|
||||
// Get system info for the target system
|
||||
const { staticInfo: targetSystemInfo } = useSystemInfo({ systemId: `${data.solar_system_target}` });
|
||||
|
||||
// Get the system class group for the target system
|
||||
const targetSystemClassGroup = useMemo(() => {
|
||||
if (!targetSystemInfo) return null;
|
||||
const systemClassId = targetSystemInfo.system_class;
|
||||
|
||||
const systemClassKey = Object.keys(SOLAR_SYSTEM_CLASS_IDS).find(
|
||||
key => SOLAR_SYSTEM_CLASS_IDS[key as keyof typeof SOLAR_SYSTEM_CLASS_IDS] === systemClassId,
|
||||
);
|
||||
|
||||
if (!systemClassKey) return null;
|
||||
|
||||
return (
|
||||
SOLAR_SYSTEM_CLASSES_TO_CLASS_GROUPS[systemClassKey as keyof typeof SOLAR_SYSTEM_CLASSES_TO_CLASS_GROUPS] || null
|
||||
);
|
||||
}, [targetSystemInfo]);
|
||||
|
||||
const handleHide = useCallback(() => {
|
||||
setVisible(false);
|
||||
}, [setVisible]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
const filterSignature = useCallback(
|
||||
(signature: SystemSignature) => {
|
||||
if (signature.group !== SignatureGroup.Wormhole || !targetSystemClassGroup) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!signature.type) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (signature.type === K162_SIGNATURE_TYPE) {
|
||||
// Parse the custom info to see if the user has specified what class this K162 leads to
|
||||
const customInfo = parseSignatureCustomInfo(signature.custom_info) as ExtendedSignatureCustomInfo;
|
||||
|
||||
// If the user has specified a k162Type for this K162
|
||||
if (customInfo.k162Type) {
|
||||
// Get the K162 type information
|
||||
const k162TypeInfo = K162_TYPES_MAP[customInfo.k162Type];
|
||||
|
||||
if (k162TypeInfo) {
|
||||
// Check if the k162Type matches our target system class
|
||||
return customInfo.k162Type === targetSystemClassGroup;
|
||||
}
|
||||
}
|
||||
|
||||
// If no k162Type is specified or we couldn't find type info, allow it
|
||||
return true;
|
||||
}
|
||||
|
||||
// Find the wormhole data for this signature type
|
||||
const wormholeData = wormholes.find(wh => wh.name === signature.type);
|
||||
if (!wormholeData) {
|
||||
return true; // If we don't know the destination, don't filter it out
|
||||
}
|
||||
|
||||
// Get the destination system class from the wormhole data
|
||||
const destinationClass = wormholeData.dest;
|
||||
|
||||
// Check if the destination class matches the target system class
|
||||
const isMatch = destinationClass === targetSystemClassGroup;
|
||||
return isMatch;
|
||||
},
|
||||
[targetSystemClassGroup, wormholes],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (signature: SystemSignature) => {
|
||||
if (!signature) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { outCommand } = ref.current;
|
||||
|
||||
outCommand({
|
||||
await outCommand({
|
||||
type: OutCommand.linkSignatureToSystem,
|
||||
data: {
|
||||
...data,
|
||||
signature_eve_id: signature.eve_id,
|
||||
},
|
||||
});
|
||||
|
||||
if (parseSignatureCustomInfo(signature.custom_info).isEOL === true) {
|
||||
await outCommand({
|
||||
type: OutCommand.updateConnectionTimeStatus,
|
||||
data: {
|
||||
source: data.solar_system_source,
|
||||
target: data.solar_system_target,
|
||||
value: TimeStatus.eol,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const whShipSize = getWhSize(wormholes, signature.type);
|
||||
if (whShipSize) {
|
||||
await outCommand({
|
||||
type: OutCommand.updateConnectionShipSizeType,
|
||||
data: {
|
||||
source: data.solar_system_source,
|
||||
target: data.solar_system_target,
|
||||
value: whShipSize,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
setVisible(false);
|
||||
},
|
||||
[data, setVisible],
|
||||
[data, setVisible, wormholes],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -69,6 +178,7 @@ export const SystemLinkSignatureDialog = ({ data, setVisible }: SystemLinkSignat
|
||||
settings={signatureSettings}
|
||||
onSelect={handleSelect}
|
||||
selectable={true}
|
||||
filterSignature={filterSignature}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -43,6 +43,7 @@ export const SystemKills: React.FC = React.memo(() => {
|
||||
systemId,
|
||||
outCommand,
|
||||
showAllVisible: visible,
|
||||
sinceHours: settings.timeRange,
|
||||
});
|
||||
|
||||
const isNothingSelected = !systemId && !visible;
|
||||
@@ -61,45 +62,41 @@ export const SystemKills: React.FC = React.memo(() => {
|
||||
}, [kills, settings.whOnly, systemBySolarSystemId, visible]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col min-h-0">
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<Widget label={<KillsHeader systemId={systemId} onOpenSettings={() => setSettingsDialogVisible(true)} />}>
|
||||
{!isSubscriptionActive ? (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<span className="select-none text-center text-stone-400/80 text-sm">
|
||||
Kills available with 'Active' map subscription only (contact map administrators)
|
||||
</span>
|
||||
</div>
|
||||
) : isNothingSelected ? (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<span className="select-none text-center text-stone-400/80 text-sm">
|
||||
No system selected (or toggle “Show all systems”)
|
||||
</span>
|
||||
</div>
|
||||
) : showLoading ? (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<span className="select-none text-center text-stone-400/80 text-sm">Loading Kills...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<span className="select-none text-center text-red-400 text-sm">{error}</span>
|
||||
</div>
|
||||
) : !filteredKills || filteredKills.length === 0 ? (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<span className="select-none text-center text-stone-400/80 text-sm">No kills found</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full" style={{ height: '100%' }}>
|
||||
<SystemKillsContent
|
||||
kills={filteredKills}
|
||||
systemNameMap={systemNameMap}
|
||||
onlyOneSystem={!visible}
|
||||
timeRange={settings.timeRange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Widget>
|
||||
</div>
|
||||
<div className="h-full flex flex-col">
|
||||
<Widget label={<KillsHeader systemId={systemId} onOpenSettings={() => setSettingsDialogVisible(true)} />}>
|
||||
{!isSubscriptionActive ? (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<span className="select-none text-center text-stone-400/80 text-sm">
|
||||
Kills available with 'Active' map subscription only (contact map administrators)
|
||||
</span>
|
||||
</div>
|
||||
) : isNothingSelected ? (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<span className="select-none text-center text-stone-400/80 text-sm">
|
||||
No system selected (or toggle "Show all systems")
|
||||
</span>
|
||||
</div>
|
||||
) : showLoading ? (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<span className="select-none text-center text-stone-400/80 text-sm">Loading Kills...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<span className="select-none text-center text-red-400 text-sm">{error}</span>
|
||||
</div>
|
||||
) : !filteredKills || filteredKills.length === 0 ? (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<span className="select-none text-center text-stone-400/80 text-sm">No kills found</span>
|
||||
</div>
|
||||
) : (
|
||||
<SystemKillsContent
|
||||
kills={filteredKills}
|
||||
systemNameMap={systemNameMap}
|
||||
onlyOneSystem={!visible}
|
||||
timeRange={settings.timeRange}
|
||||
/>
|
||||
)}
|
||||
</Widget>
|
||||
|
||||
{settingsDialogVisible && <KillsSettingsDialog visible setVisible={setSettingsDialogVisible} />}
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,37 @@
|
||||
.wrapper {
|
||||
overflow-x: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
// Custom scrollbar styling is now handled by the global custom-scrollbar class
|
||||
.scrollerContent {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.VirtualScroller {
|
||||
overflow-y: auto;
|
||||
padding-right: 8px;
|
||||
padding-left: 8px;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
// VirtualScroller specific styles that can't be handled with Tailwind
|
||||
.VirtualScroller {
|
||||
overflow: hidden !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
height: 100% !important;
|
||||
|
||||
// Target this specific VirtualScroller instance
|
||||
&:global(.p-virtualscroller) {
|
||||
height: 100% !important;
|
||||
|
||||
:global(.p-virtualscroller-content) {
|
||||
height: 100% !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fix for PrimeReact VirtualScroller - these need to be global
|
||||
:global {
|
||||
.p-virtualscroller {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.p-virtualscroller-content {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { DetailedKill } from '@/hooks/Mapper/types/kills';
|
||||
import { VirtualScroller } from 'primereact/virtualscroller';
|
||||
import { useSystemKillsItemTemplate } from '../hooks/useSystemKillsItemTemplate';
|
||||
@@ -11,7 +10,6 @@ export interface SystemKillsContentProps {
|
||||
kills: DetailedKill[];
|
||||
systemNameMap: Record<string, string>;
|
||||
onlyOneSystem?: boolean;
|
||||
autoSize?: boolean;
|
||||
timeRange?: number;
|
||||
limit?: number;
|
||||
}
|
||||
@@ -20,44 +18,54 @@ export const SystemKillsContent: React.FC<SystemKillsContentProps> = ({
|
||||
kills,
|
||||
systemNameMap,
|
||||
onlyOneSystem = false,
|
||||
autoSize = false,
|
||||
timeRange = 4,
|
||||
limit,
|
||||
}) => {
|
||||
const processedKills = useMemo(() => {
|
||||
if (!kills || kills.length === 0) return [];
|
||||
|
||||
// sort by newest first
|
||||
const sortedKills = kills
|
||||
.filter(k => k.kill_time)
|
||||
.sort((a, b) => new Date(b.kill_time!).getTime() - new Date(a.kill_time!).getTime());
|
||||
|
||||
if (limit !== undefined) {
|
||||
return sortedKills.slice(0, limit);
|
||||
} else {
|
||||
const now = Date.now();
|
||||
const cutoff = now - timeRange * 60 * 60 * 1000;
|
||||
return sortedKills.filter(k => new Date(k.kill_time!).getTime() >= cutoff);
|
||||
// filter by timeRange
|
||||
let filteredKills = sortedKills;
|
||||
if (timeRange !== undefined) {
|
||||
const cutoffTime = new Date();
|
||||
cutoffTime.setHours(cutoffTime.getHours() - timeRange);
|
||||
filteredKills = sortedKills.filter(kill => {
|
||||
const killTime = new Date(kill.kill_time!).getTime();
|
||||
return killTime >= cutoffTime.getTime();
|
||||
});
|
||||
}
|
||||
}, [kills, timeRange, limit]);
|
||||
|
||||
const computedHeight = autoSize ? Math.max(processedKills.length, 1) * ITEM_HEIGHT : undefined;
|
||||
const scrollerHeight = autoSize ? `${computedHeight}px` : '100%';
|
||||
// apply limit if present
|
||||
if (limit !== undefined) {
|
||||
return filteredKills.slice(0, limit);
|
||||
}
|
||||
return filteredKills;
|
||||
}, [kills, timeRange, limit]);
|
||||
|
||||
const itemTemplate = useSystemKillsItemTemplate(systemNameMap, onlyOneSystem);
|
||||
|
||||
// Define style for the VirtualScroller
|
||||
const virtualScrollerStyle: React.CSSProperties = {
|
||||
boxSizing: 'border-box',
|
||||
height: '100%', // Use 100% height to fill the container
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx('w-full h-full', classes.wrapper)}>
|
||||
<div className="h-full w-full flex flex-col overflow-hidden" data-testid="system-kills-content">
|
||||
<VirtualScroller
|
||||
items={processedKills}
|
||||
itemSize={ITEM_HEIGHT}
|
||||
itemTemplate={itemTemplate}
|
||||
autoSize={autoSize}
|
||||
scrollWidth="100%"
|
||||
style={{ height: scrollerHeight }}
|
||||
className={clsx('w-full h-full custom-scrollbar select-none', {
|
||||
[classes.VirtualScroller]: !autoSize,
|
||||
})}
|
||||
className={`w-full h-full flex-1 select-none ${classes.VirtualScroller}`}
|
||||
style={virtualScrollerStyle}
|
||||
pt={{
|
||||
content: {
|
||||
className: classes.scrollerContent,
|
||||
className: `custom-scrollbar ${classes.scrollerContent}`,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -90,6 +90,14 @@ export const KillsSettingsDialog: React.FC<KillsSettingsDialogProps> = ({ visibl
|
||||
const excluded = localData.excludedSystems || [];
|
||||
const timeRangeOptions = [4, 12, 24];
|
||||
|
||||
// Ensure timeRange is one of the valid options
|
||||
useEffect(() => {
|
||||
if (visible && !timeRangeOptions.includes(localData.timeRange)) {
|
||||
// If current timeRange is not in options, set it to the default (4 hours)
|
||||
handleTimeRangeChange(4);
|
||||
}
|
||||
}, [visible, localData.timeRange, handleTimeRangeChange]);
|
||||
|
||||
return (
|
||||
<Dialog header="Kills Settings" visible={visible} style={{ width: '440px' }} draggable={false} onHide={handleHide}>
|
||||
<div className="flex flex-col gap-3 p-2.5">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const ZKILL_URL = 'https://zkillboard.com';
|
||||
const BASE_IMAGE_URL = 'https://images.evetech.net';
|
||||
import { getEveImageUrl } from '@/hooks/Mapper/helpers';
|
||||
|
||||
export function zkillLink(type: 'kill' | 'character' | 'corporation' | 'alliance', id?: number | null): string {
|
||||
if (!id) return `${ZKILL_URL}`;
|
||||
@@ -10,21 +10,7 @@ export function zkillLink(type: 'kill' | 'character' | 'corporation' | 'alliance
|
||||
return `${ZKILL_URL}`;
|
||||
}
|
||||
|
||||
export function eveImageUrl(
|
||||
category: 'characters' | 'corporations' | 'alliances' | 'types',
|
||||
id?: number | null,
|
||||
variation: string = 'icon',
|
||||
size?: number,
|
||||
): string | null {
|
||||
if (!id || id <= 0) {
|
||||
return null;
|
||||
}
|
||||
let url = `${BASE_IMAGE_URL}/${category}/${id}/${variation}`;
|
||||
if (size) {
|
||||
url += `?size=${size}`;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
export const eveImageUrl = getEveImageUrl;
|
||||
|
||||
export function buildVictimImageUrls(args: {
|
||||
victim_char_id?: number | null;
|
||||
|
||||
@@ -14,7 +14,7 @@ export const DEFAULT_KILLS_WIDGET_SETTINGS: KillsWidgetSettings = {
|
||||
whOnly: true,
|
||||
excludedSystems: [],
|
||||
version: 2,
|
||||
timeRange: 1,
|
||||
timeRange: 4,
|
||||
};
|
||||
|
||||
function mergeWithDefaults(settings?: Partial<KillsWidgetSettings>): KillsWidgetSettings {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useCallback, useMemo, useState, useEffect, useRef } from 'react';
|
||||
import debounce from 'lodash.debounce';
|
||||
import { OutCommand } from '@/hooks/Mapper/types/mapHandlers';
|
||||
import { Commands, OutCommand } from '@/hooks/Mapper/types/mapHandlers';
|
||||
import { DetailedKill } from '@/hooks/Mapper/types/kills';
|
||||
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
|
||||
import { useKillsWidgetSettings } from './useKillsWidgetSettings';
|
||||
import { useMapEventListener, MapEvent } from '@/hooks/Mapper/events';
|
||||
|
||||
interface UseSystemKillsProps {
|
||||
systemId?: string;
|
||||
@@ -18,11 +19,8 @@ function combineKills(existing: DetailedKill[], incoming: DetailedKill[], sinceH
|
||||
const byId: Record<string, DetailedKill> = {};
|
||||
|
||||
for (const kill of [...existing, ...incoming]) {
|
||||
if (!kill.kill_time) {
|
||||
continue;
|
||||
}
|
||||
if (!kill.kill_time) continue;
|
||||
const killTimeMs = new Date(kill.kill_time).valueOf();
|
||||
|
||||
if (killTimeMs >= cutoff) {
|
||||
byId[kill.killmail_id] = kill;
|
||||
}
|
||||
@@ -31,15 +29,44 @@ function combineKills(existing: DetailedKill[], incoming: DetailedKill[], sinceH
|
||||
return Object.values(byId);
|
||||
}
|
||||
|
||||
interface DetailedKillsEvent extends MapEvent<Commands> {
|
||||
payload: Record<string, DetailedKill[]>;
|
||||
}
|
||||
|
||||
export function useSystemKills({ systemId, outCommand, showAllVisible = false, sinceHours = 24 }: UseSystemKillsProps) {
|
||||
const { data, update } = useMapRootState();
|
||||
const { detailedKills = {}, systems = [] } = data;
|
||||
|
||||
const [settings] = useKillsWidgetSettings();
|
||||
const excludedSystems = settings.excludedSystems;
|
||||
|
||||
// When showing all visible kills, filter out excluded systems;
|
||||
// when showAllVisible is false, ignore the exclusion filter.
|
||||
const effectiveSinceHours = sinceHours;
|
||||
|
||||
const updateDetailedKills = useCallback(
|
||||
(newKillsMap: Record<string, DetailedKill[]>) => {
|
||||
update(prev => {
|
||||
const oldKills = prev.detailedKills ?? {};
|
||||
const updated = { ...oldKills };
|
||||
for (const [sid, killsArr] of Object.entries(newKillsMap)) {
|
||||
updated[sid] = killsArr;
|
||||
}
|
||||
return { ...prev, detailedKills: updated };
|
||||
}, true);
|
||||
},
|
||||
[update],
|
||||
);
|
||||
|
||||
useMapEventListener((event: MapEvent<Commands>) => {
|
||||
if (event.name === Commands.detailedKillsUpdated) {
|
||||
const detailedEvent = event as DetailedKillsEvent;
|
||||
if (systemId && !Object.keys(detailedEvent.payload).includes(systemId.toString())) {
|
||||
return false;
|
||||
}
|
||||
updateDetailedKills(detailedEvent.payload);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const effectiveSystemIds = useMemo(() => {
|
||||
if (showAllVisible) {
|
||||
return systems.map(s => s.id).filter(id => !excludedSystems.includes(Number(id)));
|
||||
@@ -49,7 +76,6 @@ export function useSystemKills({ systemId, outCommand, showAllVisible = false, s
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const didFallbackFetch = useRef(Object.keys(detailedKills).length !== 0);
|
||||
|
||||
const mergeKillsIntoGlobal = useCallback(
|
||||
@@ -60,17 +86,14 @@ export function useSystemKills({ systemId, outCommand, showAllVisible = false, s
|
||||
|
||||
for (const [sid, newKills] of Object.entries(killsMap)) {
|
||||
const existing = updated[sid] ?? [];
|
||||
const combined = combineKills(existing, newKills, sinceHours);
|
||||
const combined = combineKills(existing, newKills, effectiveSinceHours);
|
||||
updated[sid] = combined;
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
detailedKills: updated,
|
||||
};
|
||||
return { ...prev, detailedKills: updated };
|
||||
});
|
||||
},
|
||||
[update, sinceHours],
|
||||
[update, effectiveSinceHours],
|
||||
);
|
||||
|
||||
const fetchKills = useCallback(
|
||||
@@ -86,16 +109,15 @@ export function useSystemKills({ systemId, outCommand, showAllVisible = false, s
|
||||
eventType = OutCommand.getSystemsKills;
|
||||
requestData = {
|
||||
system_ids: effectiveSystemIds,
|
||||
since_hours: sinceHours,
|
||||
since_hours: effectiveSinceHours,
|
||||
};
|
||||
} else if (systemId) {
|
||||
eventType = OutCommand.getSystemKills;
|
||||
requestData = {
|
||||
system_id: systemId,
|
||||
since_hours: sinceHours,
|
||||
since_hours: effectiveSinceHours,
|
||||
};
|
||||
} else {
|
||||
// If there's no system and not showing all, do nothing
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -105,14 +127,11 @@ export function useSystemKills({ systemId, outCommand, showAllVisible = false, s
|
||||
data: requestData,
|
||||
});
|
||||
|
||||
// Single system => `resp.kills`
|
||||
if (resp?.kills) {
|
||||
const arr = resp.kills as DetailedKill[];
|
||||
const sid = systemId ?? 'unknown';
|
||||
mergeKillsIntoGlobal({ [sid]: arr });
|
||||
}
|
||||
// multiple systems => `resp.systems_kills`
|
||||
else if (resp?.systems_kills) {
|
||||
} else if (resp?.systems_kills) {
|
||||
mergeKillsIntoGlobal(resp.systems_kills as Record<string, DetailedKill[]>);
|
||||
} else {
|
||||
console.warn('[useSystemKills] Unexpected kills response =>', resp);
|
||||
@@ -124,7 +143,7 @@ export function useSystemKills({ systemId, outCommand, showAllVisible = false, s
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[showAllVisible, systemId, outCommand, effectiveSystemIds, sinceHours, mergeKillsIntoGlobal],
|
||||
[showAllVisible, systemId, outCommand, effectiveSystemIds, effectiveSinceHours, mergeKillsIntoGlobal],
|
||||
);
|
||||
|
||||
const debouncedFetchKills = useMemo(
|
||||
@@ -137,25 +156,26 @@ export function useSystemKills({ systemId, outCommand, showAllVisible = false, s
|
||||
);
|
||||
|
||||
const finalKills = useMemo(() => {
|
||||
let result: DetailedKill[] = [];
|
||||
|
||||
if (showAllVisible) {
|
||||
return effectiveSystemIds.flatMap(sid => detailedKills[sid] ?? []);
|
||||
result = effectiveSystemIds.flatMap(sid => detailedKills[sid] ?? []);
|
||||
} else if (systemId) {
|
||||
return detailedKills[systemId] ?? [];
|
||||
result = detailedKills[systemId] ?? [];
|
||||
} else if (didFallbackFetch.current) {
|
||||
// if we already did a fallback, we may have data for multiple systems
|
||||
return effectiveSystemIds.flatMap(sid => detailedKills[sid] ?? []);
|
||||
result = effectiveSystemIds.flatMap(sid => detailedKills[sid] ?? []);
|
||||
}
|
||||
return [];
|
||||
}, [showAllVisible, systemId, effectiveSystemIds, detailedKills]);
|
||||
|
||||
return result;
|
||||
}, [showAllVisible, systemId, effectiveSystemIds, detailedKills, didFallbackFetch]);
|
||||
|
||||
const effectiveIsLoading = isLoading && finalKills.length === 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!systemId && !showAllVisible && !didFallbackFetch.current) {
|
||||
didFallbackFetch.current = true;
|
||||
// Cancel any queued debounced calls, then do the fallback.
|
||||
debouncedFetchKills.cancel();
|
||||
fetchKills(true); // forceFallback => fetch as though showAllVisible is true
|
||||
fetchKills(true);
|
||||
}
|
||||
}, [systemId, showAllVisible, debouncedFetchKills, fetchKills]);
|
||||
|
||||
@@ -163,15 +183,17 @@ export function useSystemKills({ systemId, outCommand, showAllVisible = false, s
|
||||
if (effectiveSystemIds.length === 0) return;
|
||||
|
||||
if (showAllVisible || systemId) {
|
||||
debouncedFetchKills();
|
||||
// Clean up the debounce on unmount or changes
|
||||
// Cancel any pending debounced fetch
|
||||
debouncedFetchKills.cancel();
|
||||
// Fetch kills immediately
|
||||
fetchKills();
|
||||
return () => debouncedFetchKills.cancel();
|
||||
}
|
||||
}, [showAllVisible, systemId, effectiveSystemIds, debouncedFetchKills]);
|
||||
}, [showAllVisible, systemId, effectiveSystemIds, debouncedFetchKills, fetchKills]);
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
debouncedFetchKills.cancel();
|
||||
fetchKills(); // immediate (non-debounced) call
|
||||
fetchKills();
|
||||
}, [debouncedFetchKills, fetchKills]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,15 +1,36 @@
|
||||
import { SignatureGroup, SystemSignature } from '@/hooks/Mapper/types';
|
||||
import { renderIcon } from '@/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/renders';
|
||||
import { getCharacterPortraitUrl } from '@/hooks/Mapper/helpers';
|
||||
|
||||
export interface SignatureViewProps {}
|
||||
export interface SignatureViewProps {
|
||||
signature: SystemSignature;
|
||||
showCharacterPortrait?: boolean;
|
||||
}
|
||||
|
||||
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 characterName = signature.character_name || 'Unknown character';
|
||||
|
||||
export const SignatureView = (sig: SignatureViewProps & SystemSignature) => {
|
||||
return (
|
||||
<div className="flex gap-2 items-center">
|
||||
{renderIcon(sig)}
|
||||
<div>{sig?.eve_id}</div>
|
||||
<div>{sig?.group ?? SignatureGroup.CosmicSignature}</div>
|
||||
<div>{sig?.name}</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-2 items-center">
|
||||
{renderIcon(signature)}
|
||||
<div>{signature?.eve_id}</div>
|
||||
<div>{groupDisplay}</div>
|
||||
{!isWormhole && <div>{signature?.name}</div>}
|
||||
{hasCharacterInfo && (
|
||||
<div className="flex items-center gap-1 ml-2 pl-2 border-l border-stone-700">
|
||||
<img
|
||||
src={getCharacterPortraitUrl(signature.character_eve_id)}
|
||||
alt={characterName}
|
||||
className="w-5 h-5 rounded-sm border border-stone-700"
|
||||
/>
|
||||
<div className="text-xs text-stone-300">{characterName}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ export type HeaderProps = {
|
||||
lazyDeleteValue: boolean;
|
||||
onLazyDeleteChange: (checked: boolean) => void;
|
||||
pendingCount: number;
|
||||
pendingTimeRemaining?: number; // Time remaining in ms
|
||||
onUndoClick: () => void;
|
||||
onSettingsClick: () => void;
|
||||
};
|
||||
@@ -25,9 +26,17 @@ function HeaderImpl({
|
||||
lazyDeleteValue,
|
||||
onLazyDeleteChange,
|
||||
pendingCount,
|
||||
pendingTimeRemaining,
|
||||
onUndoClick,
|
||||
onSettingsClick,
|
||||
}: HeaderProps) {
|
||||
// Format time remaining as seconds
|
||||
const formatTimeRemaining = () => {
|
||||
if (!pendingTimeRemaining) return '';
|
||||
const seconds = Math.ceil(pendingTimeRemaining / 1000);
|
||||
return ` (${seconds}s remaining)`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-between items-center text-xs w-full h-full">
|
||||
<div className="flex justify-between items-center gap-1">
|
||||
@@ -55,7 +64,10 @@ function HeaderImpl({
|
||||
<WdImgButton
|
||||
className={PrimeIcons.UNDO}
|
||||
style={{ color: 'red' }}
|
||||
tooltip={{ content: `Undo pending changes (${pendingCount})` }}
|
||||
tooltip={{
|
||||
content: `Undo pending changes (${pendingCount})${formatTimeRemaining()}`,
|
||||
position: TooltipPosition.top,
|
||||
}}
|
||||
onClick={onUndoClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -4,8 +4,15 @@ import { Button } from 'primereact/button';
|
||||
import { TabPanel, TabView } from 'primereact/tabview';
|
||||
import styles from './SystemSignatureSettingsDialog.module.scss';
|
||||
import { PrettySwitchbox } from '@/hooks/Mapper/components/mapRootContent/components/MapSettings/components';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
|
||||
export type Setting = { key: string; name: string; value: boolean; isFilter?: boolean };
|
||||
export type Setting = {
|
||||
key: string;
|
||||
name: string;
|
||||
value: boolean | number;
|
||||
isFilter?: boolean;
|
||||
options?: { label: string; value: number }[];
|
||||
};
|
||||
|
||||
export const COSMIC_SIGNATURE = 'Cosmic Signature';
|
||||
export const COSMIC_ANOMALY = 'Cosmic Anomaly';
|
||||
@@ -33,13 +40,49 @@ export const SystemSignatureSettingsDialog = ({
|
||||
const userSettings = settings.filter(setting => !setting.isFilter);
|
||||
|
||||
const handleSettingsChange = (key: string) => {
|
||||
setSettings(prevState => prevState.map(item => (item.key === key ? { ...item, value: !item.value } : item)));
|
||||
setSettings(prevState =>
|
||||
prevState.map(item =>
|
||||
item.key === key ? { ...item, value: typeof item.value === 'boolean' ? !item.value : item.value } : item,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const handleDropdownChange = (key: string, value: number) => {
|
||||
setSettings(prevState => prevState.map(item => (item.key === key ? { ...item, value } : item)));
|
||||
};
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
onSave(settings);
|
||||
}, [onSave, settings]);
|
||||
|
||||
const renderSetting = (setting: Setting) => {
|
||||
if (setting.options) {
|
||||
return (
|
||||
<div key={setting.key} className="flex items-center justify-between gap-2 mb-2">
|
||||
<label className="text-[#b8b8b8] text-[13px] select-none">{setting.name}</label>
|
||||
<Dropdown
|
||||
value={setting.value}
|
||||
options={setting.options.map(opt => ({
|
||||
...opt,
|
||||
label: opt.label.split(' ')[0], // Just take the first part (e.g., "0s" from "Immediate (0s)")
|
||||
}))}
|
||||
onChange={e => handleDropdownChange(setting.key, e.value)}
|
||||
className="w-40"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PrettySwitchbox
|
||||
key={setting.key}
|
||||
label={setting.name}
|
||||
checked={!!setting.value}
|
||||
setChecked={() => handleSettingsChange(setting.key)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog header="System Signatures Settings" visible={true} onHide={onCancel} className="w-full max-w-lg h-[500px]">
|
||||
<div className="flex flex-col gap-3 justify-between h-full">
|
||||
@@ -51,31 +94,15 @@ export const SystemSignatureSettingsDialog = ({
|
||||
className={styles.verticalTabView}
|
||||
>
|
||||
<TabPanel header="Filters" headerClassName={styles.verticalTabHeader}>
|
||||
<div className="w-full h-full flex flex-col gap-1">
|
||||
{filterSettings.map(setting => {
|
||||
return (
|
||||
<PrettySwitchbox
|
||||
key={setting.key}
|
||||
label={setting.name}
|
||||
checked={setting.value}
|
||||
setChecked={() => handleSettingsChange(setting.key)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="w-full h-full flex flex-col gap-1">{filterSettings.map(renderSetting)}</div>
|
||||
</TabPanel>
|
||||
<TabPanel header="User Interface" headerClassName={styles.verticalTabHeader}>
|
||||
<div className="w-full h-full flex flex-col gap-1">
|
||||
{userSettings.map(setting => {
|
||||
return (
|
||||
<PrettySwitchbox
|
||||
key={setting.key}
|
||||
label={setting.name}
|
||||
checked={setting.value}
|
||||
setChecked={() => handleSettingsChange(setting.key)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{userSettings.filter(setting => !setting.options).map(renderSetting)}
|
||||
{userSettings.some(setting => setting.options) && (
|
||||
<div className="my-2 border-t border-stone-700/50"></div>
|
||||
)}
|
||||
{userSettings.filter(setting => setting.options).map(renderSetting)}
|
||||
</div>
|
||||
</TabPanel>
|
||||
</TabView>
|
||||
|
||||
@@ -16,7 +16,13 @@ import { SignatureGroup, SystemSignature } from '@/hooks/Mapper/types';
|
||||
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
|
||||
import useMaxWidth from '@/hooks/Mapper/hooks/useMaxWidth';
|
||||
import { useHotkey } from '@/hooks/Mapper/hooks';
|
||||
import { COMPACT_MAX_WIDTH } from './constants';
|
||||
import {
|
||||
COMPACT_MAX_WIDTH,
|
||||
DELETION_TIMING_DEFAULT,
|
||||
DELETION_TIMING_EXTENDED,
|
||||
DELETION_TIMING_IMMEDIATE,
|
||||
DELETION_TIMING_SETTING_KEY,
|
||||
} from './constants';
|
||||
import { renderHeaderLabel } from './renders';
|
||||
|
||||
const SIGNATURE_SETTINGS_KEY = 'wanderer_system_signature_settings_v5_5';
|
||||
@@ -26,13 +32,35 @@ export const SHOW_UPDATED_COLUMN_SETTING = 'SHOW_UPDATED_COLUMN_SETTING';
|
||||
export const SHOW_CHARACTER_COLUMN_SETTING = 'SHOW_CHARACTER_COLUMN_SETTING';
|
||||
export const LAZY_DELETE_SIGNATURES_SETTING = 'LAZY_DELETE_SIGNATURES_SETTING';
|
||||
export const KEEP_LAZY_DELETE_SETTING = 'KEEP_LAZY_DELETE_ENABLED_SETTING';
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const DELETION_TIMING_SETTING = DELETION_TIMING_SETTING_KEY;
|
||||
export const COLOR_BY_TYPE_SETTING = 'COLOR_BY_TYPE_SETTING';
|
||||
export const SHOW_CHARACTER_PORTRAIT_SETTING = 'SHOW_CHARACTER_PORTRAIT_SETTING';
|
||||
|
||||
const SETTINGS: Setting[] = [
|
||||
// Extend the Setting type to include options for dropdown settings
|
||||
type ExtendedSetting = Setting & {
|
||||
options?: { label: string; value: number }[];
|
||||
};
|
||||
|
||||
const SETTINGS: ExtendedSetting[] = [
|
||||
{ key: SHOW_UPDATED_COLUMN_SETTING, name: 'Show Updated Column', value: false, isFilter: false },
|
||||
{ key: SHOW_DESCRIPTION_COLUMN_SETTING, name: 'Show Description Column', value: false, isFilter: false },
|
||||
{ key: SHOW_CHARACTER_COLUMN_SETTING, name: 'Show Character Column', value: false, isFilter: false },
|
||||
{ key: SHOW_CHARACTER_PORTRAIT_SETTING, name: 'Show Character Portrait in Tooltip', value: false, isFilter: false },
|
||||
{ key: LAZY_DELETE_SIGNATURES_SETTING, name: 'Lazy Delete Signatures', value: false, isFilter: false },
|
||||
{ key: KEEP_LAZY_DELETE_SETTING, name: 'Keep "Lazy Delete" Enabled', value: false, isFilter: false },
|
||||
{ key: COLOR_BY_TYPE_SETTING, name: 'Color Signatures by Type', value: false, isFilter: false },
|
||||
{
|
||||
key: DELETION_TIMING_SETTING,
|
||||
name: 'Deletion Timing',
|
||||
value: DELETION_TIMING_DEFAULT,
|
||||
isFilter: false,
|
||||
options: [
|
||||
{ label: '0s', value: DELETION_TIMING_IMMEDIATE },
|
||||
{ label: '10s', value: DELETION_TIMING_DEFAULT },
|
||||
{ label: '30s', value: DELETION_TIMING_EXTENDED },
|
||||
],
|
||||
},
|
||||
|
||||
{ key: COSMIC_ANOMALY, name: 'Show Anomalies', value: true, isFilter: true },
|
||||
{ key: COSMIC_SIGNATURE, name: 'Show Cosmic Signatures', value: true, isFilter: true },
|
||||
@@ -49,10 +77,36 @@ const SETTINGS: Setting[] = [
|
||||
{ key: SignatureGroup.CombatSite, name: 'Show Combat Sites', value: true, isFilter: true },
|
||||
];
|
||||
|
||||
function getDefaultSettings(): Setting[] {
|
||||
function getDefaultSettings(): ExtendedSetting[] {
|
||||
return [...SETTINGS];
|
||||
}
|
||||
|
||||
function getInitialSettings(): ExtendedSetting[] {
|
||||
const stored = localStorage.getItem(SIGNATURE_SETTINGS_KEY);
|
||||
if (stored) {
|
||||
try {
|
||||
const parsedSettings = JSON.parse(stored) as ExtendedSetting[];
|
||||
// Merge stored settings with default settings to ensure new settings are included
|
||||
const defaultSettings = getDefaultSettings();
|
||||
const mergedSettings = defaultSettings.map(defaultSetting => {
|
||||
const storedSetting = parsedSettings.find(s => s.key === defaultSetting.key);
|
||||
if (storedSetting) {
|
||||
// Keep the stored value but ensure options are from default settings
|
||||
return {
|
||||
...defaultSetting,
|
||||
value: storedSetting.value,
|
||||
};
|
||||
}
|
||||
return defaultSetting;
|
||||
});
|
||||
return mergedSettings;
|
||||
} catch (error) {
|
||||
console.error('Error parsing stored settings', error);
|
||||
}
|
||||
}
|
||||
return getDefaultSettings();
|
||||
}
|
||||
|
||||
export const SystemSignatures: React.FC = () => {
|
||||
const {
|
||||
data: { selectedSystems },
|
||||
@@ -60,17 +114,7 @@ export const SystemSignatures: React.FC = () => {
|
||||
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
const [currentSettings, setCurrentSettings] = useState<Setting[]>(() => {
|
||||
const stored = localStorage.getItem(SIGNATURE_SETTINGS_KEY);
|
||||
if (stored) {
|
||||
try {
|
||||
return JSON.parse(stored) as Setting[];
|
||||
} catch (error) {
|
||||
console.error('Error parsing stored settings', error);
|
||||
}
|
||||
}
|
||||
return getDefaultSettings();
|
||||
});
|
||||
const [currentSettings, setCurrentSettings] = useState<ExtendedSetting[]>(getInitialSettings);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(SIGNATURE_SETTINGS_KEY, JSON.stringify(currentSettings));
|
||||
@@ -78,6 +122,7 @@ export const SystemSignatures: React.FC = () => {
|
||||
|
||||
const [sigCount, setSigCount] = useState<number>(0);
|
||||
const [pendingSigs, setPendingSigs] = useState<SystemSignature[]>([]);
|
||||
const [minPendingTimeRemaining, setMinPendingTimeRemaining] = useState<number | undefined>(undefined);
|
||||
|
||||
const undoPendingFnRef = useRef<() => void>(() => {});
|
||||
|
||||
@@ -88,13 +133,23 @@ export const SystemSignatures: React.FC = () => {
|
||||
const [systemId] = selectedSystems;
|
||||
const isNotSelectedSystem = selectedSystems.length !== 1;
|
||||
|
||||
const lazyDeleteValue = useMemo(
|
||||
() => currentSettings.find(setting => setting.key === LAZY_DELETE_SIGNATURES_SETTING)?.value || false,
|
||||
[currentSettings],
|
||||
);
|
||||
const lazyDeleteValue = useMemo(() => {
|
||||
const setting = currentSettings.find(setting => setting.key === LAZY_DELETE_SIGNATURES_SETTING);
|
||||
return typeof setting?.value === 'boolean' ? setting.value : false;
|
||||
}, [currentSettings]);
|
||||
|
||||
const deletionTimingValue = useMemo(() => {
|
||||
const setting = currentSettings.find(setting => setting.key === DELETION_TIMING_SETTING);
|
||||
return typeof setting?.value === 'number' ? setting.value : DELETION_TIMING_IMMEDIATE;
|
||||
}, [currentSettings]);
|
||||
|
||||
const colorByTypeValue = useMemo(() => {
|
||||
const setting = currentSettings.find(setting => setting.key === COLOR_BY_TYPE_SETTING);
|
||||
return typeof setting?.value === 'boolean' ? setting.value : false;
|
||||
}, [currentSettings]);
|
||||
|
||||
const handleSettingsChange = useCallback((newSettings: Setting[]) => {
|
||||
setCurrentSettings(newSettings);
|
||||
setCurrentSettings(newSettings as ExtendedSetting[]);
|
||||
setVisible(false);
|
||||
}, []);
|
||||
|
||||
@@ -113,12 +168,14 @@ export const SystemSignatures: React.FC = () => {
|
||||
event.stopPropagation();
|
||||
undoPendingFnRef.current();
|
||||
setPendingSigs([]);
|
||||
setMinPendingTimeRemaining(undefined);
|
||||
}
|
||||
});
|
||||
|
||||
const handleUndoClick = useCallback(() => {
|
||||
undoPendingFnRef.current();
|
||||
setPendingSigs([]);
|
||||
setMinPendingTimeRemaining(undefined);
|
||||
}, []);
|
||||
|
||||
const handleSettingsButtonClick = useCallback(() => {
|
||||
@@ -135,6 +192,32 @@ export const SystemSignatures: React.FC = () => {
|
||||
undoPendingFnRef.current = newUndo;
|
||||
}, []);
|
||||
|
||||
// Calculate the minimum time remaining for any pending signature
|
||||
useEffect(() => {
|
||||
if (pendingSigs.length === 0) {
|
||||
setMinPendingTimeRemaining(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const calculateTimeRemaining = () => {
|
||||
const now = Date.now();
|
||||
let minTime: number | undefined = undefined;
|
||||
|
||||
pendingSigs.forEach(sig => {
|
||||
const extendedSig = sig as unknown as { pendingUntil?: number };
|
||||
if (extendedSig.pendingUntil && (minTime === undefined || extendedSig.pendingUntil - now < minTime)) {
|
||||
minTime = extendedSig.pendingUntil - now;
|
||||
}
|
||||
});
|
||||
|
||||
setMinPendingTimeRemaining(minTime && minTime > 0 ? minTime : undefined);
|
||||
};
|
||||
|
||||
calculateTimeRemaining();
|
||||
const interval = setInterval(calculateTimeRemaining, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [pendingSigs]);
|
||||
|
||||
return (
|
||||
<Widget
|
||||
label={
|
||||
@@ -146,6 +229,7 @@ export const SystemSignatures: React.FC = () => {
|
||||
sigCount,
|
||||
lazyDeleteValue,
|
||||
pendingCount: pendingSigs.length,
|
||||
pendingTimeRemaining: minPendingTimeRemaining,
|
||||
onLazyDeleteChange: handleLazyDeleteChange,
|
||||
onUndoClick: handleUndoClick,
|
||||
onSettingsClick: handleSettingsButtonClick,
|
||||
@@ -165,6 +249,8 @@ export const SystemSignatures: React.FC = () => {
|
||||
onLazyDeleteChange={handleLazyDeleteChange}
|
||||
onCountChange={handleSigCountChange}
|
||||
onPendingChange={handlePendingChange}
|
||||
deletionTiming={deletionTimingValue}
|
||||
colorByType={colorByTypeValue}
|
||||
/>
|
||||
)}
|
||||
{visible && (
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
SHOW_UPDATED_COLUMN_SETTING,
|
||||
SHOW_CHARACTER_COLUMN_SETTING,
|
||||
SIGNATURE_WINDOW_ID,
|
||||
SHOW_CHARACTER_PORTRAIT_SETTING,
|
||||
} from '../SystemSignatures';
|
||||
|
||||
import { COSMIC_SIGNATURE } from '../SystemSignatureSettingsDialog';
|
||||
@@ -48,13 +49,16 @@ const SORT_DEFAULT_VALUES: SystemSignaturesSortSettings = {
|
||||
|
||||
interface SystemSignaturesContentProps {
|
||||
systemId: string;
|
||||
settings: { key: string; value: boolean }[];
|
||||
settings: { key: string; value: boolean | number }[];
|
||||
hideLinkedSignatures?: boolean;
|
||||
selectable?: boolean;
|
||||
onSelect?: (signature: SystemSignature) => void;
|
||||
onLazyDeleteChange?: (value: boolean) => void;
|
||||
onCountChange?: (count: number) => void;
|
||||
onPendingChange?: (pending: ExtendedSystemSignature[], undo: () => void) => void;
|
||||
deletionTiming?: number;
|
||||
colorByType?: boolean;
|
||||
filterSignature?: (signature: SystemSignature) => boolean;
|
||||
}
|
||||
|
||||
const headerInlineStyle = { padding: '2px', fontSize: '12px', lineHeight: '1.333' };
|
||||
@@ -68,6 +72,9 @@ export function SystemSignaturesContent({
|
||||
onLazyDeleteChange,
|
||||
onCountChange,
|
||||
onPendingChange,
|
||||
deletionTiming,
|
||||
colorByType,
|
||||
filterSignature,
|
||||
}: SystemSignaturesContentProps) {
|
||||
const { signatures, selectedSignatures, setSelectedSignatures, handleDeleteSelected, handleSelectAll, handlePaste } =
|
||||
useSystemSignaturesData({
|
||||
@@ -76,6 +83,7 @@ export function SystemSignaturesContent({
|
||||
onCountChange,
|
||||
onPendingChange,
|
||||
onLazyDeleteChange,
|
||||
deletionTiming,
|
||||
});
|
||||
|
||||
const [sortSettings, setSortSettings] = useLocalStorageState<{ sortField: string; sortOrder: SortOrder }>(
|
||||
@@ -98,7 +106,7 @@ export function SystemSignaturesContent({
|
||||
handlePaste(clipboardContent.text);
|
||||
|
||||
setClipboardContent(null);
|
||||
}, [selectable, clipboardContent]);
|
||||
}, [selectable, clipboardContent, handlePaste, setClipboardContent]);
|
||||
|
||||
useHotkey(true, ['a'], handleSelectAll);
|
||||
useHotkey(false, ['Backspace', 'Delete'], (event: KeyboardEvent) => {
|
||||
@@ -152,6 +160,7 @@ export function SystemSignaturesContent({
|
||||
const showDescriptionColumn = settings.find(s => s.key === SHOW_DESCRIPTION_COLUMN_SETTING)?.value;
|
||||
const showUpdatedColumn = settings.find(s => s.key === SHOW_UPDATED_COLUMN_SETTING)?.value;
|
||||
const showCharacterColumn = settings.find(s => s.key === SHOW_CHARACTER_COLUMN_SETTING)?.value;
|
||||
const showCharacterPortrait = settings.find(s => s.key === SHOW_CHARACTER_PORTRAIT_SETTING)?.value;
|
||||
|
||||
const enabledGroups = settings
|
||||
.filter(s => GROUPS_LIST.includes(s.key as SignatureGroup) && s.value === true)
|
||||
@@ -159,6 +168,10 @@ export function SystemSignaturesContent({
|
||||
|
||||
const filteredSignatures = useMemo<ExtendedSystemSignature[]>(() => {
|
||||
return signatures.filter(sig => {
|
||||
if (filterSignature && !filterSignature(sig)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hideLinkedSignatures && sig.linked_system) {
|
||||
return false;
|
||||
}
|
||||
@@ -176,7 +189,7 @@ export function SystemSignaturesContent({
|
||||
return settings.find(y => y.key === sig.kind)?.value;
|
||||
}
|
||||
});
|
||||
}, [signatures, hideLinkedSignatures, settings, enabledGroups]);
|
||||
}, [signatures, hideLinkedSignatures, settings, enabledGroups, filterSignature]);
|
||||
|
||||
return (
|
||||
<div ref={tableRef} className="h-full">
|
||||
@@ -201,23 +214,17 @@ export function SystemSignaturesContent({
|
||||
sortField={sortSettings.sortField}
|
||||
sortOrder={sortSettings.sortOrder}
|
||||
onSort={e => setSortSettings({ sortField: e.sortField, sortOrder: e.sortOrder })}
|
||||
onRowMouseEnter={
|
||||
isCompact || isMedium
|
||||
? (e: DataTableRowMouseEvent) => {
|
||||
setHoveredSignature(filteredSignatures[e.index]);
|
||||
tooltipRef.current?.show(e.originalEvent);
|
||||
}
|
||||
: undefined
|
||||
onRowMouseEnter={(e: DataTableRowMouseEvent) => {
|
||||
setHoveredSignature(e.data as SystemSignature);
|
||||
tooltipRef.current?.show(e.originalEvent);
|
||||
}}
|
||||
onRowMouseLeave={() => {
|
||||
setHoveredSignature(null);
|
||||
tooltipRef.current?.hide();
|
||||
}}
|
||||
rowClassName={rowData =>
|
||||
getSignatureRowClass(rowData as ExtendedSystemSignature, selectedSignatures, colorByType)
|
||||
}
|
||||
onRowMouseLeave={
|
||||
isCompact || isMedium
|
||||
? () => {
|
||||
setHoveredSignature(null);
|
||||
tooltipRef.current?.hide();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
rowClassName={rowData => getSignatureRowClass(rowData as ExtendedSystemSignature, selectedSignatures)}
|
||||
>
|
||||
<Column
|
||||
field="icon"
|
||||
@@ -318,7 +325,11 @@ export function SystemSignaturesContent({
|
||||
<WdTooltip
|
||||
className="bg-stone-900/95 text-slate-50"
|
||||
ref={tooltipRef}
|
||||
content={hoveredSignature ? <SignatureView {...hoveredSignature} /> : null}
|
||||
content={
|
||||
hoveredSignature ? (
|
||||
<SignatureView signature={hoveredSignature} showCharacterPortrait={!!showCharacterPortrait} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
{showSignatureSettings && (
|
||||
|
||||
@@ -14,6 +14,12 @@ export const TIME_ONE_DAY = 24 * 60 * 60 * 1000;
|
||||
export const TIME_ONE_WEEK = 7 * TIME_ONE_DAY;
|
||||
export const FINAL_DURATION_MS = 10000;
|
||||
|
||||
// Signature deletion timing options
|
||||
export const DELETION_TIMING_IMMEDIATE = 0;
|
||||
export const DELETION_TIMING_DEFAULT = 10000;
|
||||
export const DELETION_TIMING_EXTENDED = 30000;
|
||||
export const DELETION_TIMING_SETTING_KEY = 'DELETION_TIMING_SETTING';
|
||||
|
||||
export const COMPACT_MAX_WIDTH = 260;
|
||||
export const MEDIUM_MAX_WIDTH = 380;
|
||||
export const OTHER_COLUMNS_WIDTH = 276;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getState } from './getState';
|
||||
|
||||
/**
|
||||
* Compare two lists of signatures and return which are added, updated, or removed.
|
||||
*
|
||||
*
|
||||
* @param oldSignatures existing signatures (in memory or from server)
|
||||
* @param newSignatures newly parsed or incoming signatures from user input
|
||||
* @param updateOnly if true, do NOT remove old signatures not found in newSignatures
|
||||
|
||||
@@ -12,11 +12,11 @@ export const getRowBackgroundColor = (date: Date | undefined): string => {
|
||||
const diff = currentDate.getTime() + currentDate.getTimezoneOffset() * TIME_ONE_MINUTE - date.getTime();
|
||||
|
||||
if (diff < TIME_ONE_MINUTE) {
|
||||
return 'bg-lime-600/50 transition hover:bg-lime-600/60';
|
||||
return 'bg-lime-600/40 transition hover:bg-lime-600/50';
|
||||
}
|
||||
|
||||
if (diff < TIME_TEN_MINUTES) {
|
||||
return 'bg-lime-700/40 transition hover:bg-lime-700/50';
|
||||
return 'bg-lime-700/30 transition hover:bg-lime-700/40';
|
||||
}
|
||||
|
||||
return '';
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
.pendingDeletion {
|
||||
background-color: #f87171;
|
||||
background-color: rgba(248, 113, 113, 0.4);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
|
||||
.pendingDeletion td {
|
||||
background-color: #f87171;
|
||||
background-color: rgba(248, 113, 113, 0.4);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.pendingDeletion {
|
||||
background-color: #f87171;
|
||||
transition: background-color 0.2s ease;
|
||||
.pendingDeletion:hover {
|
||||
background-color: rgba(248, 113, 113, 0.5);
|
||||
}
|
||||
|
||||
.pendingDeletion:hover td {
|
||||
background-color: rgba(248, 113, 113, 0.5);
|
||||
}
|
||||
|
||||
.Table thead tr {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import clsx from 'clsx';
|
||||
import { SignatureGroup } from '@/hooks/Mapper/types';
|
||||
import { ExtendedSystemSignature } from './contentHelpers';
|
||||
import { getRowBackgroundColor } from './getRowBackgroundColor';
|
||||
import classes from './rowStyles.module.scss';
|
||||
@@ -6,15 +7,67 @@ import classes from './rowStyles.module.scss';
|
||||
export function getSignatureRowClass(
|
||||
row: ExtendedSystemSignature,
|
||||
selectedSignatures: ExtendedSystemSignature[],
|
||||
colorByType?: boolean,
|
||||
): string {
|
||||
const isSelected = selectedSignatures.some(s => s.eve_id === row.eve_id);
|
||||
|
||||
if (isSelected) {
|
||||
return clsx(
|
||||
classes.TableRowCompact,
|
||||
'p-selectable-row',
|
||||
'bg-amber-500/50 hover:bg-amber-500/70 transition duration-200 text-xs',
|
||||
);
|
||||
}
|
||||
|
||||
if (row.pendingDeletion) {
|
||||
return clsx(classes.TableRowCompact, 'p-selectable-row', classes.pendingDeletion);
|
||||
}
|
||||
|
||||
// Apply color by type styling if enabled
|
||||
if (colorByType) {
|
||||
if (row.group === SignatureGroup.Wormhole) {
|
||||
return clsx(
|
||||
classes.TableRowCompact,
|
||||
'p-selectable-row',
|
||||
'bg-blue-400/20 hover:bg-blue-400/20 transition duration-200 text-xs',
|
||||
);
|
||||
}
|
||||
|
||||
if (row.group === SignatureGroup.CosmicSignature) {
|
||||
return clsx(
|
||||
classes.TableRowCompact,
|
||||
'p-selectable-row',
|
||||
'bg-red-400/20 hover:bg-red-400/20 transition duration-200 text-xs',
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
row.group === SignatureGroup.RelicSite ||
|
||||
row.group === SignatureGroup.DataSite ||
|
||||
row.group === SignatureGroup.GasSite ||
|
||||
row.group === SignatureGroup.OreSite ||
|
||||
row.group === SignatureGroup.CombatSite
|
||||
) {
|
||||
return clsx(
|
||||
classes.TableRowCompact,
|
||||
'p-selectable-row',
|
||||
'bg-green-400/20 hover:bg-green-400/20 transition duration-200 text-xs',
|
||||
);
|
||||
}
|
||||
|
||||
// Default for color by type - apply same color as CosmicSignature (red) and small text size
|
||||
return clsx(
|
||||
classes.TableRowCompact,
|
||||
'p-selectable-row',
|
||||
'bg-red-400/20 hover:bg-red-400/20 transition duration-200 text-xs',
|
||||
);
|
||||
}
|
||||
|
||||
// Original styling when color by type is disabled
|
||||
return clsx(
|
||||
classes.TableRowCompact,
|
||||
'p-selectable-row',
|
||||
isSelected && 'bg-amber-500/50 hover:bg-amber-500/70 transition duration-200',
|
||||
!isSelected && row.pendingDeletion && classes.pendingDeletion,
|
||||
!isSelected && getRowBackgroundColor(row.inserted_at ? new Date(row.inserted_at) : undefined),
|
||||
'hover:bg-purple-400/20 transition duration-200',
|
||||
!row.pendingDeletion && getRowBackgroundColor(row.inserted_at ? new Date(row.inserted_at) : undefined),
|
||||
!row.pendingDeletion && 'hover:bg-purple-400/20 transition duration-200',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@ import { ExtendedSystemSignature } from '../helpers/contentHelpers';
|
||||
|
||||
export interface UseSystemSignaturesDataProps {
|
||||
systemId: string;
|
||||
settings: { key: string; value: boolean }[];
|
||||
settings: { key: string; value: boolean | number }[];
|
||||
hideLinkedSignatures?: boolean;
|
||||
onCountChange?: (count: number) => void;
|
||||
onPendingChange?: (pending: ExtendedSystemSignature[], undo: () => void) => void;
|
||||
onLazyDeleteChange?: (value: boolean) => void;
|
||||
deletionTiming?: number;
|
||||
}
|
||||
|
||||
export interface UseFetchingParams {
|
||||
@@ -19,8 +20,10 @@ export interface UseFetchingParams {
|
||||
export interface UsePendingDeletionParams {
|
||||
systemId: string;
|
||||
setSignatures: React.Dispatch<React.SetStateAction<ExtendedSystemSignature[]>>;
|
||||
deletionTiming?: number;
|
||||
}
|
||||
|
||||
export interface UsePendingAdditionParams {
|
||||
setSignatures: React.Dispatch<React.SetStateAction<ExtendedSystemSignature[]>>;
|
||||
deletionTiming?: number;
|
||||
}
|
||||
|
||||
@@ -3,33 +3,49 @@ import { ExtendedSystemSignature, schedulePendingAdditionForSig } from '../helpe
|
||||
import { UsePendingAdditionParams } from './types';
|
||||
import { FINAL_DURATION_MS } from '../constants';
|
||||
|
||||
export function usePendingAdditions({ setSignatures }: UsePendingAdditionParams) {
|
||||
export function usePendingAdditions({ setSignatures, deletionTiming }: UsePendingAdditionParams) {
|
||||
const [pendingUndoAdditions, setPendingUndoAdditions] = useState<ExtendedSystemSignature[]>([]);
|
||||
const pendingAdditionMapRef = useRef<Record<string, { finalUntil: number; finalTimeoutId: number }>>({});
|
||||
|
||||
// Use the provided deletion timing or fall back to the default
|
||||
const finalDuration = deletionTiming !== undefined ? deletionTiming : FINAL_DURATION_MS;
|
||||
|
||||
const processAddedSignatures = useCallback(
|
||||
(added: ExtendedSystemSignature[]) => {
|
||||
if (!added.length) return;
|
||||
|
||||
// If duration is 0, don't show pending state
|
||||
if (finalDuration === 0) {
|
||||
setSignatures(prev => [
|
||||
...prev,
|
||||
...added.map(sig => ({
|
||||
...sig,
|
||||
pendingAddition: false,
|
||||
})),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
setSignatures(prev => [
|
||||
...prev,
|
||||
...added.map(sig => ({
|
||||
...sig,
|
||||
pendingAddition: true,
|
||||
pendingUntil: now + FINAL_DURATION_MS,
|
||||
pendingUntil: now + finalDuration,
|
||||
})),
|
||||
]);
|
||||
added.forEach(sig => {
|
||||
schedulePendingAdditionForSig(
|
||||
sig,
|
||||
FINAL_DURATION_MS,
|
||||
finalDuration,
|
||||
setSignatures,
|
||||
pendingAdditionMapRef,
|
||||
setPendingUndoAdditions,
|
||||
);
|
||||
});
|
||||
},
|
||||
[setSignatures],
|
||||
[setSignatures, finalDuration],
|
||||
);
|
||||
|
||||
const clearPendingAdditions = useCallback(() => {
|
||||
|
||||
@@ -5,13 +5,16 @@ import { UsePendingDeletionParams } from './types';
|
||||
import { FINAL_DURATION_MS } from '../constants';
|
||||
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
|
||||
|
||||
export function usePendingDeletions({ systemId, setSignatures }: UsePendingDeletionParams) {
|
||||
export function usePendingDeletions({ systemId, setSignatures, deletionTiming }: UsePendingDeletionParams) {
|
||||
const { outCommand } = useMapRootState();
|
||||
const [localPendingDeletions, setLocalPendingDeletions] = useState<ExtendedSystemSignature[]>([]);
|
||||
const [pendingDeletionMap, setPendingDeletionMap] = useState<
|
||||
Record<string, { finalUntil: number; finalTimeoutId: number }>
|
||||
>({});
|
||||
|
||||
// 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[],
|
||||
@@ -19,12 +22,22 @@ export function usePendingDeletions({ systemId, setSignatures }: UsePendingDelet
|
||||
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,
|
||||
pendingAddition: false,
|
||||
pendingUntil: now + FINAL_DURATION_MS,
|
||||
pendingUntil: now + finalDuration,
|
||||
}));
|
||||
setLocalPendingDeletions(prev => [...prev, ...processedRemoved]);
|
||||
|
||||
@@ -36,7 +49,7 @@ export function usePendingDeletions({ systemId, setSignatures }: UsePendingDelet
|
||||
setSignatures(prev =>
|
||||
prev.map(sig => {
|
||||
if (processedRemoved.find(r => r.eve_id === sig.eve_id)) {
|
||||
return { ...sig, pendingDeletion: true, pendingUntil: now + FINAL_DURATION_MS };
|
||||
return { ...sig, pendingDeletion: true, pendingUntil: now + finalDuration };
|
||||
}
|
||||
return sig;
|
||||
}),
|
||||
@@ -53,10 +66,10 @@ export function usePendingDeletions({ systemId, setSignatures }: UsePendingDelet
|
||||
setLocalPendingDeletions(prev => prev.filter(x => x.eve_id !== sig.eve_id));
|
||||
setSignatures(prev => prev.filter(x => x.eve_id !== sig.eve_id));
|
||||
},
|
||||
FINAL_DURATION_MS,
|
||||
finalDuration,
|
||||
);
|
||||
},
|
||||
[systemId, outCommand, setSignatures],
|
||||
[systemId, outCommand, setSignatures, finalDuration],
|
||||
);
|
||||
|
||||
const clearPendingDeletions = useCallback(() => {
|
||||
|
||||
@@ -21,6 +21,7 @@ export function useSystemSignaturesData({
|
||||
onCountChange,
|
||||
onPendingChange,
|
||||
onLazyDeleteChange,
|
||||
deletionTiming,
|
||||
}: UseSystemSignaturesDataProps) {
|
||||
const { outCommand } = useMapRootState();
|
||||
const [signatures, setSignatures, signaturesRef] = useRefState<ExtendedSystemSignature[]>([]);
|
||||
@@ -30,10 +31,12 @@ export function useSystemSignaturesData({
|
||||
usePendingDeletions({
|
||||
systemId,
|
||||
setSignatures,
|
||||
deletionTiming,
|
||||
});
|
||||
const { pendingUndoAdditions, setPendingUndoAdditions, processAddedSignatures, clearPendingAdditions } =
|
||||
usePendingAdditions({
|
||||
setSignatures,
|
||||
deletionTiming,
|
||||
});
|
||||
|
||||
const { handleGetSignatures, handleUpdateSignatures } = useSignatureFetching({
|
||||
|
||||
@@ -8,13 +8,20 @@ import { OnTheMap, RightBar } from '@/hooks/Mapper/components/mapRootContent/com
|
||||
import { MapContextMenu } from '@/hooks/Mapper/components/mapRootContent/components/MapContextMenu/MapContextMenu.tsx';
|
||||
import { useSkipContextMenu } from '@/hooks/Mapper/hooks/useSkipContextMenu';
|
||||
import { MapSettings } from '@/hooks/Mapper/components/mapRootContent/components/MapSettings';
|
||||
import { CharacterActivity } from '@/hooks/Mapper/components/mapRootContent/components/CharacterActivity/CharacterActivity';
|
||||
import { TrackAndFollow } from '@/hooks/Mapper/components/mapRootContent/components/TrackAndFollow/TrackAndFollow';
|
||||
import { useCharacterActivityHandlers } from './hooks/useCharacterActivityHandlers';
|
||||
import { useTrackAndFollowHandlers } from './hooks/useTrackAndFollowHandlers';
|
||||
|
||||
export interface MapRootContentProps {}
|
||||
|
||||
// eslint-disable-next-line no-empty-pattern
|
||||
export const MapRootContent = ({}: MapRootContentProps) => {
|
||||
const { interfaceSettings } = useMapRootState();
|
||||
const { interfaceSettings, data } = useMapRootState();
|
||||
const { isShowMenu } = interfaceSettings;
|
||||
const { showCharacterActivity, showTrackAndFollow } = data;
|
||||
const { handleHideCharacterActivity } = useCharacterActivityHandlers();
|
||||
const { handleHideTracking } = useTrackAndFollowHandlers();
|
||||
|
||||
const themeClass = `${interfaceSettings.theme ?? 'default'}-theme`;
|
||||
|
||||
@@ -49,7 +56,11 @@ export const MapRootContent = ({}: MapRootContentProps) => {
|
||||
</div>
|
||||
)}
|
||||
<OnTheMap show={showOnTheMap} onHide={() => setShowOnTheMap(false)} />
|
||||
<MapSettings show={showMapSettings} onHide={() => setShowMapSettings(false)} />
|
||||
{showMapSettings && <MapSettings visible={showMapSettings} onHide={() => setShowMapSettings(false)} />}
|
||||
{showCharacterActivity && (
|
||||
<CharacterActivity visible={showCharacterActivity} onHide={handleHideCharacterActivity} />
|
||||
)}
|
||||
{showTrackAndFollow && <TrackAndFollow visible={showTrackAndFollow} onHide={handleHideTracking} />}
|
||||
</Layout>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
:global {
|
||||
.p-datatable .p-datatable-thead > tr > th {
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.p-datatable .p-datatable-tbody > tr > td {
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.p-datatable {
|
||||
width: 100%;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.p-datatable-wrapper {
|
||||
border: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.spinnerContainer {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.columnHeader {
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem; /* text-xs */
|
||||
white-space: normal !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.numericColumnHeader {
|
||||
padding: 2px !important;
|
||||
}
|
||||
|
||||
.dataTable {
|
||||
width: 100%;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.cellContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.numericValueCell {
|
||||
text-align: center;
|
||||
font-size: 0.75rem; /* text-xs */
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { DataTable } from 'primereact/datatable';
|
||||
import { Column } from 'primereact/column';
|
||||
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
|
||||
import { ProgressSpinner } from 'primereact/progressspinner';
|
||||
import classes from './CharacterActivity.module.scss';
|
||||
import { CharacterCard } from '../../../ui-kit';
|
||||
import { CharacterTypeRaw } from '@/hooks/Mapper/types';
|
||||
|
||||
export interface ActivitySummary {
|
||||
character: CharacterTypeRaw;
|
||||
passages: number;
|
||||
connections: number;
|
||||
signatures: number;
|
||||
}
|
||||
|
||||
interface CharacterActivityProps {
|
||||
visible: boolean;
|
||||
onHide: () => void;
|
||||
}
|
||||
|
||||
const getRowClassName = () => ['text-xs', 'leading-tight', 'p-selectable-row'];
|
||||
|
||||
const renderCharacterTemplate = (rowData: ActivitySummary) => {
|
||||
return (
|
||||
<div className={classes.cellContent}>
|
||||
<CharacterCard showShipName={false} showSystem={false} compact isOwn {...rowData.character} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderValueTemplate = (rowData: ActivitySummary, field: keyof ActivitySummary) => {
|
||||
return <div className={`${classes.numericValueCell} tabular-nums`}>{rowData[field] as number}</div>;
|
||||
};
|
||||
|
||||
export const CharacterActivity = ({ visible, onHide }: CharacterActivityProps) => {
|
||||
const { data } = useMapRootState();
|
||||
const { characterActivityData } = data;
|
||||
const [localActivity, setLocalActivity] = useState<ActivitySummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const activity = useMemo(() => {
|
||||
return characterActivityData?.activity || [];
|
||||
}, [characterActivityData]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalActivity(activity);
|
||||
setLoading(characterActivityData?.loading !== false);
|
||||
}, [activity, characterActivityData]);
|
||||
|
||||
const renderContent = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-[400px] w-full">
|
||||
<ProgressSpinner className={classes.spinnerContainer} strokeWidth="4" />
|
||||
<div className="mt-4 text-text-color-secondary text-sm">Loading character activity data...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (localActivity.length === 0) {
|
||||
return (
|
||||
<div className="p-8 text-center text-text-color-secondary italic">No character activity data available</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
value={localActivity}
|
||||
scrollable
|
||||
scrollHeight="400px"
|
||||
resizableColumns
|
||||
columnResizeMode="fit"
|
||||
className="w-full"
|
||||
tableClassName={classes.dataTable}
|
||||
emptyMessage="No character activity data available"
|
||||
sortField="passages"
|
||||
sortOrder={-1}
|
||||
responsiveLayout="scroll"
|
||||
size="small"
|
||||
rowClassName={getRowClassName}
|
||||
rowHover
|
||||
>
|
||||
<Column
|
||||
field="character_name"
|
||||
header="Character"
|
||||
body={renderCharacterTemplate}
|
||||
sortable
|
||||
headerStyle={{ minWidth: '75px', height: 'auto', overflow: 'visible' }}
|
||||
bodyStyle={{ minWidth: '75px' }}
|
||||
className={classes.characterColumn}
|
||||
headerClassName={`${classes.columnHeader} ${classes.characterHeader}`}
|
||||
/>
|
||||
|
||||
<Column
|
||||
field="passages"
|
||||
header="Passages"
|
||||
body={rowData => renderValueTemplate(rowData, 'passages')}
|
||||
sortable
|
||||
headerStyle={{ width: '120px', textAlign: 'center', height: 'auto', overflow: 'visible' }}
|
||||
bodyStyle={{ width: '120px', textAlign: 'center' }}
|
||||
className={classes.numericColumn}
|
||||
headerClassName={`${classes.columnHeader} ${classes.numericColumnHeader}`}
|
||||
/>
|
||||
<Column
|
||||
field="connections"
|
||||
header="Connections"
|
||||
body={rowData => renderValueTemplate(rowData, 'connections')}
|
||||
sortable
|
||||
headerStyle={{ width: '120px', textAlign: 'center', height: 'auto', overflow: 'visible' }}
|
||||
bodyStyle={{ width: '120px', textAlign: 'center' }}
|
||||
className={classes.numericColumn}
|
||||
headerClassName={`${classes.columnHeader} ${classes.numericColumnHeader}`}
|
||||
/>
|
||||
<Column
|
||||
field="signatures"
|
||||
header="Signatures"
|
||||
body={rowData => renderValueTemplate(rowData, 'signatures')}
|
||||
sortable
|
||||
headerStyle={{ width: '120px', textAlign: 'center', height: 'auto', overflow: 'visible' }}
|
||||
bodyStyle={{ width: '120px', textAlign: 'center' }}
|
||||
className={classes.numericColumn}
|
||||
headerClassName={`${classes.columnHeader} ${classes.numericColumnHeader}`}
|
||||
/>
|
||||
</DataTable>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog header="Character Activity" visible={visible} className="max-w-[600px]" onHide={onHide} dismissableMask>
|
||||
<div className="w-full h-[400px] flex flex-col overflow-hidden p-0 m-0">{renderContent()}</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -22,8 +22,15 @@ export const MapContextMenu = ({ onShowOnTheMap, onShowMapSettings }: MapContext
|
||||
|
||||
const handleAddCharacter = useCallback(() => {
|
||||
outCommand({
|
||||
type: OutCommand.addCharacter,
|
||||
data: null,
|
||||
type: OutCommand.showTracking,
|
||||
data: {},
|
||||
});
|
||||
}, [outCommand]);
|
||||
|
||||
const handleShowActivity = useCallback(() => {
|
||||
outCommand({
|
||||
type: OutCommand.showActivity,
|
||||
data: {},
|
||||
});
|
||||
}, [outCommand]);
|
||||
|
||||
@@ -36,6 +43,12 @@ export const MapContextMenu = ({ onShowOnTheMap, onShowMapSettings }: MapContext
|
||||
command: handleAddCharacter,
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
label: 'Character Activity',
|
||||
icon: 'pi pi-chart-bar',
|
||||
command: handleShowActivity,
|
||||
visible: canTrackCharacters,
|
||||
},
|
||||
{
|
||||
label: 'On the map',
|
||||
icon: 'pi pi-hashtag',
|
||||
@@ -61,7 +74,14 @@ export const MapContextMenu = ({ onShowOnTheMap, onShowMapSettings }: MapContext
|
||||
},
|
||||
] as MenuItem[]
|
||||
).filter(item => item.visible);
|
||||
}, [canTrackCharacters, handleAddCharacter, onShowMapSettings, onShowOnTheMap, setInterfaceSettings]);
|
||||
}, [
|
||||
canTrackCharacters,
|
||||
handleAddCharacter,
|
||||
handleShowActivity,
|
||||
onShowMapSettings,
|
||||
onShowOnTheMap,
|
||||
setInterfaceSettings,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="ml-1">
|
||||
|
||||
@@ -40,7 +40,7 @@ export type UserSettingsRemote = {
|
||||
export type UserSettings = UserSettingsRemote & InterfaceStoredSettings;
|
||||
|
||||
export interface MapSettingsProps {
|
||||
show: boolean;
|
||||
visible: boolean;
|
||||
onHide: () => void;
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ const THEME_SETTING: SettingsListItem = {
|
||||
options: THEME_OPTIONS,
|
||||
};
|
||||
|
||||
export const MapSettings = ({ show, onHide }: MapSettingsProps) => {
|
||||
export const MapSettings = ({ visible, onHide }: MapSettingsProps) => {
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const { outCommand, interfaceSettings, setInterfaceSettings } = useMapRootState();
|
||||
const [userRemoteSettings, setUserRemoteSettings] = useState<UserSettingsRemote>({
|
||||
@@ -213,12 +213,12 @@ export const MapSettings = ({ show, onHide }: MapSettingsProps) => {
|
||||
return (
|
||||
<Dialog
|
||||
header="Map user settings"
|
||||
visible={show}
|
||||
visible
|
||||
draggable={false}
|
||||
style={{ width: '550px' }}
|
||||
onShow={handleShow}
|
||||
onHide={() => {
|
||||
if (!show) return;
|
||||
if (!visible) return;
|
||||
setActiveIndex(0);
|
||||
onHide();
|
||||
}}
|
||||
|
||||
@@ -21,10 +21,11 @@ export const RightBar = ({ onShowOnTheMap, onShowMapSettings }: RightBarProps) =
|
||||
|
||||
const isShowMinimap = interfaceSettings.isShowMinimap === undefined ? true : interfaceSettings.isShowMinimap;
|
||||
|
||||
const handleAddCharacter = useCallback(() => {
|
||||
const handleShowTracking = useCallback(() => {
|
||||
// Use the OutCommand pattern for showing the tracking dialog
|
||||
outCommand({
|
||||
type: OutCommand.addCharacter,
|
||||
data: null,
|
||||
type: OutCommand.showTracking,
|
||||
data: {},
|
||||
});
|
||||
}, [outCommand]);
|
||||
|
||||
@@ -63,22 +64,25 @@ export const RightBar = ({ onShowOnTheMap, onShowMapSettings }: RightBarProps) =
|
||||
<button
|
||||
className="btn bg-transparent text-gray-400 hover:text-white border-transparent hover:bg-transparent py-2 h-auto min-h-auto"
|
||||
type="button"
|
||||
onClick={handleAddCharacter}
|
||||
onClick={handleShowTracking}
|
||||
id="show-tracking-button"
|
||||
>
|
||||
<i className="pi pi-user-plus"></i>
|
||||
</button>
|
||||
</WdTooltipWrapper>
|
||||
|
||||
{canTrackCharacters && (
|
||||
<WdTooltipWrapper content="Show on the map" position={TooltipPosition.left}>
|
||||
<button
|
||||
className="btn bg-transparent text-gray-400 hover:text-white border-transparent hover:bg-transparent py-2 h-auto min-h-auto"
|
||||
type="button"
|
||||
onClick={onShowOnTheMap}
|
||||
>
|
||||
<i className="pi pi-hashtag"></i>
|
||||
</button>
|
||||
</WdTooltipWrapper>
|
||||
<>
|
||||
<WdTooltipWrapper content="Show on the map" position={TooltipPosition.left}>
|
||||
<button
|
||||
className="btn bg-transparent text-gray-400 hover:text-white border-transparent hover:bg-transparent py-2 h-auto min-h-auto"
|
||||
type="button"
|
||||
onClick={onShowOnTheMap}
|
||||
>
|
||||
<i className="pi pi-hashtag"></i>
|
||||
</button>
|
||||
</WdTooltipWrapper>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { OutCommand, SignatureGroup, SystemSignature } from '@/hooks/Mapper/types';
|
||||
import { OutCommand, SignatureGroup, SystemSignature, TimeStatus } from '@/hooks/Mapper/types';
|
||||
import { Controller, FormProvider, useForm } from 'react-hook-form';
|
||||
import {
|
||||
SignatureGroupContent,
|
||||
@@ -10,6 +10,7 @@ import { InputText } from 'primereact/inputtext';
|
||||
import { SystemsSettingsProvider } from '@/hooks/Mapper/components/mapRootContent/components/SignatureSettings/Provider.tsx';
|
||||
import { Button } from 'primereact/button';
|
||||
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
|
||||
import { getWhSize } from '@/hooks/Mapper/helpers/getWhSize';
|
||||
|
||||
type SystemSignaturePrepared = Omit<SystemSignature, 'linked_system'> & { linked_system: string };
|
||||
|
||||
@@ -21,7 +22,10 @@ export interface MapSettingsProps {
|
||||
}
|
||||
|
||||
export const SignatureSettings = ({ systemId, show, onHide, signatureData }: MapSettingsProps) => {
|
||||
const { outCommand } = useMapRootState();
|
||||
const {
|
||||
outCommand,
|
||||
data: { wormholes },
|
||||
} = useMapRootState();
|
||||
|
||||
const handleShow = async () => {};
|
||||
const signatureForm = useForm<Partial<SystemSignaturePrepared>>({});
|
||||
@@ -47,6 +51,31 @@ export const SignatureSettings = ({ systemId, show, onHide, signatureData }: Map
|
||||
solar_system_target: values.linked_system,
|
||||
},
|
||||
});
|
||||
|
||||
if (values.isEOL) {
|
||||
await outCommand({
|
||||
type: OutCommand.updateConnectionTimeStatus,
|
||||
data: {
|
||||
source: systemId,
|
||||
target: values.linked_system,
|
||||
value: TimeStatus.eol,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (values.type) {
|
||||
const whShipSize = getWhSize(wormholes, values.type);
|
||||
if (whShipSize) {
|
||||
outCommand({
|
||||
type: OutCommand.updateConnectionShipSizeType,
|
||||
data: {
|
||||
source: systemId,
|
||||
target: values.linked_system,
|
||||
value: whShipSize,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out = {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.trackFollowHeader {
|
||||
background-color: #1e1e1e;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { VirtualScroller } from 'primereact/virtualscroller';
|
||||
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
|
||||
import { OutCommand } from '@/hooks/Mapper/types/mapHandlers';
|
||||
import { TrackingCharacterWrapper } from './TrackingCharacterWrapper';
|
||||
import { TrackingCharacter } from './types';
|
||||
import classes from './TrackAndFollow.module.scss';
|
||||
|
||||
interface TrackAndFollowProps {
|
||||
visible: boolean;
|
||||
onHide: () => void;
|
||||
}
|
||||
|
||||
const renderHeader = () => {
|
||||
return (
|
||||
<div className="dialog-header">
|
||||
<span>Track & Follow</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const TrackAndFollow = ({ visible, onHide }: TrackAndFollowProps) => {
|
||||
const [trackedCharacters, setTrackedCharacters] = useState<string[]>([]);
|
||||
const [followedCharacter, setFollowedCharacter] = useState<string | null>(null);
|
||||
const { outCommand, data } = useMapRootState();
|
||||
const { trackingCharactersData } = data;
|
||||
const characters = useMemo(() => trackingCharactersData || [], [trackingCharactersData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (trackingCharactersData) {
|
||||
const newTrackedCharacters = trackingCharactersData.filter(tc => tc.tracked).map(tc => tc.character.eve_id);
|
||||
|
||||
setTrackedCharacters(newTrackedCharacters);
|
||||
|
||||
const followedChar = trackingCharactersData.find(tc => tc.followed);
|
||||
|
||||
if (followedChar?.character?.eve_id !== followedCharacter) {
|
||||
setFollowedCharacter(followedChar?.character?.eve_id || null);
|
||||
}
|
||||
}
|
||||
}, [followedCharacter, trackingCharactersData]);
|
||||
|
||||
const handleTrackToggle = (characterId: string) => {
|
||||
const isCurrentlyTracked = trackedCharacters.includes(characterId);
|
||||
|
||||
if (isCurrentlyTracked) {
|
||||
setTrackedCharacters(prev => prev.filter(id => id !== characterId));
|
||||
} else {
|
||||
setTrackedCharacters(prev => [...prev, characterId]);
|
||||
}
|
||||
|
||||
outCommand({
|
||||
type: OutCommand.toggleTrack,
|
||||
data: { 'character-id': characterId },
|
||||
});
|
||||
};
|
||||
|
||||
const handleFollowToggle = (characterId: string) => {
|
||||
const isCurrentlyFollowed = followedCharacter === characterId;
|
||||
const isCurrentlyTracked = trackedCharacters.includes(characterId);
|
||||
|
||||
// If not followed and not tracked, we need to track it first
|
||||
if (!isCurrentlyFollowed && !isCurrentlyTracked) {
|
||||
setTrackedCharacters(prev => [...prev, characterId]);
|
||||
|
||||
// Send track command first
|
||||
outCommand({
|
||||
type: OutCommand.toggleTrack,
|
||||
data: { 'character-id': characterId },
|
||||
});
|
||||
|
||||
// Then send follow command after a short delay
|
||||
setTimeout(() => {
|
||||
outCommand({
|
||||
type: OutCommand.toggleFollow,
|
||||
data: { 'character-id': characterId },
|
||||
});
|
||||
}, 100);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise just toggle follow
|
||||
outCommand({
|
||||
type: OutCommand.toggleFollow,
|
||||
data: { 'character-id': characterId },
|
||||
});
|
||||
};
|
||||
|
||||
const rowTemplate = (tc: TrackingCharacter) => {
|
||||
return (
|
||||
<TrackingCharacterWrapper
|
||||
key={tc.character.eve_id}
|
||||
character={tc.character}
|
||||
isTracked={trackedCharacters.includes(tc.character.eve_id)}
|
||||
isFollowed={followedCharacter === tc.character.eve_id}
|
||||
onTrackToggle={() => handleTrackToggle(tc.character.eve_id)}
|
||||
onFollowToggle={() => handleFollowToggle(tc.character.eve_id)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
header={renderHeader()}
|
||||
visible={visible}
|
||||
onHide={onHide}
|
||||
className="w-[500px] text-text-color"
|
||||
contentClassName="!p-0"
|
||||
>
|
||||
<div className="w-full overflow-hidden">
|
||||
<div className="grid grid-cols-[80px_80px_1fr] p-1 font-normal text-sm text-center border-b border-[#383838]">
|
||||
<div>Track</div>
|
||||
<div>Follow</div>
|
||||
<div className="text-center">Character</div>
|
||||
</div>
|
||||
<VirtualScroller items={characters} itemSize={48} itemTemplate={rowTemplate} className="h-72 w-full" />
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
.characterRow {
|
||||
border-color: var(--surface-border);
|
||||
border-width: 0 0 1px 0;
|
||||
border-style: solid;
|
||||
opacity: 0.5;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { WdCheckbox } from '@/hooks/Mapper/components/ui-kit/WdCheckbox/WdCheckbox';
|
||||
import WdRadioButton from '@/hooks/Mapper/components/ui-kit/WdRadioButton';
|
||||
import { CharacterCard, TooltipPosition, WdTooltipWrapper } from '../../../ui-kit';
|
||||
import { CharacterTypeRaw } from '@/hooks/Mapper/types';
|
||||
|
||||
interface TrackingCharacterWrapperProps {
|
||||
character: CharacterTypeRaw;
|
||||
isTracked: boolean;
|
||||
isFollowed: boolean;
|
||||
onTrackToggle: () => void;
|
||||
onFollowToggle: () => void;
|
||||
}
|
||||
|
||||
export const TrackingCharacterWrapper = ({
|
||||
character,
|
||||
isTracked,
|
||||
isFollowed,
|
||||
onTrackToggle,
|
||||
onFollowToggle,
|
||||
}: TrackingCharacterWrapperProps) => {
|
||||
const trackCheckboxId = `track-${character.eve_id}`;
|
||||
const followRadioId = `follow-${character.eve_id}`;
|
||||
|
||||
return (
|
||||
<div className="p-selectable-row grid grid-cols-[80px_80px_1fr] items-center min-h-8 hover:bg-neutral-800 border-b border-[#383838]">
|
||||
<div className="flex justify-center items-center p-0.5 text-center">
|
||||
<WdTooltipWrapper content="Track this character on the map" position={TooltipPosition.top}>
|
||||
<div className="flex justify-center items-center w-full">
|
||||
<WdCheckbox id={trackCheckboxId} label="" value={isTracked} onChange={() => onTrackToggle()} />
|
||||
</div>
|
||||
</WdTooltipWrapper>
|
||||
</div>
|
||||
<div className="flex justify-center items-center p-0.5 text-center">
|
||||
<WdTooltipWrapper content="Follow this character's movements on the map" position={TooltipPosition.top}>
|
||||
<div className="flex justify-center items-center w-full">
|
||||
<div onClick={onFollowToggle} className="cursor-pointer">
|
||||
<WdRadioButton id={followRadioId} name="followed_character" checked={isFollowed} onChange={() => {}} />
|
||||
</div>
|
||||
</div>
|
||||
</WdTooltipWrapper>
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<CharacterCard showShipName={false} showSystem={false} isOwn {...character} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { CharacterTypeRaw } from '@/hooks/Mapper/types';
|
||||
/**
|
||||
* Interface for a character that can be tracked and followed
|
||||
*/
|
||||
export interface TrackingCharacter {
|
||||
character: CharacterTypeRaw;
|
||||
tracked: boolean;
|
||||
followed: boolean;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
|
||||
import { OutCommand } from '@/hooks/Mapper/types/mapHandlers';
|
||||
import type { ActivitySummary } from '@/hooks/Mapper/components/mapRootContent/components/CharacterActivity/CharacterActivity';
|
||||
|
||||
/**
|
||||
* Hook for character activity related handlers
|
||||
*/
|
||||
export const useCharacterActivityHandlers = () => {
|
||||
const { outCommand, update } = useMapRootState();
|
||||
|
||||
/**
|
||||
* Handle hiding the character activity dialog
|
||||
*/
|
||||
const handleHideCharacterActivity = useCallback(() => {
|
||||
// Update local state to hide the dialog
|
||||
update(state => ({
|
||||
...state,
|
||||
showCharacterActivity: false,
|
||||
}));
|
||||
|
||||
// Send the command to the server
|
||||
outCommand({
|
||||
type: OutCommand.hideActivity,
|
||||
data: {},
|
||||
});
|
||||
}, [outCommand, update]);
|
||||
|
||||
/**
|
||||
* Handle showing the character activity dialog
|
||||
*/
|
||||
const handleShowActivity = useCallback(() => {
|
||||
// Update local state to show the dialog
|
||||
update(state => ({
|
||||
...state,
|
||||
showCharacterActivity: true,
|
||||
}));
|
||||
|
||||
// Send the command to the server
|
||||
outCommand({
|
||||
type: OutCommand.showActivity,
|
||||
data: {},
|
||||
});
|
||||
}, [outCommand, update]);
|
||||
|
||||
/**
|
||||
* Handle updating character activity data
|
||||
*/
|
||||
const handleUpdateActivity = useCallback(
|
||||
(activityData: { activity: ActivitySummary[] }) => {
|
||||
if (!activityData || !activityData.activity) {
|
||||
console.error('Invalid activity data received:', activityData);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update local state with the activity data
|
||||
update(state => ({
|
||||
...state,
|
||||
characterActivityData: activityData.activity,
|
||||
showCharacterActivity: true,
|
||||
}));
|
||||
},
|
||||
[update],
|
||||
);
|
||||
|
||||
return {
|
||||
handleHideCharacterActivity,
|
||||
handleShowActivity,
|
||||
handleUpdateActivity,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
|
||||
import { OutCommand, CommandData, Commands } from '@/hooks/Mapper/types/mapHandlers';
|
||||
import type { TrackingCharacter } from '@/hooks/Mapper/components/mapRootContent/components/TrackAndFollow/types';
|
||||
|
||||
/**
|
||||
* Hook for track and follow related handlers
|
||||
*/
|
||||
export const useTrackAndFollowHandlers = () => {
|
||||
const { outCommand, update } = useMapRootState();
|
||||
|
||||
/**
|
||||
* Handle hiding the track and follow dialog
|
||||
*/
|
||||
const handleHideTracking = useCallback(() => {
|
||||
// Send the command to the server first
|
||||
outCommand({
|
||||
type: OutCommand.hideTracking,
|
||||
data: {},
|
||||
});
|
||||
|
||||
// Then update local state to hide the dialog
|
||||
update(state => ({
|
||||
...state,
|
||||
showTrackAndFollow: false,
|
||||
}));
|
||||
}, [outCommand, update]);
|
||||
|
||||
/**
|
||||
* Handle showing the track and follow dialog
|
||||
*/
|
||||
const handleShowTracking = useCallback(() => {
|
||||
// Update local state to show the dialog
|
||||
update(state => ({
|
||||
...state,
|
||||
showTrackAndFollow: true,
|
||||
}));
|
||||
|
||||
// Send the command to the server
|
||||
outCommand({
|
||||
type: OutCommand.showTracking,
|
||||
data: {},
|
||||
});
|
||||
}, [outCommand, update]);
|
||||
|
||||
/**
|
||||
* Handle updating tracking data
|
||||
*/
|
||||
const handleUpdateTracking = useCallback(
|
||||
(trackingData: { characters: TrackingCharacter[] }) => {
|
||||
if (!trackingData || !trackingData.characters) {
|
||||
console.error('Invalid tracking data received:', trackingData);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update local state with the tracking data
|
||||
update(state => ({
|
||||
...state,
|
||||
trackingCharactersData: trackingData.characters,
|
||||
showTrackAndFollow: true,
|
||||
}));
|
||||
},
|
||||
[update],
|
||||
);
|
||||
|
||||
/**
|
||||
* Handle toggling character tracking
|
||||
*/
|
||||
const handleToggleTrack = useCallback(
|
||||
(characterId: string) => {
|
||||
if (!characterId) return;
|
||||
|
||||
// Send the toggle track command to the server
|
||||
outCommand({
|
||||
type: OutCommand.toggleTrack,
|
||||
data: { 'character-id': characterId },
|
||||
});
|
||||
|
||||
// Note: The local state is now updated in the TrackAndFollow component
|
||||
// for immediate UI feedback, while we wait for the server response
|
||||
},
|
||||
[outCommand],
|
||||
);
|
||||
|
||||
/**
|
||||
* Handle toggling character following
|
||||
*/
|
||||
const handleToggleFollow = useCallback(
|
||||
(characterId: string) => {
|
||||
if (!characterId) return;
|
||||
|
||||
// Send the toggle follow command to the server
|
||||
outCommand({
|
||||
type: OutCommand.toggleFollow,
|
||||
data: { 'character-id': characterId },
|
||||
});
|
||||
|
||||
// Note: The local state is now updated in the TrackAndFollow component
|
||||
// for immediate UI feedback, while we wait for the server response
|
||||
},
|
||||
[outCommand],
|
||||
);
|
||||
|
||||
|
||||
/**
|
||||
* Handle user settings updates
|
||||
*/
|
||||
const handleUserSettingsUpdated = useCallback((settingsData: CommandData[Commands.userSettingsUpdated]) => {
|
||||
if (!settingsData || !settingsData.settings) {
|
||||
console.error('Invalid settings data received:', settingsData);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
handleHideTracking,
|
||||
handleShowTracking,
|
||||
handleUpdateTracking,
|
||||
handleToggleTrack,
|
||||
handleToggleFollow,
|
||||
handleUserSettingsUpdated,
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Map } from '@/hooks/Mapper/components/map/Map.tsx';
|
||||
import { Map, MAP_ROOT_ID } from '@/hooks/Mapper/components/map/Map.tsx';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { OutCommand, OutCommandHandler, SolarSystemConnection } from '@/hooks/Mapper/types';
|
||||
import { MapRootData, useMapRootState } from '@/hooks/Mapper/mapRootProvider';
|
||||
@@ -15,7 +15,7 @@ import { Connections } from '@/hooks/Mapper/components/mapRootContent/components
|
||||
import { ContextMenuSystemMultiple, useContextMenuSystemMultipleHandlers } from '../contexts/ContextMenuSystemMultiple';
|
||||
import { getSystemById } from '@/hooks/Mapper/helpers';
|
||||
import { Commands } from '@/hooks/Mapper/types/mapHandlers.ts';
|
||||
import { Node, XYPosition } from 'reactflow';
|
||||
import { Node, useReactFlow, XYPosition } from 'reactflow';
|
||||
|
||||
import { useCommandsSystems } from '@/hooks/Mapper/mapRootProvider/hooks/api';
|
||||
import { emitMapEvent, useMapEventListener } from '@/hooks/Mapper/events';
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
AddSystemDialog,
|
||||
SearchOnSubmitCallback,
|
||||
} from '@/hooks/Mapper/components/mapInterface/components/AddSystemDialog';
|
||||
import { useHotkey } from '../../hooks/useHotkey';
|
||||
|
||||
// TODO: INFO - this component needs for abstract work with Map instance
|
||||
export const MapWrapper = () => {
|
||||
@@ -46,6 +47,7 @@ export const MapWrapper = () => {
|
||||
} = useMapRootState();
|
||||
const { deleteSystems } = useDeleteSystems();
|
||||
const { mapRef, runCommand } = useCommonMapEventProcessor();
|
||||
const { getNodes } = useReactFlow();
|
||||
|
||||
const { updateLinkSignatureToSystem } = useCommandsSystems();
|
||||
const { open, ...systemContextProps } = useContextMenuSystemHandlers({ systems, hubs, outCommand });
|
||||
@@ -114,12 +116,14 @@ export const MapWrapper = () => {
|
||||
|
||||
const handleConnectionDbClick = useCallback((e: SolarSystemConnection) => setSelectedConnection(e), []);
|
||||
|
||||
const handleManualDelete = useCallback((toDelete: string[]) => {
|
||||
const restDel = toDelete.filter(x => ref.current.systems.some(y => y.id === x));
|
||||
const handleDeleteSelected = useCallback(() => {
|
||||
const restDel = getNodes()
|
||||
.filter(x => x.selected && !x.data.locked)
|
||||
.map(x => x.data.id);
|
||||
if (restDel.length > 0) {
|
||||
ref.current.deleteSystems(restDel);
|
||||
}
|
||||
}, []);
|
||||
}, [getNodes]);
|
||||
|
||||
const onAddSystem: OnMapAddSystemCallback = useCallback(({ coordinates }) => {
|
||||
setOpenAddSystem(coordinates);
|
||||
@@ -143,6 +147,18 @@ export const MapWrapper = () => {
|
||||
[openAddSystem, outCommand],
|
||||
);
|
||||
|
||||
useHotkey(false, ['Delete'], (event: KeyboardEvent) => {
|
||||
const targetWindow = (event.target as HTMLHtmlElement)?.closest(`[data-window-id="${MAP_ROOT_ID}"]`);
|
||||
|
||||
if (!targetWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleDeleteSelected();
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Map
|
||||
@@ -155,7 +171,6 @@ export const MapWrapper = () => {
|
||||
minimapClasses={!isShowMenu ? classes.MiniMap : undefined}
|
||||
isShowMinimap={isShowMinimap}
|
||||
showKSpaceBG={isShowKSpace}
|
||||
onManualDelete={handleManualDelete}
|
||||
isThickConnections={isThickConnections}
|
||||
isShowBackgroundPattern={isShowBackgroundPattern}
|
||||
isSoftBackground={isSoftBackground}
|
||||
|
||||
@@ -7,6 +7,7 @@ import React, { useMemo } from 'react';
|
||||
let counter = 0;
|
||||
|
||||
export interface WdCheckboxProps {
|
||||
id?: string;
|
||||
label: React.ReactNode | string;
|
||||
classNameLabel?: string;
|
||||
value: boolean;
|
||||
@@ -16,6 +17,7 @@ export interface WdCheckboxProps {
|
||||
}
|
||||
|
||||
export const WdCheckbox = ({
|
||||
id: defaultId,
|
||||
label,
|
||||
className,
|
||||
classNameLabel,
|
||||
@@ -24,7 +26,7 @@ export const WdCheckbox = ({
|
||||
labelSide = 'right',
|
||||
size = 'normal',
|
||||
}: WdCheckboxProps & WithClassName) => {
|
||||
const id = useMemo(() => (++counter).toString(), []);
|
||||
const id = useMemo(() => defaultId || (++counter).toString(), [defaultId]);
|
||||
|
||||
const labelElement = (
|
||||
<label
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
.RadioInput {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border: 2px solid var(--surface-border, #ccc);
|
||||
border-radius: 50%;
|
||||
background-color: var(--surface-card, #fff);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary-color, #3B82F6);
|
||||
}
|
||||
|
||||
&:checked {
|
||||
border-color: var(--primary-color, #3B82F6);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
width: 0.625rem;
|
||||
height: 0.625rem;
|
||||
background-color: var(--primary-color, #3B82F6);
|
||||
border-radius: 50%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary-color-dark, #2563EB);
|
||||
|
||||
&::after {
|
||||
background-color: var(--primary-color-dark, #2563EB);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 0.2rem var(--primary-color-light, rgba(59, 130, 246, 0.25));
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
border-color: var(--surface-border, #ccc);
|
||||
|
||||
&:checked::after {
|
||||
background-color: var(--surface-border, #ccc);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
import styles from './WdRadioButton.module.scss';
|
||||
|
||||
export interface WdRadioButtonProps {
|
||||
id: string;
|
||||
name: string;
|
||||
checked: boolean;
|
||||
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
label?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const WdRadioButton: React.FC<WdRadioButtonProps> = ({
|
||||
id,
|
||||
name,
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
className,
|
||||
disabled = false,
|
||||
}) => {
|
||||
return (
|
||||
<div className={clsx('flex items-center', className)}>
|
||||
<input
|
||||
id={id}
|
||||
type="radio"
|
||||
name={name}
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
className={clsx(
|
||||
styles.RadioInput,
|
||||
'w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600',
|
||||
)}
|
||||
/>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300 cursor-pointer"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WdRadioButton;
|
||||
@@ -0,0 +1,4 @@
|
||||
import WdRadioButton from './WdRadioButton';
|
||||
|
||||
export default WdRadioButton;
|
||||
export type { WdRadioButtonProps } from './WdRadioButton';
|
||||
@@ -22,4 +22,4 @@
|
||||
.wdTooltipSizeLg {
|
||||
font-size: 1rem !important;
|
||||
min-width: 350px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'
|
||||
import styles from './WindowManager.module.scss';
|
||||
import debounce from 'lodash.debounce';
|
||||
import { WindowProps } from '@/hooks/Mapper/components/ui-kit/WindowManager/types.ts';
|
||||
import fastDeepEqual from 'fast-deep-equal';
|
||||
|
||||
const MIN_WINDOW_SIZE = 100;
|
||||
const SNAP_THRESHOLD = 10;
|
||||
@@ -100,6 +101,8 @@ export const WindowManager: React.FC<WindowManagerProps> = ({
|
||||
);
|
||||
|
||||
const refPrevSize = useRef({ w: 0, h: 0 });
|
||||
const ref = useRef({ windows, viewPort, onChange });
|
||||
ref.current = { windows, viewPort, onChange };
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewPort) {
|
||||
@@ -110,6 +113,16 @@ export const WindowManager: React.FC<WindowManagerProps> = ({
|
||||
}, [viewPort]);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const next = initialWindows.map(({ content, ...x }) => x);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const prev = ref.current.windows.map(({ content, ...x }) => x);
|
||||
|
||||
// Here we avoid unnecessary renders if changes was emitted from here.
|
||||
if (fastDeepEqual(next, prev)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setWindows(initialWindows.slice(0));
|
||||
}, [initialWindows]);
|
||||
|
||||
@@ -120,9 +133,6 @@ export const WindowManager: React.FC<WindowManagerProps> = ({
|
||||
const startMousePositionRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
const startWindowStateRef = useRef<{ x: number; y: number; width: number; height: number }>(DefaultWindowState);
|
||||
|
||||
const ref = useRef({ windows, viewPort, onChange });
|
||||
ref.current = { windows, viewPort, onChange };
|
||||
|
||||
const onDebouncedChange = useMemo(() => {
|
||||
return debounce(() => {
|
||||
ref.current.onChange?.({
|
||||
|
||||
@@ -13,3 +13,4 @@ export * from './WdCheckbox';
|
||||
export * from './TimeAgo';
|
||||
export * from './WdTooltipWrapper';
|
||||
export * from './WdResponsiveCheckBox';
|
||||
export * from './WdRadioButton';
|
||||
|
||||
41
assets/js/hooks/Mapper/helpers/getEveImageUrl.ts
Normal file
41
assets/js/hooks/Mapper/helpers/getEveImageUrl.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Constants for EVE Online image URLs
|
||||
*/
|
||||
const BASE_IMAGE_URL = 'https://images.evetech.net';
|
||||
|
||||
/**
|
||||
* Generates a URL for any EVE Online image resource
|
||||
* @param category - The category of the image (characters, corporations, alliances, types)
|
||||
* @param id - The EVE Online ID of the entity
|
||||
* @param variation - The variation of the image (icon, portrait, render, logo)
|
||||
* @param size - The size of the image (optional)
|
||||
* @returns The URL to the EVE Online image, or null if the ID is invalid
|
||||
*/
|
||||
export const getEveImageUrl = (
|
||||
category: 'characters' | 'corporations' | 'alliances' | 'types',
|
||||
id?: number | string | null,
|
||||
variation: string = 'icon',
|
||||
size?: number,
|
||||
): string | null => {
|
||||
if (!id || (typeof id === 'number' && id <= 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let url = `${BASE_IMAGE_URL}/${category}/${id}/${variation}`;
|
||||
if (size) {
|
||||
url += `?size=${size}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates the URL for an EVE Online character portrait
|
||||
* @param characterEveId - The EVE Online character ID
|
||||
* @param size - The size of the portrait (default: 64)
|
||||
* @returns The URL to the character's portrait, or an empty string if the ID is invalid
|
||||
*/
|
||||
export const getCharacterPortraitUrl = (characterEveId: string | number | undefined, size: number = 64): string => {
|
||||
const portraitUrl = getEveImageUrl('characters', characterEveId, 'portrait', size);
|
||||
return portraitUrl || '';
|
||||
};
|
||||
13
assets/js/hooks/Mapper/helpers/getWhSize.ts
Normal file
13
assets/js/hooks/Mapper/helpers/getWhSize.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { SHIP_MASSES_SIZE } from '../components/map/constants';
|
||||
import { ShipSizeStatus } from '../types/connection';
|
||||
import { WormholeDataRaw } from '../types/wormholes';
|
||||
|
||||
export const getWhSize = (whDatas: WormholeDataRaw[], whType: string): ShipSizeStatus | null => {
|
||||
if (whType === 'K162' || whType == null) return null;
|
||||
|
||||
const wormholeData = whDatas.find(wh => wh.name === whType);
|
||||
|
||||
if (!wormholeData?.max_mass_per_jump) return null;
|
||||
|
||||
return SHIP_MASSES_SIZE[wormholeData.max_mass_per_jump] ?? ShipSizeStatus.large;
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './sortWHClasses';
|
||||
export * from './parseSignatures';
|
||||
export * from './getSystemById';
|
||||
export * from './getEveImageUrl';
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import Mapper from './MapRoot';
|
||||
|
||||
const LAST_VERSION_KEY = 'wandererLastVersion';
|
||||
const UI_LOADED_EVENT = 'ui_loaded';
|
||||
|
||||
export default {
|
||||
_rootEl: null,
|
||||
_errorCount: 0,
|
||||
@@ -8,7 +11,7 @@ export default {
|
||||
mounted() {
|
||||
// create react root element
|
||||
const rootEl = document.getElementById(this.el.id);
|
||||
this._version = this.el.dataset.version;
|
||||
const activeVersion = localStorage.getItem(LAST_VERSION_KEY);
|
||||
this._rootEl = createRoot(rootEl!);
|
||||
|
||||
const handleError = (error: Error, componentStack: string) => {
|
||||
@@ -22,7 +25,7 @@ export default {
|
||||
onError: handleError,
|
||||
});
|
||||
|
||||
this.pushEvent('ui_loaded');
|
||||
this.pushEvent(UI_LOADED_EVENT, { version: activeVersion });
|
||||
},
|
||||
|
||||
handleEventWrapper(event: string, handler: (payload: any) => void) {
|
||||
@@ -32,7 +35,8 @@ export default {
|
||||
},
|
||||
|
||||
reconnected() {
|
||||
this.pushEvent('ui_loaded');
|
||||
const activeVersion = localStorage.getItem(LAST_VERSION_KEY);
|
||||
this.pushEvent(UI_LOADED_EVENT, { version: activeVersion });
|
||||
},
|
||||
|
||||
async pushEventAsync(event: string, payload: any) {
|
||||
@@ -46,4 +50,8 @@ export default {
|
||||
render(hooks) {
|
||||
this._rootEl.render(<Mapper hooks={hooks} />);
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
this._rootEl.unmount();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -16,12 +16,21 @@ import {
|
||||
} from '@/hooks/Mapper/mapRootProvider/hooks/useStoreWidgets.ts';
|
||||
import { WindowsManagerOnChange } from '@/hooks/Mapper/components/ui-kit/WindowManager';
|
||||
import { DetailedKill } from '../types/kills';
|
||||
import { ActivitySummary } from '../components/mapRootContent/components/CharacterActivity/CharacterActivity';
|
||||
import { TrackingCharacter } from '../components/mapRootContent/components/TrackAndFollow/types';
|
||||
|
||||
export type MapRootData = MapUnionTypes & {
|
||||
selectedSystems: string[];
|
||||
selectedConnections: Pick<SolarSystemConnection, 'source' | 'target'>[];
|
||||
linkSignatureToSystem: CommandLinkSignatureToSystem | null;
|
||||
detailedKills: Record<string, DetailedKill[]>;
|
||||
showCharacterActivity: boolean;
|
||||
characterActivityData: {
|
||||
activity: ActivitySummary[];
|
||||
loading?: boolean;
|
||||
};
|
||||
showTrackAndFollow: boolean;
|
||||
trackingCharactersData: TrackingCharacter[];
|
||||
};
|
||||
|
||||
const INITIAL_DATA: MapRootData = {
|
||||
@@ -29,6 +38,13 @@ const INITIAL_DATA: MapRootData = {
|
||||
wormholes: [],
|
||||
effects: {},
|
||||
characters: [],
|
||||
showCharacterActivity: false,
|
||||
characterActivityData: {
|
||||
activity: [],
|
||||
loading: false
|
||||
},
|
||||
showTrackAndFollow: false,
|
||||
trackingCharactersData: [],
|
||||
userCharacters: [],
|
||||
presentCharacters: [],
|
||||
systems: [],
|
||||
|
||||
@@ -6,3 +6,4 @@ export * from './useRoutes';
|
||||
export * from './useCommandsConnections';
|
||||
export * from './useCommandsSystems';
|
||||
export * from './useCommandsCharacters';
|
||||
export * from './useCommandsActivity';
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import {
|
||||
CommandCharacterActivityData,
|
||||
CommandTrackingCharactersData,
|
||||
CommandUserSettingsUpdated,
|
||||
Commands,
|
||||
} from '@/hooks/Mapper/types/mapHandlers';
|
||||
import { MapRootData } from '@/hooks/Mapper/mapRootProvider/MapRootProvider';
|
||||
import { emitMapEvent } from '@/hooks/Mapper/events';
|
||||
|
||||
export const useCommandsActivity = () => {
|
||||
const { update } = useMapRootState();
|
||||
|
||||
const ref = useRef({ update });
|
||||
ref.current = { update };
|
||||
|
||||
const characterActivityData = useCallback((data: CommandCharacterActivityData) => {
|
||||
try {
|
||||
ref.current.update((state: MapRootData) => ({
|
||||
...state,
|
||||
characterActivityData: {
|
||||
activity: data.activity,
|
||||
loading: data.loading,
|
||||
},
|
||||
showCharacterActivity: true,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to process character activity data:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const trackingCharactersData = useCallback((data: CommandTrackingCharactersData) => {
|
||||
ref.current.update((state: MapRootData) => ({
|
||||
...state,
|
||||
trackingCharactersData: data.characters,
|
||||
showTrackAndFollow: true,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const hideActivity = useCallback(() => {
|
||||
ref.current.update((state: MapRootData) => ({
|
||||
...state,
|
||||
showCharacterActivity: false,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const hideTracking = useCallback(() => {
|
||||
ref.current.update((state: MapRootData) => ({
|
||||
...state,
|
||||
showTrackAndFollow: false,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const userSettingsUpdated = useCallback((data: CommandUserSettingsUpdated) => {
|
||||
emitMapEvent({ name: Commands.userSettingsUpdated, data });
|
||||
}, []);
|
||||
|
||||
return { characterActivityData, trackingCharactersData, userSettingsUpdated, hideActivity, hideTracking };
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { ForwardedRef, useImperativeHandle } from 'react';
|
||||
import {
|
||||
CommandAddConnections,
|
||||
CommandAddSystems,
|
||||
CommandCharacterActivityData,
|
||||
CommandCharacterAdded,
|
||||
CommandCharacterRemoved,
|
||||
CommandCharactersUpdated,
|
||||
@@ -13,10 +14,12 @@ import {
|
||||
CommandRemoveConnections,
|
||||
CommandRemoveSystems,
|
||||
CommandRoutes,
|
||||
Commands,
|
||||
CommandSignaturesUpdated,
|
||||
CommandTrackingCharactersData,
|
||||
CommandUpdateConnection,
|
||||
CommandUpdateSystems,
|
||||
CommandUserSettingsUpdated,
|
||||
Commands,
|
||||
MapHandlers,
|
||||
} from '@/hooks/Mapper/types/mapHandlers.ts';
|
||||
|
||||
@@ -29,6 +32,7 @@ import {
|
||||
useRoutes,
|
||||
} from './api';
|
||||
|
||||
import { useCommandsActivity } from './api/useCommandsActivity';
|
||||
import { emitMapEvent } from '@/hooks/Mapper/events';
|
||||
import { DetailedKill } from '../../types/kills';
|
||||
|
||||
@@ -47,6 +51,7 @@ export const useMapRootHandlers = (ref: ForwardedRef<MapHandlers>) => {
|
||||
useCommandsCharacters();
|
||||
const mapUpdated = useMapUpdated();
|
||||
const mapRoutes = useRoutes();
|
||||
const { characterActivityData, trackingCharactersData, userSettingsUpdated } = useCommandsActivity();
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
@@ -123,6 +128,48 @@ export const useMapRootHandlers = (ref: ForwardedRef<MapHandlers>) => {
|
||||
updateDetailedKills(data as Record<string, DetailedKill[]>);
|
||||
break;
|
||||
|
||||
case Commands.characterActivityData:
|
||||
characterActivityData(data as CommandCharacterActivityData);
|
||||
break;
|
||||
|
||||
case Commands.trackingCharactersData:
|
||||
trackingCharactersData(data as CommandTrackingCharactersData);
|
||||
break;
|
||||
|
||||
case Commands.updateActivity:
|
||||
break;
|
||||
|
||||
case Commands.updateTracking:
|
||||
break;
|
||||
|
||||
case Commands.userSettingsUpdated:
|
||||
userSettingsUpdated(data as CommandUserSettingsUpdated);
|
||||
break;
|
||||
|
||||
case Commands.showTracking:
|
||||
// This command is handled by the TrackAndFollow component
|
||||
break;
|
||||
|
||||
case Commands.hideTracking:
|
||||
// This command is handled by the TrackAndFollow component
|
||||
break;
|
||||
|
||||
case Commands.showActivity:
|
||||
// This command is handled by the CharacterActivity component
|
||||
break;
|
||||
|
||||
case Commands.hideActivity:
|
||||
// This command is handled by the CharacterActivity component
|
||||
break;
|
||||
|
||||
case Commands.toggleTrack:
|
||||
// This command is handled by the TrackAndFollow component
|
||||
break;
|
||||
|
||||
case Commands.toggleFollow:
|
||||
// This command is handled by the TrackAndFollow component
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(`JOipP Interface handlers: Unknown command: ${type}`, data);
|
||||
break;
|
||||
|
||||
@@ -4,7 +4,9 @@ import { WormholeDataRaw } from '@/hooks/Mapper/types/wormholes.ts';
|
||||
import { CharacterTypeRaw } from '@/hooks/Mapper/types/character.ts';
|
||||
import { RoutesList } from '@/hooks/Mapper/types/routes.ts';
|
||||
import { DetailedKill, Kill } from '@/hooks/Mapper/types/kills.ts';
|
||||
import { SignatureGroup, UserPermissions } from '@/hooks/Mapper/types';
|
||||
import { UserPermissions } from '@/hooks/Mapper/types';
|
||||
import { ActivitySummary } from '../components/mapRootContent/components/CharacterActivity/CharacterActivity';
|
||||
import { TrackingCharacter } from '../components/mapRootContent/components/TrackAndFollow/types';
|
||||
|
||||
export enum Commands {
|
||||
init = 'init',
|
||||
@@ -27,6 +29,11 @@ export enum Commands {
|
||||
selectSystem = 'select_system',
|
||||
linkSignatureToSystem = 'link_signature_to_system',
|
||||
signaturesUpdated = 'signatures_updated',
|
||||
characterActivityData = 'character_activity_data',
|
||||
trackingCharactersData = 'tracking_characters_data',
|
||||
updateActivity = 'update_activity',
|
||||
updateTracking = 'update_tracking',
|
||||
userSettingsUpdated = 'user_settings_updated',
|
||||
}
|
||||
|
||||
export type Command =
|
||||
@@ -49,7 +56,12 @@ export type Command =
|
||||
| Commands.selectSystem
|
||||
| Commands.centerSystem
|
||||
| Commands.linkSignatureToSystem
|
||||
| Commands.signaturesUpdated;
|
||||
| Commands.signaturesUpdated
|
||||
| Commands.characterActivityData
|
||||
| Commands.trackingCharactersData
|
||||
| Commands.userSettingsUpdated
|
||||
| Commands.updateActivity
|
||||
| Commands.updateTracking;
|
||||
|
||||
export type CommandInit = {
|
||||
systems: SolarSystemRawType[];
|
||||
@@ -68,6 +80,7 @@ export type CommandInit = {
|
||||
reset?: boolean;
|
||||
is_subscription_active?: boolean;
|
||||
};
|
||||
|
||||
export type CommandAddSystems = SolarSystemRawType[];
|
||||
export type CommandUpdateSystems = SolarSystemRawType[];
|
||||
export type CommandRemoveSystems = number[];
|
||||
@@ -91,6 +104,46 @@ export type CommandLinkSignatureToSystem = {
|
||||
solar_system_target: number;
|
||||
};
|
||||
export type CommandLinkSignaturesUpdated = number;
|
||||
export type CommandCharacterActivityData = { activity: ActivitySummary[]; loading?: boolean };
|
||||
export type CommandTrackingCharactersData = { characters: TrackingCharacter[] };
|
||||
export type CommandUserSettingsUpdated = {
|
||||
settings: UserSettings;
|
||||
};
|
||||
|
||||
export type CommandShowActivity = null;
|
||||
export type CommandHideActivity = null;
|
||||
export type CommandShowTracking = null;
|
||||
export type CommandHideTracking = null;
|
||||
export type CommandUiLoaded = { version: string | null };
|
||||
export type CommandLogMapError = { error: string; componentStack: string };
|
||||
export type CommandMapEvent = { type: Command; data: unknown };
|
||||
export type CommandMapEvents = Array<{ type: Command; data: unknown }>;
|
||||
export type CommandUpdateActivity = {
|
||||
characterId: number;
|
||||
systemId: number;
|
||||
shipTypeId: number;
|
||||
timestamp: number;
|
||||
};
|
||||
export type CommandUpdateTracking = {
|
||||
characterId: number;
|
||||
track: boolean;
|
||||
follow: boolean;
|
||||
};
|
||||
|
||||
export interface UserSettings {
|
||||
primaryCharacterId?: string;
|
||||
mapSettings?: {
|
||||
showGrid?: boolean;
|
||||
snapToGrid?: boolean;
|
||||
gridSize?: number;
|
||||
};
|
||||
interfaceSettings?: {
|
||||
theme?: string;
|
||||
showMinimap?: boolean;
|
||||
showMenu?: boolean;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CommandData {
|
||||
[Commands.init]: CommandInit;
|
||||
@@ -113,6 +166,11 @@ export interface CommandData {
|
||||
[Commands.centerSystem]: CommandCenterSystem;
|
||||
[Commands.linkSignatureToSystem]: CommandLinkSignatureToSystem;
|
||||
[Commands.signaturesUpdated]: CommandLinkSignaturesUpdated;
|
||||
[Commands.characterActivityData]: CommandCharacterActivityData;
|
||||
[Commands.trackingCharactersData]: CommandTrackingCharactersData;
|
||||
[Commands.userSettingsUpdated]: CommandUserSettingsUpdated;
|
||||
[Commands.updateActivity]: CommandUpdateActivity;
|
||||
[Commands.updateTracking]: CommandUpdateTracking;
|
||||
}
|
||||
|
||||
export interface MapHandlers {
|
||||
@@ -151,7 +209,6 @@ export enum OutCommand {
|
||||
manualDeleteConnection = 'manual_delete_connection',
|
||||
setAutopilotWaypoint = 'set_autopilot_waypoint',
|
||||
addSystem = 'add_system',
|
||||
addCharacter = 'add_character',
|
||||
openUserSettings = 'open_user_settings',
|
||||
getPassages = 'get_passages',
|
||||
linkSignatureToSystem = 'link_signature_to_system',
|
||||
@@ -159,10 +216,13 @@ export enum OutCommand {
|
||||
getCorporationTicker = 'get_corporation_ticker',
|
||||
getSystemKills = 'get_system_kills',
|
||||
getSystemsKills = 'get_systems_kills',
|
||||
|
||||
// Only UI commands
|
||||
openSettings = 'open_settings',
|
||||
|
||||
hideActivity = 'hide_activity',
|
||||
showActivity = 'show_activity',
|
||||
hideTracking = 'hide_tracking',
|
||||
showTracking = 'show_tracking',
|
||||
toggleTrack = 'toggle_track',
|
||||
toggleFollow = 'toggle_follow',
|
||||
getUserSettings = 'get_user_settings',
|
||||
updateUserSettings = 'update_user_settings',
|
||||
unlinkSignature = 'unlink_signature',
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { RefObject, useCallback } from 'react';
|
||||
|
||||
import { MapHandlers } from '@/hooks/Mapper/types/mapHandlers.ts';
|
||||
|
||||
export const useMapperHandlers = (handlerRefs: RefObject<MapHandlers>[], hooksRef: RefObject<any>) => {
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
const countdown = (secondsCount: number) => {
|
||||
let minutes, seconds;
|
||||
|
||||
const dateEnd = new Date().getTime() + secondsCount * 1000;
|
||||
|
||||
const timer = setInterval(calculate, 1000);
|
||||
|
||||
function calculate() {
|
||||
const dateStartDefault = new Date();
|
||||
const dateStart = new Date(
|
||||
dateStartDefault.getUTCFullYear(),
|
||||
dateStartDefault.getUTCMonth(),
|
||||
dateStartDefault.getUTCDate(),
|
||||
dateStartDefault.getUTCHours(),
|
||||
dateStartDefault.getUTCMinutes(),
|
||||
dateStartDefault.getUTCSeconds(),
|
||||
);
|
||||
let timeRemaining = parseInt((dateEnd - dateStart.getTime()) / 1000);
|
||||
|
||||
if (timeRemaining >= 0) {
|
||||
timeRemaining = timeRemaining % 86400;
|
||||
timeRemaining = timeRemaining % 3600;
|
||||
minutes = parseInt(timeRemaining / 60);
|
||||
timeRemaining = timeRemaining % 60;
|
||||
seconds = parseInt(timeRemaining);
|
||||
|
||||
document.getElementById('version-update-seconds').innerHTML = minutes * 60 + seconds;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const LAST_VERSION_KEY = 'wandererLastVersion';
|
||||
|
||||
const updateVerion = (newVersion: string) => {
|
||||
localStorage.setItem(LAST_VERSION_KEY, newVersion);
|
||||
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
export default {
|
||||
mounted() {
|
||||
const hook = this;
|
||||
@@ -14,12 +53,7 @@ export default {
|
||||
el.classList.add('hex-brick--active');
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
const lastVersion = hook.el.dataset.version;
|
||||
localStorage.setItem(LAST_VERSION_KEY, lastVersion);
|
||||
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
updateVerion(hook.el.dataset.version);
|
||||
};
|
||||
|
||||
refreshZone.addEventListener('click', handleUpdate);
|
||||
@@ -33,12 +67,23 @@ export default {
|
||||
},
|
||||
|
||||
updated() {
|
||||
const hook = this;
|
||||
const activeVersion = this.getItem(LAST_VERSION_KEY);
|
||||
const lastVersion = this.el.dataset.version;
|
||||
const lastVersion = hook.el.dataset.version;
|
||||
if (activeVersion === lastVersion) {
|
||||
return;
|
||||
}
|
||||
this.el.classList.remove('hidden');
|
||||
const enabled = hook.el.dataset.enabled;
|
||||
if (enabled === 'true') {
|
||||
hook.el.classList.remove('hidden');
|
||||
const autoRefreshTimeout = Math.floor(Math.random() * (150 - 75 + 1)) + 75;
|
||||
countdown(autoRefreshTimeout);
|
||||
setTimeout(() => {
|
||||
updateVerion(hook.el.dataset.version);
|
||||
}, autoRefreshTimeout * 1000);
|
||||
} else {
|
||||
updateVerion(hook.el.dataset.version);
|
||||
}
|
||||
},
|
||||
|
||||
getItem(key: string) {
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"@shopify/draggable": "^1.1.3",
|
||||
"clsx": "^2.1.1",
|
||||
"daisyui": "^4.11.1",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"live_select": "file:../deps/live_select",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"lodash.isequal": "^4.5.0",
|
||||
|
||||
BIN
assets/static/images/news/03-05-api/swagger-ui.png
Executable file
BIN
assets/static/images/news/03-05-api/swagger-ui.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
@@ -75,6 +75,17 @@ config :phoenix_ddos,
|
||||
request_paths: ["/auth/eve"], allowed: 20, period: {1, :minute}}
|
||||
]
|
||||
|
||||
config :ash_pagify,
|
||||
default_limit: 50,
|
||||
max_limit: 1000,
|
||||
scopes: %{
|
||||
role: []
|
||||
},
|
||||
reset_on_filter?: true,
|
||||
replace_invalid_params?: true,
|
||||
pagination: [opts: {WandererAppWeb.CoreComponents, :pagination_opts}],
|
||||
table: [opts: {WandererAppWeb.CoreComponents, :table_opts}]
|
||||
|
||||
# Configures Elixir's Logger
|
||||
config :logger, :console,
|
||||
format: "$time $metadata[$level] $message\n",
|
||||
|
||||
@@ -353,3 +353,8 @@ if config_env() == :prod do
|
||||
cowboy_opts: [ip: {0, 0, 0, 0}]
|
||||
]
|
||||
end
|
||||
|
||||
# License Manager API Configuration
|
||||
config :wanderer_app, :license_manager,
|
||||
api_url: System.get_env("LM_API_URL", "http://localhost:4000"),
|
||||
auth_key: System.get_env("LM_AUTH_KEY")
|
||||
|
||||
@@ -26,5 +26,6 @@ defmodule WandererApp.Api do
|
||||
resource WandererApp.Api.UserActivity
|
||||
resource WandererApp.Api.UserTransaction
|
||||
resource WandererApp.Api.CorpWalletTransaction
|
||||
resource WandererApp.Api.License
|
||||
end
|
||||
end
|
||||
|
||||
117
lib/wanderer_app/api/license.ex
Normal file
117
lib/wanderer_app/api/license.ex
Normal file
@@ -0,0 +1,117 @@
|
||||
defmodule WandererApp.Api.License do
|
||||
@moduledoc """
|
||||
Schema for bot licenses.
|
||||
|
||||
A license is associated with a map subscription and allows access to bot functionality.
|
||||
Licenses have a unique key, validity status, and expiration date.
|
||||
"""
|
||||
|
||||
use Ash.Resource,
|
||||
domain: WandererApp.Api,
|
||||
data_layer: AshPostgres.DataLayer
|
||||
|
||||
postgres do
|
||||
repo(WandererApp.Repo)
|
||||
table("map_licenses_v1")
|
||||
end
|
||||
|
||||
code_interface do
|
||||
define(:create, action: :create)
|
||||
define(:by_id, get_by: [:id], action: :read)
|
||||
define(:by_key, get_by: [:license_key], action: :read)
|
||||
define(:by_map_id, action: :by_map_id)
|
||||
define(:invalidate, action: :invalidate)
|
||||
define(:set_valid, action: :set_valid)
|
||||
define(:update_expire_at, action: :update_expire_at)
|
||||
define(:update_key, action: :update_key)
|
||||
define(:destroy, action: :destroy)
|
||||
end
|
||||
|
||||
actions do
|
||||
default_accept [
|
||||
:lm_id,
|
||||
:map_id,
|
||||
:license_key,
|
||||
:is_valid,
|
||||
:expire_at
|
||||
]
|
||||
|
||||
defaults [:read, :update, :destroy]
|
||||
|
||||
create :create do
|
||||
primary? true
|
||||
upsert? true
|
||||
upsert_identity :uniq_map_id
|
||||
|
||||
upsert_fields [
|
||||
:lm_id,
|
||||
:is_valid,
|
||||
:license_key,
|
||||
:expire_at
|
||||
]
|
||||
end
|
||||
|
||||
read :by_map_id do
|
||||
argument(:map_id, :uuid, allow_nil?: false)
|
||||
filter(expr(map_id == ^arg(:map_id)))
|
||||
end
|
||||
|
||||
update :invalidate do
|
||||
accept([])
|
||||
|
||||
change(set_attribute(:is_valid, false))
|
||||
end
|
||||
|
||||
update :set_valid do
|
||||
accept([])
|
||||
|
||||
change(set_attribute(:is_valid, true))
|
||||
end
|
||||
|
||||
update :update_expire_at do
|
||||
accept [:expire_at]
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
update :update_key do
|
||||
accept [:license_key]
|
||||
require_atomic? false
|
||||
end
|
||||
end
|
||||
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
|
||||
attribute :lm_id, :string do
|
||||
allow_nil? false
|
||||
end
|
||||
|
||||
attribute :license_key, :string do
|
||||
allow_nil? false
|
||||
end
|
||||
|
||||
attribute :is_valid, :boolean do
|
||||
default true
|
||||
allow_nil? false
|
||||
end
|
||||
|
||||
attribute :expire_at, :utc_datetime do
|
||||
allow_nil? true
|
||||
end
|
||||
|
||||
create_timestamp(:inserted_at)
|
||||
update_timestamp(:updated_at)
|
||||
end
|
||||
|
||||
relationships do
|
||||
belongs_to :map, WandererApp.Api.Map do
|
||||
attribute_writable? true
|
||||
end
|
||||
end
|
||||
|
||||
identities do
|
||||
identity :uniq_map_id, [:map_id] do
|
||||
pre_check?(true)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -63,19 +63,59 @@ defmodule WandererApp.Api.MapCharacterSettings do
|
||||
end
|
||||
|
||||
update :track do
|
||||
change(set_attribute(:tracked, true))
|
||||
accept [:map_id, :character_id]
|
||||
argument :map_id, :string, allow_nil?: false
|
||||
argument :character_id, :uuid, allow_nil?: false
|
||||
|
||||
# Load the record first
|
||||
load do
|
||||
filter expr(map_id == ^arg(:map_id) and character_id == ^arg(:character_id))
|
||||
end
|
||||
|
||||
# Only update the tracked field
|
||||
change set_attribute(:tracked, true)
|
||||
end
|
||||
|
||||
update :untrack do
|
||||
change(set_attribute(:tracked, false))
|
||||
accept [:map_id, :character_id]
|
||||
argument :map_id, :string, allow_nil?: false
|
||||
argument :character_id, :uuid, allow_nil?: false
|
||||
|
||||
# Load the record first
|
||||
load do
|
||||
filter expr(map_id == ^arg(:map_id) and character_id == ^arg(:character_id))
|
||||
end
|
||||
|
||||
# Only update the tracked field
|
||||
change set_attribute(:tracked, false)
|
||||
end
|
||||
|
||||
update :follow do
|
||||
change(set_attribute(:followed, true))
|
||||
accept [:map_id, :character_id]
|
||||
argument :map_id, :string, allow_nil?: false
|
||||
argument :character_id, :uuid, allow_nil?: false
|
||||
|
||||
# Load the record first
|
||||
load do
|
||||
filter expr(map_id == ^arg(:map_id) and character_id == ^arg(:character_id))
|
||||
end
|
||||
|
||||
# Only update the followed field
|
||||
change set_attribute(:followed, true)
|
||||
end
|
||||
|
||||
update :unfollow do
|
||||
change(set_attribute(:followed, false))
|
||||
accept [:map_id, :character_id]
|
||||
argument :map_id, :string, allow_nil?: false
|
||||
argument :character_id, :uuid, allow_nil?: false
|
||||
|
||||
# Load the record first
|
||||
load do
|
||||
filter expr(map_id == ^arg(:map_id) and character_id == ^arg(:character_id))
|
||||
end
|
||||
|
||||
# Only update the followed field
|
||||
change set_attribute(:followed, false)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
11
lib/wanderer_app/api/preparations/load_character.ex
Normal file
11
lib/wanderer_app/api/preparations/load_character.ex
Normal file
@@ -0,0 +1,11 @@
|
||||
defmodule WandererApp.Api.Preparations.LoadCharacter do
|
||||
@moduledoc false
|
||||
|
||||
use Ash.Resource.Preparation
|
||||
require Ash.Query
|
||||
|
||||
def prepare(query, _params, _) do
|
||||
query
|
||||
|> Ash.Query.load([:character])
|
||||
end
|
||||
end
|
||||
@@ -5,6 +5,16 @@ defmodule WandererApp.Api.UserActivity do
|
||||
domain: WandererApp.Api,
|
||||
data_layer: AshPostgres.DataLayer
|
||||
|
||||
require Ash.Expr
|
||||
|
||||
@ash_pagify_options %{
|
||||
default_limit: 15,
|
||||
scopes: %{
|
||||
role: []
|
||||
}
|
||||
}
|
||||
def ash_pagify_options, do: @ash_pagify_options
|
||||
|
||||
postgres do
|
||||
repo(WandererApp.Repo)
|
||||
table("user_activity_v1")
|
||||
@@ -31,7 +41,13 @@ defmodule WandererApp.Api.UserActivity do
|
||||
|
||||
read :read do
|
||||
primary?(true)
|
||||
pagination(offset?: true, keyset?: true)
|
||||
|
||||
pagination offset?: true,
|
||||
default_limit: @ash_pagify_options.default_limit,
|
||||
countable: true,
|
||||
required?: false
|
||||
|
||||
prepare WandererApp.Api.Preparations.LoadCharacter
|
||||
end
|
||||
|
||||
create :new do
|
||||
|
||||
@@ -257,4 +257,51 @@ defmodule WandererApp.Character do
|
||||
corporation: true
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Finds a character by EVE ID from a user's active characters.
|
||||
|
||||
## Parameters
|
||||
- `current_user`: The current user struct
|
||||
- `character_eve_id`: The EVE ID of the character to find
|
||||
|
||||
## Returns
|
||||
- `{:ok, character}` if the character is found
|
||||
- `{:error, :character_not_found}` if the character is not found
|
||||
"""
|
||||
def find_character_by_eve_id(current_user, character_eve_id) do
|
||||
{:ok, all_user_characters} =
|
||||
WandererApp.Api.Character.active_by_user(%{user_id: current_user.id})
|
||||
|
||||
case Enum.find(all_user_characters, fn char ->
|
||||
"#{char.eve_id}" == "#{character_eve_id}"
|
||||
end) do
|
||||
nil ->
|
||||
{:error, :character_not_found}
|
||||
|
||||
character ->
|
||||
{:ok, character}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Finds a character by character ID from a user's characters.
|
||||
|
||||
## Parameters
|
||||
- `current_user`: The current user struct
|
||||
- `char_id`: The character ID to find
|
||||
|
||||
## Returns
|
||||
- `{:ok, character}` if the character is found
|
||||
- `{:error, :character_not_found}` if the character is not found
|
||||
"""
|
||||
def find_user_character(current_user, char_id) do
|
||||
case Enum.find(current_user.characters, &("#{&1.id}" == "#{char_id}")) do
|
||||
nil ->
|
||||
{:error, :character_not_found}
|
||||
|
||||
char ->
|
||||
{:ok, char}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
228
lib/wanderer_app/character/activity.ex
Normal file
228
lib/wanderer_app/character/activity.ex
Normal file
@@ -0,0 +1,228 @@
|
||||
defmodule WandererApp.Character.Activity do
|
||||
@moduledoc """
|
||||
Functions for processing and managing character activity data.
|
||||
"""
|
||||
require Logger
|
||||
|
||||
@doc """
|
||||
Finds a followed character ID from a list of character settings and activities.
|
||||
|
||||
## Parameters
|
||||
- `character_settings`: List of character settings with `followed` and `character_id` fields
|
||||
- `activities_by_character`: Map of activities grouped by character_id
|
||||
- `is_current_user`: Boolean indicating if this is for the current user
|
||||
|
||||
## Returns
|
||||
- Character ID of the followed character if found, nil otherwise
|
||||
"""
|
||||
def find_followed_character(character_settings, activities_by_character, is_current_user) do
|
||||
if is_current_user do
|
||||
followed_chars =
|
||||
character_settings
|
||||
|> Enum.filter(& &1.followed)
|
||||
|> Enum.map(& &1.character_id)
|
||||
|
||||
# Find if any of user's characters is followed
|
||||
user_char_ids = Map.keys(activities_by_character)
|
||||
|
||||
Enum.find(followed_chars, fn followed_id ->
|
||||
followed_id in user_char_ids
|
||||
end)
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Finds the character with the most activity from a map of activities grouped by character_id.
|
||||
|
||||
## Parameters
|
||||
- `activities_by_character`: Map of activities grouped by character_id
|
||||
|
||||
## Returns
|
||||
- Character ID of the character with the most activity, or nil if no activities
|
||||
"""
|
||||
def find_most_active_character(activities_by_character) do
|
||||
if Enum.empty?(activities_by_character) do
|
||||
nil
|
||||
else
|
||||
{char_id, _} =
|
||||
activities_by_character
|
||||
|> Enum.map(fn {char_id, activities} ->
|
||||
total_activity =
|
||||
activities
|
||||
|> Enum.map(fn a ->
|
||||
Map.get(a, :passages, 0) +
|
||||
Map.get(a, :connections, 0) +
|
||||
Map.get(a, :signatures, 0)
|
||||
end)
|
||||
|> Enum.sum()
|
||||
|
||||
{char_id, total_activity}
|
||||
end)
|
||||
|> Enum.max_by(fn {_, count} -> count end, fn -> {nil, 0} end)
|
||||
|
||||
char_id
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Processes character activity data for display.
|
||||
|
||||
## Parameters
|
||||
- `map_id`: ID of the map
|
||||
- `current_user`: Current user struct
|
||||
|
||||
## Returns
|
||||
- List of processed activity data
|
||||
"""
|
||||
def process_character_activity(map_id, current_user) do
|
||||
with {:ok, character_settings} <- get_map_character_settings(map_id),
|
||||
raw_activity <- WandererApp.Map.get_character_activity(map_id),
|
||||
{:ok, user_characters} <-
|
||||
WandererApp.Api.Character.active_by_user(%{user_id: current_user.id}) do
|
||||
process_activity_data(raw_activity, character_settings, user_characters, current_user)
|
||||
end
|
||||
end
|
||||
|
||||
defp get_map_character_settings(map_id) do
|
||||
case WandererApp.MapCharacterSettingsRepo.get_all_by_map(map_id) do
|
||||
{:ok, settings} -> {:ok, settings}
|
||||
_ -> {:ok, []}
|
||||
end
|
||||
end
|
||||
|
||||
defp process_activity_data([], _character_settings, _user_characters, _current_user), do: []
|
||||
|
||||
# Simplify the pre-processed data handling - just pass it through
|
||||
defp process_activity_data([%{character: _} | _] = activity_data, _, _, _), do: activity_data
|
||||
|
||||
defp process_activity_data(all_activity, character_settings, user_characters, current_user) do
|
||||
all_activity
|
||||
|> group_by_user_id()
|
||||
|> process_users_activity(character_settings, user_characters, current_user)
|
||||
|> sort_by_timestamp()
|
||||
end
|
||||
|
||||
defp group_by_user_id(activities) do
|
||||
Enum.group_by(activities, &Map.get(&1, :user_id, "unknown"))
|
||||
end
|
||||
|
||||
defp process_users_activity(
|
||||
activity_by_user_id,
|
||||
character_settings,
|
||||
user_characters,
|
||||
current_user
|
||||
) do
|
||||
Enum.flat_map(activity_by_user_id, fn {user_id, user_activities} ->
|
||||
process_single_user_activity(
|
||||
user_id,
|
||||
user_activities,
|
||||
character_settings,
|
||||
user_characters,
|
||||
current_user
|
||||
)
|
||||
end)
|
||||
end
|
||||
|
||||
defp process_single_user_activity(
|
||||
user_id,
|
||||
user_activities,
|
||||
character_settings,
|
||||
user_characters,
|
||||
current_user
|
||||
) do
|
||||
# Determine if this is the current user's activity
|
||||
is_current_user = user_id == current_user.id
|
||||
|
||||
# Group activities by character
|
||||
activities_by_character = group_activities_by_character(user_activities)
|
||||
|
||||
# Find the character to show (followed or most active)
|
||||
char_id_to_show =
|
||||
select_character_to_show(activities_by_character, character_settings, is_current_user)
|
||||
|
||||
# Create activity entry for the selected character
|
||||
case char_id_to_show do
|
||||
nil -> []
|
||||
id -> create_character_activity_entry(
|
||||
id,
|
||||
activities_by_character,
|
||||
user_characters,
|
||||
is_current_user
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defp group_activities_by_character(activities) do
|
||||
Enum.group_by(activities, fn activity ->
|
||||
# Character info is now in a nested 'character' field
|
||||
cond do
|
||||
character = Map.get(activity, :character) -> Map.get(character, :id)
|
||||
id = Map.get(activity, :character_id) -> id
|
||||
id = Map.get(activity, :character_eve_id) -> id
|
||||
true -> "unknown_#{System.unique_integer([:positive])}"
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp select_character_to_show(activities_by_character, character_settings, is_current_user) do
|
||||
followed_char_id =
|
||||
find_followed_character(character_settings, activities_by_character, is_current_user)
|
||||
|
||||
followed_char_id || find_most_active_character(activities_by_character)
|
||||
end
|
||||
|
||||
defp create_character_activity_entry(
|
||||
char_id,
|
||||
activities_by_character,
|
||||
user_characters,
|
||||
is_current_user
|
||||
) do
|
||||
char_activities = Map.get(activities_by_character, char_id, [])
|
||||
|
||||
case get_character_details(char_id, char_activities, user_characters, is_current_user) do
|
||||
nil -> []
|
||||
char_details -> [build_activity_entry(char_details, char_activities)]
|
||||
end
|
||||
end
|
||||
|
||||
defp get_character_details(_char_id, [activity | _], _user_characters, false) do
|
||||
# Return the raw character data without mapping
|
||||
Map.get(activity, :character)
|
||||
end
|
||||
|
||||
defp get_character_details(char_id, _char_activities, user_characters, true) do
|
||||
# Find the character in user_characters and return it without mapping
|
||||
Enum.find(user_characters, fn char ->
|
||||
char.id == char_id || to_string(char.eve_id) == char_id
|
||||
end)
|
||||
end
|
||||
|
||||
defp build_activity_entry(
|
||||
char_details,
|
||||
char_activities
|
||||
) do
|
||||
%{
|
||||
character: char_details,
|
||||
passages: sum_activity(char_activities, :passages),
|
||||
connections: sum_activity(char_activities, :connections),
|
||||
signatures: sum_activity(char_activities, :signatures),
|
||||
timestamp: get_most_recent_timestamp(char_activities)
|
||||
}
|
||||
end
|
||||
|
||||
defp sum_activity(activities, key),
|
||||
do: activities |> Enum.map(&Map.get(&1, key, 0)) |> Enum.sum()
|
||||
|
||||
defp get_most_recent_timestamp(activities) do
|
||||
activities
|
||||
|> Enum.map(&Map.get(&1, :timestamp, DateTime.utc_now()))
|
||||
|> Enum.sort_by(& &1, {:desc, DateTime})
|
||||
|> List.first() || DateTime.utc_now()
|
||||
end
|
||||
|
||||
defp sort_by_timestamp(activities) do
|
||||
Enum.sort_by(activities, & &1.timestamp, {:desc, DateTime})
|
||||
end
|
||||
end
|
||||
@@ -556,7 +556,7 @@ defmodule WandererApp.Character.Tracker do
|
||||
{:ok, [character_aff_info]} when not is_nil(character_aff_info) ->
|
||||
update_corporation(state, character_aff_info |> Map.get("corporation_id"))
|
||||
|
||||
error ->
|
||||
_error ->
|
||||
state
|
||||
end
|
||||
end
|
||||
|
||||
190
lib/wanderer_app/license/license_manager.ex
Normal file
190
lib/wanderer_app/license/license_manager.ex
Normal file
@@ -0,0 +1,190 @@
|
||||
defmodule WandererApp.License.LicenseManager do
|
||||
@moduledoc """
|
||||
Manages bot licenses, including creation, validation, and expiration.
|
||||
|
||||
This module provides functions for:
|
||||
- Creating licenses for maps with active subscriptions
|
||||
- Validating license keys
|
||||
- Checking license expiration
|
||||
- Generating unique license keys
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias WandererApp.Api.License
|
||||
alias WandererApp.Api.Map
|
||||
alias WandererApp.Map.SubscriptionManager
|
||||
alias WandererApp.License.LicenseManagerClient
|
||||
|
||||
@doc """
|
||||
Creates a new license for a map if it has an active subscription.
|
||||
Returns {:ok, license} if successful, {:error, reason} otherwise.
|
||||
"""
|
||||
def create_license_for_map(map_id) do
|
||||
with {:ok, map} <- Map.by_id(map_id),
|
||||
{:ok, true} <- WandererApp.Map.is_subscription_active?(map_id),
|
||||
{:ok, subscription} <- SubscriptionManager.get_active_map_subscription(map_id) do
|
||||
# Create a license in the local database
|
||||
|
||||
# Create a license in the external License Manager service
|
||||
license_params = %{
|
||||
"name" => "#{map.name} License",
|
||||
"description" => "License for #{map.name} map",
|
||||
"is_valid" => true,
|
||||
"valid_to" => format_date(subscription.active_till),
|
||||
"link" => generate_map_link(map.slug),
|
||||
"contact_email" => get_map_owner_email(map)
|
||||
}
|
||||
|
||||
case LicenseManagerClient.create_license(license_params) do
|
||||
{:ok, external_license} ->
|
||||
License.create(%{
|
||||
map_id: map_id,
|
||||
lm_id: external_license["id"],
|
||||
license_key: external_license["key"],
|
||||
is_valid: true,
|
||||
expire_at: subscription.active_till
|
||||
})
|
||||
|
||||
{:error, reason} ->
|
||||
# Log the error but don't fail the operation
|
||||
Logger.error("Failed to create license in external service: #{inspect(reason)}")
|
||||
{:error, reason}
|
||||
end
|
||||
else
|
||||
{:ok, false} -> {:error, :no_active_subscription}
|
||||
error -> error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Validates a license key.
|
||||
Returns {:ok, license} if valid, {:error, reason} otherwise.
|
||||
"""
|
||||
def validate_license(license_key) do
|
||||
# First check in our local database
|
||||
case License.by_key(license_key) do
|
||||
{:ok, license} ->
|
||||
case LicenseManagerClient.validate_license(license_key) do
|
||||
{:ok, %{"license_valid" => is_valid}} ->
|
||||
{:ok, %{license | is_valid: is_valid}}
|
||||
|
||||
{:error, reason} ->
|
||||
# External validation failed, but we'll still consider it valid
|
||||
# if it's valid in our local database
|
||||
{:error, reason}
|
||||
end
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Invalidates a license.
|
||||
"""
|
||||
def invalidate_license(license_id) do
|
||||
with {:ok, license} <- License.by_id(license_id) do
|
||||
# Try to invalidate in external service
|
||||
case LicenseManagerClient.update_license(license.lm_id, %{
|
||||
"is_valid" => false
|
||||
}) do
|
||||
{:ok, _} ->
|
||||
License.invalidate(license)
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates the expiration date of a license.
|
||||
"""
|
||||
def update_expiration(license_id, expire_at) do
|
||||
with {:ok, license} <- License.by_id(license_id) do
|
||||
# Update in local database
|
||||
|
||||
# Try to update in external service
|
||||
LicenseManagerClient.update_license(license.lm_id, %{
|
||||
"valid_to" => format_date(expire_at)
|
||||
})
|
||||
|> case do
|
||||
{:ok, _license} ->
|
||||
License.update_expire_at(license, %{expire_at: expire_at})
|
||||
|
||||
{:error, error} ->
|
||||
{:error, error}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a license by map ID.
|
||||
Returns {:ok, license} if found, {:error, reason} otherwise.
|
||||
"""
|
||||
def get_license_by_map_id(map_id) do
|
||||
case License.by_map_id(%{map_id: map_id}) do
|
||||
{:ok, [license | _]} ->
|
||||
{:ok, license}
|
||||
|
||||
{:ok, []} ->
|
||||
{:error, :license_not_found}
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a license's expiration date based on the map's subscription.
|
||||
"""
|
||||
def update_license_expiration_from_subscription(map_id) do
|
||||
with {:ok, license} <- get_license_by_map_id(map_id),
|
||||
{:ok, subscription} <- SubscriptionManager.get_active_map_subscription(map_id) do
|
||||
update_expiration(license.id, subscription.active_till)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a license is expired.
|
||||
"""
|
||||
defp expired?(license) do
|
||||
case license.expire_at do
|
||||
nil -> false
|
||||
expire_at -> DateTime.compare(expire_at, DateTime.utc_now()) == :lt
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates a random string of specified length.
|
||||
"""
|
||||
defp generate_random_string(length) do
|
||||
:crypto.strong_rand_bytes(length)
|
||||
|> Base.encode16(case: :upper)
|
||||
|> binary_part(0, length)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Formats a datetime as YYYY-MM-DD.
|
||||
"""
|
||||
defp format_date(datetime) do
|
||||
Calendar.strftime(datetime, "%Y-%m-%d")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates a link to the map.
|
||||
"""
|
||||
defp generate_map_link(map_slug) do
|
||||
base_url = Application.get_env(:wanderer_app, :web_app_url)
|
||||
"#{base_url}/#{map_slug}"
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the map owner's data.
|
||||
"""
|
||||
defp get_map_owner_email(map) do
|
||||
{:ok, %{owner: owner}} = map |> Ash.load([:owner])
|
||||
"#{owner.name}(#{owner.eve_id})"
|
||||
end
|
||||
end
|
||||
155
lib/wanderer_app/license/license_manager_client.ex
Normal file
155
lib/wanderer_app/license/license_manager_client.ex
Normal file
@@ -0,0 +1,155 @@
|
||||
defmodule WandererApp.License.LicenseManagerClient do
|
||||
@moduledoc """
|
||||
Client for interacting with the external License Manager API.
|
||||
|
||||
This module provides functions to create, update, and validate licenses
|
||||
through the external License Manager API.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
@doc """
|
||||
Creates a new license in the License Manager.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `license_params` - Map containing license details:
|
||||
- `name` (required) - Name of the license
|
||||
- `description` (optional) - Description of the license
|
||||
- `is_valid` (optional) - Boolean indicating if the license is valid
|
||||
- `valid_to` (optional) - Expiration date in YYYY-MM-DD format
|
||||
- `link` (required) - URL associated with the license
|
||||
- `contact_email` (optional) - Contact email for the license
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, license}` - On successful creation
|
||||
- `{:error, reason}` - On failure
|
||||
"""
|
||||
def create_license(license_params) do
|
||||
url = "#{api_url()}/api/manage/licenses"
|
||||
|
||||
auth_opts = [auth: {:bearer, auth_key()}]
|
||||
|
||||
log_request("POST", url, license_params)
|
||||
|
||||
with {:ok, %{status: status, body: license}} when status in 200..299 <-
|
||||
Req.post(url, [json: license_params] ++ auth_opts) do
|
||||
log_response(status, license)
|
||||
{:ok, license}
|
||||
else
|
||||
{:ok, %{status: status, body: body}} ->
|
||||
Logger.error("Failed to create license. Status: #{status}, Body: #{body}")
|
||||
parse_error_response(status, body)
|
||||
|
||||
{:error, error} ->
|
||||
Logger.error("HTTP request failed: #{inspect(error)}")
|
||||
{:error, :request_failed}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates an existing license in the License Manager.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `license_id` - ID of the license to update
|
||||
- `update_params` - Map containing fields to update:
|
||||
- `is_valid` (optional) - Boolean indicating if the license is valid
|
||||
- `valid_to` (optional) - Expiration date in YYYY-MM-DD format
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, license}` - On successful update
|
||||
- `{:error, reason}` - On failure
|
||||
"""
|
||||
def update_license(license_id, update_params) do
|
||||
url = "#{api_url()}/api/manage/licenses/#{license_id}"
|
||||
|
||||
auth_opts = [auth: {:bearer, auth_key()}]
|
||||
|
||||
log_request("PUT", url, update_params)
|
||||
|
||||
with {:ok, %{status: status, body: license}} when status in 200..299 <-
|
||||
Req.put(url, [json: update_params] ++ auth_opts) do
|
||||
log_response(status, license)
|
||||
{:ok, license}
|
||||
else
|
||||
{:ok, %{status: status, body: body}} ->
|
||||
Logger.error("Failed to update license. Status: #{status}, Body: #{inspect(body)}")
|
||||
parse_error_response(status, body)
|
||||
|
||||
{:error, error} ->
|
||||
Logger.error("HTTP request failed: #{inspect(error)}")
|
||||
{:error, :request_failed}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Validates a license key.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `license_key` - The license key to validate
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, result}` - On successful validation, where result is a map containing:
|
||||
- `license_valid` - Boolean indicating if the license is valid
|
||||
- `valid_to` - Expiration date of the license
|
||||
- `license_id` - UUID of the license
|
||||
- `license_name` - Name of the license
|
||||
- `bots` - List of associated bots with their details
|
||||
- `{:error, reason}` - On failure
|
||||
"""
|
||||
def validate_license(license_key) do
|
||||
url = "#{api_url()}/api/license/validate"
|
||||
|
||||
auth_opts = [auth: {:bearer, license_key}]
|
||||
|
||||
log_request("GET", url, nil)
|
||||
|
||||
with {:ok, %{status: 200, body: validation_result}} <- Req.get(url, auth_opts) do
|
||||
log_response(200, validation_result)
|
||||
{:ok, validation_result}
|
||||
else
|
||||
{:ok, %{status: 401}} ->
|
||||
{:error, :invalid_license}
|
||||
|
||||
{:ok, %{status: status, body: body}} ->
|
||||
Logger.error("Failed to validate license. Status: #{status}, Body: #{body}")
|
||||
parse_error_response(status, body)
|
||||
|
||||
{:error, error} ->
|
||||
Logger.error("HTTP request failed: #{inspect(error)}")
|
||||
{:error, :request_failed}
|
||||
end
|
||||
end
|
||||
|
||||
# Private helper functions
|
||||
defp api_url do
|
||||
Application.get_env(:wanderer_app, :license_manager)[:api_url]
|
||||
end
|
||||
|
||||
defp auth_key do
|
||||
Application.get_env(:wanderer_app, :license_manager)[:auth_key]
|
||||
end
|
||||
|
||||
defp parse_error_response(status, %{"error" => error_message}) do
|
||||
{:error, error_message}
|
||||
end
|
||||
|
||||
defp parse_error_response(status, error) do
|
||||
{:error, "HTTP #{status}: #{inspect(error)}"}
|
||||
end
|
||||
|
||||
defp log_request(method, url, params) do
|
||||
Logger.info("License Manager API Request: #{method} #{url}")
|
||||
Logger.debug("License Manager API Params: #{inspect(params)}")
|
||||
end
|
||||
|
||||
defp log_response(status, body) do
|
||||
Logger.info("License Manager API Response: Status #{status}")
|
||||
Logger.debug("License Manager API Response Body: #{inspect(body)}")
|
||||
end
|
||||
end
|
||||
@@ -3,6 +3,8 @@ defmodule WandererApp.Map do
|
||||
Represents the map structure and exposes actions that can be taken to update
|
||||
it
|
||||
"""
|
||||
import Ecto.Query
|
||||
|
||||
require Logger
|
||||
|
||||
defstruct map_id: nil,
|
||||
@@ -521,4 +523,84 @@ defmodule WandererApp.Map do
|
||||
|
||||
defp _maybe_limit_list(list, nil), do: list
|
||||
defp _maybe_limit_list(list, limit), do: Enum.take(list, limit)
|
||||
|
||||
@doc """
|
||||
Returns the raw activity data that can be processed by WandererApp.Character.Activity.
|
||||
Only includes characters that are on the map's ACL.
|
||||
"""
|
||||
def get_character_activity(map_id) do
|
||||
{:ok, map} = WandererApp.Api.Map.by_id(map_id)
|
||||
_map_with_acls = Ash.load!(map, :acls)
|
||||
|
||||
{:ok, jumps} = WandererApp.Api.MapChainPassages.by_map_id(%{map_id: map_id})
|
||||
thirty_days_ago = DateTime.utc_now() |> DateTime.add(-30 * 24 * 3600, :second)
|
||||
|
||||
# Get activity data
|
||||
connections_activity = get_connections_activity(map_id, thirty_days_ago)
|
||||
signatures_activity = get_signatures_activity(map_id, thirty_days_ago)
|
||||
|
||||
# Return raw activity data
|
||||
jumps
|
||||
|> Enum.map(fn passage ->
|
||||
%{
|
||||
character: passage.character,
|
||||
passages: passage.count,
|
||||
connections: Map.get(connections_activity, passage.character.id, 0),
|
||||
signatures: Map.get(signatures_activity, passage.character.id, 0),
|
||||
timestamp: DateTime.utc_now(),
|
||||
character_id: passage.character.id,
|
||||
user_id: passage.character.user_id
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
||||
defp get_connections_activity(map_id, thirty_days_ago) do
|
||||
from(ua in WandererApp.Api.UserActivity,
|
||||
join: c in assoc(ua, :character),
|
||||
where:
|
||||
ua.entity_id == ^map_id and
|
||||
ua.entity_type == :map and
|
||||
ua.event_type == :map_connection_added and
|
||||
ua.inserted_at > ^thirty_days_ago,
|
||||
group_by: [c.id],
|
||||
select: {c.id, count(ua.id)}
|
||||
)
|
||||
|> WandererApp.Repo.all()
|
||||
|> Map.new()
|
||||
end
|
||||
|
||||
defp get_signatures_activity(map_id, thirty_days_ago) do
|
||||
from(ua in WandererApp.Api.UserActivity,
|
||||
join: c in assoc(ua, :character),
|
||||
where:
|
||||
ua.entity_id == ^map_id and
|
||||
ua.entity_type == :map and
|
||||
ua.event_type == :signatures_added and
|
||||
ua.inserted_at > ^thirty_days_ago,
|
||||
select: {ua.character_id, ua.event_data}
|
||||
)
|
||||
|> WandererApp.Repo.all()
|
||||
|> process_signatures_data()
|
||||
end
|
||||
|
||||
defp process_signatures_data(signatures_data) do
|
||||
signatures_data
|
||||
|> Enum.group_by(fn {character_id, _} -> character_id end)
|
||||
|> Enum.map(&process_character_signatures/1)
|
||||
|> Map.new()
|
||||
end
|
||||
|
||||
defp process_character_signatures({character_id, activities}) do
|
||||
signature_count =
|
||||
activities
|
||||
|> Enum.map(fn {_, event_data} ->
|
||||
case Jason.decode(event_data) do
|
||||
{:ok, data} -> length(Map.get(data, "signatures", []))
|
||||
_ -> 0
|
||||
end
|
||||
end)
|
||||
|> Enum.sum()
|
||||
|
||||
{character_id, signature_count}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -39,7 +39,7 @@ defmodule WandererApp.Map.Audit do
|
||||
:ok
|
||||
end
|
||||
|
||||
def get_activity_page(map_id, page, per_page, period, activity) do
|
||||
def get_activity_query(map_id, period, activity) do
|
||||
{from, to} = period |> get_period()
|
||||
|
||||
query =
|
||||
@@ -65,10 +65,6 @@ defmodule WandererApp.Map.Audit do
|
||||
|
||||
query
|
||||
|> Ash.Query.sort(inserted_at: :desc)
|
||||
|> WandererApp.Api.read(
|
||||
page: [limit: per_page, offset: (page - 1) * per_page],
|
||||
load: [:character]
|
||||
)
|
||||
end
|
||||
|
||||
def track_acl_event(
|
||||
|
||||
@@ -54,7 +54,7 @@ defmodule WandererApp.Map.SubscriptionManager do
|
||||
end
|
||||
|
||||
def process() do
|
||||
@logger.info("Start map subscriptions processing...")
|
||||
Logger.info("Start map subscriptions processing...")
|
||||
|
||||
{:ok, active_map_subscriptions} =
|
||||
WandererApp.MapSubscriptionRepo.get_all_active()
|
||||
@@ -62,7 +62,7 @@ defmodule WandererApp.Map.SubscriptionManager do
|
||||
tasks =
|
||||
for map_subscription <- active_map_subscriptions do
|
||||
Task.async(fn ->
|
||||
map_subscription |> _process_subscription()
|
||||
map_subscription |> process_subscription()
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -219,22 +219,22 @@ defmodule WandererApp.Map.SubscriptionManager do
|
||||
|> DateTime.from_gregorian_seconds()
|
||||
end
|
||||
|
||||
defp _process_subscription(subscription) when is_map(subscription) do
|
||||
defp process_subscription(subscription) when is_map(subscription) do
|
||||
subscription
|
||||
|> _is_expired()
|
||||
|> is_expired()
|
||||
|> case do
|
||||
true ->
|
||||
_renew_subscription(subscription)
|
||||
renew_subscription(subscription)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp _is_expired(subscription) when is_map(subscription),
|
||||
defp is_expired(subscription) when is_map(subscription),
|
||||
do: DateTime.compare(DateTime.utc_now(), subscription.active_till) == :gt
|
||||
|
||||
defp _renew_subscription(%{auto_renew?: true} = subscription) when is_map(subscription) do
|
||||
defp renew_subscription(%{auto_renew?: true} = subscription) when is_map(subscription) do
|
||||
with {:ok, %{map: map}} <-
|
||||
subscription |> WandererApp.MapSubscriptionRepo.load_relationships([:map]),
|
||||
{:ok, estimated_price, discount} <- estimate_price(subscription, true),
|
||||
@@ -270,6 +270,49 @@ defmodule WandererApp.Map.SubscriptionManager do
|
||||
amount: estimated_price - discount
|
||||
})
|
||||
|
||||
# Check if a license already exists, if not create one
|
||||
case WandererApp.License.LicenseManager.get_license_by_map_id(map.id) do
|
||||
{:error, :license_not_found} ->
|
||||
# No license found, create one
|
||||
# The License Manager service will verify the subscription is active
|
||||
case WandererApp.License.LicenseManager.create_license_for_map(map.id) do
|
||||
{:ok, license} ->
|
||||
Logger.debug(fn ->
|
||||
"Automatically created license #{license.license_key} for map #{map.id} during renewal"
|
||||
end)
|
||||
|
||||
{:error, :no_active_subscription} ->
|
||||
Logger.warn(
|
||||
"Cannot create license for map #{map.id}: No active subscription found"
|
||||
)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error(
|
||||
"Failed to create license for map #{map.id} during renewal: #{inspect(reason)}"
|
||||
)
|
||||
end
|
||||
|
||||
{:ok, _license} ->
|
||||
# License exists, update its expiration date
|
||||
case WandererApp.License.LicenseManager.update_license_expiration_from_subscription(
|
||||
map.id
|
||||
) do
|
||||
{:ok, updated_license} ->
|
||||
Logger.info(
|
||||
"Updated license expiration for map #{map.id} to #{updated_license.expire_at}"
|
||||
)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error(
|
||||
"Failed to update license expiration for map #{map.id}: #{inspect(reason)}"
|
||||
)
|
||||
end
|
||||
|
||||
_ ->
|
||||
# Error occurred, do nothing
|
||||
:ok
|
||||
end
|
||||
|
||||
:ok
|
||||
|
||||
_ ->
|
||||
@@ -282,6 +325,15 @@ defmodule WandererApp.Map.SubscriptionManager do
|
||||
:subscription_settings_updated
|
||||
)
|
||||
|
||||
case WandererApp.License.LicenseManager.get_license_by_map_id(map.id) do
|
||||
{:ok, license} ->
|
||||
WandererApp.License.LicenseManager.invalidate_license(license.id)
|
||||
Logger.info("Cancelled license for map #{map.id}")
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to cancel license for map #{map.id}: #{inspect(reason)}")
|
||||
end
|
||||
|
||||
:telemetry.execute([:wanderer_app, :map, :subscription, :cancel], %{count: 1}, %{
|
||||
map_id: map.id
|
||||
})
|
||||
@@ -298,7 +350,7 @@ defmodule WandererApp.Map.SubscriptionManager do
|
||||
end
|
||||
end
|
||||
|
||||
defp _renew_subscription(%{auto_renew?: false} = subscription) when is_map(subscription) do
|
||||
defp renew_subscription(%{auto_renew?: false} = subscription) when is_map(subscription) do
|
||||
subscription
|
||||
|> WandererApp.MapSubscriptionRepo.expire()
|
||||
|
||||
@@ -308,6 +360,17 @@ defmodule WandererApp.Map.SubscriptionManager do
|
||||
:subscription_settings_updated
|
||||
)
|
||||
|
||||
case WandererApp.License.LicenseManager.get_license_by_map_id(subscription.map_id) do
|
||||
{:ok, license} ->
|
||||
WandererApp.License.LicenseManager.invalidate_license(license.id)
|
||||
Logger.info("Cancelled license for map #{subscription.map_id}")
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error(
|
||||
"Failed to cancel license for map #{subscription.map_id}: #{inspect(reason)}"
|
||||
)
|
||||
end
|
||||
|
||||
:telemetry.execute([:wanderer_app, :map, :subscription, :expired], %{count: 1}, %{
|
||||
map_id: subscription.map_id
|
||||
})
|
||||
|
||||
@@ -350,6 +350,18 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
|
||||
|
||||
Impl.broadcast!(map_id, :add_connection, connection)
|
||||
|
||||
{:ok, character} = WandererApp.Character.get_character(character_id)
|
||||
{:ok, character_with_user} = character |> Ash.load(:user)
|
||||
|
||||
{:ok, _} =
|
||||
WandererApp.User.ActivityTracker.track_map_event(:map_connection_added, %{
|
||||
character_id: character_id,
|
||||
user_id: character_with_user.user_id,
|
||||
map_id: map_id,
|
||||
solar_system_source_id: old_location.solar_system_id,
|
||||
solar_system_target_id: location.solar_system_id
|
||||
})
|
||||
|
||||
Impl.broadcast!(map_id, :maybe_link_signature, %{
|
||||
character_id: character_id,
|
||||
solar_system_source: old_location.solar_system_id,
|
||||
|
||||
@@ -3,6 +3,7 @@ defmodule WandererApp.Maps do
|
||||
use Nebulex.Caching
|
||||
|
||||
require Ash.Query
|
||||
require Logger
|
||||
|
||||
@minimum_route_attrs [
|
||||
:system_class,
|
||||
@@ -119,10 +120,10 @@ defmodule WandererApp.Maps do
|
||||
|
||||
@decorate cacheable(
|
||||
cache: WandererApp.Cache,
|
||||
key: "map_characters-#{_map_id}",
|
||||
key: "map_characters-#{map_id}",
|
||||
opts: [ttl: :timer.seconds(5)]
|
||||
)
|
||||
defp _get_map_characters(%{id: _map_id} = map) do
|
||||
defp _get_map_characters(%{id: map_id} = map) do
|
||||
map_acls =
|
||||
map.acls
|
||||
|> Enum.map(fn acl -> acl |> Ash.load!(:members) end)
|
||||
@@ -170,13 +171,21 @@ defmodule WandererApp.Maps do
|
||||
map_member_alliance_ids: map_member_alliance_ids
|
||||
}} = _get_map_characters(map)
|
||||
|
||||
user_characters
|
||||
filtered_characters = user_characters
|
||||
|> Enum.filter(fn c ->
|
||||
c.id == map.owner_id or
|
||||
c.id in map_acl_owner_ids or c.eve_id in map_member_eve_ids or
|
||||
to_string(c.corporation_id) in map_member_corporation_ids or
|
||||
to_string(c.alliance_id) in map_member_alliance_ids
|
||||
is_owner = c.id == map.owner_id
|
||||
is_acl_owner = c.id in map_acl_owner_ids
|
||||
is_member_eve = c.eve_id in map_member_eve_ids
|
||||
is_member_corp = to_string(c.corporation_id) in map_member_corporation_ids
|
||||
is_member_alliance = to_string(c.alliance_id) in map_member_alliance_ids
|
||||
|
||||
has_access = is_owner or is_acl_owner or is_member_eve or is_member_corp or is_member_alliance
|
||||
|
||||
has_access
|
||||
end)
|
||||
|
||||
|
||||
filtered_characters
|
||||
end
|
||||
|
||||
defp filter_blocked_maps(maps, current_user) do
|
||||
|
||||
@@ -37,18 +37,63 @@ defmodule WandererApp.MapCharacterSettingsRepo do
|
||||
end
|
||||
end
|
||||
|
||||
def track(settings), do: settings |> WandererApp.Api.MapCharacterSettings.track()
|
||||
def untrack(settings), do: settings |> WandererApp.Api.MapCharacterSettings.untrack()
|
||||
def track(settings) do
|
||||
# Only update the tracked field, preserving other fields
|
||||
WandererApp.Api.MapCharacterSettings.track(%{
|
||||
map_id: settings.map_id,
|
||||
character_id: settings.character_id
|
||||
})
|
||||
end
|
||||
|
||||
def track!(settings), do: settings |> WandererApp.Api.MapCharacterSettings.track!()
|
||||
def untrack!(settings), do: settings |> WandererApp.Api.MapCharacterSettings.untrack!()
|
||||
def untrack(settings) do
|
||||
# Only update the tracked field, preserving other fields
|
||||
WandererApp.Api.MapCharacterSettings.untrack(%{
|
||||
map_id: settings.map_id,
|
||||
character_id: settings.character_id
|
||||
})
|
||||
end
|
||||
|
||||
def follow(settings), do: settings |> WandererApp.Api.MapCharacterSettings.follow()
|
||||
def unfollow(settings), do: settings |> WandererApp.Api.MapCharacterSettings.unfollow()
|
||||
def track!(settings),
|
||||
do:
|
||||
WandererApp.Api.MapCharacterSettings.track!(%{
|
||||
map_id: settings.map_id,
|
||||
character_id: settings.character_id
|
||||
})
|
||||
|
||||
def follow!(settings), do: settings |> WandererApp.Api.MapCharacterSettings.follow!()
|
||||
def unfollow!(settings), do: settings |> WandererApp.Api.MapCharacterSettings.unfollow!()
|
||||
def untrack!(settings),
|
||||
do:
|
||||
WandererApp.Api.MapCharacterSettings.untrack!(%{
|
||||
map_id: settings.map_id,
|
||||
character_id: settings.character_id
|
||||
})
|
||||
|
||||
def follow(settings) do
|
||||
WandererApp.Api.MapCharacterSettings.follow(%{
|
||||
map_id: settings.map_id,
|
||||
character_id: settings.character_id
|
||||
})
|
||||
end
|
||||
|
||||
def unfollow(settings) do
|
||||
WandererApp.Api.MapCharacterSettings.unfollow(%{
|
||||
map_id: settings.map_id,
|
||||
character_id: settings.character_id
|
||||
})
|
||||
end
|
||||
|
||||
def follow!(settings),
|
||||
do:
|
||||
WandererApp.Api.MapCharacterSettings.follow!(%{
|
||||
map_id: settings.map_id,
|
||||
character_id: settings.character_id
|
||||
})
|
||||
|
||||
def unfollow!(settings),
|
||||
do:
|
||||
WandererApp.Api.MapCharacterSettings.unfollow!(%{
|
||||
map_id: settings.map_id,
|
||||
character_id: settings.character_id
|
||||
})
|
||||
|
||||
def destroy!(settings), do: settings |> WandererApp.Api.MapCharacterSettings.destroy!()
|
||||
end
|
||||
|
||||
@@ -4,7 +4,8 @@ defmodule WandererApp.MapUserSettingsRepo do
|
||||
@default_form_data %{
|
||||
"select_on_spash" => false,
|
||||
"link_signature_on_splash" => false,
|
||||
"delete_connection_with_sigs" => false
|
||||
"delete_connection_with_sigs" => false,
|
||||
"primary_character_id" => nil
|
||||
}
|
||||
|
||||
def get(map_id, user_id) do
|
||||
|
||||
26
lib/wanderer_app/utils/eve_util.ex
Normal file
26
lib/wanderer_app/utils/eve_util.ex
Normal file
@@ -0,0 +1,26 @@
|
||||
defmodule WandererApp.Utils.EVEUtil do
|
||||
@moduledoc """
|
||||
Utility functions for EVE Online related operations.
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Generates a URL for a character portrait.
|
||||
|
||||
## Parameters
|
||||
* `eve_id` - The EVE Online character ID
|
||||
* `size` - The size of the portrait (default: 64)
|
||||
|
||||
## Examples
|
||||
iex> WandererApp.Utils.EVEUtil.get_portrait_url(12345678)
|
||||
"https://images.evetech.net/characters/12345678/portrait?size=64"
|
||||
|
||||
iex> WandererApp.Utils.EVEUtil.get_portrait_url(12345678, 128)
|
||||
"https://images.evetech.net/characters/12345678/portrait?size=128"
|
||||
"""
|
||||
def get_portrait_url(eve_id, size \\ 64)
|
||||
def get_portrait_url(nil, size), do: "https://images.evetech.net/characters/0/portrait?size=#{size}"
|
||||
def get_portrait_url("", size), do: "https://images.evetech.net/characters/0/portrait?size=#{size}"
|
||||
def get_portrait_url(eve_id, size) do
|
||||
"https://images.evetech.net/characters/#{eve_id}/portrait?size=#{size}"
|
||||
end
|
||||
end
|
||||
18
lib/wanderer_app/utils/http_util.ex
Normal file
18
lib/wanderer_app/utils/http_util.ex
Normal file
@@ -0,0 +1,18 @@
|
||||
defmodule WandererApp.Utils.HttpUtil do
|
||||
@moduledoc """
|
||||
Utility functions for HTTP operations and error handling.
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Determines if an HTTP error is retriable.
|
||||
|
||||
Returns `true` for common transient errors like timeouts and server errors (500, 502, 503, 504).
|
||||
"""
|
||||
def retriable_error?(:timeout), do: true
|
||||
def retriable_error?("Unexpected status: 500"), do: true
|
||||
def retriable_error?("Unexpected status: 502"), do: true
|
||||
def retriable_error?("Unexpected status: 503"), do: true
|
||||
def retriable_error?("Unexpected status: 504"), do: true
|
||||
def retriable_error?("Request failed"), do: true
|
||||
def retriable_error?(_), do: false
|
||||
end
|
||||
@@ -7,6 +7,7 @@ defmodule WandererApp.Zkb.KillsProvider.Fetcher do
|
||||
use Retry
|
||||
|
||||
alias WandererApp.Zkb.KillsProvider.{Parser, KillsCache, ZkbApi}
|
||||
alias WandererApp.Utils.HttpUtil
|
||||
|
||||
@page_size 200
|
||||
@max_pages 2
|
||||
@@ -190,9 +191,28 @@ defmodule WandererApp.Zkb.KillsProvider.Fetcher do
|
||||
defp parse_partial(_other, _cutoff_dt), do: :skip
|
||||
|
||||
defp fetch_full_killmail(k_id, k_hash) do
|
||||
case WandererApp.Esi.get_killmail(k_id, k_hash) do
|
||||
{:ok, full_km} -> {:ok, full_km}
|
||||
{:error, reason} -> {:error, reason}
|
||||
retry with: exponential_backoff(300) |> randomize() |> cap(5_000) |> expiry(30_000), rescue_only: [RuntimeError] do
|
||||
case WandererApp.Esi.get_killmail(k_id, k_hash) do
|
||||
{:ok, full_km} ->
|
||||
{:ok, full_km}
|
||||
|
||||
{:error, :timeout} ->
|
||||
Logger.warning("[Fetcher] ESI get_killmail timeout => kill_id=#{k_id}, retrying...")
|
||||
raise "ESI timeout, will retry"
|
||||
|
||||
{:error, :not_found} ->
|
||||
Logger.warning("[Fetcher] ESI get_killmail not_found => kill_id=#{k_id}")
|
||||
{:error, :not_found}
|
||||
|
||||
{:error, reason} ->
|
||||
if HttpUtil.retriable_error?(reason) do
|
||||
Logger.warning("[Fetcher] ESI get_killmail retriable error => kill_id=#{k_id}, reason=#{inspect(reason)}")
|
||||
raise "ESI error: #{inspect(reason)}, will retry"
|
||||
else
|
||||
Logger.warning("[Fetcher] ESI get_killmail failed => kill_id=#{k_id}, reason=#{inspect(reason)}")
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -10,6 +10,10 @@ defmodule WandererApp.Zkb.KillsProvider.Parser do
|
||||
|
||||
require Logger
|
||||
alias WandererApp.Zkb.KillsProvider.KillsCache
|
||||
alias WandererApp.Utils.HttpUtil
|
||||
use Retry
|
||||
|
||||
# Maximum retries for enrichment calls
|
||||
|
||||
@doc """
|
||||
Merges the 'partial' from zKB and the 'full' killmail from ESI, checks its time
|
||||
@@ -254,12 +258,33 @@ defmodule WandererApp.Zkb.KillsProvider.Parser do
|
||||
nil -> km
|
||||
0 -> km
|
||||
eve_id ->
|
||||
case WandererApp.Esi.get_character_info(eve_id) do
|
||||
{:ok, %{"name" => char_name}} ->
|
||||
Map.put(km, name_key, char_name)
|
||||
result = retry with: exponential_backoff(200) |> randomize() |> cap(2_000) |> expiry(10_000), rescue_only: [RuntimeError] do
|
||||
case WandererApp.Esi.get_character_info(eve_id) do
|
||||
{:ok, %{"name" => char_name}} ->
|
||||
{:ok, char_name}
|
||||
|
||||
_ ->
|
||||
km
|
||||
{:error, :timeout} ->
|
||||
Logger.debug(fn -> "[Parser] Character info timeout, retrying => id=#{eve_id}" end)
|
||||
raise "Character info timeout, will retry"
|
||||
|
||||
{:error, :not_found} ->
|
||||
Logger.debug(fn -> "[Parser] Character not found => id=#{eve_id}" end)
|
||||
:skip
|
||||
|
||||
{:error, reason} ->
|
||||
if HttpUtil.retriable_error?(reason) do
|
||||
Logger.debug(fn -> "[Parser] Character info retriable error => id=#{eve_id}, reason=#{inspect(reason)}" end)
|
||||
raise "Character info error: #{inspect(reason)}, will retry"
|
||||
else
|
||||
Logger.debug(fn -> "[Parser] Character info failed => id=#{eve_id}, reason=#{inspect(reason)}" end)
|
||||
:skip
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
case result do
|
||||
{:ok, char_name} -> Map.put(km, name_key, char_name)
|
||||
_ -> km
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -269,18 +294,36 @@ defmodule WandererApp.Zkb.KillsProvider.Parser do
|
||||
nil -> km
|
||||
0 -> km
|
||||
corp_id ->
|
||||
case WandererApp.Esi.get_corporation_info(corp_id) do
|
||||
{:ok, %{"ticker" => ticker, "name" => corp_name}} ->
|
||||
result = retry with: exponential_backoff(200) |> randomize() |> cap(2_000) |> expiry(10_000), rescue_only: [RuntimeError] do
|
||||
case WandererApp.Esi.get_corporation_info(corp_id) do
|
||||
{:ok, %{"ticker" => ticker, "name" => corp_name}} ->
|
||||
{:ok, {ticker, corp_name}}
|
||||
|
||||
{:error, :timeout} ->
|
||||
Logger.debug(fn -> "[Parser] Corporation info timeout, retrying => id=#{corp_id}" end)
|
||||
raise "Corporation info timeout, will retry"
|
||||
|
||||
{:error, :not_found} ->
|
||||
Logger.debug(fn -> "[Parser] Corporation not found => id=#{corp_id}" end)
|
||||
:skip
|
||||
|
||||
{:error, reason} ->
|
||||
if HttpUtil.retriable_error?(reason) do
|
||||
Logger.debug(fn -> "[Parser] Corporation info retriable error => id=#{corp_id}, reason=#{inspect(reason)}" end)
|
||||
raise "Corporation info error: #{inspect(reason)}, will retry"
|
||||
else
|
||||
Logger.warning("[Parser] Failed to fetch corp info: ID=#{corp_id}, reason=#{inspect(reason)}")
|
||||
:skip
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
case result do
|
||||
{:ok, {ticker, corp_name}} ->
|
||||
km
|
||||
|> Map.put(ticker_key, ticker)
|
||||
|> Map.put(name_key, corp_name)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("[Parser] Failed to fetch corp info: ID=#{corp_id}, reason=#{inspect(reason)}")
|
||||
km
|
||||
|
||||
_ ->
|
||||
km
|
||||
_ -> km
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -290,14 +333,36 @@ defmodule WandererApp.Zkb.KillsProvider.Parser do
|
||||
nil -> km
|
||||
0 -> km
|
||||
alliance_id ->
|
||||
case WandererApp.Esi.get_alliance_info(alliance_id) do
|
||||
{:ok, %{"ticker" => alliance_ticker, "name" => alliance_name}} ->
|
||||
result = retry with: exponential_backoff(200) |> randomize() |> cap(2_000) |> expiry(10_000), rescue_only: [RuntimeError] do
|
||||
case WandererApp.Esi.get_alliance_info(alliance_id) do
|
||||
{:ok, %{"ticker" => alliance_ticker, "name" => alliance_name}} ->
|
||||
{:ok, {alliance_ticker, alliance_name}}
|
||||
|
||||
{:error, :timeout} ->
|
||||
Logger.debug(fn -> "[Parser] Alliance info timeout, retrying => id=#{alliance_id}" end)
|
||||
raise "Alliance info timeout, will retry"
|
||||
|
||||
{:error, :not_found} ->
|
||||
Logger.debug(fn -> "[Parser] Alliance not found => id=#{alliance_id}" end)
|
||||
:skip
|
||||
|
||||
{:error, reason} ->
|
||||
if HttpUtil.retriable_error?(reason) do
|
||||
Logger.debug(fn -> "[Parser] Alliance info retriable error => id=#{alliance_id}, reason=#{inspect(reason)}" end)
|
||||
raise "Alliance info error: #{inspect(reason)}, will retry"
|
||||
else
|
||||
Logger.debug(fn -> "[Parser] Alliance info failed => id=#{alliance_id}, reason=#{inspect(reason)}" end)
|
||||
:skip
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
case result do
|
||||
{:ok, {alliance_ticker, alliance_name}} ->
|
||||
km
|
||||
|> Map.put(ticker_key, alliance_ticker)
|
||||
|> Map.put(name_key, alliance_name)
|
||||
|
||||
_ ->
|
||||
km
|
||||
_ -> km
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -307,13 +372,31 @@ defmodule WandererApp.Zkb.KillsProvider.Parser do
|
||||
nil -> km
|
||||
0 -> km
|
||||
type_id ->
|
||||
case WandererApp.CachedInfo.get_ship_type(type_id) do
|
||||
{:ok, nil} -> km
|
||||
{:ok, %{name: ship_name}} -> Map.put(km, name_key, ship_name)
|
||||
{:error, reason} ->
|
||||
Logger.warning("[Parser] Failed to fetch ship type: ID=#{type_id}, reason=#{inspect(reason)}")
|
||||
km
|
||||
result = retry with: exponential_backoff(200) |> randomize() |> cap(2_000) |> expiry(10_000), rescue_only: [RuntimeError] do
|
||||
case WandererApp.CachedInfo.get_ship_type(type_id) do
|
||||
{:ok, nil} -> :skip
|
||||
{:ok, %{name: ship_name}} -> {:ok, ship_name}
|
||||
{:error, :timeout} ->
|
||||
Logger.debug(fn -> "[Parser] Ship type timeout, retrying => id=#{type_id}" end)
|
||||
raise "Ship type timeout, will retry"
|
||||
|
||||
{:error, :not_found} ->
|
||||
Logger.debug(fn -> "[Parser] Ship type not found => id=#{type_id}" end)
|
||||
:skip
|
||||
|
||||
{:error, reason} ->
|
||||
if HttpUtil.retriable_error?(reason) do
|
||||
Logger.debug(fn -> "[Parser] Ship type retriable error => id=#{type_id}, reason=#{inspect(reason)}" end)
|
||||
raise "Ship type error: #{inspect(reason)}, will retry"
|
||||
else
|
||||
Logger.warning("[Parser] Failed to fetch ship type: ID=#{type_id}, reason=#{inspect(reason)}")
|
||||
:skip
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
case result do
|
||||
{:ok, ship_name} -> Map.put(km, name_key, ship_name)
|
||||
_ -> km
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,8 +7,11 @@ defmodule WandererApp.Zkb.KillsProvider.Websocket do
|
||||
require Logger
|
||||
alias WandererApp.Zkb.KillsProvider.Parser
|
||||
alias WandererApp.Esi
|
||||
alias WandererApp.Utils.HttpUtil
|
||||
use Retry
|
||||
|
||||
@heartbeat_interval 1_000
|
||||
@max_esi_retries 3
|
||||
|
||||
# Called by `KillsProvider.handle_connect`
|
||||
def handle_connect(_status, _headers, %{connected: _} = state) do
|
||||
@@ -69,14 +72,39 @@ defmodule WandererApp.Zkb.KillsProvider.Websocket do
|
||||
# The partial from zKillboard has killmail_id + zkb.hash, but no time/victim/attackers
|
||||
defp parse_and_store_zkb_partial(%{"killmail_id" => kill_id, "zkb" => %{"hash" => kill_hash}} = partial) do
|
||||
Logger.debug(fn -> "[KillsProvider.Websocket] parse_and_store_zkb_partial => kill_id=#{kill_id}" end)
|
||||
case Esi.get_killmail(kill_id, kill_hash) do
|
||||
{:ok, full_esi_data} ->
|
||||
# Merge partial zKB fields (like totalValue) onto ESI data
|
||||
enriched = Map.merge(full_esi_data, %{"zkb" => partial["zkb"]})
|
||||
Parser.parse_and_store_killmail(enriched)
|
||||
|
||||
result = retry with: exponential_backoff(300) |> randomize() |> cap(5_000) |> expiry(30_000), rescue_only: [RuntimeError] do
|
||||
case Esi.get_killmail(kill_id, kill_hash) do
|
||||
{:ok, full_esi_data} ->
|
||||
# Merge partial zKB fields (like totalValue) onto ESI data
|
||||
enriched = Map.merge(full_esi_data, %{"zkb" => partial["zkb"]})
|
||||
Parser.parse_and_store_killmail(enriched)
|
||||
:ok
|
||||
|
||||
{:error, :timeout} ->
|
||||
Logger.warning("[KillsProvider.Websocket] ESI get_killmail timeout => kill_id=#{kill_id}, retrying...")
|
||||
raise "ESI timeout, will retry"
|
||||
|
||||
{:error, :not_found} ->
|
||||
Logger.warning("[KillsProvider.Websocket] ESI get_killmail not_found => kill_id=#{kill_id}")
|
||||
:skip
|
||||
|
||||
{:error, reason} ->
|
||||
if HttpUtil.retriable_error?(reason) do
|
||||
Logger.warning("[KillsProvider.Websocket] ESI get_killmail retriable error => kill_id=#{kill_id}, reason=#{inspect(reason)}")
|
||||
raise "ESI error: #{inspect(reason)}, will retry"
|
||||
else
|
||||
Logger.warning("[KillsProvider.Websocket] ESI get_killmail failed => kill_id=#{kill_id}, reason=#{inspect(reason)}")
|
||||
:skip
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
case result do
|
||||
:ok -> :ok
|
||||
:skip -> :skip
|
||||
{:error, reason} ->
|
||||
Logger.warning("[KillsProvider.Websocket] ESI get_killmail failed => kill_id=#{kill_id}, reason=#{inspect(reason)}")
|
||||
Logger.error("[KillsProvider.Websocket] ESI get_killmail exhausted retries => kill_id=#{kill_id}, reason=#{inspect(reason)}")
|
||||
:skip
|
||||
end
|
||||
end
|
||||
|
||||
32
lib/wanderer_app_web/api_spec.ex
Normal file
32
lib/wanderer_app_web/api_spec.ex
Normal file
@@ -0,0 +1,32 @@
|
||||
defmodule WandererAppWeb.ApiSpec do
|
||||
@behaviour OpenApiSpex.OpenApi
|
||||
|
||||
alias OpenApiSpex.{OpenApi, Info, Paths, Components, SecurityScheme, Server}
|
||||
alias WandererAppWeb.{Endpoint, Router}
|
||||
|
||||
@impl OpenApiSpex.OpenApi
|
||||
def spec do
|
||||
%OpenApi{
|
||||
info: %Info{
|
||||
title: "WandererApp API",
|
||||
version: "1.0.0",
|
||||
description: "API documentation for WandererApp"
|
||||
},
|
||||
servers: [
|
||||
Server.from_endpoint(Endpoint)
|
||||
],
|
||||
paths: Paths.from_router(Router),
|
||||
components: %Components{
|
||||
securitySchemes: %{
|
||||
"bearerAuth" => %SecurityScheme{
|
||||
type: "http",
|
||||
scheme: "bearer",
|
||||
bearerFormat: "JWT"
|
||||
}
|
||||
}
|
||||
},
|
||||
security: [%{"bearerAuth" => []}]
|
||||
}
|
||||
|> OpenApiSpex.resolve_schema_modules()
|
||||
end
|
||||
end
|
||||
@@ -1,47 +0,0 @@
|
||||
defmodule WandererAppWeb.CharacterActivity do
|
||||
use WandererAppWeb, :live_component
|
||||
use LiveViewEvents
|
||||
|
||||
@impl true
|
||||
def mount(socket) do
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def update(
|
||||
assigns,
|
||||
socket
|
||||
) do
|
||||
{:ok,
|
||||
socket
|
||||
|> handle_info_or_assign(assigns)}
|
||||
end
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div id={@id}>
|
||||
<.table class="!max-h-[80vh] !overflow-y-auto" id="activity-tbl" rows={@activity}>
|
||||
<:col :let={row} label="Character">
|
||||
<.character_item character={row.character} />
|
||||
</:col>
|
||||
<:col :let={row} label="Passages">
|
||||
<%= row.count %>
|
||||
</:col>
|
||||
</.table>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def character_item(assigns) do
|
||||
~H"""
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="avatar">
|
||||
<div class="rounded-md w-12 h-12">
|
||||
<img src={member_icon_url(@character.eve_id)} alt={@character.name} />
|
||||
</div>
|
||||
</div>
|
||||
<%= @character.name %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
1318
lib/wanderer_app_web/components/components.ex
Normal file
1318
lib/wanderer_app_web/components/components.ex
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user