Compare commits

..

28 Commits

Author SHA1 Message Date
CI
9bf6893524 chore: release version v1.97.0 2026-03-14 22:59:13 +00:00
Dmitry Popov
e45e6a39eb feat(signatures): Sync mass status with connection, show it on unsplashed sigs 2026-03-14 23:58:25 +01:00
Dmitry Popov
7f1691b2db Merge branch 'main' into feat-context-ui 2026-03-14 17:58:52 +01:00
CI
d1006b329a chore: [skip ci] 2026-03-13 11:37:39 +00:00
CI
e49471fb94 chore: release version v1.96.6 2026-03-13 11:37:39 +00:00
Dmitry Popov
ad35d9e172 fix(core): Fixed tracking issues 2026-03-13 12:33:18 +01:00
DanSylvest
18d50329bc fix: Connection context menu and wormhole. Change UI for select wormhole mass state. Change UI for select ship-size for wormhole. Add ability to set mass for signatures 2026-03-10 15:35:24 +03:00
CI
d8fb980a3b chore: [skip ci] 2026-02-27 17:48:31 +00:00
CI
b8b3bc60ad chore: release version v1.96.5 2026-02-27 17:48:31 +00:00
Dmitry Popov
80d5dd1eb1 Merge branch 'main' of github.com:wanderer-industries/wanderer 2026-02-27 18:47:51 +01:00
Dmitry Popov
1ab0e96cbb fix(core): Fixed access token refresh issues 2026-02-27 18:46:56 +01:00
CI
e3a13b9554 chore: [skip ci] 2026-02-17 19:54:43 +00:00
CI
1f3387e4ff chore: release version v1.96.4 2026-02-17 19:54:43 +00:00
Dmitry Popov
95d2fa232a Merge pull request #596 from wanderer-industries/expired-characters
Expired characters
2026-02-17 20:54:11 +01:00
DanSylvest
eed1d8bc27 fix: Change character token validity status. Now we will see red frame and icon for tracked characters which token was expired. 2026-02-17 22:32:43 +03:00
Dmitry Popov
c451735559 chore: Updated character expired token handler 2026-02-17 16:19:36 +01:00
CI
aa586b7994 chore: [skip ci] 2026-02-15 10:07:08 +00:00
CI
39317831f9 chore: release version v1.96.3 2026-02-15 10:07:08 +00:00
Dmitry Popov
b71bc94d4f fix(tracking): Fixed character tracking issues 2026-02-15 11:06:35 +01:00
CI
0e920a58e6 chore: [skip ci] 2026-02-13 09:01:38 +00:00
CI
9385751332 chore: release version v1.96.2 2026-02-13 09:01:38 +00:00
Aleksei Chichenkov
ffaa48ff9e Merge pull request #593 from wanderer-industries/routes-by-icons
fix: Added icons for RoutesBy
2026-02-13 12:01:07 +03:00
DanSylvest
94665f4e68 fix: Added icons for RoutesBy 2026-02-13 11:57:18 +03:00
CI
e9fd0665c8 chore: [skip ci] 2026-02-12 16:05:16 +00:00
CI
9a0271f711 chore: release version v1.96.1 2026-02-12 16:05:16 +00:00
Dmitry Popov
0c68535656 Merge branch 'main' of github.com:wanderer-industries/wanderer 2026-02-12 17:04:19 +01:00
Dmitry Popov
9ed350befa chore: Added news for awards nomination 2026-02-12 17:04:15 +01:00
CI
c410f5f37d chore: [skip ci] 2026-02-12 15:16:33 +00:00
51 changed files with 1120 additions and 328 deletions

View File

@@ -2,6 +2,69 @@
<!-- changelog -->
## [v1.97.0](https://github.com/wanderer-industries/wanderer/compare/v1.96.6...v1.97.0) (2026-03-14)
### Features:
* signatures: Sync mass status with connection, show it on unsplashed sigs
### Bug Fixes:
* Connection context menu and wormhole. Change UI for select wormhole mass state. Change UI for select ship-size for wormhole. Add ability to set mass for signatures
## [v1.96.6](https://github.com/wanderer-industries/wanderer/compare/v1.96.5...v1.96.6) (2026-03-13)
### Bug Fixes:
* core: Fixed tracking issues
## [v1.96.5](https://github.com/wanderer-industries/wanderer/compare/v1.96.4...v1.96.5) (2026-02-27)
### Bug Fixes:
* core: Fixed access token refresh issues
## [v1.96.4](https://github.com/wanderer-industries/wanderer/compare/v1.96.3...v1.96.4) (2026-02-17)
### Bug Fixes:
* Change character token validity status. Now we will see red frame and icon for tracked characters which token was expired.
## [v1.96.3](https://github.com/wanderer-industries/wanderer/compare/v1.96.2...v1.96.3) (2026-02-15)
### Bug Fixes:
* tracking: Fixed character tracking issues
## [v1.96.2](https://github.com/wanderer-industries/wanderer/compare/v1.96.1...v1.96.2) (2026-02-13)
### Bug Fixes:
* Added icons for RoutesBy
## [v1.96.1](https://github.com/wanderer-industries/wanderer/compare/v1.96.0...v1.96.1) (2026-02-12)
## [v1.96.0](https://github.com/wanderer-industries/wanderer/compare/v1.95.0...v1.96.0) (2026-02-12)

View File

