Compare commits

...

70 Commits

Author SHA1 Message Date
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
CI
8559be00f0 chore: release version v1.96.0 2026-02-12 15:16:33 +00:00
Dmitry Popov
1a24ee4c74 Merge branch 'develop' 2026-02-12 16:16:02 +01:00
Dmitry Popov
35ea4e5f1e feat(signatures): Fixed creator visibility issues. Added 4.5 hour color for unsplashed
Some checks failed
Build Test / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build Test / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
🧪 Test Suite / Test Suite (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build Develop / merge (push) Has been cancelled
Build Develop / 🏷 Notify about develop release (push) Has been cancelled
2026-02-12 16:15:44 +01:00
CI
de86703737 chore: [skip ci] 2026-02-11 17:12:09 +00:00
CI
c5af43dca1 chore: release version v1.95.0 2026-02-11 17:12:09 +00:00
Dmitry Popov
549fa1d2cf Merge pull request #592 from wanderer-industries/develop
Develop
2026-02-11 21:11:07 +04:00
Dmitry Popov
34a4d5dc9f feat(subscriptions): Added top map donators support
Some checks failed
Build Test / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build Test / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
🧪 Test Suite / Test Suite (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build Develop / merge (push) Has been cancelled
Build Develop / 🏷 Notify about develop release (push) Has been cancelled
2026-02-11 17:09:54 +01:00
Dmitry Popov
15142f188b Merge branch 'main' into develop 2026-02-11 10:35:32 +01:00
Dmitry Popov
daf4a81568 Merge pull request #586 from wanderer-industries/routes-by
Some checks failed
Build Test / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build Test / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
🧪 Test Suite / Test Suite (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build Develop / merge (push) Has been cancelled
Build Develop / 🏷 Notify about develop release (push) Has been cancelled
Add Routes By widget. Allow to find nearest blue loot and red l…
2026-02-09 02:49:29 +04:00
DanSylvest
8c5340e911 chore: add placeholder for no found destinations 2026-02-08 21:47:04 +03:00
DanSylvest
6b0f636964 chore: removed unnecessary comments 2026-02-08 19:45:36 +03:00
DanSylvest
09ebd29eb4 feat: Added lost files 2026-02-08 19:28:29 +03:00
DanSylvest
35bd5645bf feat: Added paywall for RoutesBy widget 2026-02-08 19:25:50 +03:00
DanSylvest
a6948ee1da feat: removed unnecessary env variable for routes 2026-02-08 17:58:03 +03:00
Dmitry Popov
98b3f5855c Merge pull request #587 from wanderer-industries/multiple-structure-owners
Some checks failed
Build Test / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build Test / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build Develop / merge (push) Has been cancelled
Build Develop / 🏷 Notify about develop release (push) Has been cancelled
🧪 Test Suite / Test Suite (push) Has been cancelled
Multiple structure owners
2026-02-08 16:44:51 +04:00
CI
11ad48b40a chore: [skip ci] 2026-02-08 12:03:11 +00:00
CI
ecd018abfe chore: release version v1.94.0 2026-02-08 12:03:11 +00:00
Dmitry Popov
f430f74e98 feat(administration): Added registered characters admin view with cort/ally info, sort and filter options 2026-02-08 13:02:35 +01:00
CI
9e146d1117 chore: [skip ci] 2026-02-08 09:08:10 +00:00
CI
0a707fb423 chore: release version v1.93.0 2026-02-08 09:08:10 +00:00
Dmitry Popov
8cda76cc43 feat(subscriptions): Added an ability to withdraw from map to user balance 2026-02-08 10:04:03 +01:00
Dmitry Popov
2005e6f3dd fix(signatures): Fixed back linked sigs data sync and leading to system override issues 2026-02-07 17:18:29 +01:00
Dmitry Popov
ab066a342f Merge branch 'develop' into multiple-structure-owners 2026-02-07 15:18:25 +01:00
Dmitry Popov
82b4a5f35a fix(signatures): Moved C1/C2/C3 and C4/C5 to the bottom of the available list
Some checks failed
Build Test / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build Test / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build Develop / merge (push) Has been cancelled
Build Develop / 🏷 Notify about develop release (push) Has been cancelled
🧪 Test Suite / Test Suite (push) Has been cancelled
2026-02-07 14:39:34 +01:00
Dmitry Popov
ca3a25b836 Merge pull request #589 from guarzo/guarzo/ssecache
Some checks failed
Build Test / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build Test / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build Develop / merge (push) Has been cancelled
Build Develop / 🏷 Notify about develop release (push) Has been cancelled
🧪 Test Suite / Test Suite (push) Has been cancelled
fix: use cache for sse
2026-02-07 04:20:13 +04:00
Guarzo
8e46c01a8a fix: use cache for sse 2026-02-06 22:48:13 +00:00
DanSylvest
9d9fa3c6b5 feat: Add systems with Security Status cleaning. Add trade hubs. Add ability to store data for this widget 2026-02-04 21:12:40 +03:00
Dmitry Popov
0e24501225 chore: Fixed review comments 2026-02-02 09:35:32 +01:00
DanSylvest
25a3d8951e feat: Add Routes By widget. Allow to find nearest blue loot and red loot stations. Added ability to set waypoint to station. 2026-01-31 12:29:25 +03:00
Dmitry Popov
f4ddc8dc8b Merge pull request #530 from s-no1ukno/main
feat(map): Update Owners on Multiple Structures
2026-01-29 19:37:27 +04:00
Dmitry Popov
ac9b46e24d Merge pull request #585 from guarzo/guarzo/addsysfromapi
Some checks failed
Build Test / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build Test / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
🧪 Test Suite / Test Suite (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build Develop / merge (push) Has been cancelled
Build Develop / 🏷 Notify about develop release (push) Has been cancelled
2026-01-27 12:21:35 +04:00
Guarzo
40d0a0777a fix: adding system when linked signature is provided 2026-01-27 03:10:33 +00:00
Dmitry Popov
608792d99a Merge pull request #584 from guarzo/guarzo/autoadd
Some checks failed
Build Test / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build Test / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build Develop / merge (push) Has been cancelled
Build Develop / 🏷 Notify about develop release (push) Has been cancelled
🧪 Test Suite / Test Suite (push) Has been cancelled
feat: auto add system on sig addition
2026-01-26 22:45:57 +04:00
Guarzo
dc9e0c821e feat: auto add system on sig addition 2026-01-26 13:47:37 +00:00
Dmitry Popov
79d4fd0e43 Merge pull request #582 from guarzo/guarzo/evenmoredev
Some checks failed
Build Test / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build Test / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
🧪 Test Suite / Test Suite (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build Develop / merge (push) Has been cancelled
Build Develop / 🏷 Notify about develop release (push) Has been cancelled
fix: saving updates to unknown sigs
2026-01-25 15:20:19 +04:00
Guarzo
5d03c1ecc7 fix: saving updates to unknown sigs 2026-01-25 01:50:14 +00:00
Dmitry Popov
2eef05495e Merge pull request #580 from guarzo/guarzo/moreapidev
Some checks failed
Build Test / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build Test / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
🧪 Test Suite / Test Suite (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build Develop / merge (push) Has been cancelled
Build Develop / 🏷 Notify about develop release (push) Has been cancelled
fix: wh position and sig type change
2026-01-24 02:07:53 +04:00
Guarzo
f724455a1e fix: wh position and sig type change 2026-01-23 16:01:52 +00:00
Dmitry Popov
33bbb3425c Merge pull request #579 from guarzo/guarzo/apidev
Some checks failed
Build Test / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build Test / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
🧪 Test Suite / Test Suite (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build Develop / merge (push) Has been cancelled
Build Develop / 🏷 Notify about develop release (push) Has been cancelled
fix: api updates and linked sig addition
2026-01-21 00:04:01 +04:00
Guarzo
a919bd9038 fix: api updates and linked sig addition 2026-01-20 17:55:30 +00:00
Dmitry Popov
8ae34cd94a Merge pull request #577 from guarzo/guarzo/apisigfixes
Some checks failed
Build Test / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build Test / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
🧪 Test Suite / Test Suite (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build Develop / merge (push) Has been cancelled
Build Develop / 🏷 Notify about develop release (push) Has been cancelled
fix: api fixes and format
2026-01-16 16:06:34 +04:00
Guarzo
2f38da52e8 fix: api fixes and format 2026-01-16 08:39:19 +00:00
CI
89d7df0ba2 chore: [skip ci] 2026-01-14 22:29:39 +00:00
Jordan Snow
a7d6b06332 feat(map): Reviewed changes
Adding the changes from first review of PR #530. This includes cleanup,
wrapping callbacks in a `useCallback()` hook, and inclusion of clsx
wrapper for styling.
2025-10-23 22:06:42 -06:00
Jordan Snow
8f6da817db Fix: Wrong file added to commits
This file should not have been added to previous commits, and was only
changed to allow for a fix in my local dev environment.
2025-10-19 12:26:28 -06:00
Jordan Snow
378f22a1ef feat(map): Logic for multiple owner updates
Finished all the logic for updating owners on multiple structures in a
single system.
2025-10-18 21:43:44 -06:00
Jordan Snow
14730097b2 feat(map) Adding all the things to the modal
Added a bunch of text and formatting to the system structures owners
dialog box
2025-10-18 20:26:28 -06:00
Jordan Snow
e8bff3098a feat(map): wip New Dialog for Structure Owners
Added the new modal to be able to update all structures within a system
in a single update.
2025-10-18 19:24:19 -06:00
117 changed files with 6702 additions and 386 deletions

View File

@@ -2,6 +2,121 @@
<!-- changelog -->
## [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)
### Features:
* signatures: Fixed creator visibility issues. Added 4.5 hour color for unsplashed
## [v1.95.0](https://github.com/wanderer-industries/wanderer/compare/v1.94.0...v1.95.0) (2026-02-11)
### Features:
* subscriptions: Added top map donators support
* Added lost files
* Added paywall for RoutesBy widget
* removed unnecessary env variable for routes
* Add systems with Security Status cleaning. Add trade hubs. Add ability to store data for this widget
* Add Routes By widget. Allow to find nearest blue loot and red loot stations. Added ability to set waypoint to station.
* auto add system on sig addition
* map: Reviewed changes
* map: Logic for multiple owner updates
* map: wip New Dialog for Structure Owners
### Bug Fixes:
* signatures: Fixed back linked sigs data sync and leading to system override issues
* signatures: Moved C1/C2/C3 and C4/C5 to the bottom of the available list
* use cache for sse
* adding system when linked signature is provided
* saving updates to unknown sigs
* wh position and sig type change
* api updates and linked sig addition
* api fixes and format
* Wrong file added to commits
## [v1.94.0](https://github.com/wanderer-industries/wanderer/compare/v1.93.0...v1.94.0) (2026-02-08)
### Features:
* administration: Added registered characters admin view with cort/ally info, sort and filter options
## [v1.93.0](https://github.com/wanderer-industries/wanderer/compare/v1.92.0...v1.93.0) (2026-02-08)
### Features:
* subscriptions: Added an ability to withdraw from map to user balance
## [v1.92.0](https://github.com/wanderer-industries/wanderer/compare/v1.91.11...v1.92.0) (2026-01-14)

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

@@ -8,3 +8,15 @@
}
}
}
.ContextMenu {
width: max-content;
min-width: unset;
:global {
.p-submenu-list {
width: max-content;
min-width: unset !important;
}
}
}

View File

@@ -1,21 +1,23 @@
import React, { RefObject, useMemo } from 'react';
import React, { RefObject, useCallback, useMemo } from 'react';
import { ContextMenu } from 'primereact/contextmenu';
import { PrimeIcons } from 'primereact/api';
import { MenuItem } from 'primereact/menuitem';
import { SolarSystemRawType, SolarSystemStaticInfoRaw } from '@/hooks/Mapper/types';
import { CharacterTypeRaw, SolarSystemRawType, SolarSystemStaticInfoRaw } from '@/hooks/Mapper/types';
import classes from './ContextMenuSystemInfo.module.scss';
import { getSystemById } from '@/hooks/Mapper/helpers';
import { useWaypointMenu } from '@/hooks/Mapper/components/contexts/hooks';
import { WaypointSetContextHandler } from '@/hooks/Mapper/components/contexts/types.ts';
import { FastSystemActions } from '@/hooks/Mapper/components/contexts/components';
import { useJumpPlannerMenu } from '@/hooks/Mapper/components/contexts/hooks';
import { Route } from '@/hooks/Mapper/types/routes.ts';
import { Route, RouteStationSummary } from '@/hooks/Mapper/types/routes.ts';
import { isWormholeSpace } from '@/hooks/Mapper/components/map/helpers/isWormholeSpace.ts';
import { MapAddIcon, MapDeleteIcon } from '@/hooks/Mapper/icons';
import { useRouteProvider } from '@/hooks/Mapper/components/mapInterface/widgets/RoutesWidget/RoutesProvider.tsx';
import { useGetOwnOnlineCharacters } from '@/hooks/Mapper/components/hooks/useGetOwnOnlineCharacters.ts';
import { sortStationsByDistance } from './sortStationsByDistance.ts';
export interface ContextMenuSystemInfoProps {
systemStatics: Map<number, SolarSystemStaticInfoRaw>;
hubs: string[];
contextMenuRef: RefObject<ContextMenu>;
systemId: string | undefined;
systemIdFrom?: string | undefined;
@@ -37,11 +39,106 @@ export const ContextMenuSystemInfo: React.FC<ContextMenuSystemInfoProps> = ({
onWaypointSet,
systemId,
systemIdFrom,
hubs,
routes,
}) => {
const getWaypointMenu = useWaypointMenu(onWaypointSet);
const getJumpPlannerMenu = useJumpPlannerMenu(systems, systemIdFrom);
const { toggleHubCommand, hubs } = useRouteProvider();
const getOwnOnlineCharacters = useGetOwnOnlineCharacters();
const getStationWaypointItems = useCallback(
(destinationId: string, chars: CharacterTypeRaw[]): MenuItem[] => [
{
label: 'Set Destination',
icon: PrimeIcons.SEND,
command: () => {
onWaypointSet({
fromBeginning: true,
clearWay: true,
destination: destinationId,
charIds: chars.map(char => char.eve_id),
});
},
},
{
label: 'Add Waypoint',
icon: PrimeIcons.DIRECTIONS_ALT,
command: () => {
onWaypointSet({
fromBeginning: false,
clearWay: false,
destination: destinationId,
charIds: chars.map(char => char.eve_id),
});
},
},
{
label: 'Add Waypoint Front',
icon: PrimeIcons.DIRECTIONS,
command: () => {
onWaypointSet({
fromBeginning: true,
clearWay: false,
destination: destinationId,
charIds: chars.map(char => char.eve_id),
});
},
},
],
[onWaypointSet],
);
const getStationsMenu = useCallback(
(stations: RouteStationSummary[]) => {
const chars = getOwnOnlineCharacters().filter(x => x.online);
const sortedStations = sortStationsByDistance(stations);
return [
{
label: 'Stations',
icon: PrimeIcons.MAP_MARKER,
items: sortedStations.map(station => {
const destinationId = station.station_id.toString();
const specialClass = station.special ? '[&_.p-menuitem-text]:text-orange-400' : '';
if (chars.length === 0) {
return {
label: station.station_name,
className: specialClass || undefined,
items: [{ label: 'No online characters', disabled: true }],
};
}
if (chars.length === 1) {
return {
label: station.station_name,
className: specialClass || undefined,
items: getStationWaypointItems(destinationId, chars.slice(0, 1)),
};
}
return {
label: station.station_name,
className: `${specialClass} w-[500px]`.trim(),
items: [
{
label: 'All',
icon: PrimeIcons.USERS,
items: getStationWaypointItems(destinationId, chars),
},
...chars.map(char => ({
label: char.name,
icon: PrimeIcons.USER,
items: getStationWaypointItems(destinationId, [char]),
})),
],
};
}),
},
];
},
[getOwnOnlineCharacters, getStationWaypointItems],
);
const items: MenuItem[] = useMemo(() => {
const system = systemId ? systemStatics.get(parseInt(systemId)) : undefined;
@@ -50,6 +147,10 @@ export const ContextMenuSystemInfo: React.FC<ContextMenuSystemInfoProps> = ({
if (!systemId || !system) {
return [];
}
const route = routes.find(x => x.destination?.toString() === systemId);
const stationItems = route?.stations?.length ? getStationsMenu(route.stations) : [];
return [
{
className: classes.FastActions,
@@ -69,15 +170,20 @@ export const ContextMenuSystemInfo: React.FC<ContextMenuSystemInfoProps> = ({
{ separator: true },
...getJumpPlannerMenu(system, routes),
...getWaypointMenu(systemId, system.system_class),
{
label: !hubs.includes(systemId) ? 'Add Route' : 'Remove Route',
icon: !hubs.includes(systemId) ? (
<MapAddIcon className="mr-1 relative left-[-2px]" />
) : (
<MapDeleteIcon className="mr-1 relative left-[-2px]" />
),
command: onHubToggle,
},
...stationItems,
...(toggleHubCommand
? [
{
label: !hubs.includes(systemId) ? 'Add Route' : 'Remove Route',
icon: !hubs.includes(systemId) ? (
<MapAddIcon className="mr-1 relative left-[-2px]" />
) : (
<MapDeleteIcon className="mr-1 relative left-[-2px]" />
),
command: onHubToggle,
},
]
: []),
...(!systemOnMap
? [
{
@@ -94,15 +200,18 @@ export const ContextMenuSystemInfo: React.FC<ContextMenuSystemInfoProps> = ({
systems,
getJumpPlannerMenu,
getWaypointMenu,
getStationsMenu,
hubs,
onHubToggle,
onAddSystem,
onOpenSettings,
toggleHubCommand,
routes,
]);
return (
<>
<ContextMenu model={items} ref={contextMenuRef} breakpoint="767px" />
<ContextMenu className={classes.ContextMenu} model={items} ref={contextMenuRef} breakpoint="767px" />
</>
);
};

View File

@@ -0,0 +1,90 @@
import { RouteStationSummary } from '@/hooks/Mapper/types/routes.ts';
const ROMAN_VALUES: Record<string, number> = {
I: 1,
V: 5,
X: 10,
L: 50,
C: 100,
D: 500,
M: 1000,
};
const MAX_DISTANCE = Number.MAX_SAFE_INTEGER;
const romanToInt = (value: string): number | null => {
const chars = value.toUpperCase().split('');
if (chars.length === 0 || chars.some(char => ROMAN_VALUES[char] === undefined)) {
return null;
}
let total = 0;
let prev = 0;
for (let i = chars.length - 1; i >= 0; i--) {
const current = ROMAN_VALUES[chars[i]];
if (current < prev) {
total -= current;
} else {
total += current;
prev = current;
}
}
return total;
};
const parseOrbitIndex = (value: string | undefined): number | null => {
if (!value) {
return null;
}
const trimmed = value.trim();
const asInt = Number.parseInt(trimmed, 10);
if (!Number.isNaN(asInt) && `${asInt}` === trimmed) {
return asInt;
}
return romanToInt(trimmed);
};
const extractPlanetOrbit = (name: string): number | null => {
const firstPart = name.split(' - ')[0] ?? '';
const match = firstPart.match(/([IVXLCDM]+|\d+)(?:\s*\([^)]*\))?$/i);
return parseOrbitIndex(match?.[1]);
};
const extractMoonOrbit = (name: string): number | null => {
const match = name.match(/\bMoon\s+([IVXLCDM]+|\d+)\b/i);
return parseOrbitIndex(match?.[1]);
};
const stationSortKey = (station: RouteStationSummary): [number, number, string, number] => {
return [
extractPlanetOrbit(station.station_name) ?? MAX_DISTANCE,
// If there is no moon in the station name, treat it as closer than moon orbits.
extractMoonOrbit(station.station_name) ?? 0,
station.station_name.toLowerCase(),
station.station_id,
];
};
export const sortStationsByDistance = (stations: RouteStationSummary[]): RouteStationSummary[] => {
return [...stations].sort((a, b) => {
const aKey = stationSortKey(a);
const bKey = stationSortKey(b);
for (let i = 0; i < aKey.length; i++) {
if (aKey[i] < bKey[i]) {
return -1;
}
if (aKey[i] > bKey[i]) {
return 1;
}
}
return 0;
});
};

View File

@@ -38,7 +38,7 @@ export const useContextMenuSystemInfoHandlers = () => {
return;
}
ref.current.toggleHubCommand(system);
ref.current.toggleHubCommand?.(system);
setSystem(undefined);
}, []);

View File

@@ -6,6 +6,7 @@ export const useDetectSettingsChanged = () => {
storedSettings: {
interfaceSettings,
settingsRoutes,
settingsRoutesBy,
settingsLocal,
settingsSignatures,
settingsOnTheMap,
@@ -16,7 +17,15 @@ export const useDetectSettingsChanged = () => {
useEffect(
() => setCounter(x => x + 1),
[interfaceSettings, settingsRoutes, settingsLocal, settingsSignatures, settingsOnTheMap, settingsKills],
[
interfaceSettings,
settingsRoutes,
settingsRoutesBy,
settingsLocal,
settingsSignatures,
settingsOnTheMap,
settingsKills,
],
);
return counter;

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

@@ -39,6 +39,10 @@ export const UnsplashedSignature = ({ signature }: UnsplashedSignatureProps) =>
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];
@@ -65,6 +69,7 @@ export const UnsplashedSignature = ({ signature }: UnsplashedSignatureProps) =>
<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

@@ -7,6 +7,7 @@ import {
SystemStructures,
WRoutesPublic,
WRoutesUser,
WRoutesBy,
WSystemKills,
} from '@/hooks/Mapper/components/mapInterface/widgets';
@@ -18,6 +19,7 @@ export enum WidgetsIds {
signatures = 'signatures',
local = 'local',
routes = 'routes',
routesBy = 'routesBy',
structures = 'structures',
kills = 'kills',
comments = 'comments',
@@ -60,6 +62,13 @@ export const DEFAULT_WIDGETS: WindowProps[] = [
zIndex: 0,
content: () => <WRoutesPublic />,
},
{
id: WidgetsIds.routesBy,
position: { x: 10, y: 740 },
size: { width: 510, height: 200 },
zIndex: 0,
content: () => <WRoutesBy />,
},
{
id: WidgetsIds.userRoutes,
position: { x: 10, y: 10 },
@@ -112,6 +121,10 @@ export const WIDGETS_CHECKBOXES_PROPS: WidgetsCheckboxesType = [
id: WidgetsIds.routes,
label: 'Routes',
},
{
id: WidgetsIds.routesBy,
label: 'Routes By',
},
{
id: WidgetsIds.userRoutes,
label: 'User Routes',

View File

@@ -41,7 +41,7 @@ export const RoutesWidgetContent = () => {
const {
data: { selectedSystems, systems, isSubscriptionActive },
} = useMapRootState();
const { hubs = [], routesList, isRestricted, loading } = useRouteProvider();
const { hubs = [], routesList, isRestricted, loading, nohubsPlaceholder } = useRouteProvider();
const [systemId] = selectedSystems;
@@ -105,7 +105,11 @@ export const RoutesWidgetContent = () => {
}
if (hubs.length === 0) {
return <div className="w-full h-full flex justify-center items-center select-none">Routes not set</div>;
return (
<div className="w-full h-full flex justify-center items-center select-none">
{nohubsPlaceholder ?? 'Routes not set'}
</div>
);
}
return (
@@ -129,7 +133,6 @@ export const RoutesWidgetContent = () => {
offset: 10,
}}
/>
<SystemView
systemId={route.destination.toString()}
className={clsx('select-none text-center cursor-context-menu')}
@@ -138,7 +141,7 @@ export const RoutesWidgetContent = () => {
showCustomName
/>
</div>
<div className="text-right pl-1">{route.has_connection ? route.systems?.length ?? 2 : ''}</div>
<div className="text-right pl-1">{route.has_connection ? (route.systems?.length ?? 2) : ''}</div>
<div className="pl-2 pb-0.5">
<RoutesList data={route} onContextMenu={handleContextMenu} />
</div>
@@ -147,9 +150,7 @@ export const RoutesWidgetContent = () => {
})}
</div>
</LoadingWrapper>
<ContextMenuSystemInfo
hubs={hubs}
routes={preparedRoutes}
systems={systems}
systemStatics={systemStatics}
@@ -162,9 +163,10 @@ export const RoutesWidgetContent = () => {
type RoutesWidgetCompProps = {
title: ReactNode | string;
renderContent?: (content: ReactNode, compact: boolean) => ReactNode;
};
export const RoutesWidgetComp = ({ title }: RoutesWidgetCompProps) => {
export const RoutesWidgetComp = ({ title, renderContent }: RoutesWidgetCompProps) => {
const [routeSettingsVisible, setRouteSettingsVisible] = useState(false);
const { data, update, addHubCommand } = useRouteProvider();
@@ -183,7 +185,7 @@ export const RoutesWidgetComp = ({ title }: RoutesWidgetCompProps) => {
const onAddSystem = useCallback(() => setOpenAddSystem(true), []);
const handleSubmitAddSystem: SearchOnSubmitCallback = useCallback(
async item => addHubCommand(item.value.toString()),
async item => addHubCommand?.(item.value.toString()),
[addHubCommand],
);
@@ -191,15 +193,17 @@ export const RoutesWidgetComp = ({ title }: RoutesWidgetCompProps) => {
<Widget
label={
<div className="flex justify-between items-center text-xs w-full" ref={ref}>
<span className="select-none">{title}</span>
<div className="select-none flex items-center gap-2">{title}</div>
<LayoutEventBlocker className="flex items-center gap-2">
<WdImgButton
className={PrimeIcons.PLUS_CIRCLE}
onClick={onAddSystem}
tooltip={{
content: 'Click here to add new system to routes',
}}
/>
{addHubCommand && (
<WdImgButton
className={PrimeIcons.PLUS_CIRCLE}
onClick={onAddSystem}
tooltip={{
content: 'Click here to add new system to routes',
}}
/>
)}
<WdTooltipWrapper content="Show shortest route" position={TooltipPosition.top}>
<WdCheckbox
@@ -223,24 +227,38 @@ export const RoutesWidgetComp = ({ title }: RoutesWidgetCompProps) => {
</div>
}
>
<RoutesWidgetContent />
{renderContent ? (
renderContent(
<div className="h-full overflow-auto bg-opacity-5 custom-scrollbar">
<RoutesWidgetContent />
</div>,
compact,
)
) : (
<div className="h-full overflow-auto bg-opacity-5 custom-scrollbar">
<RoutesWidgetContent />
</div>
)}
<RoutesSettingsDialog visible={routeSettingsVisible} setVisible={setRouteSettingsVisible} />
<AddSystemDialog
title="Add system to routes"
visible={openAddSystem}
setVisible={() => setOpenAddSystem(false)}
onSubmit={handleSubmitAddSystem}
/>
{addHubCommand && (
<AddSystemDialog
title="Add system to routes"
visible={openAddSystem}
setVisible={() => setOpenAddSystem(false)}
onSubmit={handleSubmitAddSystem}
/>
)}
</Widget>
);
};
export const RoutesWidget = forwardRef<RoutesImperativeHandle, RoutesWidgetProps & RoutesWidgetCompProps>(
({ title, ...props }, ref) => {
({ title, renderContent, ...props }, ref) => {
return (
<RoutesProvider {...props} ref={ref}>
<RoutesWidgetComp title={title} />
<RoutesWidgetComp title={title} renderContent={renderContent} />
</RoutesProvider>
);
},

View File

@@ -1 +1,2 @@
export * from './useLoadRoutes';
export * from './useLoadRoutesBy';

View File

@@ -0,0 +1,71 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import { RoutesType } from '@/hooks/Mapper/mapRootProvider/types.ts';
import { LoadRoutesCommand } from '@/hooks/Mapper/components/mapInterface/widgets/RoutesWidget/types.ts';
import { RoutesList } from '@/hooks/Mapper/types/routes.ts';
import { flattenValues } from '@/hooks/Mapper/utils/flattenValues.ts';
import { useMapEventListener } from '@/hooks/Mapper/events';
import { Commands } from '@/hooks/Mapper/types';
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}
type UseLoadRoutesByProps = {
loadRoutesCommand: LoadRoutesCommand;
routesList: RoutesList | undefined;
data: RoutesType;
deps?: unknown[];
};
export const useLoadRoutesBy = ({
data: routesSettings,
loadRoutesCommand,
routesList,
deps = [],
}: UseLoadRoutesByProps) => {
const [loading, setLoading] = useState(false);
const {
data: { selectedSystems },
} = useMapRootState();
const prevSys = usePrevious(selectedSystems);
const ref = useRef({ prevSys, selectedSystems });
ref.current = { prevSys, selectedSystems };
const loadRoutes = useCallback(
(systemId: string, settings: RoutesType) => {
loadRoutesCommand(systemId, settings);
setLoading(true);
},
[loadRoutesCommand],
);
useMapEventListener(event => {
if (event.name === Commands.routesListBy) {
setLoading(false);
}
});
useEffect(() => {
setLoading(false);
}, [routesList]);
useEffect(() => {
if (selectedSystems.length !== 1) {
return;
}
const [systemId] = selectedSystems;
loadRoutes(systemId, routesSettings);
}, [loadRoutes, selectedSystems, ...flattenValues(routesSettings), ...deps]);
return { loading, loadRoutes, setLoading };
};

View File

@@ -12,9 +12,10 @@ export type RoutesWidgetProps = {
routesList: RoutesList | undefined;
loading: boolean;
addHubCommand: AddHubCommand;
toggleHubCommand: ToggleHubCommand;
addHubCommand?: AddHubCommand;
toggleHubCommand?: ToggleHubCommand;
isRestricted?: boolean;
nohubsPlaceholder?: string;
};
export type RoutesProviderInnerProps = RoutesWidgetProps;

View File

@@ -1,6 +1,16 @@
import { ExtendedSystemSignature, SystemSignature } from '@/hooks/Mapper/types';
import { FINAL_DURATION_MS } from '../constants';
// Strip frontend-only fields that should never be sent to the backend.
// "linked_system" is an object the frontend uses; the backend expects "linked_system_id" (integer)
// which is set via a separate linkSignatureToSystem call.
function stripFrontendFields(s: ExtendedSystemSignature) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { linked_system, pendingDeletion, pendingAddition, pendingUntil, finalTimeoutId, character_name, ...rest } =
s as any;
return rest;
}
export function prepareUpdatePayload(
systemId: string,
added: ExtendedSystemSignature[],
@@ -9,9 +19,9 @@ export function prepareUpdatePayload(
) {
return {
system_id: systemId,
added: added.map(s => ({ ...s })),
updated: updated.map(s => ({ ...s })),
removed: removed.map(s => ({ ...s })),
added: added.map(stripFrontendFields),
updated: updated.map(stripFrontendFields),
removed: removed.map(stripFrontendFields),
};
}

View File

@@ -35,7 +35,7 @@ export const useSignatureFetching = ({ systemId, settings, signaturesRef, setSig
const extended = serverSigs.map(s => ({
...s,
character_name: characters.find(c => c.eve_id === s.character_eve_id)?.name,
character_name: s.character_name ?? characters.find(c => c.eve_id === s.character_eve_id)?.name,
})) as ExtendedSystemSignature[];
setSignatures(() => extended);

View File

@@ -1,4 +1,4 @@
import React, { useCallback, ClipboardEvent, useRef } from 'react';
import React, { useCallback, ClipboardEvent, useRef, useState } from 'react';
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import useMaxWidth from '@/hooks/Mapper/hooks/useMaxWidth';
import {
@@ -13,7 +13,9 @@ import { Widget } from '@/hooks/Mapper/components/mapInterface/components';
import { SystemStructuresContent } from './SystemStructuresContent/SystemStructuresContent';
import { useSystemStructures } from './hooks/useSystemStructures';
import { processSnippetText } from './helpers';
import { processSnippetText, StructureItem } from './helpers';
import { SystemStructuresOwnersDialog } from './SystemStructuresOwnersDialog/SystemStructuresOwnersDialog';
import clsx from 'clsx';
export const SystemStructures: React.FC = () => {
const {
@@ -24,6 +26,7 @@ export const SystemStructures: React.FC = () => {
const isNotSelectedSystem = selectedSystems.length !== 1;
const { structures, handleUpdateStructures } = useSystemStructures({ systemId, outCommand });
const [showEditDialog, setShowEditDialog] = useState(false);
const labelRef = useRef<HTMLDivElement>(null);
const isCompact = useMaxWidth(labelRef, 260);
@@ -48,6 +51,18 @@ export const SystemStructures: React.FC = () => {
[processClipboard],
);
const handleSave = (updatedStructures: StructureItem[]) => {
handleUpdateStructures(updatedStructures)
}
const handleOpenDialog = useCallback(() => {
setShowEditDialog(true)
}, [])
const handleCloseDialog = useCallback(() => {
setShowEditDialog(false)
}, [])
const handlePasteTimer = useCallback(async () => {
try {
const text = await navigator.clipboard.readText();
@@ -71,8 +86,19 @@ export const SystemStructures: React.FC = () => {
</div>
<LayoutEventBlocker className="flex gap-2.5">
{structures.length > 1 && (
<WdImgButton
className={clsx(PrimeIcons.USER_EDIT, 'text-sky-400 hover:text-sky-200 transition duration-300')}
onClick={handleOpenDialog}
tooltip={{
position: TooltipPosition.left,
// @ts-ignore
content: 'Update all structure owners',
}}
/>
)}
<WdImgButton
className={`${PrimeIcons.CLOCK} text-sky-400 hover:text-sky-200 transition duration-300`}
className={clsx(PrimeIcons.CLOCK, 'text-sky-400 hover:text-sky-200 transition duration-300')}
onClick={handlePasteTimer}
tooltip={{
position: TooltipPosition.left,
@@ -117,6 +143,15 @@ export const SystemStructures: React.FC = () => {
<SystemStructuresContent structures={structures} onUpdateStructures={handleUpdateStructures} />
)}
</Widget>
{showEditDialog && (
<SystemStructuresOwnersDialog
visible={showEditDialog}
structures={structures}
onClose={handleCloseDialog}
onSave={handleSave}
/>
)}
</div>
);
};

View File

@@ -4,7 +4,14 @@ import { AutoComplete } from 'primereact/autocomplete';
import { Calendar } from 'primereact/calendar';
import clsx from 'clsx';
import { formatToISO, statusesRequiringTimer, StructureItem, StructureStatus } from '../helpers';
import {
calendarDateToUtcIso,
formatToISO,
statusesRequiringTimer,
StructureItem,
StructureStatus,
utcToCalendarDate,
} from '../helpers';
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import { OutCommand } from '@/hooks/Mapper/types';
import { WdButton } from '@/hooks/Mapper/components/ui-kit';
@@ -72,7 +79,7 @@ export const SystemStructuresDialog: React.FC<StructuresEditDialogProps> = ({
// If this is the endTime (Date from Calendar), we store as ISO or string:
if (field === 'endTime' && val instanceof Date) {
return { ...prev, endTime: val.toISOString() };
return { ...prev, endTime: calendarDateToUtcIso(val) };
}
return { ...prev, [field]: val };
@@ -188,7 +195,7 @@ export const SystemStructuresDialog: React.FC<StructuresEditDialogProps> = ({
Timer <br /> (Eve Time):
</span>
<Calendar
value={editData.endTime ? new Date(editData.endTime) : undefined}
value={editData.endTime ? utcToCalendarDate(editData.endTime) : undefined}
onChange={e => handleChange('endTime', e.value ?? '')}
showTime
hourFormat="24"

View File

@@ -0,0 +1,31 @@
.systemStructuresOwnersDialog {
.p-dialog-content {
background-color: var(--surface-800) !important;
}
.p-dialog-header {
background-color: var(--surface-700);
color: var(--text-color);
}
.p-dialog-header-icon,
.p-dialog-header-title {
color: var(--gray-200);
}
.p-inputtext {
background-color: #2a2a2a !important;
color: #ddd !important;
font-size: 12px !important;
padding: 0.25rem 0.5rem !important;
}
.p-dialog-footer {
.p-button {
font-size: 12px !important;
padding: 0.3rem 0.75rem !important;
}
}
}

View File

@@ -0,0 +1,180 @@
import clsx from 'clsx';
import { AutoComplete } from 'primereact/autocomplete';
import { Dialog } from 'primereact/dialog';
import React, { useCallback, useState } from 'react';
import { WdButton } from '@/hooks/Mapper/components/ui-kit';
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import { useToast } from '@/hooks/Mapper/ToastProvider';
import { OutCommand } from '@/hooks/Mapper/types';
import { StructureItem } from '../helpers';
interface StructuresOwnersEditDialogProps {
visible: boolean;
structures: StructureItem[];
onClose: () => void;
onSave: (updatedStuctures: StructureItem[]) => void;
}
export const SystemStructuresOwnersDialog: React.FC<StructuresOwnersEditDialogProps> = ({
visible,
structures,
onClose,
onSave,
}) => {
const [ownerInput, setOwnerInput] = useState('');
const [ownerSuggestions, setOwnerSuggestions] = useState<{ label: string; value: string }[]>([]);
const { outCommand } = useMapRootState();
const { show } = useToast();
const [prevQuery, setPrevQuery] = useState('');
const [prevResults, setPrevResults] = useState<{ label: string; value: string }[]>([]);
const [editData, setEditData] = useState<StructureItem[]>(structures);
// Searching corporation owners via auto-complete
const searchOwners = useCallback(
async (e: { query: string }) => {
const newQuery = e.query.trim();
if (!newQuery) {
setOwnerSuggestions([]);
return;
}
// If user typed more text but we have partial match in prevResults
if (newQuery.startsWith(prevQuery) && prevResults.length > 0) {
const filtered = prevResults.filter(item => item.label.toLowerCase().includes(newQuery.toLowerCase()));
setOwnerSuggestions(filtered);
return;
}
try {
// TODO fix it
const { results = [] } = await outCommand({
type: OutCommand.getCorporationNames,
data: { search: newQuery },
});
setOwnerSuggestions(results);
setPrevQuery(newQuery);
setPrevResults(results);
} catch (err) {
show({
severity: 'error',
summary: 'Failed to fetch owners',
detail: `${err}`,
life: 10000,
});
}
},
[prevQuery, prevResults, outCommand],
);
// when user picks a corp from auto-complete
const handleSelectOwner = (selected: { label: string; value: string }) => {
setOwnerInput(selected.label);
setEditData(
structures.map(item => {
return { ...item, ownerName: selected.label, ownerId: selected.value };
}),
);
};
const handleSaveClick = async () => {
if (!editData) return;
// Get all unique owner IDs that need ticker lookup
const allOwnerIds = editData.filter(x => x.ownerId != null).map(x => x.ownerId as string);
const uniqueOwnerIds = [...new Set(allOwnerIds)];
// Fetch all tickers in parallel
const tickerResults = await Promise.all(
uniqueOwnerIds.map(async ownerId => {
try {
const { ticker } = await outCommand({
type: OutCommand.getCorporationTicker,
data: { corp_id: ownerId },
});
return { ownerId, ticker: ticker ?? '' };
} catch (err) {
console.error('Failed to fetch ticker for ownerId:', ownerId, err);
return { ownerId, ticker: '' };
}
}),
);
// Create a map of ownerId -> ticker for quick lookup
const tickerMap = new Map(tickerResults.map(r => [r.ownerId, r.ticker]));
// Create new array with updated values (no mutation)
const updatedStructures = editData.map(structure => {
if (!structure.ownerId) {
return structure;
}
return {
...structure,
ownerTicker: tickerMap.get(structure.ownerId) ?? '',
};
});
onSave(updatedStructures);
onClose();
};
return (
<Dialog
visible={visible}
onHide={onClose}
header={'Update All Structure Owners'}
className={clsx('myStructuresOwnersDialog', 'text-stone-200 w-full max-w-md')}
>
<div className="flex flex-col gap-2 text-[14px]">
<div className="flex gap-2">
Updating the corporation name below will update all structures currently saved within the system.
</div>
<hr />
<div className="flex flex-col gap-2">
<label className="grid grid-cols-[100px_1fr] gap-2 items-start mt-2">
<span className="mt-1">Structures to update:</span>
<ul>
{structures &&
structures.map((item, i) => (
<li key={i}>
{item.structureType || 'Unknown Type'} - {item.name}
</li>
))}
</ul>
</label>
</div>
<hr />
<div>
<label className="grid grid-cols-[100px_250px_1fr] gap-2 items-center">
<span>Owner:</span>
<AutoComplete
id="owner"
value={ownerInput}
suggestions={ownerSuggestions}
completeMethod={searchOwners}
minLength={3}
delay={400}
field="label"
placeholder="Corporation name..."
onChange={e => setOwnerInput(e.value)}
onSelect={e => handleSelectOwner(e.value)}
/>
</label>
</div>
</div>
<div className="flex justify-end items-center gap-2 mt-4">
<WdButton label="Save" className="p-button-sm" onClick={handleSaveClick} />
</div>
</Dialog>
);
};

View File

@@ -43,6 +43,29 @@ export function mapServerStructure(serverData: any): StructureItem {
};
}
export function utcToCalendarDate(utcIso: string): Date {
// Parse ISO components manually to avoid browser quirks with
// 6-digit microsecond precision from Elixir's :utc_datetime_usec.
const m = utcIso.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})/);
if (m) {
const [, yr, mo, dy, hr, mi, sc] = m;
return new Date(+yr, +mo - 1, +dy, +hr, +mi, +sc);
}
// Fallback for non-ISO strings
const d = new Date(utcIso);
return new Date(d.getTime() + d.getTimezoneOffset() * 60_000);
}
export function calendarDateToUtcIso(localDate: Date): string {
// Read local-time components (which represent EVE/UTC time) and
// build the ISO string directly — no timezone arithmetic needed.
const pad = (n: number) => String(n).padStart(2, '0');
return (
`${localDate.getFullYear()}-${pad(localDate.getMonth() + 1)}-${pad(localDate.getDate())}` +
`T${pad(localDate.getHours())}:${pad(localDate.getMinutes())}:${pad(localDate.getSeconds())}.000Z`
);
}
export function formatToISO(datetimeLocal: string): string {
if (!datetimeLocal) return '';

View File

@@ -0,0 +1,202 @@
import { useCallback, useMemo, useRef } from 'react';
import { RoutesWidget } from '@/hooks/Mapper/components/mapInterface/widgets';
import { LoadRoutesCommand } from '@/hooks/Mapper/components/mapInterface/widgets/RoutesWidget/types.ts';
import { useLoadRoutesBy } from '@/hooks/Mapper/components/mapInterface/widgets/RoutesWidget/hooks';
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import { OutCommand } from '@/hooks/Mapper/types';
import { Dropdown } from 'primereact/dropdown';
import { SelectItemOptionsType } from 'primereact/selectitem';
import useMaxWidth from '@/hooks/Mapper/hooks/useMaxWidth.ts';
import clsx from 'clsx';
import { RoutesByCategoryType, RoutesByScopeType, RoutesType } from '@/hooks/Mapper/mapRootProvider/types.ts';
import { DEFAULT_ROUTES_SETTINGS } from '@/hooks/Mapper/mapRootProvider/constants.ts';
import { TooltipPosition, WdImgButton } from '@/hooks/Mapper/components/ui-kit';
import { PrimeIcons } from 'primereact/api';
export type RoutesByType = RoutesByCategoryType;
type WRoutesByProps = {
type?: RoutesByType;
title?: string;
};
const ROUTES_BY_OPTIONS: SelectItemOptionsType = [
{
label: 'Blue Loot',
value: 'blueLoot',
icon: 'images/30747_64.png',
},
{
label: 'Red Loot',
value: 'redLoot',
icon: 'images/89219_64.png',
},
{
label: 'Thera',
value: 'thera',
icon: 'images/map.png',
},
{
label: 'Turnur',
value: 'turnur',
icon: 'images/map.png',
},
{
label: 'Security Office',
value: 'so_cleaning',
icon: 'images/concord-so.png',
},
{
label: 'Trade Hubs',
value: 'trade_hubs',
icon: 'images/market.png',
},
];
const ROUTES_BY_SECURITY_OPTIONS = [
{ label: 'All', value: 'ALL' },
{ label: 'High', value: 'HIGH' },
];
export const WRoutesBy = ({ type = 'blueLoot', title = 'Routes By' }: WRoutesByProps) => {
const {
outCommand,
storedSettings: { settingsRoutesBy, settingsRoutesByUpdate },
data,
} = useMapRootState();
const criteriaType = settingsRoutesBy.type ?? type;
const securityType = settingsRoutesBy.scope ?? 'ALL';
const routesSettings = settingsRoutesBy.routes ?? DEFAULT_ROUTES_SETTINGS;
const routesListBy = data.routesListBy;
const availableRoutesBy = data.availableRoutesBy;
const routesByOptions = useMemo(() => {
if (!availableRoutesBy || availableRoutesBy.length === 0) {
return ROUTES_BY_OPTIONS;
}
return ROUTES_BY_OPTIONS.filter(option => availableRoutesBy.includes(option.value as RoutesByType));
}, [availableRoutesBy]);
const resolvedCriteriaType = useMemo(() => {
const optionValues = routesByOptions.map(option => option.value as RoutesByType);
if (optionValues.length === 0) {
return criteriaType;
}
return optionValues.includes(criteriaType) ? criteriaType : optionValues[0];
}, [routesByOptions, criteriaType]);
const loadRoutesCommand: LoadRoutesCommand = useCallback(
async (systemId, currentRoutesSettings) => {
await outCommand({
type: OutCommand.getRoutesBy,
data: {
system_id: systemId,
type: resolvedCriteriaType,
securityType: securityType === 'HIGH' ? 'high' : 'both',
routes_settings: currentRoutesSettings,
},
});
},
[outCommand, resolvedCriteriaType, securityType],
);
const hubs = useMemo(() => routesListBy?.routes?.map(route => route.destination.toString()) ?? [], [routesListBy]);
const { loading: internalLoading } = useLoadRoutesBy({
data: routesSettings,
loadRoutesCommand,
routesList: routesListBy,
deps: [resolvedCriteriaType, securityType],
});
const updateRoutesSettings = useCallback(
(next: RoutesType) => settingsRoutesByUpdate(prev => ({ ...prev, routes: next })),
[settingsRoutesByUpdate],
);
const ref = useRef<HTMLDivElement>(null);
const compactSmall = useMaxWidth(ref, 180);
const compactMiddle = useMaxWidth(ref, 245);
const titleNode = useMemo(
() => (
<div className="flex items-center gap-2">
<span className="select-none">{title}</span>
<WdImgButton
className={PrimeIcons.QUESTION_CIRCLE}
tooltip={{
position: TooltipPosition.top,
content: 'Alpha map users can access only 1 route',
}}
/>
</div>
),
[title],
);
return (
<RoutesWidget
title={titleNode}
nohubsPlaceholder="Not found any destinations"
renderContent={(content /*, compact*/) => (
<div className="h-full grid grid-rows-[1fr_auto]" ref={ref}>
{content}
<div className="flex items-center gap-2 justify-end mb-2 px-2 pt-2">
{!compactSmall && (
<Dropdown
value={securityType}
options={ROUTES_BY_SECURITY_OPTIONS}
onChange={e => settingsRoutesByUpdate(prev => ({ ...prev, scope: e.value as RoutesByScopeType }))}
className="w-[90px] [&_span]:!text-[12px]"
/>
)}
<Dropdown
value={resolvedCriteriaType}
itemTemplate={e => (
<div className="flex items-center gap-2">
{e.icon && <img src={e.icon} height="18" width="18" />}
<span className="text-[12px]">{e.label}</span>
</div>
)}
valueTemplate={e => {
if (!e) {
return null;
}
if (compactMiddle) {
return (
<div className="flex items-center gap-2 min-w-[50px]">
{e.icon ? <img src={e.icon} height="18" width="18" /> : <span>{e.label}</span>}
</div>
);
}
return (
<div className="flex items-center gap-2">
{e.icon && <img src={e.icon} height="18" width="18" />}
<span className="text-[12px]">{e.label}</span>
</div>
);
}}
options={routesByOptions}
onChange={e => settingsRoutesByUpdate(prev => ({ ...prev, type: e.value as RoutesByCategoryType }))}
className={clsx({
['w-[130px]']: !compactMiddle,
['w-[65px]']: compactMiddle,
})}
/>
</div>
</div>
)}
data={routesSettings}
update={updateRoutesSettings}
hubs={hubs}
routesList={routesListBy}
loading={internalLoading}
/>
);
};

View File

@@ -0,0 +1,2 @@
export { WRoutesBy } from './WRoutesBy';
export type { RoutesByType } from './WRoutesBy';

View File

@@ -6,4 +6,5 @@ export * from './SystemStructures';
export * from './WSystemKills';
export * from './WRoutesUser';
export * from './WRoutesPublic';
export * from './WRoutesBy';
export * from './CommentsWidget';

View File

@@ -38,9 +38,11 @@ export const OldSettingsDialog = () => {
localWidget: createSettings(widgetLocal, {}),
widgets: createSettings(widgetsOld, {}),
routes: createSettings(widgetRoutes, {}),
routesBy: createSettings(widgetRoutes, {}),
onTheMap: createSettings(onTheMapOld, {}),
signaturesWidget: createSettings(signatures, {}),
interface: createSettings(interfaceSettings, {}),
map: createSettings(null, { viewport: { zoom: 1, x: 0, y: 0 } }),
};
if (asFile) {

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

@@ -13,7 +13,7 @@ export type SystemViewProps = {
export const SystemView = ({ systemId, systemInfo: customSystemInfo, showCustomName, ...rest }: SystemViewProps) => {
const memSystems = useMemo(() => [systemId], [systemId]);
const { systems, loading } = useLoadSystemStatic({ systems: memSystems });
const { systems, lastUpdateKey, loading } = useLoadSystemStatic({ systems: memSystems });
const {
data: { systems: mapSystems },
@@ -23,9 +23,10 @@ export const SystemView = ({ systemId, systemInfo: customSystemInfo, showCustomN
if (!systemId) {
return customSystemInfo;
}
return systems.get(parseInt(systemId));
// eslint-disable-next-line
}, [customSystemInfo, systemId, systems, loading]);
}, [customSystemInfo, systemId, systems, lastUpdateKey, loading]);
const mapSystemInfo = useMemo(() => {
if (!showCustomName) {

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

@@ -88,16 +88,6 @@ export const K162_TYPES: K162Type[] = [
value: 'ns',
whClassName: 'C248',
},
{
label: 'C1/C2/C3',
value: 'c1_c2_c3',
whClassName: 'E004_D382_L477',
},
{
label: 'C4/C5',
value: 'c4_c5',
whClassName: 'M001_L614',
},
{
label: 'C1',
value: 'c1',
@@ -143,6 +133,16 @@ export const K162_TYPES: K162Type[] = [
value: 'pochven',
whClassName: 'F216',
},
{
label: 'C1/C2/C3',
value: 'c1_c2_c3',
whClassName: 'E004_D382_L477',
},
{
label: 'C4/C5',
value: 'c4_c5',
whClassName: 'M001_L614',
},
];
export const K162_TYPES_MAP: { [key: string]: K162Type } = K162_TYPES.reduce(

View File

@@ -6,7 +6,6 @@ import {
MapUnionTypes,
OutCommandHandler,
SolarSystemConnection,
StringBoolean,
TrackingCharacter,
UseCharactersCacheData,
UseCommentsData,
@@ -28,12 +27,14 @@ import {
MapSettings,
MapUserSettings,
OnTheMapSettingsType,
RoutesByType,
RoutesType,
} from '@/hooks/Mapper/mapRootProvider/types.ts';
import {
DEFAULT_KILLS_WIDGET_SETTINGS,
DEFAULT_MAP_SETTINGS,
DEFAULT_ON_THE_MAP_SETTINGS,
DEFAULT_ROUTES_BY_SETTINGS,
DEFAULT_ROUTES_SETTINGS,
DEFAULT_WIDGET_LOCAL_SETTINGS,
STORED_INTERFACE_DEFAULT_VALUES,
@@ -55,6 +56,7 @@ export type MapRootData = MapUnionTypes & {
trackingCharactersData: TrackingCharacter[];
loadingPublicRoutes: boolean;
map_slug: string | null;
expiredCharacters: string[];
};
const INITIAL_DATA: MapRootData = {
@@ -76,6 +78,8 @@ const INITIAL_DATA: MapRootData = {
userHubs: [],
routes: undefined,
userRoutes: undefined,
routesListBy: undefined,
availableRoutesBy: [],
kills: [],
connections: [],
detailedKills: {},
@@ -99,6 +103,7 @@ const INITIAL_DATA: MapRootData = {
pings: [],
loadingPublicRoutes: false,
map_slug: null,
expiredCharacters: [],
};
export enum InterfaceStoredSettingsProps {
@@ -132,6 +137,8 @@ export interface MapRootContextProps {
setInterfaceSettings: Dispatch<SetStateAction<InterfaceStoredSettings>>;
settingsRoutes: RoutesType;
settingsRoutesUpdate: Dispatch<SetStateAction<RoutesType>>;
settingsRoutesBy: RoutesByType;
settingsRoutesByUpdate: Dispatch<SetStateAction<RoutesByType>>;
settingsLocal: LocalWidgetSettings;
settingsLocalUpdate: Dispatch<SetStateAction<LocalWidgetSettings>>;
settingsSignatures: SignatureSettingsType;
@@ -179,6 +186,8 @@ const MapRootContext = createContext<MapRootContextProps>({
setInterfaceSettings: () => null,
settingsRoutes: DEFAULT_ROUTES_SETTINGS,
settingsRoutesUpdate: () => null,
settingsRoutesBy: { ...DEFAULT_ROUTES_BY_SETTINGS, routes: { ...DEFAULT_ROUTES_BY_SETTINGS.routes } },
settingsRoutesByUpdate: () => null,
settingsLocal: DEFAULT_WIDGET_LOCAL_SETTINGS,
settingsLocalUpdate: () => null,
settingsSignatures: DEFAULT_SIGNATURE_SETTINGS,

View File

@@ -7,6 +7,7 @@ import {
MiniMapPlacement,
OnTheMapSettingsType,
PingsPlacement,
RoutesByType,
RoutesType,
} from '@/hooks/Mapper/mapRootProvider/types.ts';
import { DEFAULT_WIDGETS, STORED_VISIBLE_WIDGETS_DEFAULT } from '@/hooks/Mapper/components/mapInterface/constants.tsx';
@@ -43,6 +44,12 @@ export const DEFAULT_WIDGET_LOCAL_SETTINGS: LocalWidgetSettings = {
showShipName: false,
};
export const DEFAULT_ROUTES_BY_SETTINGS: RoutesByType = {
routes: DEFAULT_ROUTES_SETTINGS,
scope: 'ALL',
type: 'blueLoot',
};
export const DEFAULT_ON_THE_MAP_SETTINGS: OnTheMapSettingsType = {
hideOffline: false,
};

View File

@@ -3,6 +3,7 @@ import {
DEFAULT_KILLS_WIDGET_SETTINGS,
DEFAULT_MAP_SETTINGS,
DEFAULT_ON_THE_MAP_SETTINGS,
DEFAULT_ROUTES_BY_SETTINGS,
DEFAULT_ROUTES_SETTINGS,
DEFAULT_WIDGET_LOCAL_SETTINGS,
getDefaultWidgetProps,
@@ -17,6 +18,11 @@ export const createWidgetSettings = <T>(settings: T) => {
};
export const createDefaultStoredSettings = (): MapUserSettings => {
const defaultRoutesBy = {
...DEFAULT_ROUTES_BY_SETTINGS,
routes: { ...DEFAULT_ROUTES_BY_SETTINGS.routes },
};
return {
version: STORED_SETTINGS_VERSION,
migratedFromOld: false,
@@ -24,6 +30,7 @@ export const createDefaultStoredSettings = (): MapUserSettings => {
localWidget: createWidgetSettings(DEFAULT_WIDGET_LOCAL_SETTINGS),
widgets: createWidgetSettings(getDefaultWidgetProps()),
routes: createWidgetSettings(DEFAULT_ROUTES_SETTINGS),
routesBy: createWidgetSettings(defaultRoutesBy),
onTheMap: createWidgetSettings(DEFAULT_ON_THE_MAP_SETTINGS),
signaturesWidget: createWidgetSettings(DEFAULT_SIGNATURE_SETTINGS),
interface: createWidgetSettings(STORED_INTERFACE_DEFAULT_VALUES),
@@ -43,6 +50,11 @@ export const getDefaultSettingsByType = (type: SettingsTypes): SettingsWrapper<a
return createWidgetSettings(getDefaultWidgetProps());
case SettingsTypes.routes:
return createWidgetSettings(DEFAULT_ROUTES_SETTINGS);
case SettingsTypes.routesBy:
return createWidgetSettings({
...DEFAULT_ROUTES_BY_SETTINGS,
routes: { ...DEFAULT_ROUTES_BY_SETTINGS.routes },
});
case SettingsTypes.onTheMap:
return createWidgetSettings(DEFAULT_ON_THE_MAP_SETTINGS);
case SettingsTypes.signaturesWidget:

View File

@@ -24,10 +24,12 @@ export const useMapInit = () => {
user_permissions,
options,
is_subscription_active,
available_routes_by,
main_character_eve_id,
following_character_eve_id,
user_hubs,
map_slug,
expired_characters,
} = props;
const updateData: Partial<MapRootData> = {};
@@ -85,6 +87,10 @@ export const useMapInit = () => {
updateData.isSubscriptionActive = is_subscription_active;
}
if (available_routes_by) {
updateData.availableRoutesBy = available_routes_by;
}
if (system_static_infos) {
system_static_infos.forEach(static_info => {
addSystemStatic(static_info);
@@ -103,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

@@ -112,3 +112,23 @@ export const useUserRoutes = () => {
update({ userRoutes: value });
}, []);
};
export const useRoutesListBy = () => {
const {
update,
data: { routesListBy },
} = useMapRootState();
const ref = useRef({ update, routesListBy });
ref.current = { update, routesListBy };
return useCallback((value: CommandRoutes) => {
const { update, routesListBy } = ref.current;
if (areRoutesListsEqual(routesListBy, value)) {
return;
}
update({ routesListBy: value });
}, []);
};

View File

@@ -38,6 +38,7 @@ import {
useMapInit,
useMapUpdated,
useRoutes,
useRoutesListBy,
useUserRoutes,
} from './api';
@@ -61,6 +62,7 @@ export const useMapRootHandlers = (ref: ForwardedRef<MapHandlers>) => {
const mapUpdated = useMapUpdated();
const mapRoutes = useRoutes();
const mapUserRoutes = useUserRoutes();
const mapRoutesListBy = useRoutesListBy();
const { addComment, removeComment } = useCommandComments();
const { pingAdded, pingCancelled } = useCommandPings();
const { pingBlocked } = useCommandPingBlocked();
@@ -115,6 +117,9 @@ export const useMapRootHandlers = (ref: ForwardedRef<MapHandlers>) => {
case Commands.userRoutes:
mapUserRoutes(data as CommandRoutes);
break;
case Commands.routesListBy:
mapRoutesListBy(data as CommandRoutes);
break;
case Commands.signaturesUpdated: // USED
updateSystemSignatures(data as CommandSignaturesUpdated);

View File

@@ -56,6 +56,12 @@ export const useMapUserSettings = ({ map_slug }: MapRootData, outCommand: OutCom
map_slug,
'routes',
);
const [settingsRoutesBy, settingsRoutesByUpdate] = useSettingsValueAndSetter(
mapUserSettings,
setMapUserSettings,
map_slug,
'routesBy',
);
const [settingsLocal, settingsLocalUpdate] = useSettingsValueAndSetter(
mapUserSettings,
@@ -188,6 +194,8 @@ export const useMapUserSettings = ({ map_slug }: MapRootData, outCommand: OutCom
setInterfaceSettings,
settingsRoutes,
settingsRoutesUpdate,
settingsRoutesBy,
settingsRoutesByUpdate,
settingsLocal,
settingsLocalUpdate,
settingsSignatures,

View File

@@ -1,5 +1,6 @@
import { to_1 } from './to_1.ts';
import { to_2 } from './to_2.ts';
import { to_3 } from './to_3.ts';
import { MigrationStructure } from '@/hooks/Mapper/mapRootProvider/types.ts';
export default [to_1, to_2] as MigrationStructure[];
export default [to_1, to_2, to_3] as MigrationStructure[];

View File

@@ -0,0 +1,31 @@
import { MigrationStructure } from '@/hooks/Mapper/mapRootProvider/types.ts';
import { DEFAULT_ROUTES_BY_SETTINGS, DEFAULT_ROUTES_SETTINGS } from '@/hooks/Mapper/mapRootProvider/constants.ts';
export const to_3: MigrationStructure = {
to: 3,
up: (prev: any) => {
const rawRoutesBy = prev?.routesBy;
const hasStructuredRoutesBy =
rawRoutesBy && typeof rawRoutesBy === 'object' && 'routes' in rawRoutesBy;
const routes = hasStructuredRoutesBy
? { ...DEFAULT_ROUTES_SETTINGS, ...rawRoutesBy.routes }
: { ...DEFAULT_ROUTES_SETTINGS, ...(rawRoutesBy ?? prev?.routes ?? {}) };
const scopeRaw = hasStructuredRoutesBy ? rawRoutesBy?.scope : undefined;
const scope = scopeRaw === 'HIGH' ? 'HIGH' : 'ALL';
const type = hasStructuredRoutesBy && rawRoutesBy?.type ? rawRoutesBy.type : DEFAULT_ROUTES_BY_SETTINGS.type;
return {
...prev,
routesBy: {
...DEFAULT_ROUTES_BY_SETTINGS,
...(hasStructuredRoutesBy ? rawRoutesBy : {}),
scope,
type,
routes,
},
};
},
};

View File

@@ -47,6 +47,22 @@ export type RoutesType = {
avoid: number[];
};
export type RoutesByCategoryType =
| 'blueLoot'
| 'redLoot'
| 'thera'
| 'turnur'
| 'so_cleaning'
| 'trade_hubs';
export type RoutesByScopeType = 'ALL' | 'HIGH';
export type RoutesByType = {
routes: RoutesType;
scope: RoutesByScopeType;
type: RoutesByCategoryType;
};
export type LocalWidgetSettings = {
compact: boolean;
showOffline: boolean;
@@ -79,6 +95,7 @@ export type MapUserSettings = {
interface: SettingsWrapper<InterfaceStoredSettings>;
onTheMap: SettingsWrapper<OnTheMapSettingsType>;
routes: SettingsWrapper<RoutesType>;
routesBy: SettingsWrapper<RoutesByType>;
localWidget: SettingsWrapper<LocalWidgetSettings>;
signaturesWidget: SettingsWrapper<SignatureSettingsType>;
killsWidget: SettingsWrapper<KillsWidgetSettings>;
@@ -98,6 +115,7 @@ export enum SettingsTypes {
localWidget = 'localWidget',
widgets = 'widgets',
routes = 'routes',
routesBy = 'routesBy',
onTheMap = 'onTheMap',
signaturesWidget = 'signaturesWidget',
interface = 'interface',

View File

@@ -1,4 +1,4 @@
export const STORED_SETTINGS_VERSION = 2;
export const STORED_SETTINGS_VERSION = 3;
export const LS_KEY_LEGASY = 'map-user-settings';
export const LS_KEY = 'map-user-settings-v3';

View File

@@ -3,6 +3,7 @@ import { ActivitySummary, CharacterTypeRaw, TrackingCharacter } from '@/hooks/Ma
import { SolarSystemConnection } from '@/hooks/Mapper/types/connection.ts';
import { DetailedKill, Kill } from '@/hooks/Mapper/types/kills.ts';
import { RoutesList } from '@/hooks/Mapper/types/routes.ts';
import { RoutesByCategoryType } from '@/hooks/Mapper/mapRootProvider/types.ts';
import { SolarSystemRawType, SolarSystemStaticInfoRaw } from '@/hooks/Mapper/types/system.ts';
import { WormholeDataRaw } from '@/hooks/Mapper/types/wormholes.ts';
@@ -25,6 +26,7 @@ export enum Commands {
detailedKillsUpdated = 'detailed_kills_updated',
routes = 'routes',
userRoutes = 'user_routes',
routesListBy = 'routes_list_by',
centerSystem = 'center_system',
selectSystem = 'select_system',
selectSystems = 'select_systems',
@@ -62,6 +64,7 @@ export type Command =
| Commands.detailedKillsUpdated
| Commands.routes
| Commands.userRoutes
| Commands.routesListBy
| Commands.selectSystem
| Commands.selectSystems
| Commands.centerSystem
@@ -101,9 +104,11 @@ export type CommandInit = {
options: MapOptions;
reset?: boolean;
is_subscription_active?: boolean;
available_routes_by?: RoutesByCategoryType[];
main_character_eve_id?: string | null;
following_character_eve_id?: string | null;
map_slug?: string;
expired_characters: string[];
};
export type CommandAddSystems = SolarSystemRawType[];
@@ -121,6 +126,7 @@ export type CommandSignaturesUpdated = string;
export type CommandMapUpdated = Partial<CommandInit>;
export type CommandRoutes = RoutesList;
export type CommandUserRoutes = RoutesList;
export type CommandRoutesListBy = RoutesList;
export type CommandKillsUpdated = Kill[];
export type CommandDetailedKillsUpdated = Record<string, DetailedKill[]>;
export type CommandSelectSystem = string | undefined;
@@ -199,6 +205,7 @@ export interface CommandData {
[Commands.mapUpdated]: CommandMapUpdated;
[Commands.routes]: CommandRoutes;
[Commands.userRoutes]: CommandUserRoutes;
[Commands.routesListBy]: CommandRoutesListBy;
[Commands.killsUpdated]: CommandKillsUpdated;
[Commands.detailedKillsUpdated]: CommandDetailedKillsUpdated;
[Commands.selectSystem]: CommandSelectSystem;
@@ -232,6 +239,7 @@ export enum OutCommand {
deleteUserHub = 'delete_user_hub',
getRoutes = 'get_routes',
getUserRoutes = 'get_user_routes',
getRoutesBy = 'get_routes_by',
getCharacterJumps = 'get_character_jumps',
getStructures = 'get_structures',
getSignatures = 'get_signatures',

View File

@@ -6,6 +6,7 @@ import { RoutesList } from '@/hooks/Mapper/types/routes.ts';
import { SolarSystemConnection } from '@/hooks/Mapper/types/connection.ts';
import { MapOptions, PingData, UserPermissions } from '@/hooks/Mapper/types';
import { SystemSignature } from '@/hooks/Mapper/types/signatures';
import { RoutesByCategoryType } from '@/hooks/Mapper/mapRootProvider/types.ts';
export type MapUnionTypes = {
wormholesData: Record<string, WormholeDataRaw>;
@@ -20,6 +21,8 @@ export type MapUnionTypes = {
systemSignatures: Record<string, SystemSignature[]>;
routes?: RoutesList;
userRoutes?: RoutesList;
routesListBy?: RoutesList;
availableRoutesBy?: RoutesByCategoryType[];
kills: Record<number, number>;
connections: SolarSystemConnection[];
userPermissions: Partial<UserPermissions>;

View File

@@ -13,12 +13,19 @@ export type SystemStaticInfoShort = Pick<
type MappedSystem = SolarSystemStaticInfoRaw | undefined;
export type RouteStationSummary = {
station_id: number;
station_name: string;
special?: boolean;
};
export type Route = {
destination: number;
has_connection: boolean;
origin: number;
systems?: number[];
mapped_systems?: MappedSystem[];
stations?: RouteStationSummary[];
success?: boolean;
};

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

@@ -39,6 +39,8 @@ defmodule WandererApp.Api.Character do
define(:active_by_user,
action: :active_by_user
)
define(:admin_all, action: :admin_all)
end
actions do
@@ -69,6 +71,10 @@ defmodule WandererApp.Api.Character do
filter(expr(user_id == ^arg(:user_id) and deleted == false))
end
read :admin_all do
prepare build(load: [:user])
end
read :last_active do
argument(:from, :utc_datetime, allow_nil?: false)

View File

@@ -218,6 +218,11 @@ defmodule WandererApp.Api.Map do
update :toggle_webhooks do
accept [:webhooks_enabled]
require_atomic? false
change after_action(fn _changeset, record, _context ->
WandererApp.Map.update_webhooks_enabled(record.id, record.webhooks_enabled)
{:ok, record}
end)
end
update :toggle_sse do
@@ -226,6 +231,11 @@ defmodule WandererApp.Api.Map do
# Validate subscription when enabling SSE
validate &validate_sse_subscription/2
change after_action(fn _changeset, record, _context ->
WandererApp.Map.update_sse_enabled(record.id, record.sse_enabled)
{:ok, record}
end)
end
create :duplicate do

View File

@@ -123,7 +123,8 @@ defmodule WandererApp.Api.MapSystemSignature do
:group,
:type,
:custom_info,
:deleted
:deleted,
:linked_system_id
]
end
@@ -140,7 +141,8 @@ defmodule WandererApp.Api.MapSystemSignature do
:type,
:custom_info,
:deleted,
:update_forced_at
:update_forced_at,
:linked_system_id
]
primary? true

View File

@@ -5,6 +5,8 @@ defmodule WandererApp.Api.MapTransaction do
domain: WandererApp.Api,
data_layer: AshPostgres.DataLayer
import Ecto.Query
postgres do
repo(WandererApp.Repo)
table("map_transactions_v1")
@@ -19,6 +21,7 @@ defmodule WandererApp.Api.MapTransaction do
define(:by_map, action: :by_map)
define(:by_user, action: :by_user)
define(:create, action: :create)
define(:top_donators, action: :top_donators)
end
actions do
@@ -45,6 +48,35 @@ defmodule WandererApp.Api.MapTransaction do
argument(:user_id, :uuid, allow_nil?: false)
filter(expr(user_id == ^arg(:user_id)))
end
action :top_donators, {:array, :struct} do
argument(:map_id, :string, allow_nil?: false)
argument(:after, :utc_datetime, allow_nil?: true)
run fn input, _context ->
base =
from(t in __MODULE__,
where:
t.map_id == ^input.arguments.map_id and
t.type == :in and
not is_nil(t.user_id),
group_by: [t.user_id],
select: %{user_id: t.user_id, total_amount: sum(t.amount)},
order_by: [desc: sum(t.amount)],
limit: 10
)
query =
case input.arguments[:after] do
nil -> base
after_date -> base |> where([t], t.inserted_at >= ^after_date)
end
query
|> WandererApp.Repo.all()
|> then(&{:ok, &1})
end
end
end
attributes do

View File

@@ -45,7 +45,17 @@ defmodule WandererApp.Api.MapWebhookSubscription do
:active?
]
defaults [:read, :destroy]
defaults [:read]
# Custom destroy to invalidate cache
destroy :destroy do
require_atomic? false
change after_action(fn _changeset, record, _context ->
WandererApp.ExternalEvents.WebhookDispatcher.invalidate_cache(record.map_id)
{:ok, record}
end)
end
update :update do
accept [
@@ -60,6 +70,12 @@ defmodule WandererApp.Api.MapWebhookSubscription do
]
require_atomic? false
# Invalidate cache when subscription is updated
change after_action(fn _changeset, record, _context ->
WandererApp.ExternalEvents.WebhookDispatcher.invalidate_cache(record.map_id)
{:ok, record}
end)
end
read :by_map do
@@ -124,6 +140,12 @@ defmodule WandererApp.Api.MapWebhookSubscription do
secret = generate_webhook_secret()
Ash.Changeset.force_change_attribute(changeset, :secret, secret)
end
# Invalidate cache when subscription is created
change after_action(fn _changeset, record, _context ->
WandererApp.ExternalEvents.WebhookDispatcher.invalidate_cache(record.map_id)
{:ok, record}
end)
end
update :rotate_secret do
@@ -134,6 +156,11 @@ defmodule WandererApp.Api.MapWebhookSubscription do
new_secret = generate_webhook_secret()
Ash.Changeset.change_attribute(changeset, :secret, new_secret)
end
change after_action(fn _changeset, record, _context ->
WandererApp.ExternalEvents.WebhookDispatcher.invalidate_cache(record.map_id)
{:ok, record}
end)
end
end

View File

@@ -86,6 +86,11 @@ defmodule WandererApp.Application do
Supervisor.child_spec({Cachex, name: :wanderer_app_cache},
id: :wanderer_app_cache_worker
),
# Cache for webhook subscriptions - 5 minute TTL to reduce DB load
Supervisor.child_spec(
{Cachex, name: :webhook_subscriptions_cache, default_ttl: :timer.minutes(5)},
id: :webhook_subscriptions_cache_worker
),
{Registry, keys: :unique, name: WandererApp.Character.TrackerRegistry},
{PartitionSupervisor,
child_spec: DynamicSupervisor, name: WandererApp.Character.DynamicSupervisors},
@@ -112,6 +117,7 @@ defmodule WandererApp.Application do
WandererApp.Scheduler,
WandererApp.Server.ServerStatusTracker,
WandererApp.Server.TheraDataFetcher,
WandererApp.Server.TurnurDataFetcher,
{WandererApp.Character.TrackerPoolSupervisor, []},
{WandererApp.Map.MapPoolSupervisor, []},
WandererApp.Character.TrackerManager,

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

@@ -833,20 +833,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 +878,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 +888,7 @@ defmodule WandererApp.Esi.ApiClient do
"character:#{character_id}",
:character_token_invalid
)
:ok
end
end

View File

@@ -2,9 +2,11 @@ defmodule WandererApp.ExternalEvents.SseAccessControl do
@moduledoc """
Handles SSE access control checks including subscription validation.
Note: Community Edition mode is automatically handled by the
WandererApp.Map.is_subscription_active?/1 function, which returns
{:ok, true} when subscriptions are disabled globally.
IMPORTANT: This module is optimized for high-frequency calls during event delivery.
All checks use cached data to avoid database queries on every event.
Note: Community Edition mode is automatically handled - when subscriptions are
disabled globally, we skip the subscription check entirely.
"""
@doc """
@@ -15,16 +17,14 @@ defmodule WandererApp.ExternalEvents.SseAccessControl do
- {:error, reason} if SSE is not allowed
Checks in order:
1. Global SSE enabled (config)
2. Map exists
3. Map SSE enabled (per-map setting)
4. Subscription active (CE mode handled internally)
1. Global SSE enabled (config check - no DB)
2. Map SSE enabled (cache check - no DB)
3. Subscription active (cache check or skipped in CE mode - no DB)
"""
def sse_allowed?(map_id) do
with :ok <- check_sse_globally_enabled(),
{:ok, map} <- fetch_map(map_id),
:ok <- check_map_sse_enabled(map),
:ok <- check_subscription_or_ce(map_id) do
:ok <- check_map_sse_enabled_cached(map_id),
:ok <- check_subscription_or_ce_cached(map_id) do
:ok
end
end
@@ -37,31 +37,47 @@ defmodule WandererApp.ExternalEvents.SseAccessControl do
end
end
# Fetches the map by ID.
# Returns {:ok, map} or {:error, :map_not_found}
defp fetch_map(map_id) do
case WandererApp.Api.Map.by_id(map_id) do
{:ok, _map} = result -> result
_ -> {:error, :map_not_found}
# Uses the map cache with fallback to DB query
defp check_map_sse_enabled_cached(map_id) do
case WandererApp.Map.sse_enabled_with_status(map_id) do
{:ok, true} -> :ok
{:ok, false} -> {:error, :sse_disabled_for_map}
{:error, :not_found} -> {:error, :map_not_found}
end
end
defp check_map_sse_enabled(map) do
if map.sse_enabled do
# Checks subscription status using cached data.
# In CE mode (subscriptions disabled globally), this is a fast config check.
# In Enterprise mode, uses cached map state's subscription settings.
defp check_subscription_or_ce_cached(map_id) do
# Fast path: CE mode - subscriptions disabled globally
if not WandererApp.Env.map_subscriptions_enabled?() do
:ok
else
{:error, :sse_disabled_for_map}
# Enterprise mode: check cached subscription status from map state
check_subscription_from_cache(map_id)
end
end
# Checks if map has active subscription or if running Community Edition.
#
# Returns :ok if:
# - Community Edition (handled internally by is_subscription_active?/1), OR
# - Map has active subscription
#
# Returns {:error, :subscription_required} if subscription check fails.
defp check_subscription_or_ce(map_id) do
# Checks subscription status from the map cache.
# Falls back to DB query only if cache miss.
defp check_subscription_from_cache(map_id) do
case WandererApp.Map.subscription_active_cached?(map_id) do
{:ok, true} ->
:ok
{:ok, false} ->
{:error, :subscription_required}
{:error, :not_cached} ->
# Cache miss - fall back to DB check
# This should be rare as maps are initialized when accessed
fallback_subscription_check(map_id)
end
end
# Fallback to DB query - only used when cache miss
defp fallback_subscription_check(map_id) do
case WandererApp.Map.is_subscription_active?(map_id) do
{:ok, true} -> :ok
{:ok, false} -> {:error, :subscription_required}

View File

@@ -166,6 +166,37 @@ defmodule WandererApp.ExternalEvents.WebhookDispatcher do
end
defp get_active_subscriptions(map_id) do
# Use cache to avoid DB query on every event
cache_key = "map:#{map_id}"
case Cachex.get(:webhook_subscriptions_cache, cache_key) do
{:ok, nil} ->
# Cache miss - fetch from DB and cache
fetch_and_cache_subscriptions(map_id, cache_key)
{:ok, subscriptions} ->
# Cache hit
{:ok, subscriptions}
{:error, _reason} ->
# Cache error - fall back to DB
fetch_subscriptions_from_db(map_id)
end
end
defp fetch_and_cache_subscriptions(map_id, cache_key) do
case fetch_subscriptions_from_db(map_id) do
{:ok, subscriptions} = result ->
# Cache for 5 minutes (TTL set on cache, but explicit here for clarity)
Cachex.put(:webhook_subscriptions_cache, cache_key, subscriptions)
result
error ->
error
end
end
defp fetch_subscriptions_from_db(map_id) do
try do
subscriptions = MapWebhookSubscription.active_by_map!(map_id)
{:ok, subscriptions}
@@ -409,17 +440,25 @@ defmodule WandererApp.ExternalEvents.WebhookDispatcher do
end
defp webhooks_allowed?(map_id, webhooks_globally_enabled) do
with true <- webhooks_globally_enabled,
{:ok, map} <- WandererApp.Api.Map.by_id(map_id),
true <- map.webhooks_enabled do
:ok
else
false -> {:error, :webhooks_globally_disabled}
nil -> {:error, :webhooks_globally_disabled}
{:error, :not_found} -> {:error, :map_not_found}
%{webhooks_enabled: false} -> {:error, :webhooks_disabled_for_map}
{:error, reason} -> {:error, reason}
error -> {:error, {:unexpected_error, error}}
cond do
not webhooks_globally_enabled ->
{:error, :webhooks_globally_disabled}
not WandererApp.Map.webhooks_enabled?(map_id) ->
{:error, :webhooks_disabled_for_map}
true ->
:ok
end
end
@doc """
Invalidates the webhook subscriptions cache for a map.
Called when subscriptions are created, updated, or deleted.
"""
def invalidate_cache(map_id) do
cache_key = "map:#{map_id}"
Cachex.del(:webhook_subscriptions_cache, cache_key)
:ok
end
end

View File

@@ -8,6 +8,8 @@ defmodule WandererApp.Map do
require Logger
@map_state_cache :map_state_cache
# Default plan indicates no active subscription (free tier)
@default_subscription_plan :alpha
defstruct map_id: nil,
name: nil,
@@ -21,7 +23,10 @@ defmodule WandererApp.Map do
acls: [],
options: Map.new(),
characters_limit: nil,
hubs_limit: nil
hubs_limit: nil,
sse_enabled: false,
webhooks_enabled: false,
subscription_plan: @default_subscription_plan
def new(
%{id: map_id, name: name, scope: scope, owner_id: owner_id, acls: acls, hubs: hubs} =
@@ -29,6 +34,9 @@ defmodule WandererApp.Map do
) do
# Extract the new scopes array field if present (nil if not set)
scopes = Map.get(input, :scopes)
# Extract SSE/webhooks settings (default to false if not present)
sse_enabled = Map.get(input, :sse_enabled, false)
webhooks_enabled = Map.get(input, :webhooks_enabled, false)
map =
struct!(__MODULE__,
@@ -38,7 +46,9 @@ defmodule WandererApp.Map do
owner_id: owner_id,
name: name,
acls: acls,
hubs: hubs
hubs: hubs,
sse_enabled: sse_enabled,
webhooks_enabled: webhooks_enabled
)
update_map(map_id, map)
@@ -136,7 +146,7 @@ defmodule WandererApp.Map do
def is_subscription_active?(map_id, _map_subscriptions_enabled) do
{:ok, %{plan: plan}} = WandererApp.Map.SubscriptionManager.get_active_map_subscription(map_id)
{:ok, plan != :alpha}
{:ok, plan != @default_subscription_plan}
end
def get_options(map_id),
@@ -323,12 +333,17 @@ defmodule WandererApp.Map do
end
end
def update_subscription_settings!(%{map_id: map_id} = _map, %{
characters_limit: characters_limit,
hubs_limit: hubs_limit
}) do
def update_subscription_settings!(%{map_id: map_id} = _map, subscription_settings) do
characters_limit = Map.get(subscription_settings, :characters_limit)
hubs_limit = Map.get(subscription_settings, :hubs_limit)
plan = Map.get(subscription_settings, :plan, @default_subscription_plan)
map_id
|> update_map(%{characters_limit: characters_limit, hubs_limit: hubs_limit})
|> update_map(%{
characters_limit: characters_limit,
hubs_limit: hubs_limit,
subscription_plan: plan
})
map_id
|> get_map!()
@@ -342,6 +357,99 @@ defmodule WandererApp.Map do
|> get_map!()
end
@doc """
Updates SSE enabled setting in the map cache.
Called when the map's sse_enabled setting changes.
"""
def update_sse_enabled(map_id, sse_enabled)
when is_binary(map_id) and is_boolean(sse_enabled) do
update_map(map_id, %{sse_enabled: sse_enabled})
:ok
end
@doc """
Updates webhooks enabled setting in the map cache.
Called when the map's webhooks_enabled setting changes.
"""
def update_webhooks_enabled(map_id, webhooks_enabled)
when is_binary(map_id) and is_boolean(webhooks_enabled) do
update_map(map_id, %{webhooks_enabled: webhooks_enabled})
:ok
end
@doc """
Checks if SSE is enabled for a map using the cache.
Falls back to DB query if map is not in cache.
Returns a boolean (defaults to false if map not found).
"""
def sse_enabled?(map_id) do
case get_map(map_id) do
{:ok, map} ->
Map.get(map, :sse_enabled, false)
{:error, :not_found} ->
# Cache miss - fall back to DB
case WandererApp.Api.Map.by_id(map_id) do
{:ok, db_map} -> db_map.sse_enabled
_ -> false
end
end
end
@doc """
Checks if SSE is enabled for a map with explicit not_found handling.
Returns {:ok, boolean} or {:error, :not_found}.
"""
def sse_enabled_with_status(map_id) do
case get_map(map_id) do
{:ok, map} ->
{:ok, Map.get(map, :sse_enabled, false)}
{:error, :not_found} ->
# Cache miss - fall back to DB
case WandererApp.Api.Map.by_id(map_id) do
{:ok, db_map} -> {:ok, db_map.sse_enabled}
_ -> {:error, :not_found}
end
end
end
@doc """
Checks if webhooks are enabled for a map using the cache.
Falls back to DB query if map is not in cache.
"""
def webhooks_enabled?(map_id) do
case get_map(map_id) do
{:ok, map} ->
Map.get(map, :webhooks_enabled, false)
{:error, :not_found} ->
# Cache miss - fall back to DB
case WandererApp.Api.Map.by_id(map_id) do
{:ok, db_map} -> db_map.webhooks_enabled
_ -> false
end
end
end
@doc """
Checks if subscription is active for a map using the cache.
Returns {:ok, true} if active, {:ok, false} if not, or {:error, :not_cached} if not in cache.
Note: In CE mode (subscriptions disabled), use is_subscription_active?/1 which
handles this case without cache lookup.
"""
def subscription_active_cached?(map_id) do
case get_map(map_id) do
{:ok, map} ->
plan = Map.get(map, :subscription_plan, @default_subscription_plan)
{:ok, plan != @default_subscription_plan}
_ ->
{:error, :not_cached}
end
end
def add_systems!(map, []), do: map
def add_systems!(%{map_id: map_id} = map, [system | rest]) do

View File

@@ -152,7 +152,8 @@ defmodule WandererApp.Map.Manager do
"[cleanup_orphaned_pings] Found #{length(orphaned_pings)} orphaned pings, cleaning up..."
)
Enum.each(orphaned_pings, fn %{id: ping_id, map_id: map_id, type: type, system: system} = ping ->
Enum.each(orphaned_pings, fn %{id: ping_id, map_id: map_id, type: type, system: system} =
ping ->
reason =
cond do
is_nil(ping.system) -> "system deleted"
@@ -178,7 +179,10 @@ defmodule WandererApp.Map.Manager do
Ash.destroy!(ping)
end)
Logger.info("[cleanup_orphaned_pings] Cleaned up #{length(orphaned_pings)} orphaned pings")
Logger.info(
"[cleanup_orphaned_pings] Cleaned up #{length(orphaned_pings)} orphaned pings"
)
:ok
{:error, error} ->

View File

@@ -126,4 +126,12 @@ defmodule WandererApp.Map.Operations do
@doc "Delete a signature in a map"
@spec delete_signature(String.t(), String.t()) :: :ok | {:error, String.t()}
defdelegate delete_signature(map_id, sig_id), to: Signatures
@doc "Link a signature to a target system"
@spec link_signature(Plug.Conn.t(), String.t(), map()) :: {:ok, map()} | {:error, atom()}
defdelegate link_signature(conn, sig_id, params), to: Signatures
@doc "Unlink a signature from its target system"
@spec unlink_signature(Plug.Conn.t(), String.t()) :: {:ok, map()} | {:error, atom()}
defdelegate unlink_signature(conn, sig_id), to: Signatures
end

View File

@@ -63,13 +63,31 @@ defmodule WandererApp.Map.Operations.Connections do
if is_nil(src_info) or is_nil(tgt_info) do
{:error, :invalid_system_info}
else
# Get wormhole_type for ship size inference
wormhole_type = attrs["wormhole_type"]
# Build extra_info map with optional connection attributes
extra_info =
%{}
|> maybe_add_extra("time_status", attrs["time_status"])
|> maybe_add_extra("mass_status", attrs["mass_status"])
|> maybe_add_extra("locked", attrs["locked"])
|> maybe_add_extra("wormhole_type", wormhole_type)
info = %{
solar_system_source_id: src_info.solar_system_id,
solar_system_target_id: tgt_info.solar_system_id,
character_id: char_id,
type: parse_type(attrs["type"]),
ship_size_type:
resolve_ship_size(attrs["type"], attrs["ship_size_type"], src_info, tgt_info)
resolve_ship_size(
attrs["type"],
attrs["ship_size_type"],
wormhole_type,
src_info,
tgt_info
),
extra_info: if(extra_info == %{}, do: nil, else: extra_info)
}
case Server.add_connection(map_id, info) do
@@ -95,10 +113,11 @@ defmodule WandererApp.Map.Operations.Connections do
# Determines the ship size for a connection, applying wormhole-specific rules
# for C1, C13, and C4⇄NS links, falling back to the caller's provided size or Large.
defp resolve_ship_size(type_val, ship_size_val, src_info, tgt_info) do
# If wormhole_type is provided (e.g., "H296"), infer ship size from it.
defp resolve_ship_size(type_val, ship_size_val, wormhole_type, src_info, tgt_info) do
case parse_type(type_val) do
@connection_type_wormhole ->
wormhole_ship_size(ship_size_val, src_info, tgt_info)
wormhole_ship_size(ship_size_val, wormhole_type, src_info, tgt_info)
_other ->
# Stargates and others just use the parsed or default size
@@ -108,15 +127,45 @@ defmodule WandererApp.Map.Operations.Connections do
# -- Wormholespecific sizing rules ----------------------------------------
defp wormhole_ship_size(ship_size_val, src, tgt) do
defp wormhole_ship_size(ship_size_val, wormhole_type, src, tgt) do
# First, try to infer from wormhole_type (e.g., "H296", "C5", etc.)
inferred_size = infer_ship_size_from_wormhole_type(wormhole_type)
# Parse ship_size_val early to handle string values correctly
parsed_ship_size = parse_ship_size(ship_size_val, nil)
cond do
c1_system?(src, tgt) -> @medium_ship_size
c13_system?(src, tgt) -> @small_ship_size
c4_to_ns?(src, tgt) -> @small_ship_size
true -> parse_ship_size(ship_size_val, @large_ship_size)
# If user explicitly provided a ship_size_val, use it
not is_nil(parsed_ship_size) ->
parsed_ship_size
# If we could infer from wormhole_type, use that
not is_nil(inferred_size) ->
inferred_size
# Otherwise fall back to system class rules
c1_system?(src, tgt) ->
@medium_ship_size
c13_system?(src, tgt) ->
@small_ship_size
c4_to_ns?(src, tgt) ->
@small_ship_size
true ->
@large_ship_size
end
end
# Infer ship size from wormhole type name using EVE static data
defp infer_ship_size_from_wormhole_type(nil), do: nil
defp infer_ship_size_from_wormhole_type(""), do: nil
defp infer_ship_size_from_wormhole_type("K162"), do: nil
defp infer_ship_size_from_wormhole_type(wormhole_type) do
WandererApp.Utils.EVEUtil.get_wh_size(wormhole_type)
end
defp c1_system?(%{system_class: @c1_system_class}, _), do: true
defp c1_system?(_, %{system_class: @c1_system_class}), do: true
defp c1_system?(_, _), do: false
@@ -162,6 +211,9 @@ defmodule WandererApp.Map.Operations.Connections do
defp parse_type(_), do: @connection_type_wormhole
defp maybe_add_extra(map, _key, nil), do: map
defp maybe_add_extra(map, key, value), do: Map.put(map, key, value)
defp parse_int(nil, field), do: {:error, {:missing_field, field}}
defp parse_int(val, _) when is_integer(val), do: {:ok, val}

View File

@@ -5,8 +5,10 @@ defmodule WandererApp.Map.Operations.Signatures do
require Logger
alias WandererApp.Map.Operations
alias WandererApp.Map.Operations.Connections
alias WandererApp.Api.{Character, MapSystem, MapSystemSignature}
alias WandererApp.Map.Server
alias WandererApp.Utils.EVEUtil
@spec validate_character_eve_id(map() | nil, String.t()) ::
{:ok, String.t()} | {:error, :invalid_character} | {:error, :unexpected_error}
@@ -78,8 +80,7 @@ defmodule WandererApp.Map.Operations.Signatures do
)
when is_integer(solar_system_id) do
with {:ok, validated_char_uuid} <- validate_character_eve_id(params, char_id),
{:ok, system} <-
MapSystem.read_by_map_and_solar_system(%{map_id: map_id, solar_system_id: solar_system_id}) do
{:ok, system} <- ensure_system_on_map(map_id, solar_system_id, user_id, char_id) do
attrs =
params
|> Map.put("system_id", system.id)
@@ -95,6 +96,21 @@ defmodule WandererApp.Map.Operations.Signatures do
delete_connection_with_sigs: false
}) do
:ok ->
# Handle linked_system_id if provided - auto-add system and create/update connection
linked_system_id = Map.get(params, "linked_system_id")
wormhole_type = Map.get(params, "type")
if is_integer(linked_system_id) and linked_system_id != solar_system_id do
handle_linked_system(
map_id,
solar_system_id,
linked_system_id,
wormhole_type,
user_id,
char_id
)
end
# Try to fetch the created signature to return with proper fields
with {:ok, sigs} <-
MapSystemSignature.by_system_id_and_eve_ids(system.id, [attrs["eve_id"]]),
@@ -130,6 +146,13 @@ defmodule WandererApp.Map.Operations.Signatures do
Logger.error("[create_signature] Unexpected error during character validation")
{:error, :unexpected_error}
{:error, :invalid_solar_system} ->
Logger.error(
"[create_signature] Invalid solar_system_id: #{solar_system_id} (not a valid EVE system)"
)
{:error, :invalid_solar_system}
_ ->
Logger.error(
"[create_signature] System not found for solar_system_id: #{solar_system_id}"
@@ -148,6 +171,203 @@ defmodule WandererApp.Map.Operations.Signatures do
def create_signature(_conn, _params), do: {:error, :missing_params}
# Check cache (not DB) to ensure system is actually visible on the map.
@spec ensure_system_on_map(String.t(), integer(), String.t(), String.t()) ::
{:ok, map()} | {:error, atom()}
defp ensure_system_on_map(map_id, solar_system_id, user_id, char_id) do
case WandererApp.Map.find_system_by_location(map_id, %{solar_system_id: solar_system_id}) do
nil -> add_system_to_map(map_id, solar_system_id, user_id, char_id)
system -> {:ok, system}
end
end
@spec add_system_to_map(String.t(), integer(), String.t(), String.t()) ::
{:ok, map()} | {:error, atom()}
defp add_system_to_map(map_id, solar_system_id, user_id, char_id) do
with {:ok, static_info} when not is_nil(static_info) <-
WandererApp.CachedInfo.get_system_static_info(solar_system_id),
:ok <-
Server.add_system(
map_id,
%{solar_system_id: solar_system_id, coordinates: nil},
user_id,
char_id
),
system when not is_nil(system) <- fetch_system_after_add(map_id, solar_system_id) do
Logger.info("[create_signature] Auto-added system #{solar_system_id} to map #{map_id}")
{:ok, system}
else
{:ok, nil} ->
{:error, :invalid_solar_system}
{:error, _} ->
{:error, :invalid_solar_system}
nil ->
Logger.error("[add_system_to_map] Failed to fetch system after add")
{:error, :system_add_failed}
error ->
Logger.error("[add_system_to_map] Failed to add system: #{inspect(error)}")
{:error, :system_add_failed}
end
end
defp fetch_system_after_add(map_id, solar_system_id) do
case WandererApp.Map.find_system_by_location(map_id, %{solar_system_id: solar_system_id}) do
nil ->
case MapSystem.read_by_map_and_solar_system(%{
map_id: map_id,
solar_system_id: solar_system_id
}) do
{:ok, system} -> system
_ -> nil
end
system ->
system
end
end
# Handles the linked_system_id logic: auto-adds the linked system and creates/updates connection
@spec handle_linked_system(
String.t(),
integer(),
integer(),
String.t() | nil,
String.t(),
String.t()
) :: :ok | {:error, atom()}
defp handle_linked_system(
map_id,
source_system_id,
linked_system_id,
wormhole_type,
user_id,
char_id
) do
# Ensure the linked system is on the map
case ensure_system_on_map(map_id, linked_system_id, user_id, char_id) do
{:ok, _linked_system} ->
# Check if connection exists between the systems
case Connections.get_connection_by_systems(map_id, source_system_id, linked_system_id) do
{:ok, nil} ->
# No connection exists, create one
create_connection_with_wormhole_type(
map_id,
source_system_id,
linked_system_id,
wormhole_type,
char_id
)
{:ok, _existing_conn} ->
# Connection exists, update wormhole type if provided
update_connection_wormhole_type(
map_id,
source_system_id,
linked_system_id,
wormhole_type
)
{:error, reason} ->
Logger.warning(
"[handle_linked_system] Failed to check connection: #{inspect(reason)}"
)
{:error, :connection_check_failed}
end
{:error, :invalid_solar_system} ->
Logger.warning(
"[handle_linked_system] Invalid linked_system_id: #{linked_system_id} (not a valid EVE system)"
)
{:error, :invalid_linked_system}
{:error, reason} ->
Logger.warning("[handle_linked_system] Failed to add linked system: #{inspect(reason)}")
{:error, :linked_system_add_failed}
end
end
# Creates a connection between two systems with the specified wormhole type
@spec create_connection_with_wormhole_type(
String.t(),
integer(),
integer(),
String.t() | nil,
String.t()
) :: :ok | {:error, atom()}
defp create_connection_with_wormhole_type(
map_id,
source_system_id,
target_system_id,
wormhole_type,
char_id
) do
conn_attrs = %{
"solar_system_source" => source_system_id,
"solar_system_target" => target_system_id,
"type" => 0,
"wormhole_type" => wormhole_type
}
case Connections.create(conn_attrs, map_id, char_id) do
{:ok, :created} ->
Logger.info(
"[create_signature] Auto-created connection #{source_system_id} <-> #{target_system_id} (type: #{wormhole_type || "unknown"})"
)
:ok
{:skip, :exists} ->
# Connection already exists (race condition), update it instead
update_connection_wormhole_type(map_id, source_system_id, target_system_id, wormhole_type)
error ->
Logger.warning(
"[create_connection_with_wormhole_type] Failed to create connection: #{inspect(error)}"
)
{:error, :connection_create_failed}
end
end
# Updates the wormhole type and ship size for an existing connection
@spec update_connection_wormhole_type(String.t(), integer(), integer(), String.t() | nil) ::
:ok | {:error, atom()}
defp update_connection_wormhole_type(_map_id, _source, _target, nil), do: :ok
defp update_connection_wormhole_type(map_id, source_system_id, target_system_id, wormhole_type) do
# Get ship size from wormhole type
ship_size_type = EVEUtil.get_wh_size(wormhole_type)
if not is_nil(ship_size_type) do
case Server.update_connection_ship_size_type(map_id, %{
solar_system_source_id: source_system_id,
solar_system_target_id: target_system_id,
ship_size_type: ship_size_type
}) do
:ok ->
Logger.info(
"[create_signature] Updated connection #{source_system_id} <-> #{target_system_id} ship_size_type to #{ship_size_type} (wormhole: #{wormhole_type})"
)
:ok
error ->
Logger.warning(
"[update_connection_wormhole_type] Failed to update ship size: #{inspect(error)}"
)
{:error, :ship_size_update_failed}
end
else
:ok
end
end
@spec update_signature(Plug.Conn.t(), String.t(), map()) :: {:ok, map()} | {:error, atom()}
def update_signature(
%{assigns: %{map_id: map_id, owner_character_id: char_id, owner_user_id: user_id}} =
@@ -249,4 +469,197 @@ defmodule WandererApp.Map.Operations.Signatures do
end
def delete_signature(_conn, _sig_id), do: {:error, :missing_params}
@doc """
Links a signature to a target system, creating the association between
the signature and the wormhole connection to that system.
This also:
- Updates the signature's group to "Wormhole"
- Sets the target system's linked_sig_eve_id
- Copies temporary_name from signature to target system
- Updates connection time_status and ship_size_type from signature data
"""
@spec link_signature(Plug.Conn.t(), String.t(), map()) :: {:ok, map()} | {:error, atom()}
def link_signature(
%{assigns: %{map_id: map_id}} = _conn,
sig_id,
%{"solar_system_target" => solar_system_target}
)
when is_integer(solar_system_target) do
with {:ok, signature} <- MapSystemSignature.by_id(sig_id),
{:ok, source_system} <- MapSystem.by_id(signature.system_id),
true <- source_system.map_id == map_id,
target_system when not is_nil(target_system) <-
WandererApp.Map.find_system_by_location(map_id, %{solar_system_id: solar_system_target}) do
# Update signature group to Wormhole and set linked_system_id
{:ok, updated_signature} =
signature
|> MapSystemSignature.update_group!(%{group: "Wormhole"})
|> MapSystemSignature.update_linked_system(%{linked_system_id: solar_system_target})
# Update target system if it has no linked signature or is already linked to the same signature
if is_nil(target_system.linked_sig_eve_id) or
target_system.linked_sig_eve_id == signature.eve_id do
# Set the target system's linked_sig_eve_id
Server.update_system_linked_sig_eve_id(map_id, %{
solar_system_id: solar_system_target,
linked_sig_eve_id: signature.eve_id
})
# Copy temporary_name if present
if not is_nil(signature.temporary_name) do
Server.update_system_temporary_name(map_id, %{
solar_system_id: solar_system_target,
temporary_name: signature.temporary_name
})
end
# Update connection time_status from signature custom_info
signature_time_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
end
else
nil
end
# Update connection ship_size_type from signature wormhole type
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} =
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}
forward_sig ->
Logger.info(
"[link_signature] Back-link detected: " <>
"using forward sig type=#{forward_sig.type} from target system"
)
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
case Jason.decode(forward_sig.custom_info) do
{:ok, map} -> Map.get(map, "time_status")
{:error, _} -> nil
end
else
signature_time_status
end
{forward_time_status, forward_ship_size}
end
else
{signature_time_status, signature_ship_size_type}
end
if not is_nil(signature_time_status) do
Server.update_connection_time_status(map_id, %{
solar_system_source_id: source_system.solar_system_id,
solar_system_target_id: solar_system_target,
time_status: signature_time_status
})
end
if not is_nil(signature_ship_size_type) do
Server.update_connection_ship_size_type(map_id, %{
solar_system_source_id: source_system.solar_system_id,
solar_system_target_id: solar_system_target,
ship_size_type: signature_ship_size_type
})
end
end
# Broadcast update
Server.Impl.broadcast!(map_id, :signatures_updated, source_system.solar_system_id)
# Return the updated signature
result =
updated_signature
|> Map.from_struct()
|> Map.put(:solar_system_id, source_system.solar_system_id)
|> Map.drop([:system_id, :__meta__, :system, :aggregates, :calculations])
{:ok, result}
else
false ->
{:error, :not_found}
nil ->
{:error, :target_system_not_found}
{:error, %Ash.Error.Query.NotFound{}} ->
{:error, :not_found}
err ->
Logger.error("[link_signature] Unexpected error: #{inspect(err)}")
{:error, :unexpected_error}
end
end
def link_signature(_conn, _sig_id, %{"solar_system_target" => _}),
do: {:error, :invalid_solar_system_target}
def link_signature(_conn, _sig_id, _params), do: {:error, :missing_params}
@doc """
Unlinks a signature from its target system.
"""
@spec unlink_signature(Plug.Conn.t(), String.t()) :: {:ok, map()} | {:error, atom()}
def unlink_signature(%{assigns: %{map_id: map_id}} = _conn, sig_id) do
with {:ok, signature} <- MapSystemSignature.by_id(sig_id),
{:ok, source_system} <- MapSystem.by_id(signature.system_id),
:ok <- if(source_system.map_id == map_id, do: :ok, else: {:error, :not_found}),
:ok <- if(not is_nil(signature.linked_system_id), do: :ok, else: {:error, :not_linked}) do
# Clear the target system's linked_sig_eve_id
Server.update_system_linked_sig_eve_id(map_id, %{
solar_system_id: signature.linked_system_id,
linked_sig_eve_id: nil
})
# Clear the signature's linked_system_id using the wrapper for logging
{:ok, updated_signature} =
Server.SignaturesImpl.update_signature_linked_system(signature, %{
linked_system_id: nil
})
# Broadcast update
Server.Impl.broadcast!(map_id, :signatures_updated, source_system.solar_system_id)
# Return the updated signature
result =
updated_signature
|> Map.from_struct()
|> Map.put(:solar_system_id, source_system.solar_system_id)
|> Map.drop([:system_id, :__meta__, :system, :aggregates, :calculations])
{:ok, result}
else
{:error, :not_found} ->
{:error, :not_found}
{:error, :not_linked} ->
{:error, :not_linked}
{:error, %Ash.Error.Query.NotFound{}} ->
{:error, :not_found}
err ->
Logger.error("[unlink_signature] Unexpected error: #{inspect(err)}")
{:error, :unexpected_error}
end
end
def unlink_signature(_conn, _sig_id), do: {:error, :missing_params}
end

View File

@@ -36,7 +36,8 @@ defmodule WandererApp.Map.Operations.Systems do
# Private helper for batch upsert
defp create_system_batch(%{map_id: map_id, user_id: user_id, char_id: char_id}, params) do
with {:ok, solar_system_id} <- fetch_system_id(params) do
update_existing = fetch_update_existing(params, false)
# Default to true so re-submitting with new position updates the system
update_existing = fetch_update_existing(params, true)
map_id
|> WandererApp.Map.check_location(%{solar_system_id: solar_system_id})
@@ -46,9 +47,13 @@ defmodule WandererApp.Map.Operations.Systems do
{:error, :already_exists} ->
if update_existing do
do_update_system(map_id, user_id, char_id, solar_system_id, params)
# Mark as skip so it counts as "updated" not "created"
case do_update_system(map_id, user_id, char_id, solar_system_id, params) do
{:ok, _} -> {:skip, :updated}
error -> error
end
else
:ok
{:skip, :already_exists}
end
end
end
@@ -200,16 +205,22 @@ defmodule WandererApp.Map.Operations.Systems do
defp normalize_coordinates(%{"coordinates" => %{"x" => x, "y" => y}})
when is_number(x) and is_number(y),
do: %{x: x, y: y}
do: %{"x" => x, "y" => y}
defp normalize_coordinates(%{coordinates: %{x: x, y: y}}) when is_number(x) and is_number(y),
do: %{x: x, y: y}
do: %{"x" => x, "y" => y}
defp normalize_coordinates(params) do
%{
x: params |> Map.get("position_x", Map.get(params, :position_x, 0)),
y: params |> Map.get("position_y", Map.get(params, :position_y, 0))
}
x = params |> Map.get("position_x", Map.get(params, :position_x))
y = params |> Map.get("position_y", Map.get(params, :position_y))
# Only return coordinates if both x and y are provided
# Otherwise return nil to let the server use auto-positioning
if is_number(x) and is_number(y) do
%{"x" => x, "y" => y}
else
nil
end
end
defp apply_system_updates(map_id, system_id, attrs, %{x: x, y: y}) do

View File

@@ -0,0 +1,297 @@
defmodule WandererApp.Map.RoutesBy do
@moduledoc """
Routes-by helper that uses the local route builder service.
"""
require Logger
@minimum_route_attrs [
:system_class,
:class_title,
:security,
:triglavian_invasion_status,
:solar_system_id,
:solar_system_name,
:region_name,
:is_shattered
]
@default_routes_settings %{
path_type: "shortest",
include_mass_crit: true,
include_eol: false,
include_frig: true,
include_cruise: true,
avoid_wormholes: false,
avoid_pochven: false,
avoid_edencom: false,
avoid_triglavian: false,
include_thera: true,
avoid: []
}
@zarzakh_system 30_100_000
@default_avoid_systems [@zarzakh_system]
@get_link_pairs_advanced_params [
:include_mass_crit,
:include_eol,
:include_frig
]
def find(map_id, origin, routes_settings, type, count \\ 1) do
origin = parse_origin(origin)
routes_settings = @default_routes_settings |> Map.merge(routes_settings || %{})
connections = build_connections(map_id, routes_settings)
avoidance_list = build_avoidance_list(routes_settings)
security_type =
routes_settings
|> Map.get(:security_type, "both")
|> normalize_security_type()
payload = %{
origin: origin,
flag: routes_settings.path_type,
connections: connections,
avoid: avoidance_list,
count: count,
type: type,
security_type: security_type,
routes_settings: routes_settings
}
stations_by_system = WandererApp.RouteBuilderClient.stations_for(type)
case WandererApp.RouteBuilderClient.find_closest(payload) do
{:ok, body} ->
routes = normalize_routes(body, origin)
routes = attach_stations(routes, stations_by_system)
systems_static_data = fetch_systems_static_data(routes)
{:ok, %{routes: routes, systems_static_data: systems_static_data}}
{:error, reason} ->
Logger.error("[RoutesBy] Failed to fetch routes by: #{inspect(reason)}")
{:ok, %{routes: [], systems_static_data: []}}
end
end
defp parse_origin(origin) when is_integer(origin), do: origin
defp parse_origin(origin) when is_binary(origin) do
case Integer.parse(origin) do
{id, _} -> id
:error -> 0
end
end
defp parse_origin(_), do: 0
defp normalize_routes(%{"routes" => routes}, origin) when is_list(routes),
do: normalize_routes(routes, origin)
defp normalize_routes(routes, _origin) when is_list(routes) do
routes
|> Enum.map(&map_route_info/1)
|> Enum.filter(fn route_info -> not is_nil(route_info) end)
end
defp normalize_routes(_body, _origin), do: []
defp attach_stations(routes, stations_by_system) do
Enum.map(routes, fn route ->
system_key = to_string(route.destination)
stations = Map.get(stations_by_system, system_key, [])
normalized_stations =
stations
|> Enum.filter(&is_map/1)
|> Enum.map(fn station ->
%{
station_id: Map.get(station, "station_id") || Map.get(station, :station_id),
station_name: Map.get(station, "name") || Map.get(station, :name),
special: Map.get(station, "special") || Map.get(station, :special) || false
}
end)
|> Enum.filter(fn station ->
is_integer(station.station_id) and is_binary(station.station_name)
end)
Map.put(route, :stations, normalized_stations)
end)
end
defp map_route_info(%{
"origin" => origin,
"destination" => destination,
"systems" => result_systems,
"success" => success
}) do
map_route_info(%{
origin: origin,
destination: destination,
systems: result_systems,
success: success
})
end
defp map_route_info(
%{origin: origin, destination: destination, systems: result_systems, success: success} =
_route_info
) do
systems =
case result_systems do
[] -> []
_ -> result_systems |> Enum.reject(fn system_id -> system_id == origin end)
end
%{
has_connection: result_systems != [],
systems: systems,
origin: origin,
destination: destination,
success: success
}
end
defp map_route_info(_), do: nil
defp fetch_systems_static_data(routes) do
routes
|> Enum.map(fn route_info -> route_info.systems end)
|> List.flatten()
|> Enum.uniq()
|> Task.async_stream(
fn system_id ->
case WandererApp.CachedInfo.get_system_static_info(system_id) do
{:ok, nil} -> nil
{:ok, system} -> system |> Map.take(@minimum_route_attrs)
end
end,
max_concurrency: System.schedulers_online() * 4
)
|> Enum.map(fn {:ok, val} -> val end)
end
defp build_avoidance_list(routes_settings) do
{:ok, trig_systems} = WandererApp.CachedInfo.get_trig_systems()
pochven_solar_systems =
trig_systems
|> Enum.filter(fn s -> s.triglavian_invasion_status == "Final" end)
|> Enum.map(& &1.solar_system_id)
triglavian_solar_systems =
trig_systems
|> Enum.filter(fn s -> s.triglavian_invasion_status == "Triglavian" end)
|> Enum.map(& &1.solar_system_id)
edencom_solar_systems =
trig_systems
|> Enum.filter(fn s -> s.triglavian_invasion_status == "Edencom" end)
|> Enum.map(& &1.solar_system_id)
avoidance_list =
case routes_settings.avoid_edencom do
true -> edencom_solar_systems
false -> []
end
avoidance_list =
case routes_settings.avoid_triglavian do
true -> [avoidance_list | triglavian_solar_systems]
false -> avoidance_list
end
avoidance_list =
case routes_settings.avoid_pochven do
true -> [avoidance_list | pochven_solar_systems]
false -> avoidance_list
end
(@default_avoid_systems ++ [routes_settings.avoid | avoidance_list])
|> List.flatten()
|> Enum.uniq()
end
defp normalize_security_type("high"), do: "high"
defp normalize_security_type(:high), do: "high"
defp normalize_security_type("low"), do: "low"
defp normalize_security_type(:low), do: "low"
defp normalize_security_type(_), do: "both"
defp build_connections(map_id, routes_settings) do
if routes_settings.avoid_wormholes do
[]
else
map_chains =
routes_settings
|> Map.take(@get_link_pairs_advanced_params)
|> Map.put_new(:map_id, map_id)
|> WandererApp.Api.MapConnection.get_link_pairs_advanced!()
|> Enum.map(fn %{
solar_system_source: solar_system_source,
solar_system_target: solar_system_target
} ->
%{
first: solar_system_source,
second: solar_system_target
}
end)
|> Enum.uniq()
{:ok, thera_chains} =
case routes_settings.include_thera do
true ->
WandererApp.Server.TheraDataFetcher.get_chain_pairs(routes_settings)
false ->
{:ok, []}
end
chains = remove_intersection([map_chains | thera_chains] |> List.flatten())
chains =
case routes_settings.include_cruise do
false ->
{:ok, wh_class_a_systems} = WandererApp.CachedInfo.get_wh_class_a_systems()
chains
|> Enum.filter(fn x ->
not Enum.member?(wh_class_a_systems, x.first) and
not Enum.member?(wh_class_a_systems, x.second)
end)
_ ->
chains
end
chains
|> Enum.map(fn chain ->
["#{chain.first}|#{chain.second}", "#{chain.second}|#{chain.first}"]
end)
|> List.flatten()
end
end
defp remove_intersection(pairs_arr) do
tuples = pairs_arr |> Enum.map(fn x -> {x.first, x.second} end)
tuples
|> Enum.reduce([], fn {first, second} = x, acc ->
if Enum.member?(tuples, {second, first}) do
acc
else
[x | acc]
end
end)
|> Enum.uniq()
|> Enum.map(fn {first, second} ->
%{
first: first,
second: second
}
end)
end
end

View File

@@ -314,10 +314,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 +780,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 +806,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 +961,28 @@ 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 explicitly untracked (e.g., by permission cleanup) - don't re-enable
Logger.debug(fn ->
"[CharactersImpl] Skipping re-track for character #{character_id} on map #{map_id} - " <>
"character was explicitly untracked"
end)
WandererApp.Character.TrackerManager.update_track_settings(character_id, %{
map_id: map_id,
track: true
})
add_character(map_id, character, false)
_ ->
# 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

@@ -595,6 +595,7 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
time_status = get_extra_info(extra_info, "time_status", time_status)
mass_status = get_extra_info(extra_info, "mass_status", 0)
locked = get_extra_info(extra_info, "locked", false)
wormhole_type = get_extra_info(extra_info, "wormhole_type", nil)
{:ok, connection} =
WandererApp.MapConnectionRepo.create(%{
@@ -605,7 +606,8 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
ship_size_type: ship_size_type,
time_status: time_status,
mass_status: mass_status,
locked: locked
locked: locked,
wormhole_type: wormhole_type
})
if connection_type == @connection_type_wormhole do
@@ -915,8 +917,10 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
if not from_is_wormhole and not to_is_wormhole do
# Check if there's a known stargate
case find_solar_system_jump(from_solar_system_id, to_solar_system_id) do
{:ok, []} -> true # No stargate = wormhole connection
_ -> false # Stargate exists or error
# No stargate = wormhole connection
{:ok, []} -> true
# Stargate exists or error
_ -> false
end
else
false

View File

@@ -72,7 +72,6 @@ defmodule WandererApp.Map.Server.PingsImpl do
type: type
} = _ping_info
) do
result = WandererApp.MapPingsRepo.get_by_id(ping_id)
case result do

View File

@@ -109,8 +109,10 @@ defmodule WandererApp.Map.Server.SignaturesImpl do
nil ->
MapSystemSignature.create!(sig)
_ ->
:noop
existing ->
# If signature already exists, update it instead of ignoring
# This handles the case where frontend sends existing sigs as "added"
apply_update_signature(map_id, existing, sig)
end
end)
@@ -273,6 +275,21 @@ defmodule WandererApp.Map.Server.SignaturesImpl do
defp maybe_update_connection_mass_status(_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,
finds the existing signature in System A that already links to System B (e.g., H296).
"""
def find_forward_signature(target_system_uuid, source_solar_system_id) do
target_system_uuid
|> MapSystemSignature.by_system_id!()
|> Enum.find(fn sig -> sig.linked_system_id == source_solar_system_id end)
rescue
e ->
Logger.warning("[find_forward_signature] Error: #{inspect(e)}")
nil
end
@doc """
Wrapper for updating a signature's linked_system_id with logging.
Logs all unlink operations (when linked_system_id is set to nil) with context
@@ -317,7 +334,7 @@ defmodule WandererApp.Map.Server.SignaturesImpl do
@doc false
defp parse_signatures(signatures, character_eve_id, system_id) do
Enum.map(signatures, fn sig ->
%{
base = %{
system_id: system_id,
eve_id: sig["eve_id"],
name: sig["name"],
@@ -331,6 +348,15 @@ defmodule WandererApp.Map.Server.SignaturesImpl do
character_eve_id: Map.get(sig, "character_eve_id", character_eve_id),
deleted: false
}
# Only include linked_system_id when explicitly provided in the payload.
# Frontend sends "linked_system" (object), not "linked_system_id" (integer).
# Including nil would silently clear the DB value via the Ash :update action.
if Map.has_key?(sig, "linked_system_id") do
Map.put(base, :linked_system_id, sig["linked_system_id"])
else
base
end
end)
end

View File

@@ -3,4 +3,7 @@ defmodule WandererApp.MapTransactionRepo do
def create(transaction),
do: WandererApp.Api.MapTransaction.create(transaction)
def top_donators(map_id, after_date \\ nil),
do: WandererApp.Api.MapTransaction.top_donators(%{map_id: map_id, after: after_date})
end

View File

@@ -0,0 +1,268 @@
defmodule WandererApp.RouteBuilderClient do
@moduledoc """
HTTP client for the local route builder service.
"""
require Logger
@timeout_opts [pool_timeout: 5_000, receive_timeout: :timer.seconds(30)]
@loot_dir Path.join(["repo", "data", "route_by_systems"])
@available_routes_by ["blueLoot", "redLoot", "thera", "turnur", "so_cleaning", "trade_hubs"]
def available_routes_by(), do: @available_routes_by
def find_closest(
%{
origin: origin,
flag: flag,
connections: connections,
avoid: avoid,
count: count,
type: type,
security_type: security_type
} = payload
) do
url = "#{WandererApp.Env.custom_route_base_url()}/route/findClosest"
routes_settings = Map.get(payload, :routes_settings, %{})
destinations = destinations_for(type, security_type, routes_settings)
payload = %{
origin: origin,
flag: flag,
connections: connections || [],
avoid: avoid || [],
destinations: destinations,
count: count || 1
}
case Req.post(url, Keyword.merge([json: payload], @timeout_opts)) do
{:ok, %{status: status, body: body}} when status in [200, 201] ->
{:ok, body}
{:ok, %{status: status, body: body}} ->
Logger.warning("[RouteBuilderClient] Unexpected status: #{status}")
{:error, {:unexpected_status, status, body}}
{:error, reason} ->
Logger.error("[RouteBuilderClient] Request failed: #{inspect(reason)}")
{:error, reason}
end
end
defp destinations_for(type, security_type, routes_settings) do
case normalize_type(type) do
:thera ->
thera_destinations(routes_settings, security_type)
:turnur ->
turnur_destinations(routes_settings, security_type)
_ ->
case load_loot_data(type) do
{:ok, %{"system_ids_by_band" => by_band}} ->
high = Map.get(by_band, "high", [])
low = Map.get(by_band, "low", [])
pick_by_band(high, low, security_type)
{:ok, %{"system_ids" => system_ids}} when is_list(system_ids) ->
filter_by_security(system_ids, security_type)
{:error, reason} ->
Logger.error("[RouteBuilderClient] Failed to load loot data: #{inspect(reason)}")
[]
_ ->
[]
end
end
end
defp thera_destinations(routes_settings, security_type) do
{:ok, thera_chains} = WandererApp.Server.TheraDataFetcher.get_chain_pairs(routes_settings)
system_ids =
thera_chains
|> Enum.map(fn %{first: first, second: second} ->
pick_thera_destination(first, second)
end)
|> Enum.reject(&is_nil/1)
|> Enum.uniq()
filter_by_security(system_ids, security_type)
end
defp turnur_destinations(routes_settings, security_type) do
{:ok, turnur_chains} = WandererApp.Server.TurnurDataFetcher.get_chain_pairs(routes_settings)
system_ids =
turnur_chains
|> Enum.map(fn %{first: first, second: second} ->
pick_turnur_destination(first, second)
end)
|> Enum.reject(&is_nil/1)
|> Enum.uniq()
filter_by_security(system_ids, security_type)
end
defp filter_by_security(system_ids, security_type) do
case normalize_security_type(security_type) do
"high" ->
Enum.filter(system_ids, fn system_id ->
case system_security(system_id) do
{:ok, security} -> security >= 0.5
_ -> false
end
end)
"low" ->
Enum.filter(system_ids, fn system_id ->
case system_security(system_id) do
{:ok, security} -> security > 0.0 and security < 0.5
_ -> false
end
end)
_ ->
system_ids
end
end
defp system_security(system_id) do
case WandererApp.CachedInfo.get_system_static_info(system_id) do
{:ok, %{security: security}} -> parse_security(security)
_ -> {:error, :missing_security}
end
end
defp pick_thera_destination(first, second) do
first_is_thera = is_thera_system?(first)
second_is_thera = is_thera_system?(second)
cond do
first_is_thera and not second_is_thera -> second
second_is_thera and not first_is_thera -> first
true -> second
end
end
defp is_thera_system?(system_id) do
case WandererApp.CachedInfo.get_system_static_info(system_id) do
{:ok, %{system_class: 12}} -> true
_ -> false
end
end
defp pick_turnur_destination(first, second) do
first_is_turnur = is_turnur_system?(first)
second_is_turnur = is_turnur_system?(second)
cond do
first_is_turnur and not second_is_turnur -> second
second_is_turnur and not first_is_turnur -> first
true -> second
end
end
defp is_turnur_system?(system_id) do
case WandererApp.CachedInfo.get_system_static_info(system_id) do
{:ok, %{solar_system_name: name}} when is_binary(name) ->
String.downcase(name) == "turnur"
_ ->
false
end
end
defp parse_security(security) when is_float(security), do: {:ok, security}
defp parse_security(security) when is_integer(security), do: {:ok, security * 1.0}
defp parse_security(security) when is_binary(security) do
case Float.parse(security) do
{value, _} -> {:ok, value}
_ -> {:error, :invalid_security}
end
end
defp parse_security(_), do: {:error, :invalid_security}
defp normalize_security_type("high"), do: "high"
defp normalize_security_type(:high), do: "high"
defp normalize_security_type("hight"), do: "high"
defp normalize_security_type(:hight), do: "high"
defp normalize_security_type("low"), do: "low"
defp normalize_security_type(:low), do: "low"
defp normalize_security_type(_), do: "both"
def stations_for(type) do
case normalize_type(type) do
:thera ->
%{}
:turnur ->
%{}
_ ->
case load_loot_data(type) do
{:ok, %{"system_stations" => system_stations}} when is_map(system_stations) ->
system_stations
{:ok, _} ->
%{}
{:error, reason} ->
Logger.error("[RouteBuilderClient] Failed to load loot stations: #{inspect(reason)}")
%{}
end
end
end
defp pick_by_band(high, _low, "high"), do: high
defp pick_by_band(high, _low, :high), do: high
defp pick_by_band(high, _low, "hight"), do: high
defp pick_by_band(high, _low, :hight), do: high
defp pick_by_band(_high, low, "low"), do: low
defp pick_by_band(_high, low, :low), do: low
defp pick_by_band(high, low, _), do: high ++ low
defp load_loot_data("blueLoot"), do: load_loot_file("blueloot.json")
defp load_loot_data(:blueLoot), do: load_loot_file("blueloot.json")
defp load_loot_data("redLoot"), do: load_loot_file("redloot.json")
defp load_loot_data(:redLoot), do: load_loot_file("redloot.json")
defp load_loot_data("so_cleaning"), do: load_loot_file("ss_cleaning.json")
defp load_loot_data(:so_cleaning), do: load_loot_file("ss_cleaning.json")
defp load_loot_data("trade_hubs"), do: load_loot_file("trade_hubs.json")
defp load_loot_data(:trade_hubs), do: load_loot_file("trade_hubs.json")
defp load_loot_data(_), do: load_loot_file("blueloot.json")
defp normalize_type("thera"), do: :thera
defp normalize_type(:thera), do: :thera
defp normalize_type("turnur"), do: :turnur
defp normalize_type(:turnur), do: :turnur
defp normalize_type("so_cleaning"), do: :so_cleaning
defp normalize_type(:so_cleaning), do: :so_cleaning
defp normalize_type("trade_hubs"), do: :trade_hubs
defp normalize_type(:trade_hubs), do: :trade_hubs
defp normalize_type(type), do: type
defp load_loot_file(filename) do
key = {__MODULE__, :loot_data, filename}
case :persistent_term.get(key, :missing) do
:missing ->
path = Path.join([:code.priv_dir(:wanderer_app), @loot_dir, filename])
with {:ok, body} <- File.read(path),
{:ok, json} <- Jason.decode(body) do
:persistent_term.put(key, json)
{:ok, json}
else
error -> error
end
cached ->
{:ok, cached}
end
end
end

View File

@@ -0,0 +1,161 @@
defmodule WandererApp.Server.TurnurDataFetcher do
@moduledoc false
use GenServer
require Logger
@name :turnur_data_fetcher
@system_name "turnur"
defstruct [
:retries_count,
:restart_timeout
]
@eve_scout_base_url "https://api.eve-scout.com/v2/public"
@refresh_timeout :timer.minutes(1)
@initial_state %{
retries_count: 5,
restart_timeout: @refresh_timeout
}
def get_chain_pairs(params) do
case WandererApp.Cache.get(@name) do
nil ->
{:ok, []}
data ->
{:ok,
data
|> Enum.filter(fn item -> _is_filtered(item, params) end)
|> Enum.map(fn item ->
%{
first: item.source_solar_system_id,
second: item.destination_solar_system_id
}
end)}
end
end
defp _is_filtered(%{ship_size_type: 0}, %{
include_frig: false
}),
do: false
defp _is_filtered(%{time_status: 1}, %{
include_eol: false
}),
do: false
defp _is_filtered(%{time_status: 2}, %{
include_mass_crit: false
}),
do: false
defp _is_filtered(_, _), do: true
def start_link(opts \\ []) do
GenServer.start(__MODULE__, opts, name: @name)
end
@impl true
def init(_opts) do
Logger.info("#{__MODULE__} started")
{:ok, @initial_state, {:continue, :start}}
end
@impl true
def terminate(_reason, _state) do
:ok
end
@impl true
def handle_call(:stop, _, state), do: {:stop, :normal, :ok, state}
@impl true
def handle_call(:error, _, state), do: {:stop, :error, :ok, state}
@impl true
def handle_continue(:start, state) do
Process.send_after(self(), :refresh_data, 500)
{:noreply, state}
end
@impl true
def handle_info(
:refresh_data,
state
) do
Task.async(fn -> load_data() end)
Process.send_after(self(), :refresh_data, @refresh_timeout)
{:noreply, state}
end
@impl true
def handle_info({ref, result}, state) do
Process.demonitor(ref, [:flush])
case result do
{:ok, data} ->
_cache_items(data)
{:noreply, state}
_ ->
Logger.error("#{__MODULE__} failed to load data")
{:noreply, state}
end
end
def handle_info(_action, state),
do: {:noreply, state}
defp load_data() do
case Req.get("#{@eve_scout_base_url}/signatures", params: [system_name: @system_name]) do
{:ok, %{status: 200, body: body}} ->
{:ok, body |> _get_infos()}
{:error, reason} ->
{:error, reason}
_ ->
{:error, "Request failed"}
end
end
defp _get_infos(data) do
data
|> Enum.map(&_get_info/1)
end
defp _get_info(%{
"in_system_id" => in_system_id,
"max_ship_size" => max_ship_size,
"out_system_id" => out_system_id,
"remaining_hours" => remaining_hours
}) do
%{
source_solar_system_id: in_system_id,
destination_solar_system_id: out_system_id,
mass_status: 0,
time_status: _get_time_status(remaining_hours),
ship_size_type: _get_ship_size(max_ship_size)
}
end
defp _get_ship_size("small"), do: 0
defp _get_ship_size("medium"), do: 1
defp _get_ship_size("large"), do: 1
defp _get_ship_size("xlarge"), do: 2
defp _get_ship_size(_), do: 1
defp _get_time_status(remaining_hours) when remaining_hours < 2, do: 0
defp _get_time_status(_), do: 1
defp _cache_items([]), do: WandererApp.Cache.put(@name, [])
defp _cache_items(items), do: WandererApp.Cache.put(@name, items)
end

View File

@@ -256,6 +256,11 @@ defmodule WandererAppWeb.Layouts do
Admin
</.link>
</li>
<li :if={@show_admin}>
<.link navigate="/admin/characters">
Characters
</.link>
</li>
<li :if={@show_admin}>
<.link navigate="/admin/errors">
Errors

View File

@@ -41,14 +41,18 @@
<div class="absolute rounded-m top-0 left-0 w-full h-full bg-gradient-to-b from-transparent to-black opacity-75 group-hover:opacity-25 transition-opacity duration-300">
</div>
<div class="absolute w-full bottom-2 p-4">
<% {first_part, second_part} = case String.split(post.title, ":", parts: 2) do
[first, second] -> {first, second}
[first] -> {first, nil}
end %>
<% {first_part, second_part} =
case String.split(post.title, ":", parts: 2) do
[first, second] -> {first, second}
[first] -> {first, nil}
end %>
<h3 class="!m-0 !text-s font-bold break-normal ccp-font whitespace-nowrap text-white">
{first_part}
</h3>
<p :if={second_part} class="!m-0 !text-s text-white text-ellipsis overflow-hidden whitespace-nowrap ccp-font">
<p
:if={second_part}
class="!m-0 !text-s text-white text-ellipsis overflow-hidden whitespace-nowrap ccp-font"
>
{second_part}
</p>
</div>

View File

@@ -487,10 +487,17 @@ defmodule WandererAppWeb.MapSystemAPIController do
)
def create(conn, params) do
# Support both batch format {"systems": [...], "connections": [...]}
# and single system format {"solar_system_id": ..., ...}
# Support multiple formats:
# 1. Batch format: {"systems": [...], "connections": [...]}
# 2. Wrapped batch format: {"data": {"systems": [...], "connections": [...]}}
# 3. Single system format: {"solar_system_id": ..., ...}
{systems, connections} =
cond do
Map.has_key?(params, "data") and is_map(params["data"]) ->
# Wrapped batch format - extract from data wrapper
data = params["data"]
{Map.get(data, "systems", []), Map.get(data, "connections", [])}
Map.has_key?(params, "systems") ->
# Batch format
{Map.get(params, "systems", []), Map.get(params, "connections", [])}

View File

@@ -190,9 +190,37 @@ defmodule WandererAppWeb.MapSystemSignatureAPIController do
The `character_eve_id` field is optional. If provided, it must be a valid character
that exists in the database, otherwise a 422 error will be returned. If not provided,
the signature will be associated with the map owner's character.
## Auto-add System Behavior
If the `solar_system_id` is not already on the map, it will be automatically added.
The system must be a valid EVE Online solar system ID.
## Linked System and Connection Behavior
If `linked_system_id` is provided (for wormhole signatures):
- The linked system will be automatically added to the map if not present
- A connection will be created between the source and linked systems if one doesn't exist
- If a connection already exists, its ship size will be updated based on the wormhole `type`
- The wormhole `type` (e.g., "H296", "C2", "K162") is used to determine connection ship size:
- H296 → XL/Freighter size (1B kg max mass)
- N770, D845 → Large size (375M kg max mass)
- etc.
"""
operation(:create,
summary: "Create a new signature",
description: """
Creates a new cosmic signature in the specified solar system.
**Auto-add behavior**: If the solar_system_id is not already on the map, it will be
automatically added. The system must be a valid EVE Online solar system ID.
**Linked system behavior**: If linked_system_id is provided:
- The linked system is auto-added to the map if not present
- A wormhole connection is auto-created between the systems
- The connection's ship_size_type is inferred from the wormhole type (e.g., H296 → XL)
- If the connection already exists, its ship size is updated based on the wormhole type
""",
parameters: [
map_identifier: [
in: :path,
@@ -218,7 +246,7 @@ defmodule WandererAppWeb.MapSystemSignatureAPIController do
error: %OpenApiSpex.Schema{
type: :string,
description:
"Error type (e.g., 'invalid_character', 'system_not_found', 'missing_params')"
"Error type (e.g., 'invalid_character', 'invalid_solar_system', 'missing_params')"
}
},
example: %{error: "invalid_character"}
@@ -311,4 +339,117 @@ defmodule WandererAppWeb.MapSystemSignatureAPIController do
{:error, error} -> conn |> put_status(:unprocessable_entity) |> json(%{error: error})
end
end
@doc """
Link a signature to a target system.
This creates the association between a wormhole signature and the system it leads to.
It also updates the connection's time_status and ship_size_type based on the signature data.
"""
operation(:link,
summary: "Link a signature to a target system",
description: """
Links a wormhole signature to its destination system. This operation:
- Sets the signature's linked_system_id to the target system
- Updates the signature's group to "Wormhole"
- Sets the target system's linked_sig_eve_id (if not already set)
- Copies temporary_name from signature to target system
- Updates the connection's time_status and ship_size_type from signature data
""",
parameters: [
map_identifier: [
in: :path,
description: "Map identifier (UUID or slug)",
type: :string,
required: true
],
id: [in: :path, description: "Signature UUID", type: :string, required: true]
],
request_body:
{"Link request", "application/json",
%OpenApiSpex.Schema{
type: :object,
properties: %{
solar_system_target: %OpenApiSpex.Schema{
type: :integer,
description: "Target solar system ID to link to"
}
},
required: [:solar_system_target],
example: %{solar_system_target: 31_001_922}
}},
responses: [
ok:
{"Linked signature", "application/json",
%OpenApiSpex.Schema{
type: :object,
properties: %{data: @signature_schema},
example: %{data: @signature_schema.example}
}},
unprocessable_entity:
{"Error", "application/json",
%OpenApiSpex.Schema{
type: :object,
properties: %{
error: %OpenApiSpex.Schema{
type: :string,
description: "Error type"
}
},
example: %{error: "target_system_not_found"}
}}
]
)
def link(conn, %{"id" => id} = params) do
case MapOperations.link_signature(conn, id, params) do
{:ok, sig} -> json(conn, %{data: sig})
{:error, error} -> conn |> put_status(:unprocessable_entity) |> json(%{error: error})
end
end
@doc """
Unlink a signature from its target system.
"""
operation(:unlink,
summary: "Unlink a signature from its target system",
description: "Removes the link between a signature and its destination system.",
parameters: [
map_identifier: [
in: :path,
description: "Map identifier (UUID or slug)",
type: :string,
required: true
],
id: [in: :path, description: "Signature UUID", type: :string, required: true]
],
responses: [
ok:
{"Unlinked signature", "application/json",
%OpenApiSpex.Schema{
type: :object,
properties: %{data: @signature_schema},
example: %{data: Map.put(@signature_schema.example, :linked_system_id, nil)}
}},
unprocessable_entity:
{"Error", "application/json",
%OpenApiSpex.Schema{
type: :object,
properties: %{
error: %OpenApiSpex.Schema{
type: :string,
description: "Error type"
}
},
example: %{error: "not_linked"}
}}
]
)
def unlink(conn, %{"id" => id}) do
case MapOperations.unlink_signature(conn, id) do
{:ok, sig} -> json(conn, %{data: sig})
{:error, error} -> conn |> put_status(:unprocessable_entity) |> json(%{error: error})
end
end
end

View File

@@ -0,0 +1,27 @@
defmodule WandererAppWeb.RouteBuilderController do
use WandererAppWeb, :controller
require Logger
def find_closest(conn, params) do
payload = %{
origin: Map.get(params, "origin") || Map.get(params, :origin),
flag: Map.get(params, "flag") || Map.get(params, :flag) || "shortest",
connections: Map.get(params, "connections") || Map.get(params, :connections) || [],
avoid: Map.get(params, "avoid") || Map.get(params, :avoid) || [],
count: Map.get(params, "count") || Map.get(params, :count) || 1,
type: Map.get(params, "type") || Map.get(params, :type) || "blueLoot"
}
case WandererApp.RouteBuilderClient.find_closest(payload) do
{:ok, body} ->
json(conn, body)
{:error, reason} ->
Logger.warning("[RouteBuilderController] find_closest failed: #{inspect(reason)}")
conn
|> put_status(:bad_gateway)
|> json(%{error: "route_builder_failed"})
end
end
end

View File

@@ -0,0 +1,157 @@
defmodule WandererAppWeb.AdminCharactersLive do
@moduledoc """
Admin LiveView for viewing all registered characters on the server.
"""
use WandererAppWeb, :live_view
alias Phoenix.LiveView.AsyncResult
@characters_per_page 50
@impl true
def mount(_params, %{"user_id" => user_id} = _session, socket)
when not is_nil(user_id) and is_connected?(socket) do
{:ok,
socket
|> assign(
characters: AsyncResult.loading(),
search_term: "",
show_deleted: true,
page: 1,
per_page: @characters_per_page,
sort_by: :name,
sort_dir: :asc
)
|> load_characters_async()}
end
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(
characters: AsyncResult.loading(),
search_term: "",
show_deleted: true,
page: 1,
per_page: @characters_per_page,
sort_by: :name,
sort_dir: :asc
)}
end
@impl true
def handle_params(params, _url, socket) when is_connected?(socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
@impl true
def handle_params(_params, _url, socket) do
{:noreply, socket}
end
defp apply_action(socket, :index, _params) do
socket
|> assign(:active_page, :admin)
|> assign(:page_title, "Admin - Characters")
end
defp load_characters_async(socket) do
socket
|> assign_async(:characters, fn -> load_all_characters() end)
end
defp load_all_characters do
case WandererApp.Api.Character.admin_all() do
{:ok, characters} ->
{:ok, %{characters: characters}}
_ ->
{:ok, %{characters: []}}
end
end
@impl true
def handle_event("search", %{"value" => term}, socket) do
{:noreply, socket |> assign(:search_term, term) |> assign(:page, 1)}
end
@impl true
def handle_event("toggle_deleted", _params, socket) do
{:noreply,
socket |> assign(:show_deleted, not socket.assigns.show_deleted) |> assign(:page, 1)}
end
@impl true
def handle_event("sort", %{"field" => field}, socket) do
field = String.to_existing_atom(field)
{sort_by, sort_dir} =
if socket.assigns.sort_by == field do
{field, toggle_dir(socket.assigns.sort_dir)}
else
{field, :asc}
end
{:noreply, socket |> assign(sort_by: sort_by, sort_dir: sort_dir, page: 1)}
end
@impl true
def handle_event("page", %{"page" => page}, socket) do
{:noreply, socket |> assign(:page, String.to_integer(page))}
end
@impl true
def handle_event(_event, _params, socket) do
{:noreply, socket}
end
def filter_characters(characters, search_term, show_deleted) do
characters
|> Enum.filter(fn char ->
(show_deleted or not char.deleted) and
(search_term == "" or
String.contains?(String.downcase(char.name || ""), String.downcase(search_term)) or
String.contains?(
String.downcase(char.corporation_name || ""),
String.downcase(search_term)
) or
String.contains?(
String.downcase(char.alliance_name || ""),
String.downcase(search_term)
))
end)
end
def sort_characters(characters, sort_by, sort_dir) do
Enum.sort_by(characters, &sort_value(&1, sort_by), sort_dir)
end
defp sort_value(char, :name), do: String.downcase(char.name || "")
defp sort_value(char, :corporation), do: String.downcase(char.corporation_name || "")
defp sort_value(char, :alliance), do: String.downcase(char.alliance_name || "")
defp sort_value(char, :user), do: String.downcase(user_name(char.user))
defp sort_value(char, :registered), do: char.inserted_at || ~U[1970-01-01 00:00:00Z]
defp toggle_dir(:asc), do: :desc
defp toggle_dir(:desc), do: :asc
def paginate(items, page, per_page) do
items
|> Enum.drop((page - 1) * per_page)
|> Enum.take(per_page)
end
def total_pages(items, per_page) do
max(1, ceil(length(items) / per_page))
end
def format_date(nil), do: "-"
def format_date(datetime) do
Calendar.strftime(datetime, "%Y-%m-%d %H:%M")
end
def user_name(nil), do: "Unlinked"
def user_name(%{name: name}), do: name
end

View File

@@ -0,0 +1,166 @@
<main class="w-full h-full col-span-2 lg:col-span-1 p-4 pl-20 overflow-auto">
<div class="page-content">
<div class="container-fluid px-[0.625rem]">
<!-- Header -->
<div class="grid grid-cols-1 pb-6">
<div class="md:flex items-center justify-between px-[2px]">
<h4 class="text-[18px] font-medium text-gray-800 mb-sm-0 grow dark:text-gray-100 mb-2 md:mb-0">
Admin - Characters
</h4>
<.link navigate={~p"/admin"} class="btn btn-ghost btn-sm">
<.icon name="hero-arrow-left-solid" class="w-4 h-4" /> Back to Admin
</.link>
</div>
</div>
<!-- Search and Filters -->
<div class="card dark:bg-zinc-800 dark:border-zinc-600 mb-4">
<div class="card-body flex flex-row gap-4 items-center">
<div class="flex-1">
<input
type="text"
placeholder="Search by name, corporation, or alliance..."
value={@search_term}
phx-keyup="search"
phx-debounce="300"
name="search"
class="input input-bordered w-full"
/>
</div>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
class="checkbox"
checked={@show_deleted}
phx-click="toggle_deleted"
/>
<span class="text-sm">Show deleted</span>
</label>
</div>
</div>
<!-- Characters Table -->
<div class="card dark:bg-zinc-800 dark:border-zinc-600">
<div class="card-body">
<.async_result :let={characters} assign={@characters}>
<:loading>
<div class="flex justify-center p-8">
<span class="loading loading-spinner loading-lg"></span>
</div>
</:loading>
<:failed :let={reason}>
<div class="alert alert-error">{inspect(reason)}</div>
</:failed>
<% filtered = filter_characters(characters, @search_term, @show_deleted) %>
<% sorted = sort_characters(filtered, @sort_by, @sort_dir) %>
<% paginated = paginate(sorted, @page, @per_page) %>
<div class="overflow-x-auto !max-h-[60vh] !overflow-y-auto">
<table class="table table-xs">
<thead>
<tr>
<th
:for={
{label, field} <- [
{"Character", :name},
{"Corporation", :corporation},
{"Alliance", :alliance},
{"User Account", :user},
{"Registered", :registered}
]
}
phx-click="sort"
phx-value-field={field}
class="cursor-pointer select-none hover:bg-base-200"
>
<div class="flex items-center gap-1">
{label}
<span :if={@sort_by == field}>
<.icon :if={@sort_dir == :asc} name="hero-chevron-up" class="w-3 h-3" />
<.icon
:if={@sort_dir == :desc}
name="hero-chevron-down"
class="w-3 h-3"
/>
</span>
</div>
</th>
</tr>
</thead>
<tbody id="admin-characters" phx-update="replace">
<tr :for={char <- paginated} id={"char-#{char.id}"}>
<td>
<div class="flex items-center gap-2">
<.avatar url={member_icon_url(char.eve_id)} label={char.name} />
<span class={if char.deleted, do: "line-through text-gray-500", else: ""}>
{char.name}
</span>
<span :if={char.deleted} class="badge badge-error badge-sm">
Deleted
</span>
<span :if={char.online} class="badge badge-success badge-sm">
Online
</span>
</div>
</td>
<td>
<span :if={char.corporation_name}>
{char.corporation_name}
<span :if={char.corporation_ticker} class="text-gray-400">
[{char.corporation_ticker}]
</span>
</span>
</td>
<td>
<span :if={char.alliance_name}>
{char.alliance_name}
<span :if={char.alliance_ticker} class="text-gray-400">
[{char.alliance_ticker}]
</span>
</span>
</td>
<td>{user_name(char.user)}</td>
<td>
<span class="text-sm">{format_date(char.inserted_at)}</span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div :if={length(filtered) > @per_page} class="flex items-center justify-between mt-4">
<span class="text-sm text-gray-400">
Page {@page} of {total_pages(filtered, @per_page)} ({length(filtered)} characters)
</span>
<div class="flex gap-2">
<button
phx-click="page"
phx-value-page={max(1, @page - 1)}
disabled={@page <= 1}
class={"btn btn-sm btn-ghost " <> if(@page <= 1, do: "btn-disabled", else: "")}
>
<.icon name="hero-chevron-left" class="w-4 h-4" />
</button>
<button
phx-click="page"
phx-value-page={min(total_pages(filtered, @per_page), @page + 1)}
disabled={@page >= total_pages(filtered, @per_page)}
class={"btn btn-sm btn-ghost " <> if(@page >= total_pages(filtered, @per_page), do: "btn-disabled", else: "")}
>
<.icon name="hero-chevron-right" class="w-4 h-4" />
</button>
</div>
</div>
<!-- Empty state -->
<div :if={length(filtered) == 0} class="text-center py-8 text-gray-400">
No characters found
</div>
</.async_result>
</div>
</div>
</div>
</div>
</main>

View File

@@ -24,6 +24,18 @@
</.link>
</div>
</div>
<div class="card dark:bg-zinc-800 dark:border-zinc-600">
<div class="card-body">
<span class="text-gray-400 dark:text-gray-400">Characters</span>
<.link
class="btn mt-2 w-full btn-neutral rounded-none"
navigate={~p"/admin/characters"}
>
<.icon name="hero-users-solid" class="w-6 h-6" />
<h3 class="card-title text-center text-md">View All Characters</h3>
</.link>
</div>
</div>
<div :if={@restrict_maps_creation?} class="card dark:bg-zinc-800 dark:border-zinc-600">
<div class="card-body">
<.button class="mt-2" type="button" phx-click="create-map">

View File

@@ -34,7 +34,9 @@
<.icon name="hero-gift-solid" class="w-4 h-4 text-green-400 flex-shrink-0" />
<span class="text-sm text-gray-300">
Support development by using promocode
<code class="ml-1 px-1.5 py-0.5 bg-stone-800 rounded text-green-400 text-xs font-mono">WANDERER</code>
<code class="ml-1 px-1.5 py-0.5 bg-stone-800 rounded text-green-400 text-xs font-mono">
WANDERER
</code>
<span class="ml-1">at official</span>
</span>
<a

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,
@@ -713,6 +707,7 @@ defmodule WandererAppWeb.MapCoreEventHandler do
main_character_eve_id: main_character_eve_id,
following_character_eve_id: following_character_eve_id,
is_subscription_active: is_subscription_active,
available_routes_by: WandererApp.RouteBuilderClient.available_routes_by(),
user_permissions: user_permissions,
characters: map_characters,
options: options,

View File

@@ -5,6 +5,13 @@ defmodule WandererAppWeb.MapRoutesEventHandler do
alias WandererAppWeb.{MapEventHandler, MapCoreEventHandler}
@alpha_routes_limit_by_type %{
"trade_hubs" => 5,
:trade_hubs => 5
}
@default_alpha_routes_limit 1
@paid_routes_limit 15
def handle_server_event(
%{
event: :routes,
@@ -43,6 +50,25 @@ defmodule WandererAppWeb.MapRoutesEventHandler do
}
)
def handle_server_event(
%{
event: :routes_list_by,
payload: {solar_system_id, %{routes: routes, systems_static_data: systems_static_data}}
},
socket
),
do:
socket
|> MapEventHandler.push_map_event(
"routes_list_by",
%{
solar_system_id: solar_system_id,
loading: false,
routes: routes,
systems_static_data: systems_static_data
}
)
def handle_server_event(event, socket),
do: MapCoreEventHandler.handle_server_event(event, socket)
@@ -142,6 +168,41 @@ defmodule WandererAppWeb.MapRoutesEventHandler do
end
end
def handle_ui_event(
"get_routes_by",
%{"system_id" => solar_system_id, "routes_settings" => routes_settings} = event,
%{assigns: %{map_id: map_id, map_loaded?: true}} = socket
) do
routes_type = Map.get(event, "type", "blueLoot")
security_type = Map.get(event, "securityType", "both")
is_subscription_active? = Map.get(socket.assigns, :is_subscription_active?, false)
routes_limit =
if is_subscription_active? == true do
@paid_routes_limit
else
Map.get(@alpha_routes_limit_by_type, routes_type, @default_alpha_routes_limit)
end
routes_settings =
routes_settings
|> get_routes_settings()
|> Map.put(:security_type, security_type)
Task.async(fn ->
{:ok, routes} =
WandererApp.Map.RoutesBy.find(
map_id,
solar_system_id,
routes_settings,
routes_type,
routes_limit
)
{:routes_list_by, {solar_system_id, routes}}
end)
{:noreply, socket}
end
def handle_ui_event(
"add_hub",
%{"system_id" => solar_system_id} = _event,

View File

@@ -4,6 +4,7 @@ defmodule WandererAppWeb.MapSignaturesEventHandler do
require Logger
alias WandererAppWeb.{MapEventHandler, MapCoreEventHandler}
alias WandererApp.Map.Server.SignaturesImpl
alias WandererApp.Utils.EVEUtil
def handle_server_event(
@@ -279,7 +280,8 @@ defmodule WandererAppWeb.MapSignaturesEventHandler do
linked_system_id: solar_system_target
})
if is_nil(target_system.linked_sig_eve_id) do
if is_nil(target_system.linked_sig_eve_id) or
target_system.linked_sig_eve_id == signature_eve_id do
map_id
|> WandererApp.Map.Server.update_system_linked_sig_eve_id(%{
solar_system_id: solar_system_target,
@@ -301,6 +303,37 @@ defmodule WandererAppWeb.MapSignaturesEventHandler do
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} =
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}
forward_sig ->
Logger.info(
"[link_signature_to_system] Back-link detected: " <>
"using forward sig type=#{forward_sig.type} from target system"
)
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")
else
signature_time_status
end
{forward_time_status, forward_ship_size}
end
else
{signature_time_status, signature_ship_size_type}
end
if not is_nil(signature_time_status) do
map_id
|> WandererApp.Map.Server.update_connection_time_status(%{
@@ -310,8 +343,6 @@ defmodule WandererAppWeb.MapSignaturesEventHandler do
})
end
signature_ship_size_type = EVEUtil.get_wh_size(signature.type)
if not is_nil(signature_ship_size_type) do
map_id
|> WandererApp.Map.Server.update_connection_ship_size_type(%{
@@ -403,32 +434,46 @@ defmodule WandererAppWeb.MapSignaturesEventHandler do
def handle_ui_event(event, body, socket),
do: MapCoreEventHandler.handle_ui_event(event, body, socket)
def get_system_signatures(system_id),
do:
system_id
|> WandererApp.Api.MapSystemSignature.by_system_id!()
|> Enum.map(fn %{
inserted_at: inserted_at,
updated_at: updated_at,
linked_system_id: linked_system_id
} = s ->
s
|> Map.take([
:eve_id,
:character_eve_id,
:name,
:temporary_name,
:description,
:kind,
:group,
:type,
:custom_info
])
|> Map.put(:linked_system, MapEventHandler.get_system_static_info(linked_system_id))
|> Map.put(:inserted_at, inserted_at |> Calendar.strftime("%Y/%m/%d %H:%M:%S"))
|> Map.put(:updated_at, updated_at |> Calendar.strftime("%Y/%m/%d %H:%M:%S"))
def get_system_signatures(system_id) do
signatures = system_id |> WandererApp.Api.MapSystemSignature.by_system_id!()
character_eve_ids =
signatures |> Enum.map(& &1.character_eve_id) |> Enum.reject(&is_nil/1) |> Enum.uniq()
character_names_map =
character_eve_ids
|> Enum.reduce(%{}, fn eve_id, acc ->
case WandererApp.Character.get_by_eve_id(eve_id) do
{:ok, character} -> Map.put(acc, eve_id, character.name)
_ -> acc
end
end)
signatures
|> Enum.map(fn %{
inserted_at: inserted_at,
updated_at: updated_at,
linked_system_id: linked_system_id
} = s ->
s
|> Map.take([
:eve_id,
:character_eve_id,
:name,
:temporary_name,
:description,
:kind,
:group,
:type,
:custom_info
])
|> Map.put(:character_name, Map.get(character_names_map, s.character_eve_id))
|> Map.put(:linked_system, MapEventHandler.get_system_static_info(linked_system_id))
|> Map.put(:inserted_at, inserted_at |> Calendar.strftime("%Y/%m/%d %H:%M:%S"))
|> Map.put(:updated_at, updated_at |> Calendar.strftime("%Y/%m/%d %H:%M:%S"))
end)
end
defp get_integer(nil), do: nil
defp get_integer(value) when is_binary(value), do: String.to_integer(value)
defp get_integer(value), do: value

View File

@@ -101,11 +101,13 @@ defmodule WandererAppWeb.MapEventHandler do
@map_routes_events [
:routes,
:user_routes
:user_routes,
:routes_list_by
]
@map_routes_ui_events [
"get_routes",
"get_routes_by",
"get_user_routes",
"set_autopilot_waypoint",
"add_hub",

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

@@ -126,7 +126,10 @@
<li
class={[
"p-unselectable-text",
classes("p-tabview-selected p-highlight": false)
classes(
"p-tabview-selected p-highlight":
@active_subscription_tab == "top_donators"
)
]}
role="presentation"
data-pc-name=""
@@ -140,12 +143,11 @@
aria-selected="false"
aria-disabled="false"
data-pc-section="headeraction"
phx-click="change_settings_tab"
phx-value-tab="balance"
phx-click="change_subscription_tab"
phx-value-tab="top_donators"
>
<span class="p-tabview-title" data-pc-section="headertitle">
<.icon name="hero-arrow-up-solid" class="w-4 h-4" />&nbsp;Top Donators
<span class="badge">coming soon</span>
</span>
</a>
</li>
@@ -186,6 +188,19 @@
(@user_permissions || %{}) |> Map.get(:delete_map, false) |> Kernel.not()
}
/>
<.live_component
:if={
@active_subscription_tab == "top_donators" &&
not is_nil(assigns |> Map.get(:map_id))
}
module={WandererAppWeb.Maps.MapTopDonatorsComponent}
id="map-top-donators-component"
map_id={@map_id}
notify_to={self()}
event_name="top_donators_event"
current_user={@current_user}
/>
</div>
</div>
</div>

View File

@@ -11,6 +11,7 @@ defmodule WandererAppWeb.Maps.MapBalanceComponent do
{:ok,
assign(socket,
is_topping_up?: false,
is_withdrawing?: false,
error: nil
)}
end
@@ -61,12 +62,102 @@ defmodule WandererAppWeb.Maps.MapBalanceComponent do
{"ALL", nil}
]
)
|> assign(is_topping_up?: true)}
|> assign(is_topping_up?: true, is_withdrawing?: false)}
@impl true
def handle_event("hide_topup", _, socket),
do: {:noreply, socket |> assign(is_topping_up?: false)}
@impl true
def handle_event("show_withdraw", _, socket),
do:
{:noreply,
socket
|> assign(
:withdraw_amounts,
[
{"50M", 50_000_000},
{"100M", 100_000_000},
{"250M", 250_000_000},
{"500M", 500_000_000},
{"1B", 1_000_000_000},
{"2.5B", 2_500_000_000},
{"5B", 5_000_000_000},
{"10B", 10_000_000_000},
{"ALL", nil}
]
)
|> assign(is_withdrawing?: true, is_topping_up?: false)}
@impl true
def handle_event("hide_withdraw", _, socket),
do: {:noreply, socket |> assign(is_withdrawing?: false)}
@impl true
def handle_event(
"withdraw",
%{"amount" => amount} = _event,
%{assigns: %{current_user: current_user, map: map, map_id: map_id}} = socket
) do
user =
current_user.id
|> WandererApp.User.load()
{:ok, map_balance} = WandererApp.Map.SubscriptionManager.get_balance(map)
amount =
if amount == "" do
map_balance
else
amount |> Decimal.new() |> Decimal.to_float()
end
case amount <= map_balance do
true ->
{:ok, _t} =
WandererApp.Api.MapTransaction.create(%{
map_id: map_id,
user_id: current_user.id,
amount: amount,
type: :out
})
{:ok, user_balance} =
user
|> WandererApp.User.get_balance()
{:ok, _user} =
user
|> WandererApp.Api.User.update_balance(%{
balance: (user_balance || 0.0) + amount
})
{:ok, user_balance} =
current_user.id
|> WandererApp.User.load()
|> WandererApp.User.get_balance()
{:ok, map_balance} = WandererApp.Map.SubscriptionManager.get_balance(map)
{:noreply,
socket
|> assign(
is_withdrawing?: false,
map_balance: map_balance,
user_balance: user_balance
)}
_ ->
notify_to(
socket.assigns.notify_to,
socket.assigns.event_name,
{:flash, :error, "Not enough ISK in map balance!"}
)
{:noreply, socket}
end
end
@impl true
def handle_event(
"topup",
@@ -142,7 +233,7 @@ defmodule WandererAppWeb.Maps.MapBalanceComponent do
<div class="stat">
<div class="stat-figure text-primary">
<.button
:if={not @is_topping_up?}
:if={not @is_topping_up? and not @is_withdrawing?}
class="mt-2"
type="button"
phx-click="show_topup"
@@ -150,6 +241,15 @@ defmodule WandererAppWeb.Maps.MapBalanceComponent do
>
Top Up
</.button>
<.button
:if={not @is_topping_up? and not @is_withdrawing?}
class="mt-2"
type="button"
phx-click="show_withdraw"
phx-target={@myself}
>
Withdraw
</.button>
</div>
<div class="stat-title">Map balance</div>
<div class="stat-value text-white">
@@ -210,6 +310,32 @@ defmodule WandererAppWeb.Maps.MapBalanceComponent do
</.button>
</div>
</.form>
<.form
:let={f}
:if={@is_withdrawing?}
for={@topup_form}
class="mt-2"
phx-submit="withdraw"
phx-target={@myself}
>
<.input
type="select"
field={f[:amount]}
class="select h-8 min-h-[10px] !pt-1 !pb-1 text-sm bg-neutral-900"
label="Withdraw amount"
placeholder="Select withdraw amount"
options={@withdraw_amounts}
/>
<div class="modal-action">
<.button class="mt-2" type="button" phx-click="hide_withdraw" phx-target={@myself}>
Cancel
</.button>
<.button class="mt-2" type="submit">
Withdraw
</.button>
</div>
</.form>
</div>
"""
end

View File

@@ -0,0 +1,153 @@
defmodule WandererAppWeb.Maps.MapTopDonatorsComponent do
use WandererAppWeb, :live_component
use LiveViewEvents
require Logger
alias BetterNumber, as: Number
@impl true
def mount(socket) do
{:ok,
assign(socket, top_donators: [], period: "all", image_base_url: "https://images.evetech.net")}
end
@impl true
def update(%{map_id: map_id} = assigns, socket) do
socket = handle_info_or_assign(socket, assigns)
socket =
socket
|> assign(assigns)
|> assign(map_id: map_id)
|> load_top_donators()
{:ok, socket}
end
@impl true
def handle_event("change_period", %{"period" => period}, socket) do
socket =
socket
|> assign(period: period)
|> load_top_donators()
{:noreply, socket}
end
defp load_top_donators(%{assigns: %{map_id: map_id, period: period}} = socket) do
after_date = period_to_date(period)
case WandererApp.Api.MapTransaction.top_donators(%{map_id: map_id, after: after_date}) do
{:ok, donators} ->
enriched = enrich_with_characters(donators)
assign(socket, top_donators: enriched)
{:error, reason} ->
Logger.warning("Failed to load top donators: #{inspect(reason)}")
assign(socket, top_donators: [])
end
end
defp period_to_date("all"), do: nil
defp period_to_date("30d"), do: DateTime.utc_now() |> DateTime.add(-30, :day)
defp period_to_date("7d"), do: DateTime.utc_now() |> DateTime.add(-7, :day)
defp period_to_date(_), do: nil
defp enrich_with_characters(donators) do
donators
|> Enum.map(fn %{user_id: user_id, total_amount: total_amount} ->
case WandererApp.Api.Character.active_by_user(%{user_id: user_id}) do
{:ok, [character | _]} ->
%{
character_name: character.name,
eve_id: character.eve_id,
corporation_name: character.corporation_name,
corporation_ticker: character.corporation_ticker,
total_amount: total_amount
}
_ ->
nil
end
end)
|> Enum.reject(&is_nil/1)
end
@impl true
def render(assigns) do
~H"""
<div class="map-top-donators">
<div class="flex gap-2 mb-4">
<button
type="button"
class={[
"btn btn-sm",
if(@period == "all", do: "btn-primary", else: "btn-ghost")
]}
phx-click="change_period"
phx-value-period="all"
phx-target={@myself}
>
All Time
</button>
<button
type="button"
class={[
"btn btn-sm",
if(@period == "30d", do: "btn-primary", else: "btn-ghost")
]}
phx-click="change_period"
phx-value-period="30d"
phx-target={@myself}
>
30 Days
</button>
<button
type="button"
class={[
"btn btn-sm",
if(@period == "7d", do: "btn-primary", else: "btn-ghost")
]}
phx-click="change_period"
phx-value-period="7d"
phx-target={@myself}
>
7 Days
</button>
</div>
<div :if={@top_donators == []} class="text-center text-gray-400 py-8">
No donations found for this period.
</div>
<div :if={@top_donators != []} class="space-y-2">
<div
:for={{donator, index} <- Enum.with_index(@top_donators)}
class="flex items-center gap-3 p-2 rounded-lg bg-base-200/50"
>
<span class="text-lg font-bold text-gray-400 w-6 text-right">
{index + 1}
</span>
<img
src={"#{@image_base_url}/characters/#{donator.eve_id}/portrait?size=64"}
class="w-10 h-10 rounded-full"
alt={donator.character_name}
/>
<div class="flex-1 min-w-0">
<div class="font-medium text-white truncate">
{donator.character_name}
</div>
<div :if={donator.corporation_name} class="text-xs text-gray-400 truncate">
[{donator.corporation_ticker}] {donator.corporation_name}
</div>
</div>
<div class="text-right font-mono text-sm text-green-400">
ISK {donator.total_amount |> Number.to_human(units: ["", "K", "M", "B", "T", "P"])}
</div>
</div>
</div>
</div>
"""
end
end

Some files were not shown because too many files have changed in this diff Show More