@@ -1,13 +1,18 @@
import { emitMapEvent } from '@/hooks/Mapper/events';
import { isDocked } from '@/hooks/Mapper/helpers/isDocked.ts';
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import { CharacterTypeRaw } from '@/hooks/Mapper/types';
import { Commands, OutCommand } from '@/hooks/Mapper/types/mapHandlers.ts';
import { useAutoAnimate } from '@formkit/auto-animate/react';
import clsx from 'clsx';
import { PrimeIcons } from 'primereact/api';
import { useCallback } from 'react';
import classes from './Characters.module.scss';
import {
TooltipPosition,
WdEveEntityPortrait,
WdEveEntityPortraitSize,
WdTooltipWrapper,
} from '@/hooks/Mapper/components/ui-kit';
import { WdCharStateWrapper } from '@/hooks/Mapper/components/characters/components/WdCharStateWrapper.tsx';
interface CharactersProps {
data: CharacterTypeRaw[];
}
@@ -17,7 +22,7 @@ export const Characters = ({ data }: CharactersProps) => {
const {
outCommand,
data: { mainCharacterEveId, followingCharacterEveId },
data: { mainCharacterEveId, followingCharacterEveId, expiredCharacters },
} = useMapRootState();
const handleSelect = useCallback(async (character: CharacterTypeRaw) => {
@@ -35,61 +40,48 @@ export const Characters = ({ data }: CharactersProps) => {
});
}, []);
const items = data.map(character => (
<li
key={character.eve_id}
className="flex flex-col items-center justify-center"
onClick={() => handleSelect(character)}
>
<div
className={clsx(
'overflow-hidden relative',
'flex w-[35px] h-[35px] rounded-[4px] border-[1px] border-solid bg-transparent cursor-pointer',
'transition-colors duration-250 hover:bg-stone-300/90',
{
['border-stone-800/90']: !character.online,
['border-lime-600/70']: character.online,
},
)}
title={character.name}
>
{mainCharacterEveId === character.eve_id && (
<span
className={clsx(
'absolute top-[2px] left-[22px] w-[9px] h-[9px]',
'text-yellow-500 text-[9px] rounded-[1px] z-10',
'pi',
PrimeIcons.STAR_FILL,
)}
/>
)}
const items = data.map(character => {
const isExpired = expiredCharacters.includes(character.eve_id);
{followingCharacterEveId === character.eve_id && (
<span
className={clsx(
'absolute top-[23px] left-[22px] w-[10px] h-[10px]',
'text-sky-300 text-[10px] rounded-[1px] z-10',
'pi pi-angle-double-right',
)}
/>
)}
{isDocked(character.location) && <div className={classes.Docked} />}
<div
className={clsx(
'flex w-full h-full bg-transparent cursor-pointer',
'bg-center bg-no-repeat bg-[length:100%]',
'transition-opacity',
'shadow-[inset_0_1px_6px_1px_#000000]',
{
['opacity-60']: !character.online,
['opacity-100']: character.online,
},
)}
style={{ backgroundImage: `url(https://images.evetech.net/characters/${character.eve_id}/portrait)` }}
></div>
</div>
</li>
));
return (
<li
key={character.eve_id}
className="flex flex-col items-center justify-center"
onClick={() => handleSelect(character)}
>
<WdTooltipWrapper
position={TooltipPosition.bottom}
content={isExpired ? `Token is expired for ${character.name}` : character.name}
>
<WdCharStateWrapper
eve_id={character.eve_id}
location={character.location}
isExpired={isExpired}
isMain={mainCharacterEveId === character.eve_id}
isFollowing={followingCharacterEveId === character.eve_id}
isOnline={character.online}
>
<WdEveEntityPortrait
eveId={character.eve_id}
size={WdEveEntityPortraitSize.w33}
className={clsx(
'flex w-full h-full bg-transparent cursor-pointer',
'bg-center bg-no-repeat bg-[length:100%]',
'transition-opacity',
'shadow-[inset_0_1px_6px_1px_#000000]',
{
['opacity-60']: !isExpired && !character.online,
['opacity-100']: !isExpired && character.online,
['opacity-50']: isExpired,
},
'!border-0',
)}
/>
</WdCharStateWrapper>
</WdTooltipWrapper>
</li>
);
});
return (
<ul className="flex gap-1 characters" id="characters" ref={parent}>

View File

@@ -0,0 +1,71 @@
import clsx from 'clsx';
import { PrimeIcons } from 'primereact/api';
import { isDocked } from '@/hooks/Mapper/helpers/isDocked.ts';
import classes from './WdCharStateWrapper.module.scss';
import { WithChildren } from '@/hooks/Mapper/types/common.ts';
import { LocationRaw } from '@/hooks/Mapper/types';
type WdCharStateWrapperProps = {
eve_id: string;
isExpired?: boolean;
isMain?: boolean;
isFollowing?: boolean;
location: LocationRaw | null;
isOnline: boolean;
} & WithChildren;
export const WdCharStateWrapper = ({
location,
isOnline,
isMain,
isFollowing,
isExpired,
children,
}: WdCharStateWrapperProps) => {
return (
<div
className={clsx(
'overflow-hidden relative',
'flex w-[35px] h-[35px] rounded-[4px] border-[1px] border-solid bg-transparent cursor-pointer',
'transition-colors duration-250 hover:bg-stone-300/90',
{
['border-stone-800/90']: !isExpired && !isOnline,
['border-lime-600/70']: !isExpired && isOnline,
['border-red-600/70']: isExpired,
},
)}
>
{isMain && (
<span
className={clsx(
'absolute top-[2px] left-[22px] w-[9px] h-[9px]',
'text-yellow-500 text-[9px] rounded-[1px] z-10',
'pi',
PrimeIcons.STAR_FILL,
)}
/>
)}
{isFollowing && (
<span
className={clsx(
'absolute top-[23px] left-[22px] w-[10px] h-[10px]',
'text-sky-300 text-[10px] rounded-[1px] z-10',
'pi pi-angle-double-right',
)}
/>
)}
{isDocked(location) && <div className={classes.Docked} />}
{isExpired && (
<span
className={clsx(
'absolute top-[4px] left-[4px] w-[10px] h-[10px]',
'text-red-400 text-[10px] rounded-[1px] z-10',
'pi pi-exclamation-triangle',
)}
/>
)}
{children}
</div>
);
};

View File

@@ -0,0 +1 @@
export * from './WdCharStateWrapper.tsx';

View File

@@ -1,11 +1,3 @@
import {
MASS_STATE_NAMES,
MASS_STATE_NAMES_ORDER,
SHIP_SIZES_NAMES,
SHIP_SIZES_NAMES_ORDER,
SHIP_SIZES_NAMES_SHORT,
SHIP_SIZES_SIZE,
} from '@/hooks/Mapper/components/map/constants.ts';
import { ConnectionType, MassState, ShipSizeStatus, SolarSystemConnection, TimeStatus } from '@/hooks/Mapper/types';
import clsx from 'clsx';
import { PrimeIcons } from 'primereact/api';
@@ -14,6 +6,8 @@ import { MenuItem } from 'primereact/menuitem';
import React, { RefObject, useMemo } from 'react';
import { Edge } from 'reactflow';
import { LifetimeActionsWrapper } from '@/hooks/Mapper/components/map/components/ContextMenuConnection/LifetimeActionsWrapper.tsx';
import { MassStatusActionsWrapper } from '@/hooks/Mapper/components/map/components/ContextMenuConnection/MassStatusActionsWrapper.tsx';
import { ShipSizeActionsWrapper } from '@/hooks/Mapper/components/map/components/ContextMenuConnection/ShipSizeActionsWrapper.tsx';
import classes from './ContextMenuConnection.module.scss';
import { getSystemStaticInfo } from '@/hooks/Mapper/mapRootProvider/hooks/useLoadSystemStatic.ts';
import { isNullsecSpace } from '@/hooks/Mapper/components/map/helpers/isKnownSpace.ts';
@@ -86,16 +80,28 @@ export const ContextMenuConnection: React.FC<ContextMenuConnectionProps> = ({
return <LifetimeActionsWrapper lifetime={edge.data?.time_status} onChangeLifetime={onChangeTimeState} />;
},
},
...(!isFrigateSize
? [
{
className: clsx(classes.FastActions, '!h-[54px]'),
template: () => {
return (
<MassStatusActionsWrapper
massStatus={edge.data?.mass_status}
onChangeMassStatus={onChangeMassState}
/>
);
},
},
]
: []),
{
label: `Frigate`,
className: clsx({
[classes.ConnectionFrigate]: isFrigateSize,
}),
icon: PrimeIcons.CLOUD,
command: () =>
onChangeShipSizeStatus(
edge.data?.ship_size_type === ShipSizeStatus.small ? ShipSizeStatus.large : ShipSizeStatus.small,
),
className: clsx(classes.FastActions, '!h-[64px]'),
template: () => {
return (
<ShipSizeActionsWrapper shipSize={edge.data?.ship_size_type} onChangeShipSize={onChangeShipSizeStatus} />
);
},
},
{
label: `Save mass`,
@@ -105,41 +111,6 @@ export const ContextMenuConnection: React.FC<ContextMenuConnectionProps> = ({
icon: PrimeIcons.LOCK,
command: () => onToggleMassSave(!edge.data?.locked),
},
...(!isFrigateSize
? [
{
label: `Mass status`,
icon: PrimeIcons.CHART_PIE,
items: MASS_STATE_NAMES_ORDER.map(x => ({
label: MASS_STATE_NAMES[x],
className: clsx({
[classes.SelectedItem]: edge.data?.mass_status === x,
}),
command: () => onChangeMassState(x),
})),
},
]
: []),
{
label: `Ship Size`,
icon: PrimeIcons.CLOUD,
items: SHIP_SIZES_NAMES_ORDER.map(x => ({
label: (
<div className="grid grid-cols-[20px_120px_1fr_40px] gap-2 items-center">
<div className="text-[12px] font-bold text-stone-400">{SHIP_SIZES_NAMES_SHORT[x]}</div>
<div>{SHIP_SIZES_NAMES[x]}</div>
<div></div>
<div className="flex justify-end whitespace-nowrap text-[12px] font-bold text-stone-500">
{SHIP_SIZES_SIZE[x]} t.
</div>
</div>
) as unknown as string, // TODO my lovely kostyl
className: clsx({
[classes.SelectedItem]: edge.data?.ship_size_type === x,
}),
command: () => onChangeShipSizeStatus(x),
})),
},
...(bothNullsec
? [
{

View File

@@ -0,0 +1,15 @@
import { LayoutEventBlocker } from '@/hooks/Mapper/components/ui-kit';
import {
WdMassStatusSelector,
WdMassStatusSelectorProps,
} from '@/hooks/Mapper/components/ui-kit/WdMassStatusSelector.tsx';
export const MassStatusActionsWrapper = (props: WdMassStatusSelectorProps) => {
return (
<LayoutEventBlocker className="flex flex-col gap-1 w-[100%] h-full px-2 pt-[4px]">
<div className="text-[12px] text-stone-500 font-semibold">Mass status:</div>
<WdMassStatusSelector {...props} />
</LayoutEventBlocker>
);
};

View File

@@ -0,0 +1,12 @@
import { LayoutEventBlocker } from '@/hooks/Mapper/components/ui-kit';
import { WdShipSizeSelector, WdShipSizeSelectorProps } from '@/hooks/Mapper/components/ui-kit/WdShipSizeSelector.tsx';
export const ShipSizeActionsWrapper = (props: WdShipSizeSelectorProps) => {
return (
<LayoutEventBlocker className="flex flex-col gap-1 w-[100%] h-full px-2 pt-[4px]">
<div className="text-[12px] text-stone-500 font-semibold">Ship size:</div>
<WdShipSizeSelector {...props} />
</LayoutEventBlocker>
);
};

View File

@@ -11,7 +11,7 @@ import {
STATUS_CLASSES,
} from '@/hooks/Mapper/components/map/constants';
import { WormholeClassComp } from '@/hooks/Mapper/components/map/components/WormholeClassComp';
import { UnsplashedSignature } from '@/hooks/Mapper/components/map/components/UnsplashedSignature';
import { UnsplashedSignatureColumn } from '@/hooks/Mapper/components/map/components/UnsplashedSignature';
import { TooltipSize } from '@/hooks/Mapper/components/ui-kit/WdTooltipWrapper/utils.ts';
import { TooltipPosition, WdTooltipWrapper } from '@/hooks/Mapper/components/ui-kit';
import { Tag } from 'primereact/tag';
@@ -177,17 +177,19 @@ export const SolarSystemNodeDefault = memo((props: NodeProps<MapSolarSystemType>
<>
{nodeVars.unsplashedLeft.length > 0 && (
<div className={classes.Unsplashed}>
{nodeVars.unsplashedLeft.map(sig => (
<UnsplashedSignature key={sig.eve_id} signature={sig} />
))}
<UnsplashedSignatureColumn
signatures={nodeVars.unsplashedLeft}
wormholesData={nodeVars.wormholesData}
/>
</div>
)}
{nodeVars.unsplashedRight.length > 0 && (
<div className={clsx(classes.Unsplashed, classes['Unsplashed--right'])}>
{nodeVars.unsplashedRight.map(sig => (
<UnsplashedSignature key={sig.eve_id} signature={sig} />
))}
<UnsplashedSignatureColumn
signatures={nodeVars.unsplashedRight}
wormholesData={nodeVars.wormholesData}
/>
</div>
)}
</>

View File

@@ -11,7 +11,7 @@ import {
STATUS_CLASSES,
} from '@/hooks/Mapper/components/map/constants';
import { WormholeClassComp } from '@/hooks/Mapper/components/map/components/WormholeClassComp';
import { UnsplashedSignature } from '@/hooks/Mapper/components/map/components/UnsplashedSignature';
import { UnsplashedSignatureColumn } from '@/hooks/Mapper/components/map/components/UnsplashedSignature';
import { TooltipPosition, WdTooltipWrapper } from '@/hooks/Mapper/components/ui-kit';
import { TooltipSize } from '@/hooks/Mapper/components/ui-kit/WdTooltipWrapper/utils.ts';
import { LocalCounter } from '@/hooks/Mapper/components/map/components/LocalCounter';
@@ -157,17 +157,19 @@ export const SolarSystemNodeTheme = memo((props: NodeProps<MapSolarSystemType>)
<>
{nodeVars.unsplashedLeft.length > 0 && (
<div className={classes.Unsplashed}>
{nodeVars.unsplashedLeft.map(sig => (
<UnsplashedSignature key={sig.eve_id} signature={sig} />
))}
<UnsplashedSignatureColumn
signatures={nodeVars.unsplashedLeft}
wormholesData={nodeVars.wormholesData}
/>
</div>
)}
{nodeVars.unsplashedRight.length > 0 && (
<div className={clsx(classes.Unsplashed, classes['Unsplashed--right'])}>
{nodeVars.unsplashedRight.map(sig => (
<UnsplashedSignature key={sig.eve_id} signature={sig} />
))}
<UnsplashedSignatureColumn
signatures={nodeVars.unsplashedRight}
wormholesData={nodeVars.wormholesData}
/>
</div>
)}
</>

View File

@@ -1,22 +0,0 @@
@use '@/hooks/Mapper/components/map/styles/eve-common-variables';
.Signature {
position: relative;
top: 3px;
display: block;
& > .Box {
width: 13px;
height: 4px;
border-radius: 4px;
color: var(--text-color);
font-size: 8px;
text-align: center;
font-weight: bolder;
display: block;
}
& > .Eol {
display: block;
}
}

View File

@@ -1,77 +0,0 @@
import { InfoDrawer } from '@/hooks/Mapper/components/ui-kit';
import { WdTooltipWrapper } from '@/hooks/Mapper/components/ui-kit/WdTooltipWrapper';
import { WORMHOLE_CLASS_STYLES, WORMHOLES_ADDITIONAL_INFO } from '@/hooks/Mapper/components/map/constants.ts';
import { renderInfoColumn } from '@/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/renders';
import { K162_TYPES_MAP } from '@/hooks/Mapper/constants.ts';
import { parseSignatureCustomInfo } from '@/hooks/Mapper/helpers/parseSignatureCustomInfo.ts';
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import { TimeStatus } from '@/hooks/Mapper/types';
import { SystemSignature } from '@/hooks/Mapper/types/signatures';
import clsx from 'clsx';
import { useMemo } from 'react';
import classes from './UnsplashedSignature.module.scss';
interface UnsplashedSignatureProps {
signature: SystemSignature;
}
export const UnsplashedSignature = ({ signature }: UnsplashedSignatureProps) => {
const {
data: { wormholesData },
} = useMapRootState();
const whData = useMemo(() => wormholesData[signature.type], [signature.type, wormholesData]);
const whClass = useMemo(() => (whData ? WORMHOLES_ADDITIONAL_INFO[whData.dest] : null), [whData]);
const customInfo = useMemo(() => {
return parseSignatureCustomInfo(signature.custom_info);
}, [signature]);
const k162TypeOption = useMemo(() => {
if (!customInfo?.k162Type) {
return null;
}
return K162_TYPES_MAP[customInfo.k162Type];
}, [customInfo]);
const isEOL = useMemo(() => {
return customInfo?.time_status === TimeStatus._1h;
}, [customInfo]);
const is4H = useMemo(() => {
return customInfo?.time_status === TimeStatus._4h;
}, [customInfo]);
const whClassStyle = useMemo(() => {
if (signature.type === 'K162' && k162TypeOption) {
const k162Data = wormholesData[k162TypeOption.whClassName];
const k162Class = k162Data ? WORMHOLES_ADDITIONAL_INFO[k162Data.dest] : null;
return k162Class ? WORMHOLE_CLASS_STYLES[k162Class.wormholeClassID] : '';
}
return whClass ? WORMHOLE_CLASS_STYLES[whClass.wormholeClassID] : '';
}, [signature, whClass, k162TypeOption, wormholesData]);
return (
<WdTooltipWrapper
className={clsx(classes.Signature)}
// @ts-ignore
content={
<div className="flex flex-col gap-1">
<InfoDrawer title={<b className="text-slate-50">{signature.eve_id}</b>}>
{renderInfoColumn(signature)}
</InfoDrawer>
</div>
}
smallPaddings
>
<div className={clsx(classes.Box, whClassStyle)}>
<svg width="13" height="8" viewBox="0 0 13 8" xmlns="http://www.w3.org/2000/svg">
<rect y="1" width="13" height="4" rx="2" className={whClassStyle} fill="currentColor" />
{isEOL && <rect x="4" width="5" height="6" rx="1" className={clsx(classes.Eol)} fill="#a153ac" />}
{is4H && <rect x="4" width="5" height="6" rx="1" className={clsx(classes.Eol)} fill="#d8b4fe" />}
</svg>
</div>
</WdTooltipWrapper>
);
};

View File

@@ -0,0 +1,151 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { InfoDrawer } from '@/hooks/Mapper/components/ui-kit';
import { renderInfoColumn } from '@/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/renders';
import { parseSignatureCustomInfo } from '@/hooks/Mapper/helpers/parseSignatureCustomInfo';
import { resolveSignatureFillVar } from '@/hooks/Mapper/components/map/helpers/wormholeClassFillVars';
import { MassState, TimeStatus } from '@/hooks/Mapper/types';
import { SystemSignature } from '@/hooks/Mapper/types/signatures';
import { WormholeDataRaw } from '@/hooks/Mapper/types/wormholes';
interface PillData {
signature: SystemSignature;
fill: string;
isEOL: boolean;
is4H: boolean;
isVerge: boolean;
isHalf: boolean;
}
const PILL_W = 13;
const PILL_H = 8;
const GAP = 2;
const COLS = 4;
interface UnsplashedSignatureColumnProps {
signatures: SystemSignature[];
wormholesData: Record<string, WormholeDataRaw>;
}
export const UnsplashedSignatureColumn = ({ signatures, wormholesData }: UnsplashedSignatureColumnProps) => {
const svgRef = useRef<SVGSVGElement>(null);
const [hoveredIndex, setHoveredIndex] = useState(-1);
const pills: PillData[] = useMemo(() => {
return signatures.map(sig => {
const customInfo = parseSignatureCustomInfo(sig.custom_info);
return {
signature: sig,
fill: resolveSignatureFillVar(sig, wormholesData),
isEOL: customInfo.time_status === TimeStatus._1h,
is4H: customInfo.time_status === TimeStatus._4h,
isVerge: customInfo.mass_status === MassState.verge,
isHalf: customInfo.mass_status === MassState.half,
};
});
}, [signatures, wormholesData]);
const count = pills.length;
if (count === 0) return null;
const cols = Math.min(count, COLS);
const rows = Math.ceil(count / COLS);
const svgWidth = cols * (PILL_W + GAP) - GAP;
const svgHeight = rows * (PILL_H + GAP) - GAP;
const onMouseMove = useCallback(
(e: React.MouseEvent<SVGSVGElement>) => {
const svg = svgRef.current;
if (!svg) return;
const rect = svg.getBoundingClientRect();
const localX = e.clientX - rect.left;
const localY = e.clientY - rect.top;
const col = Math.floor(localX / (PILL_W + GAP));
const row = Math.floor(localY / (PILL_H + GAP));
const withinPill =
localX - col * (PILL_W + GAP) <= PILL_W && localY - row * (PILL_H + GAP) <= PILL_H;
const idx = withinPill ? row * COLS + col : -1;
setHoveredIndex(idx < count ? idx : -1);
},
[count],
);
const onMouseLeave = useCallback(() => setHoveredIndex(-1), []);
// Compute tooltip position relative to viewport
let tooltipX = 0;
let tooltipY = 0;
if (hoveredIndex >= 0 && svgRef.current) {
const svgRect = svgRef.current.getBoundingClientRect();
const col = hoveredIndex % COLS;
const row = Math.floor(hoveredIndex / COLS);
tooltipX = svgRect.left + col * (PILL_W + GAP) + PILL_W / 2;
tooltipY = svgRect.top + row * (PILL_H + GAP) - 4;
}
return (
<>
<svg
ref={svgRef}
width={svgWidth}
height={svgHeight}
viewBox={`0 0 ${svgWidth} ${svgHeight}`}
xmlns="http://www.w3.org/2000/svg"
style={{ display: 'block', position: 'relative', top: 3 }}
onMouseMove={onMouseMove}
onMouseLeave={onMouseLeave}
>
{pills.map((pill, i) => {
const col = i % COLS;
const row = Math.floor(i / COLS);
const x = col * (PILL_W + GAP);
const y = row * (PILL_H + GAP);
return (
<g key={pill.signature.eve_id} transform={`translate(${x},${y})`}>
<rect y="1" width="13" height="4" rx="2" fill={pill.fill} />
{pill.isEOL && <rect x="4" width="5" height="6" rx="1" fill="#a153ac" />}
{pill.is4H && <rect x="4" width="5" height="6" rx="1" fill="#d8b4fe" />}
{pill.isVerge && <rect x="0" width="5" height="6" rx="1" fill="#af0000" />}
{pill.isHalf && <rect x="0" width="5" height="6" rx="1" fill="#ffd700" />}
</g>
);
})}
</svg>
{hoveredIndex >= 0 &&
pills[hoveredIndex] &&
createPortal(
<div
style={{
position: 'fixed',
left: tooltipX,
top: tooltipY,
transform: 'translate(-50%, -100%)',
zIndex: 9999,
pointerEvents: 'none',
}}
>
<div
style={{
background: 'var(--surface-card, #1e1e2e)',
border: '1px solid var(--surface-border, #333)',
borderRadius: 6,
padding: '4px 6px',
boxShadow: '0 2px 8px rgba(0,0,0,0.4)',
}}
>
<div className="flex flex-col gap-1">
<InfoDrawer title={<b className="text-slate-50">{pills[hoveredIndex].signature.eve_id}</b>}>
{renderInfoColumn(pills[hoveredIndex].signature)}
</InfoDrawer>
</div>
</div>
</div>,
document.body,
)}
</>
);
};

View File

@@ -1 +1 @@
export * from './UnsplashedSignature.tsx';
export * from './UnsplashedSignatureColumn.tsx';

View File

@@ -5,3 +5,4 @@ export * from './getShapeClass';
export * from './getBackgroundClass';
export * from './prepareUnsplashedChunks';
export * from './checkPermissions';
export * from './wormholeClassFillVars';

View File

@@ -0,0 +1,63 @@
import { WORMHOLE_CLASS_STYLES, WORMHOLES_ADDITIONAL_INFO } from '@/hooks/Mapper/components/map/constants';
import { K162_TYPES_MAP } from '@/hooks/Mapper/constants';
import { parseSignatureCustomInfo } from '@/hooks/Mapper/helpers/parseSignatureCustomInfo';
import { WormholeDataRaw } from '@/hooks/Mapper/types/wormholes';
import { SystemSignature } from '@/hooks/Mapper/types/signatures';
// Maps WORMHOLE_CLASS_STYLES CSS class names → CSS variable references for inline SVG fill.
// The CSS classes set `color: var(--eve-wh-type-color-*)`, but SVG `fill` needs an explicit value.
const CLASS_NAME_TO_CSS_VAR: Record<string, string> = {
'eve-wh-type-color-c1': 'var(--eve-wh-type-color-c1)',
'eve-wh-type-color-c2': 'var(--eve-wh-type-color-c2)',
'eve-wh-type-color-c3': 'var(--eve-wh-type-color-c3)',
'eve-wh-type-color-c4': 'var(--eve-wh-type-color-c4)',
'eve-wh-type-color-c5': 'var(--eve-wh-type-color-c5)',
'eve-wh-type-color-c6': 'var(--eve-wh-type-color-c6)',
'eve-wh-type-color-high': 'var(--eve-wh-type-color-high)',
'eve-wh-type-color-low': 'var(--eve-wh-type-color-low)',
'eve-wh-type-color-null': 'var(--eve-wh-type-color-null)',
'eve-wh-type-color-thera': 'var(--eve-wh-type-color-thera)',
'eve-wh-type-color-c13': 'var(--eve-wh-type-color-c13)',
'eve-wh-type-color-drifter': 'var(--eve-wh-type-color-drifter)',
'eve-wh-type-color-zarzakh': 'var(--eve-wh-type-color-zarzakh)',
// eve-kind-color-abyss and eve-kind-color-penalty both resolve to --eve-wh-type-color-c6
'eve-kind-color-abyss': 'var(--eve-wh-type-color-c6)',
'eve-kind-color-penalty': 'var(--eve-wh-type-color-c6)',
};
const DEFAULT_FILL = '#833ca4';
export function resolveSignatureFillVar(
signature: SystemSignature,
wormholesData: Record<string, WormholeDataRaw>,
): string {
const customInfo = parseSignatureCustomInfo(signature.custom_info);
// K162 override: use the k162Type to look up the real destination class
if (signature.type === 'K162' && customInfo.k162Type) {
const k162Option = K162_TYPES_MAP[customInfo.k162Type];
if (k162Option) {
const k162Data = wormholesData[k162Option.whClassName];
const k162Class = k162Data ? WORMHOLES_ADDITIONAL_INFO[k162Data.dest] : null;
if (k162Class) {
const className = WORMHOLE_CLASS_STYLES[k162Class.wormholeClassID];
if (className && CLASS_NAME_TO_CSS_VAR[className]) {
return CLASS_NAME_TO_CSS_VAR[className];
}
}
}
return DEFAULT_FILL;
}
// Normal type lookup
const whData = wormholesData[signature.type];
if (!whData) return DEFAULT_FILL;
const whClass = WORMHOLES_ADDITIONAL_INFO[whData.dest];
if (!whClass) return DEFAULT_FILL;
const className = WORMHOLE_CLASS_STYLES[whClass.wormholeClassID];
if (!className) return DEFAULT_FILL;
return CLASS_NAME_TO_CSS_VAR[className] || DEFAULT_FILL;
}

View File

@@ -9,7 +9,7 @@ import { Regions, REGIONS_MAP, SPACE_TO_CLASS } from '@/hooks/Mapper/constants';
import { isWormholeSpace } from '@/hooks/Mapper/components/map/helpers/isWormholeSpace';
import { getSystemClassStyles } from '@/hooks/Mapper/components/map/helpers';
import { sortWHClasses } from '@/hooks/Mapper/helpers';
import { CharacterTypeRaw, OutCommand, PingType, SystemSignature } from '@/hooks/Mapper/types';
import { CharacterTypeRaw, OutCommand, PingType, SystemSignature, WormholeDataRaw } from '@/hooks/Mapper/types';
import { useUnsplashedSignatures } from './useUnsplashedSignatures';
import { useSystemName } from './useSystemName';
import { LabelInfo, useLabelsInfo } from './useLabelsInfo';
@@ -46,6 +46,7 @@ export interface SolarSystemNodeVars {
userCharacters: string[];
unsplashedLeft: Array<SystemSignature>;
unsplashedRight: Array<SystemSignature>;
wormholesData: Record<string, WormholeDataRaw>;
isThickConnections: boolean;
isRally: boolean;
classTitle: string | null;
@@ -210,6 +211,7 @@ export const useSolarSystemNode = (props: NodeProps<MapSolarSystemType>): SolarS
charactersInSystem,
unsplashedLeft,
unsplashedRight,
wormholesData,
isThickConnections,
classTitle: class_title,
temporaryName: computedTemporaryName,

View File

@@ -1,5 +1,5 @@
import { SystemViewStandalone, TooltipPosition, WHClassView } from '@/hooks/Mapper/components/ui-kit';
import { SignatureGroup, SystemSignature, TimeStatus } from '@/hooks/Mapper/types';
import { MassState, SignatureGroup, SystemSignature, TimeStatus } from '@/hooks/Mapper/types';
import { PrimeIcons } from 'primereact/api';
import { renderK162Type } from '@/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureK162TypeSelect';
@@ -21,17 +21,27 @@ export const renderInfoColumn = (row: SystemSignature) => {
{row.temporary_name && <span className={clsx('text-[12px]')}>{row.temporary_name}</span>}
{customInfo.time_status === TimeStatus._1h && (
<WdTooltipWrapper offset={5} position={TooltipPosition.top} content="Signature marked as EOL">
<WdTooltipWrapper offset={5} position={TooltipPosition.bottom} content="Signature marked as EOL">
<div className="pi pi-clock text-fuchsia-400 text-[11px] mr-[2px]"></div>
</WdTooltipWrapper>
)}
{customInfo.isCrit && (
<WdTooltipWrapper offset={5} position={TooltipPosition.top} content="Signature marked as Crit">
<WdTooltipWrapper offset={5} position={TooltipPosition.bottom} content="Signature marked as Crit">
<div className="pi pi-clock text-fuchsia-400 text-[11px] mr-[2px]"></div>
</WdTooltipWrapper>
)}
{customInfo.mass_status === MassState.verge && (
<WdTooltipWrapper
offset={5}
position={TooltipPosition.bottom}
content="Signature marked as Verge of collapse"
>
<div className="pi pi-exclamation-triangle text-red-400 text-[11px] mr-[2px]"></div>
</WdTooltipWrapper>
)}
{row.type && (
<WHClassView
className="text-[11px]"

View File

@@ -5,7 +5,7 @@ import {
import { SystemsSettingsProvider } from '@/hooks/Mapper/components/mapRootContent/components/SignatureSettings/Provider.tsx';
import { WdButton } from '@/hooks/Mapper/components/ui-kit';
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import { OutCommand, SignatureGroup, SystemSignature, TimeStatus } from '@/hooks/Mapper/types';
import { MassState, OutCommand, SignatureGroup, SystemSignature, TimeStatus } from '@/hooks/Mapper/types';
import { Dialog } from 'primereact/dialog';
import { InputText } from 'primereact/inputtext';
import { useCallback, useEffect } from 'react';
@@ -15,6 +15,7 @@ type SystemSignaturePrepared = Omit<SystemSignature, 'linked_system'> & {
linked_system: string;
k162Type: string;
time_status: TimeStatus;
mass_status: MassState;
};
export interface MapSettingsProps {
@@ -59,6 +60,7 @@ export const SignatureSettings = ({ systemId, show, onHide, signatureData }: Map
custom_info: JSON.stringify({
k162Type: values.k162Type,
time_status: values.time_status,
mass_status: values.mass_status,
}),
};
@@ -139,16 +141,19 @@ export const SignatureSettings = ({ systemId, show, onHide, signatureData }: Map
let k162Type = null;
let time_status = TimeStatus._24h;
let mass_status = MassState.normal;
if (custom_info) {
const customInfo = JSON.parse(custom_info);
k162Type = customInfo.k162Type;
time_status = customInfo.time_status;
mass_status = customInfo.mass_status ?? MassState.normal;
}
signatureForm.reset({
linked_system: linked_system?.solar_system_id.toString() ?? undefined,
k162Type: k162Type,
time_status: time_status,
mass_status: mass_status,
...rest,
});
}, [signatureForm, signatureData]);

View File

@@ -5,6 +5,7 @@ import { SignatureK162TypeSelect } from '@/hooks/Mapper/components/mapRootConten
import { SignatureLeadsToSelect } from '@/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureLeadsToSelect';
import { SignatureLifetimeSelect } from '@/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureLifetimeSelect.tsx';
import { SignatureTempName } from '@/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureTempName.tsx';
import { SignatureMassStatusSelect } from '@/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureMassStatusSelect.tsx';
export const SignatureGroupContentWormholes = () => {
const { watch } = useFormContext<SystemSignature>();
@@ -34,6 +35,11 @@ export const SignatureGroupContentWormholes = () => {
<SignatureLifetimeSelect name="time_status" />
</div>
<div className="grid grid-cols-[100px_250px_1fr] gap-2 items-center text-[14px]">
<span>Mass status:</span>
<SignatureMassStatusSelect name="mass_status" />
</div>
<label className="grid grid-cols-[100px_250px_1fr] gap-2 items-center text-[14px]">
<span>Temp. Name:</span>
<SignatureTempName />

View File

@@ -0,0 +1,30 @@
import { Controller, useFormContext } from 'react-hook-form';
import { MassState, SystemSignature } from '@/hooks/Mapper/types';
import { WdMassStatusSelector } from '@/hooks/Mapper/components/ui-kit/WdMassStatusSelector.tsx';
export interface SignatureMassStatusSelectProps {
name: string;
defaultValue?: MassState;
}
export const SignatureMassStatusSelect = ({
name,
defaultValue = MassState.normal,
}: SignatureMassStatusSelectProps) => {
const { control } = useFormContext<SystemSignature>();
return (
<div className="my-1">
<Controller
// @ts-ignore
name={name}
control={control}
defaultValue={defaultValue}
render={({ field }) => {
// @ts-ignore
return <WdMassStatusSelector massStatus={field.value} onChangeMassStatus={e => field.onChange(e)} />;
}}
/>
</div>
);
};

View File

@@ -2,3 +2,4 @@ export * from './SignatureGroupSelect';
export * from './SignatureGroupContent';
export * from './SignatureK162TypeSelect';
export * from './SignatureLifetimeSelect';
export * from './SignatureMassStatusSelect';

View File

@@ -4,12 +4,16 @@ import { DataTable } from 'primereact/datatable';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { TrackingCharacter } from '@/hooks/Mapper/types';
import { useTracking } from '@/hooks/Mapper/components/mapRootContent/components/TrackingDialog/TrackingProvider.tsx';
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
export const TrackingCharactersList = () => {
const [selected, setSelected] = useState<TrackingCharacter[]>([]);
const { trackingCharacters, main, following, updateTracking } = useTracking();
const refVars = useRef({ trackingCharacters });
refVars.current = { trackingCharacters };
const {
data: { expiredCharacters },
} = useMapRootState();
useEffect(() => {
setSelected(trackingCharacters.filter(x => x.tracked));
@@ -66,7 +70,9 @@ export const TrackingCharactersList = () => {
bodyClassName="text-ellipsis overflow-hidden whitespace-nowrap"
headerClassName="[&_div]:ml-2"
body={row => {
return <CharacterCard showCorporationLogo showTicker isOwn {...row.character} />;
const isExpired = expiredCharacters.includes(row.character.eve_id);
return <CharacterCard showCorporationLogo showTicker isOwn isExpired={isExpired} {...row.character} />;
}}
/>
</DataTable>

View File

@@ -38,7 +38,7 @@ const TrackingDialogComp = ({ visible, onHide }: TrackingDialogProps) => {
resizable={false}
visible={visible}
onHide={onHide}
className="w-[640px] h-[400px] text-text-color min-h-0"
className="w-[640px] h-[600px] text-text-color min-h-0"
>
<TabView
className="vertical-tabs-container h-full [&_.p-tabview-panels]:!pr-0"

View File

@@ -1,4 +1,4 @@
import { createContext, useCallback, useContext, useRef, useState, useEffect } from 'react';
import { createContext, useCallback, useContext, useRef, useState } from 'react';
import { Commands, OutCommand, TrackingCharacter } from '@/hooks/Mapper/types';
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import { IncomingEvent, WithChildren } from '@/hooks/Mapper/types/common.ts';

View File

@@ -17,6 +17,7 @@ import { useCallback } from 'react';
import classes from './CharacterCard.module.scss';
import { ZKB_ICON } from '@/hooks/Mapper/icons';
import { charEveWhoLink, charZKBLink } from '@/hooks/Mapper/helpers/linkHelpers.ts';
import { WdCharStateWrapper } from '../../characters/components';
export type CharacterCardProps = {
compact?: boolean;
@@ -29,6 +30,9 @@ export type CharacterCardProps = {
showAllyLogo?: boolean;
showAllyLogoPlaceholder?: boolean;
simpleMode?: boolean;
isExpired?: boolean;
isMain?: boolean;
isFollowing?: boolean;
} & WithIsOwnCharacter &
WithClassName;
@@ -55,6 +59,10 @@ export const CharacterCard = ({
showTicker,
useSystemsCache,
className,
isExpired,
isMain,
isFollowing,
...char
}: CharacterCardInnerProps) => {
const handleSelect = useCallback(() => {
@@ -204,7 +212,22 @@ export const CharacterCard = ({
<div className={clsx('w-full text-xs box-border')} onClick={handleSelect}>
<div className="w-full flex items-center gap-2">
<div className="flex items-center gap-1">
<WdEveEntityPortrait eveId={char.eve_id} size={WdEveEntityPortraitSize.w33} />
<WdCharStateWrapper
eve_id={char.eve_id}
location={char.location}
isExpired={isExpired}
isOnline={char.online}
isMain={isMain}
isFollowing={isFollowing}
>
<WdEveEntityPortrait
eveId={char.eve_id}
size={WdEveEntityPortraitSize.w33}
className={clsx({
['border-red-600/50']: isExpired,
})}
/>
</WdCharStateWrapper>
{showCorporationLogo && (
<WdTooltipWrapper position={TooltipPosition.top} content={char.corporation_name}>

View File

@@ -0,0 +1,65 @@
import { WdButton } from '@/hooks/Mapper/components/ui-kit/WdButton.tsx';
import { MassState } from '@/hooks/Mapper/types';
import clsx from 'clsx';
import { BUILT_IN_TOOLTIP_OPTIONS } from './constants.ts';
const MASS_STATUS = [
{
id: MassState.verge,
label: 'Verge',
className: 'bg-red-400 hover:!bg-red-400',
inactiveClassName: 'bg-red-400/30',
description: 'Mass status: Verge of collapse',
},
{
id: MassState.half,
label: 'Half',
className: 'bg-orange-300 hover:!bg-orange-300',
inactiveClassName: 'bg-orange-300/30',
description: 'Mass status: Half',
},
{
id: MassState.normal,
label: 'Normal',
className: 'bg-indigo-300 hover:!bg-indigo-300',
inactiveClassName: 'bg-indigo-300/30',
description: 'Mass status: Normal',
},
];
export interface WdMassStatusSelectorProps {
massStatus?: MassState;
onChangeMassStatus(massStatus: MassState): void;
className?: string;
}
export const WdMassStatusSelector = ({
massStatus = MassState.normal,
onChangeMassStatus,
className,
}: WdMassStatusSelectorProps) => {
return (
<form>
<div className={clsx('grid grid-cols-[auto_auto_auto] gap-1', className)}>
{MASS_STATUS.map(x => (
<WdButton
key={x.id}
outlined={false}
value={x.label}
tooltip={x.description}
tooltipOptions={BUILT_IN_TOOLTIP_OPTIONS}
size="small"
className={clsx(
`py-[1px] justify-center min-w-auto w-auto border-0 text-[12px] font-bold leading-[20px]`,
{ [x.inactiveClassName]: massStatus !== x.id },
x.className,
)}
onClick={() => onChangeMassStatus(x.id)}
>
{x.label}
</WdButton>
))}
</div>
</form>
);
};

View File

@@ -0,0 +1,76 @@
import { WdButton } from '@/hooks/Mapper/components/ui-kit/WdButton.tsx';
import { ShipSizeStatus } from '@/hooks/Mapper/types';
import clsx from 'clsx';
import { BUILT_IN_TOOLTIP_OPTIONS } from './constants.ts';
import {
SHIP_SIZES_DESCRIPTION,
SHIP_SIZES_NAMES,
SHIP_SIZES_NAMES_ORDER,
SHIP_SIZES_NAMES_SHORT,
SHIP_SIZES_SIZE,
} from '@/hooks/Mapper/components/map/constants.ts';
const SHIP_SIZE_STYLES: Record<ShipSizeStatus, { className: string; inactiveClassName: string }> = {
[ShipSizeStatus.small]: {
className: 'bg-indigo-400 hover:!bg-indigo-400',
inactiveClassName: 'bg-indigo-400/30',
},
[ShipSizeStatus.medium]: {
className: 'bg-cyan-500 hover:!bg-cyan-500',
inactiveClassName: 'bg-cyan-500/30',
},
[ShipSizeStatus.large]: {
className: 'bg-indigo-300 hover:!bg-indigo-300',
inactiveClassName: 'bg-indigo-300/30',
},
[ShipSizeStatus.freight]: {
className: 'bg-indigo-300 hover:!bg-indigo-300',
inactiveClassName: 'bg-indigo-300/30',
},
[ShipSizeStatus.capital]: {
className: 'bg-indigo-300 hover:!bg-indigo-300',
inactiveClassName: 'bg-indigo-300/30',
},
};
export interface WdShipSizeSelectorProps {
shipSize?: ShipSizeStatus;
onChangeShipSize(shipSize: ShipSizeStatus): void;
className?: string;
}
export const WdShipSizeSelector = ({
shipSize = ShipSizeStatus.large,
onChangeShipSize,
className,
}: WdShipSizeSelectorProps) => {
return (
<form>
<div className={clsx('grid grid-cols-[1fr_1fr_1fr_1fr_1fr] gap-1', className)}>
{SHIP_SIZES_NAMES_ORDER.map(size => {
const style = SHIP_SIZE_STYLES[size];
const tooltip = `${SHIP_SIZES_NAMES[size]}${SHIP_SIZES_SIZE[size]} t. ${SHIP_SIZES_DESCRIPTION[size]}`;
return (
<WdButton
key={size}
outlined={false}
value={SHIP_SIZES_NAMES_SHORT[size]}
tooltip={tooltip}
tooltipOptions={BUILT_IN_TOOLTIP_OPTIONS}
size="small"
className={clsx(
`py-[1px] justify-center min-w-auto w-auto border-0 text-[11px] font-bold leading-[20px]`,
{ [style.inactiveClassName]: shipSize !== size },
style.className,
)}
onClick={() => onChangeShipSize(size)}
>
<span className="text-[11px] font-bold">{SHIP_SIZES_NAMES_SHORT[size]}</span>
</WdButton>
);
})}
</div>
</form>
);
};

View File

@@ -56,6 +56,7 @@ export type MapRootData = MapUnionTypes & {
trackingCharactersData: TrackingCharacter[];
loadingPublicRoutes: boolean;
map_slug: string | null;
expiredCharacters: string[];
};
const INITIAL_DATA: MapRootData = {
@@ -102,6 +103,7 @@ const INITIAL_DATA: MapRootData = {
pings: [],
loadingPublicRoutes: false,
map_slug: null,
expiredCharacters: [],
};
export enum InterfaceStoredSettingsProps {

View File

@@ -29,6 +29,7 @@ export const useMapInit = () => {
following_character_eve_id,
user_hubs,
map_slug,
expired_characters,
} = props;
const updateData: Partial<MapRootData> = {};
@@ -108,6 +109,10 @@ export const useMapInit = () => {
updateData.map_slug = map_slug;
}
if ('expired_characters' in props) {
updateData.expiredCharacters = expired_characters;
}
update(updateData);
},
[update, addSystemStatic],

View File

@@ -108,6 +108,7 @@ export type CommandInit = {
main_character_eve_id?: string | null;
following_character_eve_id?: string | null;
map_slug?: string;
expired_characters: string[];
};
export type CommandAddSystems = SolarSystemRawType[];

View File

@@ -31,6 +31,7 @@ export type SignatureCustomInfo = {
k162Type?: string;
time_status?: number;
isCrit?: boolean;
mass_status?: number;
};
export type SystemSignature = {

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

View File

@@ -265,6 +265,10 @@ defmodule WandererApp.Character.Tracker do
end
_ ->
Logger.debug(fn ->
"[Tracker] update_online skipped for character #{character_id} - no valid access token"
end)
{:error, :skipped}
end
end
@@ -601,6 +605,10 @@ defmodule WandererApp.Character.Tracker do
end
_ ->
Logger.debug(fn ->
"[Tracker] update_location skipped for character #{character_id} - no valid access token"
end)
{:error, :skipped}
end
end

View File

@@ -215,8 +215,11 @@ defmodule WandererApp.Character.TrackingUtils do
end
end
# Check if a character has permission to be tracked on a map
defp check_character_tracking_permission(character, map_id) do
@doc """
Checks if a character has permission to be tracked on a map.
Returns {:ok, :allowed} or {:error, reason}.
"""
def check_character_tracking_permission(character, map_id) do
with {:ok, %{acls: acls, owner_id: owner_id}} <-
WandererApp.MapRepo.get(map_id,
acls: [

View File

@@ -754,6 +754,9 @@ defmodule WandererApp.Esi.ApiClient do
new_expires_at: token.expires_at
)
# Clear any previous invalid_grant failure counter on successful refresh
WandererApp.Cache.delete("character:#{character_id}:invalid_grant_count")
{:ok, _character} =
character
|> WandererApp.Api.Character.update(%{
@@ -786,12 +789,12 @@ defmodule WandererApp.Esi.ApiClient do
expires_at_datetime = DateTime.from_unix!(expires_at)
time_since_expiry = DateTime.diff(DateTime.utc_now(), expires_at_datetime, :second)
Logger.warning("TOKEN_REFRESH_FAILED: Invalid grant error during token refresh",
character_id: character_id,
error_message: error_message,
time_since_expiry_seconds: time_since_expiry,
original_expires_at: expires_at
)
# Track consecutive invalid_grant failures before permanently invalidating tokens.
# EVE SSO can return invalid_grant for transient server issues, so we require
# 3 consecutive failures before wiping tokens.
fail_key = "character:#{character_id}:invalid_grant_count"
count = WandererApp.Cache.lookup!(fail_key, 0) + 1
WandererApp.Cache.put(fail_key, count, ttl: :timer.hours(2))
# Emit telemetry for token refresh failures
:telemetry.execute([:wanderer_app, :token, :refresh_failed], %{count: 1}, %{
@@ -800,8 +803,28 @@ defmodule WandererApp.Esi.ApiClient do
time_since_expiry: time_since_expiry
})
invalidate_character_tokens(character, character_id, expires_at, scopes)
{:error, :invalid_grant}
if count >= 3 do
Logger.warning("TOKEN_REFRESH_FAILED: Invalid grant error (#{count}/3, invalidating tokens)",
character_id: character_id,
error_message: error_message,
time_since_expiry_seconds: time_since_expiry,
original_expires_at: expires_at
)
WandererApp.Cache.delete(fail_key)
invalidate_character_tokens(character, character_id, expires_at, scopes)
{:error, :invalid_grant}
else
Logger.warning(
"TOKEN_REFRESH_FAILED: Invalid grant error (#{count}/3, deferring invalidation)",
character_id: character_id,
error_message: error_message,
time_since_expiry_seconds: time_since_expiry,
original_expires_at: expires_at
)
{:error, :token_refresh_failed}
end
end
defp handle_refresh_token_result(
@@ -833,20 +856,44 @@ defmodule WandererApp.Esi.ApiClient do
defp handle_refresh_token_result(
{:error, %OAuth2.Error{} = error},
character,
_character,
character_id,
expires_at,
scopes
_scopes
) do
invalidate_character_tokens(character, character_id, expires_at, scopes)
Logger.warning("Failed to refresh token for #{character_id}: #{inspect(error)}")
{:error, :invalid_grant}
time_since_expiry = DateTime.diff(DateTime.utc_now(), DateTime.from_unix!(expires_at), :second)
Logger.warning("TOKEN_REFRESH_FAILED: Transient OAuth2 error during token refresh",
character_id: character_id,
error: inspect(error),
time_since_expiry_seconds: time_since_expiry
)
:telemetry.execute([:wanderer_app, :token, :refresh_failed], %{count: 1}, %{
character_id: character_id,
error_type: "oauth2_error",
time_since_expiry: time_since_expiry
})
{:error, :token_refresh_failed}
end
defp handle_refresh_token_result(error, character, character_id, expires_at, scopes) do
Logger.warning("Failed to refresh token for #{character_id}: #{inspect(error)}")
invalidate_character_tokens(character, character_id, expires_at, scopes)
{:error, :failed}
defp handle_refresh_token_result(error, _character, character_id, expires_at, _scopes) do
time_since_expiry = DateTime.diff(DateTime.utc_now(), DateTime.from_unix!(expires_at), :second)
Logger.warning("TOKEN_REFRESH_FAILED: Unexpected error during token refresh",
character_id: character_id,
error: inspect(error),
time_since_expiry_seconds: time_since_expiry
)
:telemetry.execute([:wanderer_app, :token, :refresh_failed], %{count: 1}, %{
character_id: character_id,
error_type: "unexpected_error",
time_since_expiry: time_since_expiry
})
{:error, :token_refresh_failed}
end
defp invalidate_character_tokens(character, character_id, expires_at, scopes) do
@@ -854,7 +901,6 @@ defmodule WandererApp.Esi.ApiClient do
with {:ok, _} <- WandererApp.Api.Character.update(character, attrs) do
WandererApp.Character.update_character(character_id, attrs)
:ok
else
error ->
Logger.error("Failed to clear tokens for #{character_id}: #{inspect(error)}")
@@ -865,5 +911,7 @@ defmodule WandererApp.Esi.ApiClient do
"character:#{character_id}",
:character_token_invalid
)
:ok
end
end

View File

@@ -515,15 +515,15 @@ defmodule WandererApp.Map.Operations.Signatures do
})
end
# Update connection time_status from signature custom_info
signature_time_status =
# Update connection time_status and mass_status from signature custom_info
{signature_time_status, signature_mass_status} =
if not is_nil(signature.custom_info) do
case Jason.decode(signature.custom_info) do
{:ok, map} -> Map.get(map, "time_status")
{:error, _} -> nil
{:ok, decoded} -> {Map.get(decoded, "time_status"), Map.get(decoded, "mass_status")}
{:error, _} -> {nil, nil}
end
else
nil
{nil, nil}
end
# Update connection ship_size_type from signature wormhole type
@@ -531,14 +531,14 @@ defmodule WandererApp.Map.Operations.Signatures do
# Back-link detection: if current signature yields no ship_size_type (e.g., K162),
# look for a forward signature in the target system that links back to our source
{signature_time_status, signature_ship_size_type} =
{signature_time_status, signature_ship_size_type, signature_mass_status} =
if is_nil(signature_ship_size_type) do
case Server.SignaturesImpl.find_forward_signature(
target_system.id,
source_system.solar_system_id
) do
nil ->
{signature_time_status, signature_ship_size_type}
{signature_time_status, signature_ship_size_type, signature_mass_status}
forward_sig ->
Logger.info(
@@ -548,20 +548,33 @@ defmodule WandererApp.Map.Operations.Signatures do
forward_ship_size = EVEUtil.get_wh_size(forward_sig.type)
forward_time_status =
if is_nil(signature_time_status) and not is_nil(forward_sig.custom_info) do
{forward_time_status, forward_mass_status} =
if not is_nil(forward_sig.custom_info) do
case Jason.decode(forward_sig.custom_info) do
{:ok, map} -> Map.get(map, "time_status")
{:error, _} -> nil
{:ok, decoded} ->
fwd_time =
if is_nil(signature_time_status),
do: Map.get(decoded, "time_status"),
else: signature_time_status
fwd_mass =
if is_nil(signature_mass_status),
do: Map.get(decoded, "mass_status"),
else: signature_mass_status
{fwd_time, fwd_mass}
{:error, _} ->
{signature_time_status, signature_mass_status}
end
else
signature_time_status
{signature_time_status, signature_mass_status}
end
{forward_time_status, forward_ship_size}
{forward_time_status, forward_ship_size, forward_mass_status}
end
else
{signature_time_status, signature_ship_size_type}
{signature_time_status, signature_ship_size_type, signature_mass_status}
end
if not is_nil(signature_time_status) do
@@ -579,6 +592,14 @@ defmodule WandererApp.Map.Operations.Signatures do
ship_size_type: signature_ship_size_type
})
end
if not is_nil(signature_mass_status) do
Server.update_connection_mass_status(map_id, %{
solar_system_source_id: source_system.solar_system_id,
solar_system_target_id: solar_system_target,
mass_status: signature_mass_status
})
end
end
# Broadcast update

View File

@@ -159,7 +159,7 @@ defmodule WandererApp.Map.Server.CharactersImpl do
if is_same_user_as_owner do
# All characters from the map owner's account have full access
:ok
{:ok, character_id}
else
[character_permissions] =
WandererApp.Permissions.check_characters_access([character], acls)
@@ -179,12 +179,12 @@ defmodule WandererApp.Map.Server.CharactersImpl do
{:remove_character, character_id, :no_track_permission}
_ ->
:ok
{:ok, character_id}
end
end
_ ->
:ok
{:ok, character_id}
end
end,
timeout: :timer.seconds(60),
@@ -193,7 +193,26 @@ defmodule WandererApp.Map.Server.CharactersImpl do
)
|> Enum.reduce([], fn
{:ok, {:remove_character, character_id, reason}}, acc ->
[{character_id, reason} | acc]
# Track consecutive permission failures - only remove after 3 consecutive hourly failures
fail_key = "map_#{map_id}:char_#{character_id}:perm_fail_count"
count = WandererApp.Cache.lookup!(fail_key, 0) + 1
WandererApp.Cache.put(fail_key, count, ttl: :timer.hours(4))
if count >= 3 do
WandererApp.Cache.delete(fail_key)
[{character_id, reason} | acc]
else
Logger.info(
"[CharacterCleanup] Character #{character_id} permission fail #{count}/3 on map #{map_id}, deferring removal"
)
acc
end
{:ok, {:ok, character_id}}, acc ->
# Character passed permission check - clear any previous failure counter
WandererApp.Cache.delete("map_#{map_id}:char_#{character_id}:perm_fail_count")
acc
{:ok, _result}, acc ->
acc
@@ -314,10 +333,10 @@ defmodule WandererApp.Map.Server.CharactersImpl do
settings
|> Enum.each(fn s ->
Logger.info(fn ->
"[CharacterCleanup] Map #{map_id} - destroying settings and removing character #{s.character_id}"
"[CharacterCleanup] Map #{map_id} - untracking settings and removing character #{s.character_id}"
end)
WandererApp.MapCharacterSettingsRepo.destroy!(s)
WandererApp.MapCharacterSettingsRepo.untrack!(%{map_id: s.map_id, character_id: s.character_id})
remove_character(map_id, s.character_id)
end)
@@ -780,10 +799,14 @@ defmodule WandererApp.Map.Server.CharactersImpl do
old_alliance_id = Map.get(cached_values, alliance_key)
if character.alliance_id != old_alliance_id do
{
[{:character_alliance, %{alliance_id: character.alliance_id}} | updates],
Map.put(cache_updates, alliance_key, character.alliance_id)
}
cache_updates = Map.put(cache_updates, alliance_key, character.alliance_id)
if is_nil(old_alliance_id) do
# Initial cache population, not a real change - just update cache
{updates, cache_updates}
else
{[{:character_alliance, %{alliance_id: character.alliance_id}} | updates], cache_updates}
end
else
{updates, cache_updates}
end
@@ -802,10 +825,15 @@ defmodule WandererApp.Map.Server.CharactersImpl do
old_corporation_id = Map.get(cached_values, corporation_key)
if character.corporation_id != old_corporation_id do
{
[{:character_corporation, %{corporation_id: character.corporation_id}} | updates],
Map.put(cache_updates, corporation_key, character.corporation_id)
}
cache_updates = Map.put(cache_updates, corporation_key, character.corporation_id)
if is_nil(old_corporation_id) do
# Initial cache population, not a real change - just update cache
{updates, cache_updates}
else
{[{:character_corporation, %{corporation_id: character.corporation_id}} | updates],
cache_updates}
end
else
{updates, cache_updates}
end
@@ -952,12 +980,63 @@ defmodule WandererApp.Map.Server.CharactersImpl do
{:ok, character} =
WandererApp.Character.get_character(character_id)
add_character(map_id, character, true)
case WandererApp.Api.MapCharacterSettings.read_by_map_and_character(%{
map_id: map_id,
character_id: character_id
}) do
{:ok, %{tracked: false}} ->
# Was previously untracked (by system cleanup or user).
# If character now has valid tokens, check permissions and auto-restore tracking.
# This handles the case where re-auth gives fresh tokens but DB still says tracked: false.
if not is_nil(character.access_token) do
case WandererApp.Character.TrackingUtils.check_character_tracking_permission(
character,
map_id
) do
{:ok, :allowed} ->
Logger.info(
"[CharactersImpl] Auto-restoring tracking for character #{character_id} on map #{map_id} - " <>
"character has valid tokens and permissions after reconnect"
)
WandererApp.Character.TrackerManager.update_track_settings(character_id, %{
map_id: map_id,
track: true
})
add_character(map_id, character, true)
WandererApp.MapCharacterSettingsRepo.track(%{
map_id: map_id,
character_id: character_id
})
WandererApp.Character.TrackerManager.update_track_settings(character_id, %{
map_id: map_id,
track: true
})
_ ->
Logger.debug(fn ->
"[CharactersImpl] Skipping re-track for character #{character_id} on map #{map_id} - " <>
"character lacks permissions"
end)
add_character(map_id, character, false)
end
else
Logger.debug(fn ->
"[CharactersImpl] Skipping re-track for character #{character_id} on map #{map_id} - " <>
"character has no valid access token"
end)
add_character(map_id, character, false)
end
_ ->
# New character or already tracked - enable tracking
add_character(map_id, character, true)
WandererApp.Character.TrackerManager.update_track_settings(character_id, %{
map_id: map_id,
track: true
})
end
end
# Broadcasts permission update to trigger LiveView refresh for the character's user.

View File

@@ -276,7 +276,14 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
map_id,
connection_update
),
do: update_connection(map_id, :update_mass_status, [:mass_status], connection_update)
do:
update_connection(map_id, :update_mass_status, [:mass_status], connection_update, fn
%{mass_status: old_mass_status},
%{mass_status: mass_status} = updated_connection ->
if mass_status != old_mass_status do
maybe_update_linked_signature_mass_status(map_id, updated_connection)
end
end)
def update_connection_ship_size_type(
map_id,
@@ -528,6 +535,71 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
Impl.broadcast!(map_id, :signatures_updated, solar_system_id)
end
defp maybe_update_linked_signature_mass_status(
map_id,
%{
mass_status: mass_status,
solar_system_source: solar_system_source,
solar_system_target: solar_system_target
} = _updated_connection
) do
with source_system when not is_nil(source_system) <-
WandererApp.Map.find_system_by_location(
map_id,
%{solar_system_id: solar_system_source}
),
target_system when not is_nil(target_system) <-
WandererApp.Map.find_system_by_location(
map_id,
%{solar_system_id: solar_system_target}
),
source_linked_signatures <-
find_linked_signatures(source_system, target_system),
target_linked_signatures <- find_linked_signatures(target_system, source_system) do
update_signatures_mass_status(
map_id,
source_system.solar_system_id,
source_linked_signatures,
mass_status
)
update_signatures_mass_status(
map_id,
target_system.solar_system_id,
target_linked_signatures,
mass_status
)
else
error ->
Logger.warning("Failed to update_linked_signature_mass_status: #{inspect(error)}")
end
end
defp update_signatures_mass_status(_map_id, _solar_system_id, [], _mass_status), do: :ok
defp update_signatures_mass_status(map_id, solar_system_id, signatures, mass_status) do
signatures
|> Enum.each(fn %{custom_info: custom_info_json} = sig ->
update_params =
if not is_nil(custom_info_json) do
updated_custom_info =
custom_info_json
|> Jason.decode!()
|> Map.merge(%{"mass_status" => mass_status})
|> Jason.encode!()
%{custom_info: updated_custom_info}
else
updated_custom_info = Jason.encode!(%{"mass_status" => mass_status})
%{custom_info: updated_custom_info}
end
SignaturesImpl.apply_update_signature(map_id, sig, update_params)
end)
Impl.broadcast!(map_id, :signatures_updated, solar_system_id)
end
def maybe_add_connection(
map_id,
location,

View File

@@ -222,6 +222,7 @@ defmodule WandererApp.Map.Server.SignaturesImpl do
{:ok, updated} ->
maybe_update_connection_time_status(map_id, existing, updated)
maybe_update_connection_mass_status(map_id, existing, updated)
maybe_sync_custom_mass_status_to_connection(map_id, existing, updated)
:ok
{:error, reason} ->
@@ -275,6 +276,29 @@ defmodule WandererApp.Map.Server.SignaturesImpl do
defp maybe_update_connection_mass_status(_map_id, _old_sig, _updated_sig), do: :ok
defp maybe_sync_custom_mass_status_to_connection(
map_id,
%{custom_info: old_custom_info} = _old_sig,
%{custom_info: new_custom_info, system_id: system_id, linked_system_id: linked_system_id} =
_updated_sig
)
when not is_nil(linked_system_id) do
old_mass_status = get_mass_status(old_custom_info)
new_mass_status = get_mass_status(new_custom_info)
if old_mass_status != new_mass_status and not is_nil(new_mass_status) do
{:ok, source_system} = MapSystem.by_id(system_id)
ConnectionsImpl.update_connection_mass_status(map_id, %{
solar_system_source_id: source_system.solar_system_id,
solar_system_target_id: linked_system_id,
mass_status: new_mass_status
})
end
end
defp maybe_sync_custom_mass_status_to_connection(_map_id, _old_sig, _updated_sig), do: :ok
@doc """
Finds the "forward" signature in a target system that links back to the source system.
Used for back-link detection: when a K162 is linked from System B → System A,
@@ -367,4 +391,12 @@ defmodule WandererApp.Map.Server.SignaturesImpl do
|> Jason.decode!()
|> Map.get("time_status")
end
defp get_mass_status(nil), do: nil
defp get_mass_status(custom_info_json) do
custom_info_json
|> Jason.decode!()
|> Map.get("mass_status")
end
end

View File

@@ -385,14 +385,6 @@ defmodule WandererAppWeb.MapCharactersEventHandler do
end
end
defp handle_tracking_event(:invalid_token_message, socket, _map_id) do
socket
|> put_flash(
:error,
"One of your characters has expired token. Please refresh it on characters page."
)
end
defp handle_tracking_event(:map_character_limit, socket, _map_id) do
socket
|> put_flash(

View File

@@ -553,23 +553,14 @@ defmodule WandererAppWeb.MapCoreEventHandler do
{:ok, map_character_ids} <-
WandererApp.Cache.lookup("map_#{map_id}:presence_character_ids", []) do
events =
case tracked_characters |> Enum.any?(&(&1.access_token == nil)) do
case track_character && not has_tracked_characters? do
true ->
[:invalid_token_message]
[:empty_tracked_characters]
_ ->
[]
end
events =
case track_character && not has_tracked_characters? do
true ->
events ++ [:empty_tracked_characters]
_ ->
events
end
character_limit_reached? = map_character_ids |> Enum.count() >= characters_limit
events =
@@ -623,6 +614,8 @@ defmodule WandererAppWeb.MapCoreEventHandler do
nil
end
expired_characters = tracked_characters |> Enum.filter(&(&1.access_token == nil)) |> Enum.map(& &1.eve_id)
initial_data =
%{
kills: kills_data,
@@ -630,6 +623,7 @@ defmodule WandererAppWeb.MapCoreEventHandler do
map_character_ids
|> WandererApp.Character.get_character_eve_ids!(),
user_characters: tracked_characters |> Enum.map(& &1.eve_id),
expired_characters: expired_characters,
system_static_infos: nil,
wormholes: nil,
effects: nil,

View File

@@ -296,22 +296,23 @@ defmodule WandererAppWeb.MapSignaturesEventHandler do
})
end
signature_time_status =
{signature_time_status, signature_mass_status} =
if not is_nil(signature.custom_info) do
signature.custom_info |> Jason.decode!() |> Map.get("time_status")
decoded = signature.custom_info |> Jason.decode!()
{Map.get(decoded, "time_status"), Map.get(decoded, "mass_status")}
else
nil
{nil, nil}
end
signature_ship_size_type = EVEUtil.get_wh_size(signature.type)
# Back-link detection: if current signature yields no ship_size_type (e.g., K162),
# look for a forward signature in the target system that links back to our source
{signature_time_status, signature_ship_size_type} =
{signature_time_status, signature_ship_size_type, signature_mass_status} =
if is_nil(signature_ship_size_type) do
case SignaturesImpl.find_forward_signature(target_system.id, solar_system_source) do
nil ->
{signature_time_status, signature_ship_size_type}
{signature_time_status, signature_ship_size_type, signature_mass_status}
forward_sig ->
Logger.info(
@@ -321,17 +322,29 @@ defmodule WandererAppWeb.MapSignaturesEventHandler do
forward_ship_size = EVEUtil.get_wh_size(forward_sig.type)
forward_time_status =
if is_nil(signature_time_status) and not is_nil(forward_sig.custom_info) do
forward_sig.custom_info |> Jason.decode!() |> Map.get("time_status")
{forward_time_status, forward_mass_status} =
if not is_nil(forward_sig.custom_info) do
decoded = forward_sig.custom_info |> Jason.decode!()
fwd_time =
if is_nil(signature_time_status),
do: Map.get(decoded, "time_status"),
else: signature_time_status
fwd_mass =
if is_nil(signature_mass_status),
do: Map.get(decoded, "mass_status"),
else: signature_mass_status
{fwd_time, fwd_mass}
else
signature_time_status
{signature_time_status, signature_mass_status}
end
{forward_time_status, forward_ship_size}
{forward_time_status, forward_ship_size, forward_mass_status}
end
else
{signature_time_status, signature_ship_size_type}
{signature_time_status, signature_ship_size_type, signature_mass_status}
end
if not is_nil(signature_time_status) do
@@ -351,6 +364,15 @@ defmodule WandererAppWeb.MapSignaturesEventHandler do
ship_size_type: signature_ship_size_type
})
end
if not is_nil(signature_mass_status) do
map_id
|> WandererApp.Map.Server.update_connection_mass_status(%{
solar_system_source_id: solar_system_source,
solar_system_target_id: solar_system_target,
mass_status: signature_mass_status
})
end
end
WandererApp.Map.Server.Impl.broadcast!(map_id, :signatures_updated, solar_system_source)

View File

@@ -55,16 +55,6 @@ defmodule WandererAppWeb.MapLive do
{:noreply, socket |> push_navigate(to: ~p"/#{map_slug}")}
end
@impl true
def handle_info(:character_token_invalid, socket),
do:
{:noreply,
socket
|> put_flash(
:error,
"One of your characters has expired token. Please refresh it on characters page."
)}
def handle_info(:no_main_character_set, socket),
do:
{:noreply,

View File

@@ -3,7 +3,7 @@ defmodule WandererApp.MixProject do
@source_url "https://github.com/wanderer-industries/wanderer"
@version "1.96.0"
@version "1.97.0"
def project do
[

View File

@@ -0,0 +1,53 @@
%{
title: "EVE Creator Awards - Nominate Wanderer!",
author: "Wanderer Team",
cover_image_uri: "/images/news/2026/02-12-eve-creator-awards/cover.jpg",
tags: ~w(event community awards nomination),
description: "CCP has opened nominations for the EVE Creator Awards! Support Wanderer by voting for Third-Party App of the Year and Developer of the Year."
}
---
![EVE Creator Awards](/images/news/2026/02-12-eve-creator-awards/cover.jpg "EVE Creator Awards")
### EVE Creator Awards - We Need Your Vote!
CCP has opened nominations for the **EVE Creator Awards**, including **Best Third-Party App** and **Developer of the Year**, and you can support us by voting.
---
### How to Nominate Us
You can nominate us for **Third-Party App of the Year**, and choose one of the team as **Developer of the Year**: Dan Sylvest, vvrong, or Gustav Oswaldo.
**App field** may be filled with: `Wanderer` / `https://wanderer.ltd/`
---
### A Bit of Stats
Over the past months, Wanderer has grown to more than **7,000 monthly users**, with pilots joining from all around the world.
---
### Meet the Team
- **Dan Sylvest** — leads frontend, design, and frontend architecture, along with several supporting services.
- **vvrong** (you know him as Demiro) — responsible for backend development, core architecture, and APIs, with additional frontend contributions.
- **Gustav Oswaldo** — contributes across backend and frontend, including zKillboard-related services, APIs, and bots.
---
### Vote Now
- **[Vote for us here](https://eve-creator-awards.paperform.co/)**
- **[Read the announcement](https://www.eveonline.com/news/view/eve-creator-awards)**
---
Thank you for your support!
Fly safe,
**Wanderer Team**
---

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
priv/static/images/map.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB