diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f41ff8a..76e7123a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,60 @@ +## [v1.62.3](https://github.com/wanderer-industries/wanderer/compare/v1.62.2...v1.62.3) (2025-05-08) + + + + +### Bug Fixes: + +* Core: Fixed map characters got untracked + +## [v1.62.2](https://github.com/wanderer-industries/wanderer/compare/v1.62.1...v1.62.2) (2025-05-05) + + + + +### Bug Fixes: + +* Core: Fixed audit export API + +## [v1.62.1](https://github.com/wanderer-industries/wanderer/compare/v1.62.0...v1.62.1) (2025-05-05) + + + + +## [v1.62.0](https://github.com/wanderer-industries/wanderer/compare/v1.61.2...v1.62.0) (2025-05-05) + + + + +### Features: + +* Core: added user routes support + +### Bug Fixes: + +* Map: Fixed link signature modal crash afrer destination system removed + +* Map: Change design for tags (#358) + +* Map: Removed paywall restriction from public routes + +* Core: Fixed issues with structures loading + +* Map: Removed unnecessary logs + +* Map: Add support user routes + +* Map: Add support for User Routes on FE side. + +* Map: Refactor Local - show ship name, change placement of ship name. Refactor On the Map - show corp and ally logo. Fixed problem with ellipsis at long character and ship names. + +* Map: Refactored routes widget. Add loader for routes. Prepared for custom hubs + +* Map: Refactor init and update of mapper + ## [v1.61.2](https://github.com/wanderer-industries/wanderer/compare/v1.61.1...v1.61.2) (2025-04-29) diff --git a/assets/js/hooks/Mapper/common-styles/fixes.scss b/assets/js/hooks/Mapper/common-styles/fixes.scss index 0bcb0a58..8a23afa6 100644 --- a/assets/js/hooks/Mapper/common-styles/fixes.scss +++ b/assets/js/hooks/Mapper/common-styles/fixes.scss @@ -99,6 +99,11 @@ .p-dropdown-item { padding: 0.25rem 0.5rem; font-size: 14px; + width: 100%; + + .p-dropdown-item-label { + width: 100%; + } } .p-dropdown-item-group { @@ -180,3 +185,16 @@ .p-datatable .p-datatable-tbody > tr.p-highlight { background: initial; } + +.suppress-menu-behaviour { + pointer-events: none; + + .p-menuitem-content { + pointer-events: initial; + background-color: initial !important; + } + .p-menuitem-content:hover { + background-color: initial !important; + } +} + diff --git a/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/ContextMenuSystem.tsx b/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/ContextMenuSystem.tsx index 0e36fece..10529547 100644 --- a/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/ContextMenuSystem.tsx +++ b/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/ContextMenuSystem.tsx @@ -6,6 +6,7 @@ import { WaypointSetContextHandler } from '@/hooks/Mapper/components/contexts/ty export interface ContextMenuSystemProps { hubs: string[]; + userHubs: string[]; contextMenuRef: RefObject; systemId: string | undefined; systems: SolarSystemRawType[]; @@ -13,6 +14,7 @@ export interface ContextMenuSystemProps { onLockToggle(): void; onOpenSettings(): void; onHubToggle(): void; + onUserHubToggle(): void; onSystemTag(val?: string): void; onSystemStatus(val: number): void; onSystemLabels(val: string): void; @@ -25,7 +27,7 @@ export const ContextMenuSystem: React.FC = ({ contextMen return ( <> - + ); }; diff --git a/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/hooks/useTagMenu/index.ts b/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/hooks/useTagMenu/index.ts index 7ed8fcad..59207777 100644 --- a/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/hooks/useTagMenu/index.ts +++ b/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/hooks/useTagMenu/index.ts @@ -1 +1 @@ -export * from './useTagMenu.ts'; +export * from './useTagMenu.tsx'; diff --git a/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/hooks/useTagMenu/useTagMenu.ts b/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/hooks/useTagMenu/useTagMenu.ts deleted file mode 100644 index 771dc163..00000000 --- a/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/hooks/useTagMenu/useTagMenu.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { MenuItem } from 'primereact/menuitem'; -import { PrimeIcons } from 'primereact/api'; -import { useCallback, useRef } from 'react'; -import { SolarSystemRawType } from '@/hooks/Mapper/types'; -import { getSystemById } from '@/hooks/Mapper/helpers'; -import clsx from 'clsx'; -import { GRADIENT_MENU_ACTIVE_CLASSES } from '@/hooks/Mapper/constants.ts'; - -const AVAILABLE_LETTERS = ['A', 'B', 'C', 'D', 'E', 'F', 'X', 'Y', 'Z']; -const AVAILABLE_NUMBERS = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']; - -export const useTagMenu = ( - systems: SolarSystemRawType[], - systemId: string | undefined, - onSystemTag: (val?: string) => void, -): (() => MenuItem) => { - const ref = useRef({ onSystemTag, systems, systemId }); - ref.current = { onSystemTag, systems, systemId }; - - return useCallback(() => { - const { onSystemTag, systemId, systems } = ref.current; - const system = systemId ? getSystemById(systems, systemId) : undefined; - - const isSelectedLetters = AVAILABLE_LETTERS.includes(system?.tag ?? ''); - const isSelectedNumbers = AVAILABLE_NUMBERS.includes(system?.tag ?? ''); - - const menuItem: MenuItem = { - label: 'Tag', - icon: PrimeIcons.HASHTAG, - className: clsx({ [GRADIENT_MENU_ACTIVE_CLASSES]: isSelectedLetters || isSelectedNumbers }), - items: [ - ...(system?.tag !== '' && system?.tag !== null - ? [ - { - label: 'Clear', - icon: PrimeIcons.BAN, - command: () => onSystemTag(), - }, - ] - : []), - { - label: 'Letter', - icon: PrimeIcons.TAGS, - className: clsx({ [GRADIENT_MENU_ACTIVE_CLASSES]: isSelectedLetters }), - items: AVAILABLE_LETTERS.map(x => ({ - label: x, - icon: PrimeIcons.TAG, - command: () => onSystemTag(x), - className: clsx({ [GRADIENT_MENU_ACTIVE_CLASSES]: system?.tag === x }), - })), - }, - { - label: 'Digit', - icon: PrimeIcons.TAGS, - className: clsx({ [GRADIENT_MENU_ACTIVE_CLASSES]: isSelectedNumbers }), - items: AVAILABLE_NUMBERS.map(x => ({ - label: x, - icon: PrimeIcons.TAG, - command: () => onSystemTag(x), - className: clsx({ [GRADIENT_MENU_ACTIVE_CLASSES]: system?.tag === x }), - })), - }, - ], - }; - - return menuItem; - }, []); -}; diff --git a/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/hooks/useTagMenu/useTagMenu.tsx b/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/hooks/useTagMenu/useTagMenu.tsx new file mode 100644 index 00000000..4670b7d3 --- /dev/null +++ b/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/hooks/useTagMenu/useTagMenu.tsx @@ -0,0 +1,95 @@ +import { MenuItem } from 'primereact/menuitem'; +import { PrimeIcons } from 'primereact/api'; +import { useCallback, useRef } from 'react'; +import { SolarSystemRawType } from '@/hooks/Mapper/types'; +import { getSystemById } from '@/hooks/Mapper/helpers'; +import clsx from 'clsx'; +import { GRADIENT_MENU_ACTIVE_CLASSES } from '@/hooks/Mapper/constants.ts'; +import { LayoutEventBlocker } from '@/hooks/Mapper/components/ui-kit'; +import { Button } from 'primereact/button'; + +const AVAILABLE_TAGS = [ + 'A', + 'B', + 'C', + 'D', + 'E', + 'F', + 'G', + 'H', + 'I', + 'X', + 'Y', + 'Z', + '0', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', +]; + +export const useTagMenu = ( + systems: SolarSystemRawType[], + systemId: string | undefined, + onSystemTag: (val?: string) => void, +): (() => MenuItem) => { + const ref = useRef({ onSystemTag, systems, systemId }); + ref.current = { onSystemTag, systems, systemId }; + + return useCallback(() => { + const { onSystemTag, systemId, systems } = ref.current; + const system = systemId ? getSystemById(systems, systemId) : undefined; + + const isSelectedTag = AVAILABLE_TAGS.includes(system?.tag ?? ''); + + const menuItem: MenuItem = { + label: 'Tag', + icon: PrimeIcons.HASHTAG, + className: clsx({ [GRADIENT_MENU_ACTIVE_CLASSES]: isSelectedTag }), + items: [ + { + label: 'Digit', + icon: PrimeIcons.TAGS, + className: '!h-[128px] suppress-menu-behaviour', + template: () => { + return ( + +
+ {AVAILABLE_TAGS.map(x => ( + + ))} + +
+
+ ); + }, + }, + ], + }; + + return menuItem; + }, []); +}; diff --git a/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/useContextMenuSystemHandlers.ts b/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/useContextMenuSystemHandlers.ts index 3c7e21a7..5df749a2 100644 --- a/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/useContextMenuSystemHandlers.ts +++ b/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/useContextMenuSystemHandlers.ts @@ -8,19 +8,25 @@ import { useDeleteSystems } from '@/hooks/Mapper/components/contexts/hooks'; interface UseContextMenuSystemHandlersProps { hubs: string[]; + userHubs: string[]; systems: SolarSystemRawType[]; outCommand: OutCommandHandler; } -export const useContextMenuSystemHandlers = ({ systems, hubs, outCommand }: UseContextMenuSystemHandlersProps) => { +export const useContextMenuSystemHandlers = ({ + systems, + hubs, + userHubs, + outCommand, +}: UseContextMenuSystemHandlersProps) => { const contextMenuRef = useRef(null); const [system, setSystem] = useState(); const { deleteSystems } = useDeleteSystems(); - const ref = useRef({ hubs, system, systems, outCommand, deleteSystems }); - ref.current = { hubs, system, systems, outCommand, deleteSystems }; + const ref = useRef({ hubs, userHubs, system, systems, outCommand, deleteSystems }); + ref.current = { hubs, userHubs, system, systems, outCommand, deleteSystems }; const open = useCallback((ev: any, systemId: string) => { setSystem(systemId); @@ -72,6 +78,21 @@ export const useContextMenuSystemHandlers = ({ systems, hubs, outCommand }: UseC setSystem(undefined); }, []); + const onUserHubToggle = useCallback(() => { + const { userHubs, system, outCommand } = ref.current; + if (!system) { + return; + } + + outCommand({ + type: !userHubs.includes(system) ? OutCommand.addUserHub : OutCommand.deleteUserHub, + data: { + system_id: system, + }, + }); + setSystem(undefined); + }, []); + const onSystemTag = useCallback((tag?: string) => { const { system, outCommand } = ref.current; if (!system) { @@ -104,7 +125,6 @@ export const useContextMenuSystemHandlers = ({ systems, hubs, outCommand }: UseC setSystem(undefined); }, []); - const onSystemStatus = useCallback((status: number) => { const { system, outCommand } = ref.current; if (!system) { @@ -177,6 +197,7 @@ export const useContextMenuSystemHandlers = ({ systems, hubs, outCommand }: UseC onDeleteSystem, onLockToggle, onHubToggle, + onUserHubToggle, onSystemTag, onSystemTemporaryName, onSystemStatus, diff --git a/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/useContextMenuSystemItems.tsx b/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/useContextMenuSystemItems.tsx index beb6105a..be7ebf7a 100644 --- a/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/useContextMenuSystemItems.tsx +++ b/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/useContextMenuSystemItems.tsx @@ -10,11 +10,13 @@ import { useMapCheckPermissions } from '@/hooks/Mapper/mapRootProvider/hooks/api import { UserPermission } from '@/hooks/Mapper/types/permissions.ts'; import { isWormholeSpace } from '@/hooks/Mapper/components/map/helpers/isWormholeSpace.ts'; import { getSystemStaticInfo } from '@/hooks/Mapper/mapRootProvider/hooks/useLoadSystemStatic'; +import { MapAddIcon, MapDeleteIcon, MapUserAddIcon, MapUserDeleteIcon } from '@/hooks/Mapper/icons'; export const useContextMenuSystemItems = ({ onDeleteSystem, onLockToggle, onHubToggle, + onUserHubToggle, onSystemTag, onSystemStatus, onSystemLabels, @@ -23,6 +25,7 @@ export const useContextMenuSystemItems = ({ onWaypointSet, systemId, hubs, + userHubs, systems, }: Omit) => { const getTags = useTagMenu(systems, systemId, onSystemTag); @@ -61,10 +64,23 @@ export const useContextMenuSystemItems = ({ ...getLabels(), ...getWaypointMenu(systemId, systemStaticInfo.system_class), { - label: !hubs.includes(systemId) ? 'Add in Routes' : 'Remove from Routes', - icon: PrimeIcons.MAP_MARKER, + label: !hubs.includes(systemId) ? 'Add Route' : 'Remove Route', + icon: !hubs.includes(systemId) ? ( + + ) : ( + + ), command: onHubToggle, }, + { + label: !userHubs.includes(systemId) ? 'Add User Route' : 'Remove User Route', + icon: !userHubs.includes(systemId) ? ( + + ) : ( + + ), + command: onUserHubToggle, + }, ...(system.locked ? canLockSystem ? [ diff --git a/assets/js/hooks/Mapper/components/contexts/ContextMenuSystemInfo/ContextMenuSystemInfo.tsx b/assets/js/hooks/Mapper/components/contexts/ContextMenuSystemInfo/ContextMenuSystemInfo.tsx index 115fd876..7a604a4c 100644 --- a/assets/js/hooks/Mapper/components/contexts/ContextMenuSystemInfo/ContextMenuSystemInfo.tsx +++ b/assets/js/hooks/Mapper/components/contexts/ContextMenuSystemInfo/ContextMenuSystemInfo.tsx @@ -11,6 +11,7 @@ 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 { isWormholeSpace } from '@/hooks/Mapper/components/map/helpers/isWormholeSpace.ts'; +import { MapAddIcon, MapDeleteIcon } from '@/hooks/Mapper/icons'; export interface ContextMenuSystemInfoProps { systemStatics: Map; @@ -69,8 +70,12 @@ export const ContextMenuSystemInfo: React.FC = ({ ...getJumpPlannerMenu(system, routes), ...getWaypointMenu(systemId, system.system_class), { - label: !hubs.includes(systemId) ? 'Add in Routes' : 'Remove from Routes', - icon: PrimeIcons.MAP_MARKER, + label: !hubs.includes(systemId) ? 'Add Route' : 'Remove Route', + icon: !hubs.includes(systemId) ? ( + + ) : ( + + ), command: onHubToggle, }, ...(!systemOnMap diff --git a/assets/js/hooks/Mapper/components/contexts/ContextMenuSystemInfo/useContextMenuSystemInfoHandlers.ts b/assets/js/hooks/Mapper/components/contexts/ContextMenuSystemInfo/useContextMenuSystemInfoHandlers.ts index 0d197791..2d167ec2 100644 --- a/assets/js/hooks/Mapper/components/contexts/ContextMenuSystemInfo/useContextMenuSystemInfoHandlers.ts +++ b/assets/js/hooks/Mapper/components/contexts/ContextMenuSystemInfo/useContextMenuSystemInfoHandlers.ts @@ -1,25 +1,25 @@ import * as React from 'react'; import { useCallback, useRef, useState } from 'react'; import { ContextMenu } from 'primereact/contextmenu'; -import { Commands, OutCommand, OutCommandHandler } from '@/hooks/Mapper/types/mapHandlers.ts'; +import { Commands, OutCommand } from '@/hooks/Mapper/types/mapHandlers.ts'; import { WaypointSetContextHandler } from '@/hooks/Mapper/components/contexts/types.ts'; import { ctxManager } from '@/hooks/Mapper/utils/contextManager.ts'; import { SolarSystemStaticInfoRaw } from '@/hooks/Mapper/types'; import { emitMapEvent } from '@/hooks/Mapper/events'; +import { useMapRootState } from '@/hooks/Mapper/mapRootProvider'; +import { useRouteProvider } from '@/hooks/Mapper/components/mapInterface/widgets/RoutesWidget/RoutesProvider.tsx'; -interface UseContextMenuSystemHandlersProps { - hubs: string[]; - outCommand: OutCommandHandler; -} +export const useContextMenuSystemInfoHandlers = () => { + const { outCommand } = useMapRootState(); + const { hubs = [], toggleHubCommand } = useRouteProvider(); -export const useContextMenuSystemInfoHandlers = ({ hubs, outCommand }: UseContextMenuSystemHandlersProps) => { const contextMenuRef = useRef(null); const [system, setSystem] = useState(); const routeRef = useRef<(SolarSystemStaticInfoRaw | undefined)[]>([]); - const ref = useRef({ hubs, system, outCommand }); - ref.current = { hubs, system, outCommand }; + const ref = useRef({ hubs, system, outCommand, toggleHubCommand }); + ref.current = { hubs, system, outCommand, toggleHubCommand }; const open = useCallback( (ev: React.SyntheticEvent, systemId: string, route: (SolarSystemStaticInfoRaw | undefined)[]) => { @@ -33,17 +33,12 @@ export const useContextMenuSystemInfoHandlers = ({ hubs, outCommand }: UseContex ); const onHubToggle = useCallback(() => { - const { hubs, system, outCommand } = ref.current; + const { system } = ref.current; if (!system) { return; } - outCommand({ - type: !hubs.includes(system) ? OutCommand.addHub : OutCommand.deleteHub, - data: { - system_id: system, - }, - }); + ref.current.toggleHubCommand(system); setSystem(undefined); }, []); @@ -59,6 +54,8 @@ export const useContextMenuSystemInfoHandlers = ({ hubs, outCommand }: UseContex system_id: solarSystemId, }, }); + + // TODO add it to some queue setTimeout(() => { emitMapEvent({ name: Commands.centerSystem, diff --git a/assets/js/hooks/Mapper/components/contexts/components/FastSystemActions/FastSystemActions.tsx b/assets/js/hooks/Mapper/components/contexts/components/FastSystemActions/FastSystemActions.tsx index b44b532c..41a09732 100644 --- a/assets/js/hooks/Mapper/components/contexts/components/FastSystemActions/FastSystemActions.tsx +++ b/assets/js/hooks/Mapper/components/contexts/components/FastSystemActions/FastSystemActions.tsx @@ -1,6 +1,6 @@ import { useCallback, useRef } from 'react'; import { LayoutEventBlocker, WdImageSize, WdImgButton } from '@/hooks/Mapper/components/ui-kit'; -import { ANOIK_ICON, DOTLAN_ICON, ZKB_ICON } from '@/hooks/Mapper/icons.ts'; +import { ANOIK_ICON, DOTLAN_ICON, ZKB_ICON } from '@/hooks/Mapper/icons'; import classes from './FastSystemActions.module.scss'; import clsx from 'clsx'; diff --git a/assets/js/hooks/Mapper/components/hooks/useSystemInfo.ts b/assets/js/hooks/Mapper/components/hooks/useSystemInfo.ts index c159b9c9..7d10fa6c 100644 --- a/assets/js/hooks/Mapper/components/hooks/useSystemInfo.ts +++ b/assets/js/hooks/Mapper/components/hooks/useSystemInfo.ts @@ -1,6 +1,6 @@ +import { getSystemById } from '@/hooks/Mapper/helpers'; import { useMapRootState } from '@/hooks/Mapper/mapRootProvider'; import { useMemo } from 'react'; -import { getSystemById } from '@/hooks/Mapper/helpers'; import { getSystemStaticInfo } from '../../mapRootProvider/hooks/useLoadSystemStatic'; interface UseSystemInfoProps { @@ -17,7 +17,7 @@ export const useSystemInfo = ({ systemId }: UseSystemInfoProps) => { const dynamicInfo = getSystemById(systems, systemId); if (!staticInfo || !dynamicInfo) { - throw new Error(`Error on getting system ${systemId}`); + return { dynamicInfo, staticInfo, leadsTo: [] }; } const leadsTo = connections diff --git a/assets/js/hooks/Mapper/components/map/MapProvider.tsx b/assets/js/hooks/Mapper/components/map/MapProvider.tsx index a9ae4555..58eabca9 100644 --- a/assets/js/hooks/Mapper/components/map/MapProvider.tsx +++ b/assets/js/hooks/Mapper/components/map/MapProvider.tsx @@ -38,6 +38,8 @@ const INITIAL_DATA: MapData = { systemSignatures: {} as Record, options: {} as Record, isSubscriptionActive: false, + mainCharacterEveId: null, + followingCharacterEveId: null, }; export interface MapContextProps { diff --git a/assets/js/hooks/Mapper/components/map/components/SolarSystemNode/SolarSystemKillsCounter.tsx b/assets/js/hooks/Mapper/components/map/components/SolarSystemNode/SolarSystemKillsCounter.tsx index 4036d53b..e20ad77e 100644 --- a/assets/js/hooks/Mapper/components/map/components/SolarSystemNode/SolarSystemKillsCounter.tsx +++ b/assets/js/hooks/Mapper/components/map/components/SolarSystemNode/SolarSystemKillsCounter.tsx @@ -26,11 +26,7 @@ export const KillsCounter = ({ children, size = TooltipSize.xs, }: KillsBookmarkTooltipProps) => { - const { - isLoading, - kills: detailedKills, - systemNameMap, - } = useKillsCounter({ + const { isLoading, kills: detailedKills } = useKillsCounter({ realSystemId: systemId, }); diff --git a/assets/js/hooks/Mapper/components/map/components/SolarSystemNode/SolarSystemLocalCounter.tsx b/assets/js/hooks/Mapper/components/map/components/SolarSystemNode/SolarSystemLocalCounter.tsx index 88ca1821..ee562acd 100644 --- a/assets/js/hooks/Mapper/components/map/components/SolarSystemNode/SolarSystemLocalCounter.tsx +++ b/assets/js/hooks/Mapper/components/map/components/SolarSystemNode/SolarSystemLocalCounter.tsx @@ -6,8 +6,8 @@ import { CharItemProps, LocalCharactersList } from '../../../mapInterface/widget import { useLocalCharactersItemTemplate } from '../../../mapInterface/widgets/LocalCharacters/hooks/useLocalCharacters'; import { useLocalCharacterWidgetSettings } from '../../../mapInterface/widgets/LocalCharacters/hooks/useLocalWidgetSettings'; import classes from './SolarSystemLocalCounter.module.scss'; -import { AvailableThemes } from '@/hooks/Mapper/mapRootProvider'; import { useTheme } from '@/hooks/Mapper/hooks/useTheme.ts'; +import { AvailableThemes } from '@/hooks/Mapper/mapRootProvider/types.ts'; interface LocalCounterProps { localCounterCharacters: Array; diff --git a/assets/js/hooks/Mapper/components/map/components/SolarSystemNode/SolarSystemNodeDefault.tsx b/assets/js/hooks/Mapper/components/map/components/SolarSystemNode/SolarSystemNodeDefault.tsx index 7437a93e..2e2a381a 100644 --- a/assets/js/hooks/Mapper/components/map/components/SolarSystemNode/SolarSystemNodeDefault.tsx +++ b/assets/js/hooks/Mapper/components/map/components/SolarSystemNode/SolarSystemNodeDefault.tsx @@ -16,22 +16,20 @@ import { LocalCounter } from './SolarSystemLocalCounter'; import { KillsCounter } from './SolarSystemKillsCounter'; import { TooltipSize } from '@/hooks/Mapper/components/ui-kit/WdTooltipWrapper/utils.ts'; import { TooltipPosition, WdTooltipWrapper } from '@/hooks/Mapper/components/ui-kit'; +import { Tag } from 'primereact/tag'; +// let render = 0; export const SolarSystemNodeDefault = memo((props: NodeProps) => { const nodeVars = useSolarSystemNode(props); const { localCounterCharacters } = useLocalCounter(nodeVars); const localKillsCount = useNodeKillsCount(nodeVars.solarSystemId, nodeVars.killsCount); + // console.log('JOipP', `render ${nodeVars.id}`, render++); + return ( <> {nodeVars.visible && (
- {nodeVars.labelCustom !== '' && ( -
- {nodeVars.labelCustom} -
- )} - {nodeVars.isShattered && (
@@ -55,6 +53,12 @@ export const SolarSystemNodeDefault = memo((props: NodeProps )} + {nodeVars.labelCustom !== '' && ( +
+ {nodeVars.labelCustom} +
+ )} + {nodeVars.labelsInfo.map(x => (
{x.shortName} @@ -86,7 +90,11 @@ export const SolarSystemNodeDefault = memo((props: NodeProps
{nodeVars.tag != null && nodeVars.tag !== '' && ( -
{nodeVars.tag}
+ )}
) => { const nodeVars = useSolarSystemNode(props); const { localCounterCharacters } = useLocalCounter(nodeVars); const localKillsCount = useNodeKillsCount(nodeVars.solarSystemId, nodeVars.killsCount); + // console.log('JOipP', `render ${nodeVars.id}`, render++); + return ( <> {nodeVars.visible && (
- {nodeVars.labelCustom !== '' && ( -
- {nodeVars.labelCustom} -
- )} - {nodeVars.isShattered && (
@@ -55,6 +52,12 @@ export const SolarSystemNodeTheme = memo((props: NodeProps) )} + {nodeVars.labelCustom !== '' && ( +
+ {nodeVars.labelCustom} +
+ )} + {nodeVars.labelsInfo.map(x => (
{x.shortName} diff --git a/assets/js/hooks/Mapper/components/map/hooks/api/useMapCommands.ts b/assets/js/hooks/Mapper/components/map/hooks/api/useMapCommands.ts index 2d8dd1c1..3a958e50 100644 --- a/assets/js/hooks/Mapper/components/map/hooks/api/useMapCommands.ts +++ b/assets/js/hooks/Mapper/components/map/hooks/api/useMapCommands.ts @@ -1,6 +1,6 @@ import { MapData, useMapState } from '@/hooks/Mapper/components/map/MapProvider.tsx'; -import { useCallback, useRef } from 'react'; import { CommandKillsUpdated, CommandMapUpdated } from '@/hooks/Mapper/types'; +import { useCallback, useRef } from 'react'; export const useMapCommands = () => { const { update } = useMapState(); @@ -8,13 +8,21 @@ export const useMapCommands = () => { const ref = useRef({ update }); ref.current = { update }; - const mapUpdated = useCallback(({ hubs }: CommandMapUpdated) => { + const mapUpdated = useCallback(({ hubs, system_signatures, kills }: CommandMapUpdated) => { const out: Partial = {}; if (hubs) { out.hubs = hubs; } + if (system_signatures) { + out.systemSignatures = system_signatures; + } + + if (kills) { + out.kills = kills.reduce((acc, x) => ({ ...acc, [x.solar_system_id]: x.kills }), {}); + } + ref.current.update(out); }, []); diff --git a/assets/js/hooks/Mapper/components/map/hooks/useNodeKillsCount.ts b/assets/js/hooks/Mapper/components/map/hooks/useNodeKillsCount.ts index 6e4c5a1d..9b4a1daa 100644 --- a/assets/js/hooks/Mapper/components/map/hooks/useNodeKillsCount.ts +++ b/assets/js/hooks/Mapper/components/map/hooks/useNodeKillsCount.ts @@ -13,28 +13,26 @@ interface MapEvent { payload?: Kill[]; } -export function useNodeKillsCount( - systemId: number | string, - initialKillsCount: number | null -): number | null { +export function useNodeKillsCount(systemId: number | string, initialKillsCount: number | null): number | null { const [killsCount, setKillsCount] = useState(initialKillsCount); useEffect(() => { setKillsCount(initialKillsCount); }, [initialKillsCount]); - const handleEvent = useCallback((event: MapEvent): boolean => { - if (event.name === Commands.killsUpdated && Array.isArray(event.payload)) { - const killForSystem = event.payload.find( - kill => kill.solar_system_id.toString() === systemId.toString() - ); - if (killForSystem && typeof killForSystem.kills === 'number') { - setKillsCount(killForSystem.kills); + const handleEvent = useCallback( + (event: MapEvent): boolean => { + if (event.name === Commands.killsUpdated && Array.isArray(event.payload)) { + const killForSystem = event.payload.find(kill => kill.solar_system_id.toString() === systemId.toString()); + if (killForSystem && typeof killForSystem.kills === 'number') { + setKillsCount(killForSystem.kills); + } + return true; } - return true; - } - return false; - }, [systemId]); + return false; + }, + [systemId], + ); useMapEventListener(handleEvent); diff --git a/assets/js/hooks/Mapper/components/map/hooks/useSolarSystemNode.ts b/assets/js/hooks/Mapper/components/map/hooks/useSolarSystemNode.ts index ca8fa274..71364312 100644 --- a/assets/js/hooks/Mapper/components/map/hooks/useSolarSystemNode.ts +++ b/assets/js/hooks/Mapper/components/map/hooks/useSolarSystemNode.ts @@ -55,7 +55,7 @@ export function useSolarSystemNode(props: NodeProps): SolarS } = data; const { - interfaceSettings, + storedSettings: { interfaceSettings }, data: { systemSignatures: mapSystemSignatures }, } = useMapRootState(); diff --git a/assets/js/hooks/Mapper/components/mapInterface/components/SystemLinkSignatureDialog/SystemLinkSignatureDialog.tsx b/assets/js/hooks/Mapper/components/mapInterface/components/SystemLinkSignatureDialog/SystemLinkSignatureDialog.tsx index e29fe153..574e9561 100644 --- a/assets/js/hooks/Mapper/components/mapInterface/components/SystemLinkSignatureDialog/SystemLinkSignatureDialog.tsx +++ b/assets/js/hooks/Mapper/components/mapInterface/components/SystemLinkSignatureDialog/SystemLinkSignatureDialog.tsx @@ -1,23 +1,23 @@ -import { useCallback, useMemo, useRef } from 'react'; import { Dialog } from 'primereact/dialog'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; -import { OutCommand } from '@/hooks/Mapper/types/mapHandlers.ts'; -import { CommandLinkSignatureToSystem, SignatureGroup, SystemSignature, TimeStatus } from '@/hooks/Mapper/types'; -import { useMapRootState } from '@/hooks/Mapper/mapRootProvider'; -import { SystemSignaturesContent } from '@/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/SystemSignaturesContent'; -import { parseSignatureCustomInfo } from '@/hooks/Mapper/helpers/parseSignatureCustomInfo'; -import { getWhSize } from '@/hooks/Mapper/helpers/getWhSize'; import { useSystemInfo } from '@/hooks/Mapper/components/hooks'; import { SOLAR_SYSTEM_CLASS_IDS, SOLAR_SYSTEM_CLASSES_TO_CLASS_GROUPS, WORMHOLES_ADDITIONAL_INFO_BY_SHORT_NAME, } from '@/hooks/Mapper/components/map/constants.ts'; -import { K162_TYPES_MAP } from '@/hooks/Mapper/constants.ts'; import { SETTINGS_KEYS, SignatureSettingsType, } from '@/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/constants.ts'; +import { SystemSignaturesContent } from '@/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/SystemSignaturesContent'; +import { K162_TYPES_MAP } from '@/hooks/Mapper/constants.ts'; +import { getWhSize } from '@/hooks/Mapper/helpers/getWhSize'; +import { parseSignatureCustomInfo } from '@/hooks/Mapper/helpers/parseSignatureCustomInfo'; +import { useMapRootState } from '@/hooks/Mapper/mapRootProvider'; +import { CommandLinkSignatureToSystem, SignatureGroup, SystemSignature, TimeStatus } from '@/hooks/Mapper/types'; +import { OutCommand } from '@/hooks/Mapper/types/mapHandlers.ts'; const K162_SIGNATURE_TYPE = WORMHOLES_ADDITIONAL_INFO_BY_SHORT_NAME['K162'].shortName; @@ -49,7 +49,9 @@ export const SystemLinkSignatureDialog = ({ data, setVisible }: SystemLinkSignat ref.current = { outCommand }; // Get system info for the target system - const { staticInfo: targetSystemInfo } = useSystemInfo({ systemId: `${data.solar_system_target}` }); + const { staticInfo: targetSystemInfo, dynamicInfo: targetSystemDynamicInfo } = useSystemInfo({ + systemId: `${data.solar_system_target}`, + }); // Get the system class group for the target system const targetSystemClassGroup = useMemo(() => { @@ -160,6 +162,12 @@ export const SystemLinkSignatureDialog = ({ data, setVisible }: SystemLinkSignat [data, setVisible, wormholes], ); + useEffect(() => { + if (!targetSystemDynamicInfo) { + handleHide(); + } + }, [targetSystemDynamicInfo]); + return ( , + content: () => , + }, + { + id: WidgetsIds.userRoutes, + position: { x: 10, y: 530 }, + size: { width: 510, height: 200 }, + zIndex: 0, + content: () => , }, { id: WidgetsIds.structures, @@ -103,6 +112,10 @@ export const WIDGETS_CHECKBOXES_PROPS: WidgetsCheckboxesType = [ id: WidgetsIds.routes, label: 'Routes', }, + { + id: WidgetsIds.userRoutes, + label: 'User Routes', + }, { id: WidgetsIds.structures, label: 'Structures', diff --git a/assets/js/hooks/Mapper/components/mapInterface/widgets/LocalCharacters/components/LocalCharactersItemTemplate/LocalCharactersItemTemplate.tsx b/assets/js/hooks/Mapper/components/mapInterface/widgets/LocalCharacters/components/LocalCharactersItemTemplate/LocalCharactersItemTemplate.tsx index 8f6f16a9..dcc466c4 100644 --- a/assets/js/hooks/Mapper/components/mapInterface/widgets/LocalCharacters/components/LocalCharactersItemTemplate/LocalCharactersItemTemplate.tsx +++ b/assets/js/hooks/Mapper/components/mapInterface/widgets/LocalCharacters/components/LocalCharactersItemTemplate/LocalCharactersItemTemplate.tsx @@ -22,7 +22,7 @@ export const LocalCharactersItemTemplate = ({ showShipName, ...options }: LocalC )} style={{ height: `${options.props.itemSize}px` }} > - +
); }; diff --git a/assets/js/hooks/Mapper/components/mapInterface/widgets/RoutesWidget/RoutesProvider.tsx b/assets/js/hooks/Mapper/components/mapInterface/widgets/RoutesWidget/RoutesProvider.tsx index 24916420..4d1775d7 100644 --- a/assets/js/hooks/Mapper/components/mapInterface/widgets/RoutesWidget/RoutesProvider.tsx +++ b/assets/js/hooks/Mapper/components/mapInterface/widgets/RoutesWidget/RoutesProvider.tsx @@ -1,79 +1,34 @@ -import React, { createContext, useContext, useEffect } from 'react'; -import { ContextStoreDataUpdate, useContextStore } from '@/hooks/Mapper/utils'; -import { SESSION_KEY } from '@/hooks/Mapper/constants.ts'; +import React, { createContext, forwardRef, useContext, useImperativeHandle, useState } from 'react'; +import { + RoutesImperativeHandle, + RoutesProviderInnerProps, + RoutesWidgetProps, +} from '@/hooks/Mapper/components/mapInterface/widgets/RoutesWidget/types.ts'; -export type RoutesType = { - path_type: 'shortest' | 'secure' | 'insecure'; - include_mass_crit: boolean; - include_eol: boolean; - include_frig: boolean; - include_cruise: boolean; - include_thera: boolean; - avoid_wormholes: boolean; - avoid_pochven: boolean; - avoid_edencom: boolean; - avoid_triglavian: boolean; - avoid: number[]; -}; - -interface MapProviderProps { +type MapProviderProps = { children: React.ReactNode; -} +} & RoutesWidgetProps; -export const DEFAULT_SETTINGS: RoutesType = { - path_type: 'shortest', - include_mass_crit: true, - include_eol: true, - include_frig: true, - include_cruise: true, - include_thera: true, - avoid_wormholes: false, - avoid_pochven: false, - avoid_edencom: false, - avoid_triglavian: false, - avoid: [], -}; - -export interface MapContextProps { - update: ContextStoreDataUpdate; - data: RoutesType; -} - -const RoutesContext = createContext({ +const RoutesContext = createContext({ update: () => {}, - data: { ...DEFAULT_SETTINGS }, + // @ts-ignore + data: {}, }); -export const RoutesProvider: React.FC = ({ children }) => { - const { update, ref } = useContextStore( - { ...DEFAULT_SETTINGS }, - { - onAfterAUpdate: values => { - localStorage.setItem(SESSION_KEY.routes, JSON.stringify(values)); - }, +export const RoutesProvider = forwardRef(({ children, ...props }, ref) => { + const [loading, setLoading] = useState(false); + + useImperativeHandle(ref, () => ({ + stopLoading() { + setLoading(false); }, - ); + })); - useEffect(() => { - const items = localStorage.getItem(SESSION_KEY.routes); - if (items) { - update(JSON.parse(items)); - } - }, [update]); - - return ( - - {children} - - ); -}; + return {children}; +}); +RoutesProvider.displayName = 'RoutesProvider'; export const useRouteProvider = () => { - const context = useContext(RoutesContext); + const context = useContext(RoutesContext); return context; }; diff --git a/assets/js/hooks/Mapper/components/mapInterface/widgets/RoutesWidget/RoutesWidget.tsx b/assets/js/hooks/Mapper/components/mapInterface/widgets/RoutesWidget/RoutesWidget.tsx index 541dc966..6ac4ea20 100644 --- a/assets/js/hooks/Mapper/components/mapInterface/widgets/RoutesWidget/RoutesWidget.tsx +++ b/assets/js/hooks/Mapper/components/mapInterface/widgets/RoutesWidget/RoutesWidget.tsx @@ -2,13 +2,14 @@ import { Widget } from '@/hooks/Mapper/components/mapInterface/components'; import { useMapRootState } from '@/hooks/Mapper/mapRootProvider'; import { LayoutEventBlocker, + LoadingWrapper, SystemViewStandalone, TooltipPosition, WdCheckbox, WdImgButton, } from '@/hooks/Mapper/components/ui-kit'; import { useLoadSystemStatic } from '@/hooks/Mapper/mapRootProvider/hooks/useLoadSystemStatic.ts'; -import { MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { forwardRef, MouseEvent, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { getSystemById } from '@/hooks/Mapper/helpers/getSystemById.ts'; import classes from './RoutesWidget.module.scss'; import { useLoadRoutes } from './hooks'; @@ -25,7 +26,10 @@ import { AddSystemDialog, SearchOnSubmitCallback, } from '@/hooks/Mapper/components/mapInterface/components/AddSystemDialog'; -import { OutCommand } from '@/hooks/Mapper/types'; +import { + RoutesImperativeHandle, + RoutesWidgetProps, +} from '@/hooks/Mapper/components/mapInterface/widgets/RoutesWidget/types.ts'; const sortByDist = (a: Route, b: Route) => { const distA = a.has_connection ? a.systems?.length || 0 : Infinity; @@ -36,19 +40,16 @@ const sortByDist = (a: Route, b: Route) => { export const RoutesWidgetContent = () => { const { - data: { selectedSystems, hubs = [], systems, routes }, - outCommand, + data: { selectedSystems, systems, isSubscriptionActive }, } = useMapRootState(); + const { hubs = [], routesList, isRestricted } = useRouteProvider(); const [systemId] = selectedSystems; const { loading } = useLoadRoutes(); const { systems: systemStatics, loadSystems, lastUpdateKey } = useLoadSystemStatic({ systems: hubs ?? [] }); - const { open, ...systemCtxProps } = useContextMenuSystemInfoHandlers({ - outCommand, - hubs, - }); + const { open, ...systemCtxProps } = useContextMenuSystemInfoHandlers(); const preparedHubs = useMemo(() => { return hubs.map(x => { @@ -61,20 +62,20 @@ export const RoutesWidgetContent = () => { const preparedRoutes: Route[] = useMemo(() => { return ( - routes?.routes + routesList?.routes .sort(sortByDist) - .filter(x => x.destination.toString() !== systemId) + // .filter(x => x.destination.toString() !== systemId) .map(route => ({ ...route, mapped_systems: route.systems?.map(solar_system_id => - routes?.systems_static_data.find( + routesList?.systems_static_data.find( system_static_data => system_static_data.solar_system_id === solar_system_id, ), ) ?? [], })) ?? [] ); - }, [routes?.routes, routes?.systems_static_data, systemId]); + }, [routesList?.routes, routesList?.systems_static_data, systemId]); const refData = useRef({ open, loadSystems, preparedRoutes }); refData.current = { open, loadSystems, preparedRoutes }; @@ -97,9 +98,13 @@ export const RoutesWidgetContent = () => { [handleClick], ); - if (loading) { + if (isRestricted && !isSubscriptionActive) { return ( -
Loading routes...
+
+ + User Routes available with 'Active' map subscription only (contact map administrators) + +
); } @@ -117,7 +122,7 @@ export const RoutesWidgetContent = () => { return ( <> - {systemId !== undefined && routes && ( +
{preparedRoutes.map(route => { const sys = preparedHubs.find(x => x.solar_system_id === route.destination)!; @@ -132,7 +137,11 @@ export const RoutesWidgetContent = () => { handleClick(e, route.destination.toString())} - tooltip={{ content: 'Click here to open system menu', position: TooltipPosition.top, offset: 10 }} + tooltip={{ + content: 'Click here to open system menu', + position: TooltipPosition.top, + offset: 10, + }} /> { ); })}
- )} +
{ ); }; -export const RoutesWidgetComp = () => { - const [routeSettingsVisible, setRouteSettingsVisible] = useState(false); - const { data, update } = useRouteProvider(); - const { - data: { hubs = [] }, - outCommand, - } = useMapRootState(); +type RoutesWidgetCompProps = { + title: ReactNode | string; +}; - const preparedHubs = useMemo(() => hubs.map(x => parseInt(x)), [hubs]); +export const RoutesWidgetComp = ({ title }: RoutesWidgetCompProps) => { + const [routeSettingsVisible, setRouteSettingsVisible] = useState(false); + const { data, update, addHubCommand } = useRouteProvider(); const isSecure = data.path_type === 'secure'; const handleSecureChange = useCallback(() => { @@ -190,24 +197,15 @@ export const RoutesWidgetComp = () => { const onAddSystem = useCallback(() => setOpenAddSystem(true), []); const handleSubmitAddSystem: SearchOnSubmitCallback = useCallback( - async item => { - if (preparedHubs.includes(item.value)) { - return; - } - - await outCommand({ - type: OutCommand.addHub, - data: { system_id: item.value }, - }); - }, - [hubs, outCommand], + async item => addHubCommand(item.value.toString()), + [addHubCommand], ); return ( - Routes + {title} { className={PrimeIcons.SLIDERS_H} onClick={() => setRouteSettingsVisible(true)} tooltip={{ + position: TooltipPosition.top, content: 'Click here to open Routes settings', }} /> @@ -251,10 +250,13 @@ export const RoutesWidgetComp = () => { ); }; -export const RoutesWidget = () => { - return ( - - - - ); -}; +export const RoutesWidget = forwardRef( + ({ title, ...props }, ref) => { + return ( + + + + ); + }, +); +RoutesWidget.displayName = 'RoutesWidget'; diff --git a/assets/js/hooks/Mapper/components/mapInterface/widgets/RoutesWidget/hooks/useLoadRoutes.ts b/assets/js/hooks/Mapper/components/mapInterface/widgets/RoutesWidget/hooks/useLoadRoutes.ts index 95a8b992..85b4629d 100644 --- a/assets/js/hooks/Mapper/components/mapInterface/widgets/RoutesWidget/hooks/useLoadRoutes.ts +++ b/assets/js/hooks/Mapper/components/mapInterface/widgets/RoutesWidget/hooks/useLoadRoutes.ts @@ -1,10 +1,7 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { OutCommand } from '@/hooks/Mapper/types'; +import { useCallback, useEffect, useRef } from 'react'; import { useMapRootState } from '@/hooks/Mapper/mapRootProvider'; -import { - RoutesType, - useRouteProvider, -} from '@/hooks/Mapper/components/mapInterface/widgets/RoutesWidget/RoutesProvider.tsx'; +import { useRouteProvider } from '@/hooks/Mapper/components/mapInterface/widgets/RoutesWidget/RoutesProvider.tsx'; +import { RoutesType } from '@/hooks/Mapper/mapRootProvider/types.ts'; function usePrevious(value: T): T | undefined { const ref = useRef(); @@ -17,12 +14,10 @@ function usePrevious(value: T): T | undefined { } export const useLoadRoutes = () => { - const [loading, setLoading] = useState(false); - const { data: routesSettings } = useRouteProvider(); + const { data: routesSettings, loadRoutesCommand, hubs, routesList, loading, setLoading } = useRouteProvider(); const { - outCommand, - data: { selectedSystems, hubs, systems, connections }, + data: { selectedSystems, systems, connections }, } = useMapRootState(); const prevSys = usePrevious(systems); @@ -31,17 +26,16 @@ export const useLoadRoutes = () => { const loadRoutes = useCallback( (systemId: string, routesSettings: RoutesType) => { - outCommand({ - type: OutCommand.getRoutes, - data: { - system_id: systemId, - routes_settings: routesSettings, - }, - }); + loadRoutesCommand(systemId, routesSettings); + setLoading(true); }, - [outCommand], + [loadRoutesCommand], ); + useEffect(() => { + setLoading(false); + }, [routesList]); + useEffect(() => { if (selectedSystems.length !== 1) { return; diff --git a/assets/js/hooks/Mapper/components/mapInterface/widgets/RoutesWidget/types.ts b/assets/js/hooks/Mapper/components/mapInterface/widgets/RoutesWidget/types.ts new file mode 100644 index 00000000..590e63de --- /dev/null +++ b/assets/js/hooks/Mapper/components/mapInterface/widgets/RoutesWidget/types.ts @@ -0,0 +1,27 @@ +import { RoutesType } from '@/hooks/Mapper/mapRootProvider/types.ts'; +import { RoutesList } from '@/hooks/Mapper/types/routes.ts'; + +export type LoadRoutesCommand = (systemId: string, routesSettings: RoutesType) => Promise; +export type AddHubCommand = (systemId: string) => Promise; +export type ToggleHubCommand = (systemId: string) => Promise; + +export type RoutesWidgetProps = { + data: RoutesType; + update: (d: RoutesType) => void; + hubs: string[]; + routesList: RoutesList | undefined; + + loadRoutesCommand: LoadRoutesCommand; + addHubCommand: AddHubCommand; + toggleHubCommand: ToggleHubCommand; + isRestricted?: boolean; +}; + +export type RoutesProviderInnerProps = RoutesWidgetProps & { + loading: boolean; + setLoading(loading: boolean): void; +}; + +export type RoutesImperativeHandle = { + stopLoading: () => void; +}; diff --git a/assets/js/hooks/Mapper/components/mapInterface/widgets/WRoutesPublic/WRoutesPublic.tsx b/assets/js/hooks/Mapper/components/mapInterface/widgets/WRoutesPublic/WRoutesPublic.tsx new file mode 100644 index 00000000..7aeaf957 --- /dev/null +++ b/assets/js/hooks/Mapper/components/mapInterface/widgets/WRoutesPublic/WRoutesPublic.tsx @@ -0,0 +1,83 @@ +import { Commands, OutCommand } from '@/hooks/Mapper/types'; +import { useMapRootState } from '@/hooks/Mapper/mapRootProvider'; +import { + AddHubCommand, + LoadRoutesCommand, + RoutesImperativeHandle, +} from '@/hooks/Mapper/components/mapInterface/widgets/RoutesWidget/types.ts'; +import { useCallback, useRef } from 'react'; +import { RoutesWidget } from '@/hooks/Mapper/components/mapInterface/widgets'; +import { useMapEventListener } from '@/hooks/Mapper/events'; + +export const WRoutesPublic = () => { + const { + outCommand, + storedSettings: { settingsRoutes, settingsRoutesUpdate }, + data: { hubs, routes }, + } = useMapRootState(); + + const ref = useRef(null); + + const loadRoutesCommand: LoadRoutesCommand = useCallback( + async (systemId, routesSettings) => { + outCommand({ + type: OutCommand.getRoutes, + data: { + system_id: systemId, + routes_settings: routesSettings, + }, + }); + }, + [outCommand], + ); + + const addHubCommand: AddHubCommand = useCallback( + async systemId => { + if (hubs.includes(systemId)) { + return; + } + + await outCommand({ + type: OutCommand.addHub, + data: { system_id: systemId }, + }); + }, + [hubs, outCommand], + ); + + const toggleHubCommand: AddHubCommand = useCallback( + async (systemId: string | undefined) => { + if (!systemId) { + return; + } + + outCommand({ + type: !hubs.includes(systemId) ? OutCommand.addHub : OutCommand.deleteHub, + data: { + system_id: systemId, + }, + }); + }, + [hubs, outCommand], + ); + + useMapEventListener(event => { + if (event.name === Commands.routes) { + ref.current?.stopLoading(); + } + }); + + return ( + + ); +}; diff --git a/assets/js/hooks/Mapper/components/mapInterface/widgets/WRoutesPublic/index.ts b/assets/js/hooks/Mapper/components/mapInterface/widgets/WRoutesPublic/index.ts new file mode 100644 index 00000000..5002c8e4 --- /dev/null +++ b/assets/js/hooks/Mapper/components/mapInterface/widgets/WRoutesPublic/index.ts @@ -0,0 +1 @@ +export * from './WRoutesPublic'; diff --git a/assets/js/hooks/Mapper/components/mapInterface/widgets/WRoutesUser/WRoutesUser.tsx b/assets/js/hooks/Mapper/components/mapInterface/widgets/WRoutesUser/WRoutesUser.tsx new file mode 100644 index 00000000..4bfc2dff --- /dev/null +++ b/assets/js/hooks/Mapper/components/mapInterface/widgets/WRoutesUser/WRoutesUser.tsx @@ -0,0 +1,85 @@ +import { Commands, OutCommand } from '@/hooks/Mapper/types'; +import { useMapRootState } from '@/hooks/Mapper/mapRootProvider'; +import { + AddHubCommand, + LoadRoutesCommand, + RoutesImperativeHandle, +} from '@/hooks/Mapper/components/mapInterface/widgets/RoutesWidget/types.ts'; +import { useCallback, useRef } from 'react'; +import { RoutesWidget } from '@/hooks/Mapper/components/mapInterface/widgets'; +import { useMapEventListener } from '@/hooks/Mapper/events'; + +export const WRoutesUser = () => { + const { + outCommand, + storedSettings: { settingsRoutes, settingsRoutesUpdate }, + data: { userHubs, userRoutes }, + } = useMapRootState(); + + const ref = useRef(null); + + const loadRoutesCommand: LoadRoutesCommand = useCallback( + async (systemId, routesSettings) => { + outCommand({ + type: OutCommand.getUserRoutes, + data: { + system_id: systemId, + routes_settings: routesSettings, + }, + }); + }, + [outCommand], + ); + + const addHubCommand: AddHubCommand = useCallback( + async systemId => { + if (userHubs.includes(systemId)) { + return; + } + + await outCommand({ + type: OutCommand.addUserHub, + data: { system_id: systemId }, + }); + }, + [userHubs, outCommand], + ); + + const toggleHubCommand: AddHubCommand = useCallback( + async (systemId: string | undefined) => { + if (!systemId) { + return; + } + + outCommand({ + type: !userHubs.includes(systemId) ? OutCommand.addUserHub : OutCommand.deleteUserHub, + data: { + system_id: systemId, + }, + }); + }, + [userHubs, outCommand], + ); + + useMapEventListener(event => { + if (event.name === Commands.userRoutes) { + ref.current?.stopLoading(); + } + return true; + }); + + return ( + + ); +}; diff --git a/assets/js/hooks/Mapper/components/mapInterface/widgets/WRoutesUser/index.ts b/assets/js/hooks/Mapper/components/mapInterface/widgets/WRoutesUser/index.ts new file mode 100644 index 00000000..4cba6c89 --- /dev/null +++ b/assets/js/hooks/Mapper/components/mapInterface/widgets/WRoutesUser/index.ts @@ -0,0 +1 @@ +export * from './WRoutesUser'; diff --git a/assets/js/hooks/Mapper/components/mapInterface/widgets/index.ts b/assets/js/hooks/Mapper/components/mapInterface/widgets/index.ts index 8a29e136..e525ccb1 100644 --- a/assets/js/hooks/Mapper/components/mapInterface/widgets/index.ts +++ b/assets/js/hooks/Mapper/components/mapInterface/widgets/index.ts @@ -4,3 +4,6 @@ export * from './RoutesWidget'; export * from './SystemSignatures'; export * from './SystemStructures'; export * from './WSystemKills'; +export * from './WRoutesUser'; +export * from './WRoutesPublic'; +export * from './CommentsWidget'; diff --git a/assets/js/hooks/Mapper/components/mapRootContent/MapRootContent.tsx b/assets/js/hooks/Mapper/components/mapRootContent/MapRootContent.tsx index 59069804..629db822 100644 --- a/assets/js/hooks/Mapper/components/mapRootContent/MapRootContent.tsx +++ b/assets/js/hooks/Mapper/components/mapRootContent/MapRootContent.tsx @@ -18,7 +18,10 @@ export interface MapRootContentProps {} // eslint-disable-next-line no-empty-pattern export const MapRootContent = ({}: MapRootContentProps) => { - const { interfaceSettings, data } = useMapRootState(); + const { + storedSettings: { interfaceSettings }, + data, + } = useMapRootState(); const { isShowMenu } = interfaceSettings; const { showCharacterActivity } = data; const { handleHideCharacterActivity } = useCharacterActivityHandlers(); diff --git a/assets/js/hooks/Mapper/components/mapRootContent/components/MapSettings/MapSettingsProvider.tsx b/assets/js/hooks/Mapper/components/mapRootContent/components/MapSettings/MapSettingsProvider.tsx index d59d0293..89460db7 100644 --- a/assets/js/hooks/Mapper/components/mapRootContent/components/MapSettings/MapSettingsProvider.tsx +++ b/assets/js/hooks/Mapper/components/mapRootContent/components/MapSettings/MapSettingsProvider.tsx @@ -31,13 +31,16 @@ type MapSettingsContextType = { const MapSettingsContext = createContext(undefined); export const MapSettingsProvider = ({ children }: { children: ReactNode }) => { - const { outCommand, interfaceSettings, setInterfaceSettings } = useMapRootState(); + const { + outCommand, + storedSettings: { interfaceSettings, setInterfaceSettings }, + } = useMapRootState(); const [userRemoteSettings, setUserRemoteSettings] = useState({ ...DEFAULT_REMOTE_SETTINGS, }); - const mergedSettings = useMemo(() => { + const mergedSettings: UserSettings = useMemo(() => { return { ...userRemoteSettings, ...interfaceSettings, @@ -75,7 +78,7 @@ export const MapSettingsProvider = ({ children }: { children: ReactNode }) => { if (item.type === 'checkbox') { return ( handleSettingChange(item.prop, checked)} @@ -85,7 +88,7 @@ export const MapSettingsProvider = ({ children }: { children: ReactNode }) => { if (item.type === 'dropdown' && item.options) { return ( -
+
- +
); }; @@ -48,6 +50,8 @@ export const OnTheMap = ({ show, onHide }: OnTheMapProps) => { data: { characters, userCharacters }, } = useMapRootState(); + const [searchVal, setSearchVal] = useState(''); + const [settings, setSettings] = useLocalStorageState('window:onTheMap:settings', { defaultValue: STORED_DEFAULT_VALUES, }); @@ -61,13 +65,54 @@ export const OnTheMap = ({ show, onHide }: OnTheMapProps) => { ); const sorted = useMemo(() => { - const out = characters.map(x => ({ ...x, isOwn: userCharacters.includes(x.eve_id) })).sort(sortCharacters); + let out = characters.map(x => ({ ...x, isOwn: userCharacters.includes(x.eve_id) })).sort(sortCharacters); + + if (searchVal !== '') { + out = out.filter(x => { + const normalized = searchVal.toLowerCase(); + + if (x.name.toLowerCase().includes(normalized)) { + return true; + } + + if (x.corporation_name.toLowerCase().includes(normalized)) { + return true; + } + + if (x.alliance_name?.toLowerCase().includes(normalized)) { + return true; + } + + if (x.corporation_ticker.toLowerCase().includes(normalized)) { + return true; + } + + if (x.alliance_ticker?.toLowerCase().includes(normalized)) { + return true; + } + + if (x.ship?.ship_name?.toLowerCase().includes(normalized)) { + return true; + } + + if (x.ship?.ship_type_info.name?.toLowerCase().includes(normalized)) { + return true; + } + + if (x.ship?.ship_type_info.group_name?.toLowerCase().includes(normalized)) { + return true; + } + + return false; + }); + } + if (showOffline && !settings.hideOffline) { return out; } return out.filter(x => x.online); - }, [showOffline, characters, settings.hideOffline, userCharacters]); + }, [showOffline, searchVal, characters, settings.hideOffline, userCharacters]); return ( { icons={<>} >
-
+
+ + {searchVal.length > 0 && ( + setSearchVal('')} + /> + )} + setSearchVal(e.target.value)} + /> + + {showOffline && ( { - const { interfaceSettings, setInterfaceSettings } = useMapRootState(); + const { + storedSettings: { interfaceSettings, setInterfaceSettings }, + } = useMapRootState(); const canTrackCharacters = useMapCheckPermissions([UserPermission.TRACK_CHARACTER]); diff --git a/assets/js/hooks/Mapper/components/mapRootContent/components/TrackingDialog/TrackingCharactersList.tsx b/assets/js/hooks/Mapper/components/mapRootContent/components/TrackingDialog/TrackingCharactersList.tsx index 0d05917c..ee5faf42 100644 --- a/assets/js/hooks/Mapper/components/mapRootContent/components/TrackingDialog/TrackingCharactersList.tsx +++ b/assets/js/hooks/Mapper/components/mapRootContent/components/TrackingDialog/TrackingCharactersList.tsx @@ -44,7 +44,7 @@ export const TrackingCharactersList = () => { bodyClassName="text-ellipsis overflow-hidden whitespace-nowrap" headerClassName="[&_div]:ml-2" body={row => { - return ; + return ; }} /> diff --git a/assets/js/hooks/Mapper/components/mapRootContent/components/TrackingDialog/TrackingSettings.tsx b/assets/js/hooks/Mapper/components/mapRootContent/components/TrackingDialog/TrackingSettings.tsx index f672903a..7965980f 100644 --- a/assets/js/hooks/Mapper/components/mapRootContent/components/TrackingDialog/TrackingSettings.tsx +++ b/assets/js/hooks/Mapper/components/mapRootContent/components/TrackingDialog/TrackingSettings.tsx @@ -10,8 +10,8 @@ const renderValCharacterTemplate = (row: TrackingCharacter | undefined) => { } return ( -
- +
+
); }; @@ -21,7 +21,11 @@ const renderCharacterTemplate = (row: TrackingCharacter | undefined) => { return
Character is not selected
; } - return ; + return ( +
+ +
+ ); }; export const TrackingSettings = () => { diff --git a/assets/js/hooks/Mapper/components/mapRootContent/hooks/useCharacterActivityHandlers.ts b/assets/js/hooks/Mapper/components/mapRootContent/hooks/useCharacterActivityHandlers.ts index cb46040a..b861dc0a 100644 --- a/assets/js/hooks/Mapper/components/mapRootContent/hooks/useCharacterActivityHandlers.ts +++ b/assets/js/hooks/Mapper/components/mapRootContent/hooks/useCharacterActivityHandlers.ts @@ -1,7 +1,7 @@ import { useCallback } from 'react'; import { useMapRootState } from '@/hooks/Mapper/mapRootProvider'; import { OutCommand } from '@/hooks/Mapper/types/mapHandlers'; -import type { ActivitySummary } from '@/hooks/Mapper/components/mapRootContent/components/CharacterActivity/CharacterActivity'; +import { ActivitySummary } from '@/hooks/Mapper/types'; /** * Hook for character activity related handlers diff --git a/assets/js/hooks/Mapper/components/mapWrapper/MapWrapper.tsx b/assets/js/hooks/Mapper/components/mapWrapper/MapWrapper.tsx index 0f8d688b..0e821831 100644 --- a/assets/js/hooks/Mapper/components/mapWrapper/MapWrapper.tsx +++ b/assets/js/hooks/Mapper/components/mapWrapper/MapWrapper.tsx @@ -20,7 +20,6 @@ import { Node, useReactFlow, XYPosition } from 'reactflow'; import { useCommandsSystems } from '@/hooks/Mapper/mapRootProvider/hooks/api'; import { emitMapEvent, useMapEventListener } from '@/hooks/Mapper/events'; -import { STORED_INTERFACE_DEFAULT_VALUES } from '@/hooks/Mapper/mapRootProvider/MapRootProvider'; import { useDeleteSystems } from '@/hooks/Mapper/components/contexts/hooks'; import { useCommonMapEventProcessor } from '@/hooks/Mapper/components/mapWrapper/hooks/useCommonMapEventProcessor.ts'; import { @@ -28,30 +27,34 @@ import { SearchOnSubmitCallback, } from '@/hooks/Mapper/components/mapInterface/components/AddSystemDialog'; import { useHotkey } from '../../hooks/useHotkey'; +import { STORED_INTERFACE_DEFAULT_VALUES } from '@/hooks/Mapper/mapRootProvider/constants.ts'; // TODO: INFO - this component needs for abstract work with Map instance export const MapWrapper = () => { const { update, outCommand, - data: { selectedConnections, selectedSystems, hubs, systems, linkSignatureToSystem, systemSignatures }, - interfaceSettings: { - isShowMenu, - isShowMinimap = STORED_INTERFACE_DEFAULT_VALUES.isShowMinimap, - isShowKSpace, - isThickConnections, - isShowBackgroundPattern, - isShowUnsplashedSignatures, - isSoftBackground, - theme, - }, + data: { selectedConnections, selectedSystems, hubs, userHubs, systems, linkSignatureToSystem, systemSignatures }, + storedSettings: { interfaceSettings }, } = useMapRootState(); + + const { + isShowMenu, + isShowMinimap = STORED_INTERFACE_DEFAULT_VALUES.isShowMinimap, + isShowKSpace, + isThickConnections, + isShowBackgroundPattern, + isShowUnsplashedSignatures, + isSoftBackground, + theme, + } = interfaceSettings; + const { deleteSystems } = useDeleteSystems(); const { mapRef, runCommand } = useCommonMapEventProcessor(); const { getNodes } = useReactFlow(); const { updateLinkSignatureToSystem } = useCommandsSystems(); - const { open, ...systemContextProps } = useContextMenuSystemHandlers({ systems, hubs, outCommand }); + const { open, ...systemContextProps } = useContextMenuSystemHandlers({ systems, hubs, userHubs, outCommand }); const { handleSystemMultipleContext, ...systemMultipleCtxProps } = useContextMenuSystemMultipleHandlers(); const [openSettings, setOpenSettings] = useState(null); @@ -107,17 +110,20 @@ export const MapWrapper = () => { [outCommand], ); - const handleSystemContextMenu = useCallback((ev: any, systemId: string) => { - const { selectedSystems, systems } = ref.current; - if (selectedSystems.length > 1) { - const systemsInfo: Node[] = selectedSystems.map(x => ({ data: getSystemById(systems, x), id: x }) as Node); + const handleSystemContextMenu = useCallback( + (ev: any, systemId: string) => { + const { selectedSystems, systems } = ref.current; + if (selectedSystems.length > 1) { + const systemsInfo: Node[] = selectedSystems.map(x => ({ data: getSystemById(systems, x), id: x }) as Node); - handleSystemMultipleContext(ev, systemsInfo); - return; - } + handleSystemMultipleContext(ev, systemsInfo); + return; + } - open(ev, systemId); - }, []); + open(ev, systemId); + }, + [handleSystemMultipleContext, open], + ); const handleConnectionDbClick = useCallback((e: SolarSystemConnection) => setSelectedConnection(e), []); @@ -215,6 +221,7 @@ export const MapWrapper = () => { { systemContextProps.systemId && setOpenSettings(systemContextProps.systemId); diff --git a/assets/js/hooks/Mapper/components/ui-kit/CharacterCard/CharacterCard.tsx b/assets/js/hooks/Mapper/components/ui-kit/CharacterCard/CharacterCard.tsx index 1ace478a..d50cca51 100644 --- a/assets/js/hooks/Mapper/components/ui-kit/CharacterCard/CharacterCard.tsx +++ b/assets/js/hooks/Mapper/components/ui-kit/CharacterCard/CharacterCard.tsx @@ -4,15 +4,24 @@ import { SystemView } from '@/hooks/Mapper/components/ui-kit/SystemView'; import { CharacterTypeRaw, WithIsOwnCharacter } from '@/hooks/Mapper/types'; import { Commands } from '@/hooks/Mapper/types/mapHandlers'; import { emitMapEvent } from '@/hooks/Mapper/events'; -import { CharacterPortrait, CharacterPortraitSize } from '@/hooks/Mapper/components/ui-kit'; +import { + TooltipPosition, + WdEveEntityPortrait, + WdEveEntityPortraitSize, + WdEveEntityPortraitType, + WdTooltipWrapper, +} from '@/hooks/Mapper/components/ui-kit'; import { isDocked } from '@/hooks/Mapper/helpers/isDocked.ts'; import classes from './CharacterCard.module.scss'; type CharacterCardProps = { compact?: boolean; showSystem?: boolean; + showTicker?: boolean; showShipName?: boolean; useSystemsCache?: boolean; + showCorporationLogo?: boolean; + showAllyLogo?: boolean; } & CharacterTypeRaw & WithIsOwnCharacter; @@ -29,6 +38,9 @@ export const CharacterCard = ({ isOwn, showSystem, showShipName, + showCorporationLogo, + showAllyLogo, + showTicker, useSystemsCache, ...char }: CharacterCardProps) => { @@ -46,26 +58,80 @@ export const CharacterCard = ({ if (compact) { return ( -
+
- + + + {showCorporationLogo && ( + + + + )} + + {showAllyLogo && char.alliance_id && ( + + + + )} + {isDocked(char.location) && } -
-
- {char.name}{' '} - - {!locationShown && showShipName && shipNameText ? `- ${shipNameText}` : `[${tickerText}]`} +
+
+ + {char.name} + {showTicker && [{tickerText}]}
+ {shipType && ( -
- {shipType} -
+ <> + {!showShipName && ( +
+ {shipType} +
+ )} + {showShipName && ( +
+
+
+ {shipNameText} +
+
+
+ )} + {char.ship && ( + + + + )} + )}
@@ -75,11 +141,41 @@ export const CharacterCard = ({ return (
- -
-
- {char.name}{' '} - [{tickerText}] +
+ + + {showCorporationLogo && ( + + + + )} + + {showAllyLogo && char.alliance_id && ( + + + + )} +
+ +
+
+ + {char.name} + + {showTicker && [{tickerText}]}
{locationShown ? (
@@ -97,15 +193,30 @@ export const CharacterCard = ({ )}
{shipType && ( -
-
- {shipType} + <> +
+
+ {shipType} +
+
+ {shipNameText} +
-
+ + {char.ship && ( + + )} + )}
diff --git a/assets/js/hooks/Mapper/components/ui-kit/CharacterPortrait/CharacterPortrait.tsx b/assets/js/hooks/Mapper/components/ui-kit/CharacterPortrait/CharacterPortrait.tsx deleted file mode 100644 index 6bc8862a..00000000 --- a/assets/js/hooks/Mapper/components/ui-kit/CharacterPortrait/CharacterPortrait.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import clsx from 'clsx'; -import { WithClassName } from '@/hooks/Mapper/types/common.ts'; - -export enum CharacterPortraitSize { - default, - w18, - w33, -} - -// TODO IF YOU NEED ANOTHER ONE SIZE PLEASE ADD IT HERE and IN CharacterPortraitSize -const getSize = (size: CharacterPortraitSize) => { - switch (size) { - case CharacterPortraitSize.w18: - return 'min-w-[18px] min-h-[18px] w-[18px] h-[18px]'; - case CharacterPortraitSize.w33: - return 'min-w-[33px] min-h-[33px] w-[33px] h-[33px]'; - default: - return ''; - } -}; - -export type CharacterPortraitProps = { - characterEveId: string | undefined; - size?: CharacterPortraitSize; -} & WithClassName; - -export const CharacterPortrait = ({ - characterEveId, - size = CharacterPortraitSize.default, - className, -}: CharacterPortraitProps) => { - if (characterEveId == null) { - return null; - } - - return ( - - ); -}; diff --git a/assets/js/hooks/Mapper/components/ui-kit/CharacterPortrait/index.ts b/assets/js/hooks/Mapper/components/ui-kit/CharacterPortrait/index.ts deleted file mode 100644 index edee3e08..00000000 --- a/assets/js/hooks/Mapper/components/ui-kit/CharacterPortrait/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './CharacterPortrait'; diff --git a/assets/js/hooks/Mapper/components/ui-kit/LoadingWrapper.tsx b/assets/js/hooks/Mapper/components/ui-kit/LoadingWrapper.tsx new file mode 100644 index 00000000..b6ca099c --- /dev/null +++ b/assets/js/hooks/Mapper/components/ui-kit/LoadingWrapper.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { ProgressSpinner } from 'primereact/progressspinner'; + +type LoadingWrapperProps = { + loading?: boolean; + children: React.ReactNode; +}; + +export const LoadingWrapper: React.FC = ({ loading, children }) => { + return ( +
+ {children} + {loading && ( +
+ +
+ )} +
+ ); +}; diff --git a/assets/js/hooks/Mapper/components/ui-kit/SvgIconWrapper.tsx b/assets/js/hooks/Mapper/components/ui-kit/SvgIconWrapper.tsx new file mode 100644 index 00000000..175fe1c7 --- /dev/null +++ b/assets/js/hooks/Mapper/components/ui-kit/SvgIconWrapper.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import clsx from 'clsx'; + +export type SvgIconProps = React.SVGAttributes & { + width?: number; + height?: number; + className?: string; +}; + +export const SvgIconWrapper = ({ + width = 24, + height = 24, + children, + className, + ...props +}: SvgIconProps & { children: React.ReactNode }) => { + return ( + + {children} + + ); +}; diff --git a/assets/js/hooks/Mapper/components/ui-kit/WdEveEntityPortrait/WdEveEntityPortrait.tsx b/assets/js/hooks/Mapper/components/ui-kit/WdEveEntityPortrait/WdEveEntityPortrait.tsx new file mode 100644 index 00000000..06f172f0 --- /dev/null +++ b/assets/js/hooks/Mapper/components/ui-kit/WdEveEntityPortrait/WdEveEntityPortrait.tsx @@ -0,0 +1,71 @@ +import clsx from 'clsx'; +import { WithClassName } from '@/hooks/Mapper/types/common.ts'; + +export enum WdEveEntityPortraitType { + character, + corporation, + alliance, + ship, +} + +export enum WdEveEntityPortraitSize { + default, + w18, + w33, +} + +export const getLogo = (type: WdEveEntityPortraitType, eveId: string | number) => { + switch (type) { + case WdEveEntityPortraitType.alliance: + return `url(https://images.evetech.net/alliances/${eveId}/logo?size=64)`; + case WdEveEntityPortraitType.corporation: + return `url(https://images.evetech.net/corporations/${eveId}/logo?size=64)`; + case WdEveEntityPortraitType.character: + return `url(https://images.evetech.net/characters/${eveId}/portrait)`; + case WdEveEntityPortraitType.ship: + return `url(https://images.evetech.net/types/${eveId}/icon)`; + } + + return ''; +}; + +// TODO IF YOU NEED ANOTHER ONE SIZE PLEASE ADD IT HERE and IN WdEveEntityPortraitSize +const getSize = (size: WdEveEntityPortraitSize) => { + switch (size) { + case WdEveEntityPortraitSize.w18: + return 'min-w-[18px] min-h-[18px] w-[18px] h-[18px]'; + case WdEveEntityPortraitSize.w33: + return 'min-w-[33px] min-h-[33px] w-[33px] h-[33px]'; + default: + return ''; + } +}; + +export type WdEveEntityPortraitProps = { + eveId: string | undefined; + type?: WdEveEntityPortraitType; + size?: WdEveEntityPortraitSize; +} & WithClassName; + +export const WdEveEntityPortrait = ({ + eveId, + size = WdEveEntityPortraitSize.default, + type = WdEveEntityPortraitType.character, + className, +}: WdEveEntityPortraitProps) => { + if (eveId == null) { + return null; + } + + return ( + + ); +}; diff --git a/assets/js/hooks/Mapper/components/ui-kit/WdEveEntityPortrait/index.ts b/assets/js/hooks/Mapper/components/ui-kit/WdEveEntityPortrait/index.ts new file mode 100644 index 00000000..94d5c74d --- /dev/null +++ b/assets/js/hooks/Mapper/components/ui-kit/WdEveEntityPortrait/index.ts @@ -0,0 +1 @@ +export * from './WdEveEntityPortrait.tsx'; diff --git a/assets/js/hooks/Mapper/components/ui-kit/index.ts b/assets/js/hooks/Mapper/components/ui-kit/index.ts index a7966777..e0ff2398 100644 --- a/assets/js/hooks/Mapper/components/ui-kit/index.ts +++ b/assets/js/hooks/Mapper/components/ui-kit/index.ts @@ -14,5 +14,6 @@ export * from './TimeAgo'; export * from './WdTooltipWrapper'; export * from './WdResponsiveCheckBox'; export * from './WdRadioButton'; -export * from './CharacterPortrait'; +export * from './WdEveEntityPortrait'; export * from './WdTransition'; +export * from './LoadingWrapper'; diff --git a/assets/js/hooks/Mapper/hooks/index.ts b/assets/js/hooks/Mapper/hooks/index.ts index 8bf9eb6f..584b367d 100644 --- a/assets/js/hooks/Mapper/hooks/index.ts +++ b/assets/js/hooks/Mapper/hooks/index.ts @@ -2,3 +2,4 @@ export * from './usePageVisibility'; export * from './useClipboard'; export * from './useHotkey'; export * from './useSkipContextMenu'; +export * from './useActualizeSettings'; diff --git a/assets/js/hooks/Mapper/hooks/useActualizeSettings.ts b/assets/js/hooks/Mapper/hooks/useActualizeSettings.ts new file mode 100644 index 00000000..0dac0a23 --- /dev/null +++ b/assets/js/hooks/Mapper/hooks/useActualizeSettings.ts @@ -0,0 +1,23 @@ +import { useEffect } from 'react'; + +type Settings = Record; +export const useActualizeSettings = (defaultVals: T, vals: T, setVals: (newVals: T) => void) => { + useEffect(() => { + let foundNew = false; + const newVals = Object.keys(defaultVals).reduce((acc, x) => { + if (Object.keys(acc).includes(x)) { + return acc; + } + + foundNew = true; + + // @ts-ignore + return { ...acc, [x]: defaultVals[x] }; + }, vals); + + if (foundNew) { + setVals(newVals); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); +}; diff --git a/assets/js/hooks/Mapper/hooks/useTheme.ts b/assets/js/hooks/Mapper/hooks/useTheme.ts index 0d13feb9..d0e6289a 100644 --- a/assets/js/hooks/Mapper/hooks/useTheme.ts +++ b/assets/js/hooks/Mapper/hooks/useTheme.ts @@ -1,7 +1,8 @@ -import { AvailableThemes, useMapRootState } from '@/hooks/Mapper/mapRootProvider'; +import { useMapRootState } from '@/hooks/Mapper/mapRootProvider'; +import { AvailableThemes } from '@/hooks/Mapper/mapRootProvider/types.ts'; export const useTheme = (): AvailableThemes => { - const { interfaceSettings } = useMapRootState(); + const { storedSettings } = useMapRootState(); - return interfaceSettings.theme; + return storedSettings.interfaceSettings.theme; }; diff --git a/assets/js/hooks/Mapper/icons/MapAddIcon.tsx b/assets/js/hooks/Mapper/icons/MapAddIcon.tsx new file mode 100644 index 00000000..313c0d55 --- /dev/null +++ b/assets/js/hooks/Mapper/icons/MapAddIcon.tsx @@ -0,0 +1,18 @@ +import { SvgIconProps, SvgIconWrapper } from '@/hooks/Mapper/components/ui-kit/SvgIconWrapper.tsx'; + +export const MapAddIcon = (props: SvgIconProps) => ( + + + + +); diff --git a/assets/js/hooks/Mapper/icons/MapDeleteIcon.tsx b/assets/js/hooks/Mapper/icons/MapDeleteIcon.tsx new file mode 100644 index 00000000..b6a4af90 --- /dev/null +++ b/assets/js/hooks/Mapper/icons/MapDeleteIcon.tsx @@ -0,0 +1,19 @@ +import { SvgIconProps, SvgIconWrapper } from '@/hooks/Mapper/components/ui-kit/SvgIconWrapper.tsx'; + +export const MapDeleteIcon = (props: SvgIconProps) => ( + + + + + +); diff --git a/assets/js/hooks/Mapper/icons/MapUserAddIcon.tsx b/assets/js/hooks/Mapper/icons/MapUserAddIcon.tsx new file mode 100644 index 00000000..63297d6d --- /dev/null +++ b/assets/js/hooks/Mapper/icons/MapUserAddIcon.tsx @@ -0,0 +1,14 @@ +import { SvgIconProps, SvgIconWrapper } from '@/hooks/Mapper/components/ui-kit/SvgIconWrapper.tsx'; + +export const MapUserAddIcon = (props: SvgIconProps) => ( + + + + +); diff --git a/assets/js/hooks/Mapper/icons/MapUserDeleteIcon.tsx b/assets/js/hooks/Mapper/icons/MapUserDeleteIcon.tsx new file mode 100644 index 00000000..d7e991d9 --- /dev/null +++ b/assets/js/hooks/Mapper/icons/MapUserDeleteIcon.tsx @@ -0,0 +1,15 @@ +import { SvgIconProps, SvgIconWrapper } from '@/hooks/Mapper/components/ui-kit/SvgIconWrapper.tsx'; + +export const MapUserDeleteIcon = (props: SvgIconProps) => ( + + + + + +); diff --git a/assets/js/hooks/Mapper/icons.ts b/assets/js/hooks/Mapper/icons/index.ts similarity index 98% rename from assets/js/hooks/Mapper/icons.ts rename to assets/js/hooks/Mapper/icons/index.ts index 245e0d3a..00a1628a 100644 --- a/assets/js/hooks/Mapper/icons.ts +++ b/assets/js/hooks/Mapper/icons/index.ts @@ -1,3 +1,8 @@ +export * from './MapDeleteIcon'; +export * from './MapAddIcon'; +export * from './MapUserAddIcon'; +export * from './MapUserDeleteIcon'; + export const ANOIK_ICON = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAMAAACdt4HsAAAAAXNSR0IB2cksfwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAvdQTFRFHBMTIBoVJR8aIh4dIBwbLigjJSUeIhcTKR4aJBkVNjAqOjQuPzkzRT85PjIuOi4qMSYiMSEeLCEdNSUjNisnMyIbNyUeMR8ZKhQOKBQUJhUNKRgSLRwWKhoYLhkZJBAOHRgSISAaJyklLC8sJhsYMiwmT0hCQjYyRjImNB0ZNR8fHhQPLyMgU1NLPz44TUE9YFhRU0ZCST05PywlOykiLRcTLBwaGBQTKSQeXlxRT05GOjoyMC8oXE9NaF9WZldUMRsWHBELREQ9RTcuPTUqLiEZfWxpNi4kPzEpOSsjdm1jSjwzOyonU0U8STc0V0VCTTs5QjEuGhMNKiMZLygeMyYeVU5IYFFHcWFfNyAbKh4WfmlhY05HOSIdJR4VRz80Z1hOPCQgPiciIh0YZ2ZdbHJndnxzeGZXWUU+cmJZRDEqTzs0jqOSgo99jZmJkpiOcnBkbFdPdWBYgG5fQysmSUlBpKyVp7eorLGjxdPHnZiNhYR8iIJ1V09EcFtOj31tk351hXVxX0xJPScmSTYvVFNGeYt/ko+Fi3VtyL2xoIiARi8v2Ofh5vXsucS2Ukk/r56V0r+6Lzk0X2ZcbHxx+/77bWJSl4t5n4p1x7CrVlxSf3hte3JggXlnqJqDs52HiHZnvaegoYmHhG9nOSMjQSgfhouAm5WCqKKPu6WPrpeImod4ZktFXEI9Uzo6MRYTR01ET1VM397PuaWV39nE8/Hms6yZqpWNhmpkdFhSOR0aZFNEbVJMVDo1Ry8qzMe1/vDqtZ2WknZyk4B8el5YPiEfREE1ZnRpurKXwryn8NvV383Hlnt6TTQw7uG+kId1RikmNBkXinxltZiVyKmkGhAQlopz29CwOh0W7NDBQiUiPyEabGdVg21UTjktQCwhSzEoVTYuQyUdwZyXn3t3JBQSIREOSy0qJxgWUDIvUTEpTC0lVjc0MR4TWkFBRykhcFhcQh8bSCUiWDMyHg8ROyMaUy4oWDEmTCcdYjovd0M0NBcQLhMQdoqRdQAADR1JREFUeJw9V21AU+cVvkm95uYDyAe5N0BAqgQaE1G4NxhBMREUEBSD1XCFVBK0jaEfSpO1q9paQWXYFq1ip3a2irRdq6LdqpAKrUpa15YurKazYWslWwIlrpnOVlf8sfPe0J2QD36c533Ox3PeczGMx39gGj59+nQ+TyDg8eCDEPJxkUgsSQCTSBKTpFKZXKqQJUsVSpJSpahS09LUivSMGXHDHuBlPjhz1ky+YMqyhMkajUaUnQ3OgJHNWUJOYmJSYpJC/ZBSq1KlpqalzM7ISIkDTMucPmvmA3CwDkwgFPL1Gr1kzpxcsQbH9VIpQktOnps9Ly8/L4+m1GqKSVGpVAZVwWwZRwKb/+DM6Yi4DiOQvxEXJywoLEIkNAtx+BQnJUul0qT8/EXFi+cmydQGE61WqVIyVBkZCgPHYOY0XiY63Qz8CVy0JLfIiIMnspLSpJyExCTZUn1yfsLcecsW5zMMlWZQkoBgUBlMCuCAzZzGuQN9nRA3iorAGZfiUo2oTCQSleYkJJSUV1RUkImJ+fOWL65cTFdRaooiDSkqg0GVMVuFcoDFw9fxNbgIN2pw49Kl0rKy0rKypGRRYqIkMVkhrShfUbJy0bLl1assVVVVapWSMaA8qAwyFZZZg9XUQPQCSJrGON3IJ1Y/vEYqLROXirkarM1LSEyWkeWVK6y1yxbnWZgqmskwsXEEg0GGzQcACJ8vIuQijZEnWCfXy/XJotJ4ARMk+fmJiTl5+TmWEmtdbb01DyiQpE3LsnGEDDh+PucvNC40CoHJIwRhFEnWJ2QXZq+dB8fPzYb6k8n5c/OsDQ0N9Xl5K2k7TbMGh1KF0mDA5s8383iNoka+kafLWgeVkONisTh77doFGzY+umHB2uy1OVDI5KSc6obHGpzWTdWLVrqaaJvJpGVRCAZgwGtsnCXk83mQSIFASIA/0F+w8fHHn3jyqc0b5hUDharkxJz8LY81O5vqn26w1jvddtZkIE0GgwkAMvmNIj7/QR6qhIBPiEQJniVLCjc+8eSvnnn2188+t3Vb8faScgVdMtf69PPOJmf9Czte3OlqYbUmU6vBpJ2N1fB4OGhJoDPrdEK9USxeUlRUOGfXgs2PPvnE7mcAYc/m7etLKitLKje9sAUAtrT9pr121V47y5rsbq1Ji2Xy+Jrps7hWEOK4WJO7JDfXM2cXIDzx+O7HX3r5pVf2FHckQBEbNr2wyep0btm3f1/zzlWOFttslmZNJgCYNetVngDo86S4BF+4pEhS5JkDFA5s3H1w9zMvd3a+snXzhgar9dBrv316k7V62f7DbXX11a6WlhYb64AcZPJffZUvQI2s0YhxUVGZSOIpLASEOYULDhw5+Epn58tPHd28bdvrvzv2xpvPN9TuO36ia3G9s2nv3paT2m6tCePNfPVBFIDACMorKtI8rBF7NhwpnFNY6PEU7jrw1kudnc88tXXbtrff+f277z3dfOL4iRdrT5121p9xOE6ypod6AGBaJhoGSAkLi/DVoOezBw6cm3PEs359R8eCo+9DDM9tfWvr0X1/eOPdtrbj7ctqt9Q7653VDruN1TrsGA+JOVMnEMk1+EL5aqNekwsABw+eLRVVdnRYl/+x84Pzby8vXr5n2/l9F9oPX+hdtnPxlr7Tp880nXTbWO9DIKZMMwDgcgLH+XKjHi/KPXtk9+5zRZqyyg8PLd/zQef74G+1Hn2v68K+/Yd3NG+p6+292A/mtjtY0wA0EswSHYHzCRCiERqhyHPk4MEjZ8Vlou2vvXP+o87Oj7YdLbYWd22q69r/8aXLzXVX6gZP9fc7nQM+aAYbNh/8zTqjns8nHlm3erWmqPSTA59+euRsriSh+J2r589/8P6f9ry1efmLzc1d+z77/PiVL9q6ui6eOtXv7B9yuH2sFrRsNmMCI4EAQImih0uXnN115JzHs37BhmNfHnvt/HNXr/756NEX67ra97df+fzS5bq+tkFE4XT/gN/HDmPzAcDMbxRyCFlLH8YlklyP59w5z4GOjr98eWh58dGrV99suHLlSvtXdctO7L/W2zfYPth38dTp0/1un8/LAQRq+LxGADCuzjIacZiGkAfPuV1rt3/9V+v2ju3Hrlprr7R/caF58PKJry5c7u3r6uUoDCAALwDMrxHweSDnRqOckBulOC6Vij7xSBI8679+vbKkvOPrD1tr23Zcbu4a7L107VJ772Bf78VT/QMD7iEAuI7V6DCzUAgAMFKMhB6HgUboy3IlYklpeUklTOQVK+idg/W9F/ugi745fOlEL/Rhf//QQHe3Pegb8UISAzVCmCN8uA34cB9I9QCwRiSRfCJOpko3vv637eUuS+vFU311xz//+Jvjbc1Ol8s1APzdbocjGPRikMJMobCRMKKbDK4jqVROwA9Nwq7k9HWeQ3//lqZbSVd971fffPzZpbYGZ5PrO/o799DQjaHRAXcwBAA6TIcA5MAeEijVAwXgok8Sy1bKZRUV5ZTa0upsv3btH//cUVfvbG110WADQ2D97m7fdQAwwywQZhFGOfKEXua+5PL0dKVUGo6o1JTFebp3R2/vYN0W50rgTzN2u2PA4XePjQVHfBgaZWiYAgSw0MvhbYRyCMfHU1ILZOHIjDQqx+Wqb+471d/U1Opy07QSLgX7gHvs+4kbUQ7AHAcQEoAgJ0BQRgL+SwvPSCXpqnSifP1c1uWsv3h6AA4Hf4YFBQwNjY3enJhAIQCAmVsMhFnAAVkWkSUQhOHsGf9iXJaS7dlK26qdP/T9MATxu1qbXIzN4XAP+Me+Hxvz+/y/AHAkCDm4CxGYIBIOz5iRqk5TK9k0yrJo584f9lpo2uF2N9HAgAUd+ccQwAiGNgMAwBACDzzRd5YgHImEU8Ip6PYymJi82trFQ4yFYewM08SykAO3wxeLfj8RDEEOOApoKnOp4PyFYbDx9II0tMyYmOpltf/+YS9zkmEcZKsLEWDdvmAsOhENhoIcA0wXX5Hi9YDzw2FBOKVA0QLXj9Z+03/r9n9ukqyWsTvcTQ5Wa2dtCCA2EQuFRqATYSIATIALRRcWjI+PQwIEwEJWNdvWksEGvbf7z1SpTSRtZywuG9TQ7nb7QsAgFgsGMXNNIBAAV0AKQDARRAJKEBEQUgVsI5Yze1c5q6tpWM9oBw19YLPZ3A5IgS8OEIoDAAIWiCOEIRcRznTSxHzY7RblLapeKYP1jnS39g+5HTbIoNvrC/nG/DF/dGQKIMD5B8wRWDY477AOPsalOflglRZKrWQoytVKu212LajQDgSC/qDf3/0Lg4B5KoQ7YQzORjEgE/IJvaKiCtYydTrFgIhIGMQOh83G+kKghWg0FkXzIGDmKIBxeTRHfvGPQHtLNVVrYEVOSyOrGJJkUAptdtYbgvz5o/5QDJYsM8eeoxEIQAoRee4DEmnUlCZWKNVpaVSBUlHB0CRrs9vtrP16zOcLTowhBoEa7BdvwNFxHSEAXcoJSEYYVtekCopSMgpSRpHljJZFMWhNIZ8PejkajU5g6FgzYNyJFwM1I9IWSAI0MT6eRshkVIXFUgV1cJWvUTrgeKXBFvL6gmOxMf9oDJuiHn9jOh4CQFdFBGkqLTU1XSaDNCoyUrQ0Q66BZnbYTdofvd5gzH87GB2NTvHnmgAyyckynoBxmPFEOqGXyWTKAoNaaSdJimRIB9yodhsiELx9czSKGExlMICZuRCmJBXmRpRcDv4URWmRgpRKJQnzxGRDBMaCt25HJ/xB7P/541IQ4dxB1aAmIRpxUoVCiSwOQNI0xUIXsMExf/TWrYmbY3401uONi9oHhJSVnp4FL2pdwVJqjRQqRzOo/gwaBrSLtv/Y3T0yErsRDY3c/umn6Ohd0GEknAovMPQ0pS4AwkuhcCTyoqfsIbDvbnz7bXd3LHrjRiw6Onrv3r27d+/+9+efOQY6Xbzx0BQBUxPr4KFCpgCMNdzhAIGGIW1n4fwfQ7EbN0IjI7fv3RsdvXs3ngPQAAIyIwwUBwqjADpAn6SAJFQlWSw5FlJJoUR4WTQNrl+/HroVisZit+ONdOcO98e1r4CDkKUXFBRACyikVRVJSVUkFEKrZdHbDoUcnpwECJ8f+hHj8h+JwNedAGBgUwAolHQOQaZUUug5iVLCgq2FYWY3DQ8PX5/0+vxwMfmn5sAdMGhnTsmC8LgwjsCRkBXANFKrTRS4o2lmG+7p6ZnkOgEEhRhwhTRHUCxA4I5AMJ4+XpAepzAFAP4mtYklgT9rmJzsGe4Z9vrBnwsBKETgaMgB/NKZ9brweBg5j08BAH+TSZYB8xnFr+0xDN/vmezxBuP+cQYRVAUIIQxiMAsisnHkGg+hIINC/NWz4UkNlKjtuQ82eX8S+XrR3xQA8kfnc5OdO5t7F6RTahllUmtNSrYFPeYYOPf7k9dHfD47rFhTAKgEd5AedVgY/OMAiAOCyFBrWWq28iTUwGDoMdz3qu5PhqCMXpsbNhzf/wDqH9yTspBlMwAAAABJRU5ErkJggg=='; diff --git a/assets/js/hooks/Mapper/mapRootProvider/MapRootProvider.tsx b/assets/js/hooks/Mapper/mapRootProvider/MapRootProvider.tsx index d618f830..682bc152 100644 --- a/assets/js/hooks/Mapper/mapRootProvider/MapRootProvider.tsx +++ b/assets/js/hooks/Mapper/mapRootProvider/MapRootProvider.tsx @@ -1,5 +1,5 @@ import { ContextStoreDataUpdate, useContextStore } from '@/hooks/Mapper/utils'; -import { createContext, Dispatch, ForwardedRef, forwardRef, SetStateAction, useContext, useEffect } from 'react'; +import { createContext, Dispatch, ForwardedRef, forwardRef, SetStateAction, useContext } from 'react'; import { ActivitySummary, CommandLinkSignatureToSystem, @@ -12,7 +12,6 @@ import { } from '@/hooks/Mapper/types'; import { useCharactersCache, useComments, useMapRootHandlers } from '@/hooks/Mapper/mapRootProvider/hooks'; import { WithChildren } from '@/hooks/Mapper/types/common.ts'; -import useLocalStorageState from 'use-local-storage-state'; import { ToggleWidgetVisibility, useStoreWidgets, @@ -20,6 +19,9 @@ import { } from '@/hooks/Mapper/mapRootProvider/hooks/useStoreWidgets.ts'; import { WindowsManagerOnChange } from '@/hooks/Mapper/components/ui-kit/WindowManager'; import { DetailedKill } from '../types/kills'; +import { InterfaceStoredSettings, RoutesType } from '@/hooks/Mapper/mapRootProvider/types.ts'; +import { DEFAULT_ROUTES_SETTINGS, STORED_INTERFACE_DEFAULT_VALUES } from '@/hooks/Mapper/mapRootProvider/constants.ts'; +import { useMapUserSettings } from '@/hooks/Mapper/mapRootProvider/hooks/useMapUserSettings.ts'; export type MapRootData = MapUnionTypes & { selectedSystems: string[]; @@ -50,7 +52,9 @@ const INITIAL_DATA: MapRootData = { systems: [], systemSignatures: {}, hubs: [], + userHubs: [], routes: undefined, + userRoutes: undefined, kills: [], connections: [], detailedKills: {}, @@ -64,11 +68,6 @@ const INITIAL_DATA: MapRootData = { followingCharacterEveId: null, }; -export enum AvailableThemes { - default = 'default', - pathfinder = 'pathfinder', -} - export enum InterfaceStoredSettingsProps { isShowMenu = 'isShowMenu', isShowMinimap = 'isShowMinimap', @@ -80,40 +79,28 @@ export enum InterfaceStoredSettingsProps { theme = 'theme', } -export type InterfaceStoredSettings = { - isShowMenu: boolean; - isShowMinimap: boolean; - isShowKSpace: boolean; - isThickConnections: boolean; - isShowUnsplashedSignatures: boolean; - isShowBackgroundPattern: boolean; - isSoftBackground: boolean; - theme: AvailableThemes; -}; - -export const STORED_INTERFACE_DEFAULT_VALUES: InterfaceStoredSettings = { - isShowMenu: false, - isShowMinimap: true, - isShowKSpace: false, - isThickConnections: false, - isShowUnsplashedSignatures: false, - isShowBackgroundPattern: true, - isSoftBackground: false, - theme: AvailableThemes.default, -}; - export interface MapRootContextProps { update: ContextStoreDataUpdate; data: MapRootData; outCommand: OutCommandHandler; - interfaceSettings: InterfaceStoredSettings; - setInterfaceSettings: Dispatch>; windowsSettings: WindowStoreInfo; toggleWidgetVisibility: ToggleWidgetVisibility; updateWidgetSettings: WindowsManagerOnChange; resetWidgets: () => void; comments: UseCommentsData; charactersCache: UseCharactersCacheData; + + /** + * !!! + * DO NOT PASS THIS PROP INTO COMPONENT + * !!! + * */ + storedSettings: { + interfaceSettings: InterfaceStoredSettings; + setInterfaceSettings: Dispatch>; + settingsRoutes: RoutesType; + settingsRoutesUpdate: Dispatch>; + }; } const MapRootContext = createContext({ @@ -121,8 +108,6 @@ const MapRootContext = createContext({ data: { ...INITIAL_DATA }, // @ts-ignore outCommand: async () => void 0, - interfaceSettings: STORED_INTERFACE_DEFAULT_VALUES, - setInterfaceSettings: () => null, comments: { loadComments: async () => {}, comments: new Map(), @@ -141,6 +126,12 @@ const MapRootContext = createContext({ characters: new Map(), lastUpdateKey: 0, }, + storedSettings: { + interfaceSettings: STORED_INTERFACE_DEFAULT_VALUES, + setInterfaceSettings: () => null, + settingsRoutes: DEFAULT_ROUTES_SETTINGS, + settingsRoutesUpdate: () => null, + }, }); type MapRootProviderProps = { @@ -159,49 +150,25 @@ const MapRootHandlers = forwardRef(({ children }: WithChildren, fwdRef: Forwarde export const MapRootProvider = ({ children, fwdRef, outCommand }: MapRootProviderProps) => { const { update, ref } = useContextStore({ ...INITIAL_DATA }); - const [interfaceSettings, setInterfaceSettings] = useLocalStorageState( - 'window:interface:settings', - { - defaultValue: STORED_INTERFACE_DEFAULT_VALUES, - }, - ); + const storedSettings = useMapUserSettings(); + const { windowsSettings, toggleWidgetVisibility, updateWidgetSettings, resetWidgets } = useStoreWidgets(); const comments = useComments({ outCommand }); const charactersCache = useCharactersCache({ outCommand }); - useEffect(() => { - let foundNew = false; - const newVals = Object.keys(STORED_INTERFACE_DEFAULT_VALUES).reduce((acc, x) => { - if (Object.keys(acc).includes(x)) { - return acc; - } - - foundNew = true; - - // @ts-ignore - return { ...acc, [x]: STORED_INTERFACE_DEFAULT_VALUES[x] }; - }, interfaceSettings); - - if (foundNew) { - setInterfaceSettings(newVals); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - return ( {children} diff --git a/assets/js/hooks/Mapper/mapRootProvider/constants.ts b/assets/js/hooks/Mapper/mapRootProvider/constants.ts new file mode 100644 index 00000000..19f2ad4b --- /dev/null +++ b/assets/js/hooks/Mapper/mapRootProvider/constants.ts @@ -0,0 +1,26 @@ +import { AvailableThemes, InterfaceStoredSettings, RoutesType } from '@/hooks/Mapper/mapRootProvider/types.ts'; + +export const STORED_INTERFACE_DEFAULT_VALUES: InterfaceStoredSettings = { + isShowMenu: false, + isShowMinimap: true, + isShowKSpace: false, + isThickConnections: false, + isShowUnsplashedSignatures: false, + isShowBackgroundPattern: true, + isSoftBackground: false, + theme: AvailableThemes.default, +}; + +export const DEFAULT_ROUTES_SETTINGS: RoutesType = { + path_type: 'shortest', + include_mass_crit: true, + include_eol: true, + include_frig: true, + include_cruise: true, + include_thera: true, + avoid_wormholes: false, + avoid_pochven: false, + avoid_edencom: false, + avoid_triglavian: false, + avoid: [], +}; diff --git a/assets/js/hooks/Mapper/mapRootProvider/hooks/api/useMapInit.ts b/assets/js/hooks/Mapper/mapRootProvider/hooks/api/useMapInit.ts index 0cd95323..36dbce4a 100644 --- a/assets/js/hooks/Mapper/mapRootProvider/hooks/api/useMapInit.ts +++ b/assets/js/hooks/Mapper/mapRootProvider/hooks/api/useMapInit.ts @@ -26,6 +26,7 @@ export const useMapInit = () => { is_subscription_active, main_character_eve_id, following_character_eve_id, + user_hubs, } = props; const updateData: Partial = {}; @@ -71,6 +72,10 @@ export const useMapInit = () => { updateData.hubs = hubs; } + if (user_hubs) { + updateData.userHubs = user_hubs; + } + if (options) { updateData.options = options; } diff --git a/assets/js/hooks/Mapper/mapRootProvider/hooks/api/useMapUpdated.ts b/assets/js/hooks/Mapper/mapRootProvider/hooks/api/useMapUpdated.ts index 62458771..013f67af 100644 --- a/assets/js/hooks/Mapper/mapRootProvider/hooks/api/useMapUpdated.ts +++ b/assets/js/hooks/Mapper/mapRootProvider/hooks/api/useMapUpdated.ts @@ -8,13 +8,33 @@ export const useMapUpdated = () => { const ref = useRef({ update }); ref.current = { update }; - return useCallback(({ hubs }: CommandMapUpdated) => { + return useCallback((props: CommandMapUpdated) => { const { update } = ref.current; const out: Partial = {}; - if (hubs) { - out.hubs = hubs; + if ('hubs' in props) { + out.hubs = props.hubs; + } + + if ('user_hubs' in props) { + out.userHubs = props.user_hubs; + } + + if ('system_signatures' in props) { + out.systemSignatures = props.system_signatures; + } + + if ('following_character_eve_id' in props) { + out.userCharacters = props.user_characters; + } + + if ('following_character_eve_id' in props) { + out.followingCharacterEveId = props.following_character_eve_id; + } + + if ('main_character_eve_id' in props) { + out.mainCharacterEveId = props.main_character_eve_id; } update(out); diff --git a/assets/js/hooks/Mapper/mapRootProvider/hooks/api/useRoutes.ts b/assets/js/hooks/Mapper/mapRootProvider/hooks/api/useRoutes.ts index 33cffbb8..57212e23 100644 --- a/assets/js/hooks/Mapper/mapRootProvider/hooks/api/useRoutes.ts +++ b/assets/js/hooks/Mapper/mapRootProvider/hooks/api/useRoutes.ts @@ -92,3 +92,23 @@ export const useRoutes = () => { update({ routes: value }); }, []); }; + +export const useUserRoutes = () => { + const { + update, + data: { userRoutes }, + } = useMapRootState(); + + const ref = useRef({ update, userRoutes }); + ref.current = { update, userRoutes }; + + return useCallback((value: CommandRoutes) => { + const { update, userRoutes } = ref.current; + + if (areRoutesListsEqual(userRoutes, value)) { + return; + } + + update({ userRoutes: value }); + }, []); +}; diff --git a/assets/js/hooks/Mapper/mapRootProvider/hooks/useMapRootHandlers.ts b/assets/js/hooks/Mapper/mapRootProvider/hooks/useMapRootHandlers.ts index 0a654a96..4768ceea 100644 --- a/assets/js/hooks/Mapper/mapRootProvider/hooks/useMapRootHandlers.ts +++ b/assets/js/hooks/Mapper/mapRootProvider/hooks/useMapRootHandlers.ts @@ -33,6 +33,7 @@ import { useMapInit, useMapUpdated, useRoutes, + useUserRoutes, } from './api'; import { useCommandsActivity } from './api/useCommandsActivity'; @@ -54,6 +55,7 @@ export const useMapRootHandlers = (ref: ForwardedRef) => { useCommandsCharacters(); const mapUpdated = useMapUpdated(); const mapRoutes = useRoutes(); + const mapUserRoutes = useUserRoutes(); const { addComment, removeComment } = useCommandComments(); const { characterActivityData, trackingCharactersData, userSettingsUpdated } = useCommandsActivity(); @@ -105,6 +107,9 @@ export const useMapRootHandlers = (ref: ForwardedRef) => { case Commands.routes: mapRoutes(data as CommandRoutes); break; + case Commands.userRoutes: + mapUserRoutes(data as CommandRoutes); + break; case Commands.signaturesUpdated: // USED updateSystemSignatures(data as CommandSignaturesUpdated); diff --git a/assets/js/hooks/Mapper/mapRootProvider/hooks/useMapUserSettings.ts b/assets/js/hooks/Mapper/mapRootProvider/hooks/useMapUserSettings.ts new file mode 100644 index 00000000..128fcfd7 --- /dev/null +++ b/assets/js/hooks/Mapper/mapRootProvider/hooks/useMapUserSettings.ts @@ -0,0 +1,39 @@ +import useLocalStorageState from 'use-local-storage-state'; +import { InterfaceStoredSettings, RoutesType } from '@/hooks/Mapper/mapRootProvider/types.ts'; +import { DEFAULT_ROUTES_SETTINGS, STORED_INTERFACE_DEFAULT_VALUES } from '@/hooks/Mapper/mapRootProvider/constants.ts'; +import { useActualizeSettings } from '@/hooks/Mapper/hooks'; +import { useEffect } from 'react'; +import { SESSION_KEY } from '@/hooks/Mapper/constants.ts'; + +export const useMigrationRoutesSettingsV1 = (update: (upd: RoutesType) => void) => { + //TODO if current Date is more than 01.01.2026 - remove this hook. + + useEffect(() => { + const items = localStorage.getItem(SESSION_KEY.routes); + if (items) { + update(JSON.parse(items)); + localStorage.removeItem(SESSION_KEY.routes); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); +}; + +export const useMapUserSettings = () => { + const [interfaceSettings, setInterfaceSettings] = useLocalStorageState( + 'window:interface:settings', + { + defaultValue: STORED_INTERFACE_DEFAULT_VALUES, + }, + ); + + const [settingsRoutes, settingsRoutesUpdate] = useLocalStorageState('window:interface:routes', { + defaultValue: DEFAULT_ROUTES_SETTINGS, + }); + + useActualizeSettings(STORED_INTERFACE_DEFAULT_VALUES, interfaceSettings, setInterfaceSettings); + useActualizeSettings(DEFAULT_ROUTES_SETTINGS, settingsRoutes, settingsRoutesUpdate); + + useMigrationRoutesSettingsV1(settingsRoutesUpdate); + + return { interfaceSettings, setInterfaceSettings, settingsRoutes, settingsRoutesUpdate }; +}; diff --git a/assets/js/hooks/Mapper/mapRootProvider/types.ts b/assets/js/hooks/Mapper/mapRootProvider/types.ts new file mode 100644 index 00000000..b1a1b222 --- /dev/null +++ b/assets/js/hooks/Mapper/mapRootProvider/types.ts @@ -0,0 +1,29 @@ +export enum AvailableThemes { + default = 'default', + pathfinder = 'pathfinder', +} + +export type InterfaceStoredSettings = { + isShowMenu: boolean; + isShowMinimap: boolean; + isShowKSpace: boolean; + isThickConnections: boolean; + isShowUnsplashedSignatures: boolean; + isShowBackgroundPattern: boolean; + isSoftBackground: boolean; + theme: AvailableThemes; +}; + +export type RoutesType = { + path_type: 'shortest' | 'secure' | 'insecure'; + include_mass_crit: boolean; + include_eol: boolean; + include_frig: boolean; + include_cruise: boolean; + include_thera: boolean; + avoid_wormholes: boolean; + avoid_pochven: boolean; + avoid_edencom: boolean; + avoid_triglavian: boolean; + avoid: number[]; +}; diff --git a/assets/js/hooks/Mapper/types/character.ts b/assets/js/hooks/Mapper/types/character.ts index 12daa235..fb09589f 100644 --- a/assets/js/hooks/Mapper/types/character.ts +++ b/assets/js/hooks/Mapper/types/character.ts @@ -28,8 +28,8 @@ export type CharacterTypeRaw = { ship: ShipTypeRaw | null; alliance_id: number | null; - alliance_name: number | null; - alliance_ticker: number | null; + alliance_name: string | null; + alliance_ticker: string | null; corporation_id: number; corporation_name: string; corporation_ticker: string; diff --git a/assets/js/hooks/Mapper/types/mapHandlers.ts b/assets/js/hooks/Mapper/types/mapHandlers.ts index d4b741f8..5c7d3486 100644 --- a/assets/js/hooks/Mapper/types/mapHandlers.ts +++ b/assets/js/hooks/Mapper/types/mapHandlers.ts @@ -24,6 +24,7 @@ export enum Commands { killsUpdated = 'kills_updated', detailedKillsUpdated = 'detailed_kills_updated', routes = 'routes', + userRoutes = 'user_routes', centerSystem = 'center_system', selectSystem = 'select_system', linkSignatureToSystem = 'link_signature_to_system', @@ -55,6 +56,7 @@ export type Command = | Commands.killsUpdated | Commands.detailedKillsUpdated | Commands.routes + | Commands.userRoutes | Commands.selectSystem | Commands.centerSystem | Commands.linkSignatureToSystem @@ -82,6 +84,7 @@ export type CommandInit = { user_characters: string[]; user_permissions: UserPermissions; hubs: string[]; + user_hubs: string[]; routes: RoutesList; options: Record; reset?: boolean; @@ -104,6 +107,7 @@ export type CommandUpdateConnection = SolarSystemConnection; export type CommandSignaturesUpdated = string; export type CommandMapUpdated = Partial; export type CommandRoutes = RoutesList; +export type CommandUserRoutes = RoutesList; export type CommandKillsUpdated = Kill[]; export type CommandDetailedKillsUpdated = Record; export type CommandSelectSystem = string | undefined; @@ -170,6 +174,7 @@ export interface CommandData { [Commands.updateConnection]: CommandUpdateConnection; [Commands.mapUpdated]: CommandMapUpdated; [Commands.routes]: CommandRoutes; + [Commands.userRoutes]: CommandUserRoutes; [Commands.killsUpdated]: CommandKillsUpdated; [Commands.detailedKillsUpdated]: CommandDetailedKillsUpdated; [Commands.selectSystem]: CommandSelectSystem; @@ -194,7 +199,10 @@ export interface MapHandlers { export enum OutCommand { addHub = 'add_hub', deleteHub = 'delete_hub', + addUserHub = 'add_user_hub', + deleteUserHub = 'delete_user_hub', getRoutes = 'get_routes', + getUserRoutes = 'get_user_routes', getCharacterJumps = 'get_character_jumps', getStructures = 'get_structures', getSignatures = 'get_signatures', diff --git a/assets/js/hooks/Mapper/types/mapUnionTypes.ts b/assets/js/hooks/Mapper/types/mapUnionTypes.ts index 1007bf79..3798a1cb 100644 --- a/assets/js/hooks/Mapper/types/mapUnionTypes.ts +++ b/assets/js/hooks/Mapper/types/mapUnionTypes.ts @@ -15,9 +15,11 @@ export type MapUnionTypes = { userCharacters: string[]; presentCharacters: string[]; hubs: string[]; + userHubs: string[]; systems: SolarSystemRawType[]; systemSignatures: Record; routes?: RoutesList; + userRoutes?: RoutesList; kills: Record; connections: SolarSystemConnection[]; userPermissions: Partial; diff --git a/assets/js/hooks/Mapper/useMapperHandlers.ts b/assets/js/hooks/Mapper/useMapperHandlers.ts index ec1709ce..dd7385d6 100644 --- a/assets/js/hooks/Mapper/useMapperHandlers.ts +++ b/assets/js/hooks/Mapper/useMapperHandlers.ts @@ -1,8 +1,8 @@ import { MapHandlers } from '@/hooks/Mapper/types/mapHandlers.ts'; import { RefObject, useCallback } from 'react'; -// Force reload the page after 30 minutes of inactivity -const FORCE_PAGE_RELOAD_TIMEOUT = 1000 * 60 * 30; +// Force reload the page after 5 minutes of inactivity +const FORCE_PAGE_RELOAD_TIMEOUT = 1000 * 60 * 5; export const useMapperHandlers = (handlerRefs: RefObject[], hooksRef: RefObject) => { const handleCommand = useCallback( diff --git a/lib/wanderer_app/api/map_system_signature.ex b/lib/wanderer_app/api/map_system_signature.ex index fdb9956a..6a7d8d1e 100644 --- a/lib/wanderer_app/api/map_system_signature.ex +++ b/lib/wanderer_app/api/map_system_signature.ex @@ -190,4 +190,23 @@ defmodule WandererApp.Api.MapSystemSignature do identities do identity :uniq_system_eve_id, [:system_id, :eve_id] end + + @derive {Jason.Encoder, + only: [ + :id, + :system_id, + :eve_id, + :character_eve_id, + :name, + :description, + :type, + :linked_system_id, + :kind, + :group, + :custom_info, + :updated, + :inserted_at, + :updated_at + ] + } end diff --git a/lib/wanderer_app/api/map_system_structure.ex b/lib/wanderer_app/api/map_system_structure.ex index 0d49f38a..4a2baf98 100644 --- a/lib/wanderer_app/api/map_system_structure.ex +++ b/lib/wanderer_app/api/map_system_structure.ex @@ -4,6 +4,27 @@ defmodule WandererApp.Api.MapSystemStructure do """ + @derive {Jason.Encoder, + only: [ + :id, + :system_id, + :solar_system_id, + :solar_system_name, + :structure_type_id, + :structure_type, + :character_eve_id, + :name, + :notes, + :owner_name, + :owner_ticker, + :owner_id, + :status, + :end_time, + :inserted_at, + :updated_at + ] + } + use Ash.Resource, domain: WandererApp.Api, data_layer: AshPostgres.DataLayer diff --git a/lib/wanderer_app/api/map_user_settings.ex b/lib/wanderer_app/api/map_user_settings.ex index d5b8ec36..74eda5c6 100644 --- a/lib/wanderer_app/api/map_user_settings.ex +++ b/lib/wanderer_app/api/map_user_settings.ex @@ -21,6 +21,8 @@ defmodule WandererApp.Api.MapUserSettings do define(:update_settings, action: :update_settings) define(:update_main_character, action: :update_main_character) define(:update_following_character, action: :update_following_character) + + define(:update_hubs, action: :update_hubs) end actions do @@ -43,6 +45,10 @@ defmodule WandererApp.Api.MapUserSettings do update :update_following_character do accept [:following_character_eve_id] end + + update :update_hubs do + accept [:hubs] + end end attributes do @@ -59,6 +65,12 @@ defmodule WandererApp.Api.MapUserSettings do attribute :following_character_eve_id, :string do allow_nil? true end + + attribute :hubs, {:array, :string} do + allow_nil?(true) + + default([]) + end end relationships do diff --git a/lib/wanderer_app/character/tracker.ex b/lib/wanderer_app/character/tracker.ex index b4f19f1e..19210248 100644 --- a/lib/wanderer_app/character/tracker.ex +++ b/lib/wanderer_app/character/tracker.ex @@ -33,7 +33,7 @@ defmodule WandererApp.Character.Tracker do status: binary() } - @online_error_timeout :timer.minutes(2) + @online_error_timeout :timer.minutes(5) @forbidden_ttl :timer.minutes(1) @pubsub_client Application.compile_env(:wanderer_app, :pubsub_client) @@ -49,7 +49,7 @@ defmodule WandererApp.Character.Tracker do |> new() end - def update_track_settings(character_id, track_settings) do + def update_settings(character_id, track_settings) do {:ok, character_state} = WandererApp.Character.get_character_state(character_id) {:ok, @@ -494,7 +494,7 @@ defmodule WandererApp.Character.Tracker do state, location ) do - location = get_location(location) + location = get_location(location) if not is_location_started?(character_id) do WandererApp.Cache.lookup!("character:#{character_id}:start_solar_system_id", nil) @@ -544,14 +544,18 @@ defmodule WandererApp.Character.Tracker do ) defp is_location_updated?( - %{solar_system_id: new_solar_system_id, station_id: new_station_id, structure_id: new_structure_id} = _location, + %{ + solar_system_id: new_solar_system_id, + station_id: new_station_id, + structure_id: new_structure_id + } = _location, solar_system_id, structure_id, station_id ), do: solar_system_id != new_solar_system_id || - solar_system_id != new_solar_system_id || + solar_system_id != new_solar_system_id || structure_id != new_structure_id || station_id != new_station_id @@ -724,14 +728,7 @@ defmodule WandererApp.Character.Tracker do ) end - WandererApp.Character.update_character(character_id, %{online: false}) - - %{ - state - | track_ship: false, - track_online: false, - track_location: false - } + state end defp maybe_stop_tracking( @@ -759,5 +756,5 @@ defmodule WandererApp.Character.Tracker do defp get_online(%{"online" => online}), do: %{online: online} - defp get_online(_), do: %{online: false} + defp get_online(_), do: %{online: true} end diff --git a/lib/wanderer_app/character/tracker_manager_impl.ex b/lib/wanderer_app/character/tracker_manager_impl.ex index 8f0424cb..668ea47e 100644 --- a/lib/wanderer_app/character/tracker_manager_impl.ex +++ b/lib/wanderer_app/character/tracker_manager_impl.ex @@ -118,7 +118,7 @@ defmodule WandererApp.Character.TrackerManager.Impl do ) {:ok, character_state} = - WandererApp.Character.Tracker.update_track_settings(character_id, track_settings) + WandererApp.Character.Tracker.update_settings(character_id, track_settings) WandererApp.Character.update_character_state(character_id, character_state) else @@ -213,15 +213,17 @@ defmodule WandererApp.Character.TrackerManager.Impl do WandererApp.Cache.get_and_remove!("character_untrack_queue", []) |> Task.async_stream( fn {map_id, character_id} -> - WandererApp.Cache.delete("map_#{map_id}:character_#{character_id}:tracked") + if not character_is_present(map_id, character_id) do + WandererApp.Cache.delete("map_#{map_id}:character_#{character_id}:tracked") - {:ok, character_state} = - WandererApp.Character.Tracker.update_track_settings(character_id, %{ - map_id: map_id, - track: false - }) + {:ok, character_state} = + WandererApp.Character.Tracker.update_settings(character_id, %{ + map_id: map_id, + track: false + }) - WandererApp.Character.update_character_state(character_id, character_state) + WandererApp.Character.update_character_state(character_id, character_state) + end end, max_concurrency: System.schedulers_online(), on_timeout: :kill_task, @@ -250,4 +252,11 @@ defmodule WandererApp.Character.TrackerManager.Impl do def handle_info(_event, state), do: state + + defp character_is_present(map_id, character_id) do + {:ok, presence_character_ids} = + WandererApp.Cache.lookup("map_#{map_id}:presence_character_ids", []) + + Enum.member?(presence_character_ids, character_id) + end end diff --git a/lib/wanderer_app/character/tracking_utils.ex b/lib/wanderer_app/character/tracking_utils.ex index cfb21527..54d03fea 100644 --- a/lib/wanderer_app/character/tracking_utils.ex +++ b/lib/wanderer_app/character/tracking_utils.ex @@ -120,7 +120,7 @@ defmodule WandererApp.Character.TrackingUtils do {:ok, updated_settings} = WandererApp.MapCharacterSettingsRepo.untrack(existing_settings) - :ok = untrack_characters([character], map_id, caller_pid) + :ok = untrack([character], map_id, caller_pid) :ok = remove_characters([character], map_id) {:ok, updated_settings} else @@ -131,7 +131,7 @@ defmodule WandererApp.Character.TrackingUtils do {:ok, %{tracked: false} = existing_settings} -> if track do {:ok, updated_settings} = WandererApp.MapCharacterSettingsRepo.track(existing_settings) - :ok = track_characters([character], map_id, true, caller_pid) + :ok = track([character], map_id, true, caller_pid) :ok = add_characters([character], map_id, true) {:ok, updated_settings} else @@ -148,7 +148,7 @@ defmodule WandererApp.Character.TrackingUtils do tracked: true }) - :ok = track_characters([character], map_id, true, caller_pid) + :ok = track([character], map_id, true, caller_pid) :ok = add_characters([character], map_id, true) {:ok, settings} else @@ -161,25 +161,23 @@ defmodule WandererApp.Character.TrackingUtils do end # Helper functions for character tracking - def track_characters(_, _, false, _), do: :ok - def track_characters([], _map_id, _is_track_character?, _), do: :ok + def track(_, _, false, _), do: :ok + def track([], _map_id, _is_track_character?, _), do: :ok - def track_characters([character | characters], map_id, true, caller_pid) do + def track([character | characters], map_id, true, caller_pid) do with :ok <- track_character(character, map_id, caller_pid) do - track_characters(characters, map_id, true, caller_pid) + track(characters, map_id, true, caller_pid) end end - def track_character( - %{ - id: character_id, - eve_id: eve_id, - corporation_id: corporation_id, - alliance_id: alliance_id - }, - map_id, - caller_pid - ) do + defp track_character( + %{ + id: character_id, + eve_id: eve_id + }, + map_id, + caller_pid + ) do with false <- is_nil(caller_pid) do WandererAppWeb.Presence.track(caller_pid, map_id, character_id, %{}) @@ -202,7 +200,7 @@ defmodule WandererApp.Character.TrackingUtils do end end - def untrack_characters(characters, map_id, caller_pid) do + def untrack(characters, map_id, caller_pid) do with false <- is_nil(caller_pid) do characters |> Enum.each(fn character -> diff --git a/lib/wanderer_app/esi/api_client.ex b/lib/wanderer_app/esi/api_client.ex index 5bc0e886..514c2015 100644 --- a/lib/wanderer_app/esi/api_client.ex +++ b/lib/wanderer_app/esi/api_client.ex @@ -32,7 +32,7 @@ defmodule WandererApp.Esi.ApiClient do } @cache_opts [cache: true] - @retry_opts [max_retries: 1, retry_log_level: :warning] + @retry_opts [max_retries: 0, retry_log_level: :warning] @timeout_opts [pool_timeout: 15_000, receive_timeout: :timer.seconds(30)] @api_retry_count 1 @@ -490,7 +490,11 @@ defmodule WandererApp.Esi.ApiClient do try do case Req.get( "#{@base_url}#{path}", - api_opts |> with_user_agent_opts() |> with_cache_opts() |> Keyword.merge(@retry_opts) |> Keyword.merge(@timeout_opts) + api_opts + |> with_user_agent_opts() + |> with_cache_opts() + |> Keyword.merge(@retry_opts) + |> Keyword.merge(@timeout_opts) ) do {:ok, %{status: 200, body: body}} -> {:ok, body} diff --git a/lib/wanderer_app/map.ex b/lib/wanderer_app/map.ex index f2795ba7..aca75a48 100644 --- a/lib/wanderer_app/map.ex +++ b/lib/wanderer_app/map.ex @@ -72,6 +72,9 @@ defmodule WandererApp.Map do def get_characters_limit(map_id), do: {:ok, map_id |> get_map!() |> Map.get(:characters_limit, 50)} + def get_hubs_limit(map_id), + do: {:ok, map_id |> get_map!() |> Map.get(:hubs_limit, 20)} + def is_subscription_active?(map_id), do: is_subscription_active?(map_id, WandererApp.Env.map_subscriptions_enabled?()) @@ -105,10 +108,14 @@ defmodule WandererApp.Map do def list_hubs(map_id) do {:ok, map} = map_id |> get_map() - hubs = map |> Map.get(:hubs, []) - hubs_limit = map |> Map.get(:hubs_limit, 20) - {:ok, hubs |> _maybe_limit_list(hubs_limit)} + {:ok, map |> Map.get(:hubs, [])} + end + + def list_hubs(map_id, hubs) do + {:ok, map} = map_id |> get_map() + + {:ok, hubs} end def list_connections(map_id), @@ -148,15 +155,16 @@ defmodule WandererApp.Map do case not (characters |> Enum.member?(character_id)) do true -> - {:ok, %{ - alliance_id: alliance_id, - corporation_id: corporation_id, - solar_system_id: solar_system_id, - structure_id: structure_id, - station_id: station_id, - ship: ship_type_id, - ship_name: ship_name - }} = WandererApp.Character.get_character(character_id) + {:ok, + %{ + alliance_id: alliance_id, + corporation_id: corporation_id, + solar_system_id: solar_system_id, + structure_id: structure_id, + station_id: station_id, + ship: ship_type_id, + ship_name: ship_name + }} = WandererApp.Character.get_character(character_id) map_id |> update_map(%{characters: [character_id | characters]}) @@ -536,9 +544,6 @@ defmodule WandererApp.Map do end end - defp _maybe_limit_list(list, nil), do: list - defp _maybe_limit_list(list, limit), do: Enum.take(list, limit) - @doc """ Returns the raw activity data that can be processed by WandererApp.Character.Activity. Only includes characters that are on the map's ACL. @@ -549,7 +554,8 @@ defmodule WandererApp.Map do _map_with_acls = Ash.load!(map, :acls) # Calculate cutoff date if days is provided - cutoff_date = if days, do: DateTime.utc_now() |> DateTime.add(-days * 24 * 3600, :second), else: nil + cutoff_date = + if days, do: DateTime.utc_now() |> DateTime.add(-days * 24 * 3600, :second), else: nil # Get activity data passages_activity = get_passages_activity(map_id, cutoff_date) diff --git a/lib/wanderer_app/map/map_operations.ex b/lib/wanderer_app/map/map_operations.ex index 647c8cad..41a36191 100644 --- a/lib/wanderer_app/map/map_operations.ex +++ b/lib/wanderer_app/map/map_operations.ex @@ -1,490 +1,128 @@ +# File: lib/wanderer_app/map/operations.ex defmodule WandererApp.Map.Operations do @moduledoc """ - Orchestrates map systems and connections. - Centralizes cross-repo logic for controllers, templates, and batch operations. + Central entrypoint for map operations. Delegates responsibilities to specialized submodules: + - Owner: Fetching and caching owner character info + - Systems: CRUD and batch upsert for systems + - Connections: CRUD and batch upsert for connections + - Structures: CRUD for structures + - Signatures: CRUD for signatures """ - # Cache TTL in milliseconds (24 hours) - @owner_info_cache_ttl 86_400_000 - - alias WandererApp.{ - MapRepo, - MapSystemRepo, - MapConnectionRepo, - MapCharacterSettingsRepo, - MapUserSettingsRepo + alias WandererApp.Map.Operations.{ + Owner, + Systems, + Connections, + Structures, + Signatures } - alias WandererApp.Map.Server - alias WandererApp.Character - alias WandererApp.Character.TrackingUtils - @doc """ - Fetch main character ID for the map owner. + # -- Owner Info ------------------------------------------------------------- - Returns {:ok, %{id: character_id, user_id: user_id}} on success - Returns {:error, reason} on failure - """ - @spec get_owner_character_id(String.t()) :: {:ok, %{id: term(), user_id: term()}} | {:error, String.t()} - def get_owner_character_id(map_id) do - case WandererApp.Cache.lookup!("map_#{map_id}:owner_info") do - nil -> - with {:ok, owner} <- fetch_map_owner(map_id), - {:ok, char_ids} <- fetch_character_ids(map_id), - {:ok, characters} <- load_characters(char_ids), - {:ok, user_settings} <- MapUserSettingsRepo.get(map_id, owner.id), - {:ok, main} <- TrackingUtils.get_main_character(user_settings, characters, characters) do - result = %{id: main.id, user_id: main.user_id} - WandererApp.Cache.insert("map_#{map_id}:owner_info", result, ttl: @owner_info_cache_ttl) - {:ok, result} - else - {:error, msg} -> - {:error, msg} - _ -> - {:error, "Failed to resolve main character"} - end - cached -> - {:ok, cached} - end - end + @doc "Fetch cached main character info for a map owner" + @spec get_owner_character_id(String.t()) :: + {:ok, %{id: term(), user_id: term()}} | {:error, String.t()} + defdelegate get_owner_character_id(map_id), to: Owner - defp fetch_map_owner(map_id) do - case MapRepo.get(map_id, [:owner]) do - {:ok, %{owner: %_{} = owner}} -> {:ok, owner} - {:ok, %{owner: nil}} -> {:error, "Map has no owner"} - {:error, _} -> {:error, "Map not found"} - end - end - - defp fetch_character_ids(map_id) do - case MapCharacterSettingsRepo.get_all_by_map(map_id) do - {:ok, settings} when is_list(settings) and settings != [] -> {:ok, Enum.map(settings, & &1.character_id)} - {:ok, []} -> {:error, "No character settings found"} - {:error, _} -> {:error, "Failed to fetch character settings"} - end - end - - defp load_characters(ids) when is_list(ids) do - ids - |> Enum.map(&Character.get_character/1) - |> Enum.flat_map(fn - {:ok, ch} -> [ch] - _ -> [] - end) - |> case do - [] -> {:error, "No valid characters found"} - chars -> {:ok, chars} - end - end + # -- Systems ---------------------------------------------------------------- @doc "List visible systems" - @spec list_systems(String.t()) :: [any()] - def list_systems(map_id) do - case MapSystemRepo.get_visible_by_map(map_id) do - {:ok, systems} -> systems - _ -> [] - end - end + @spec list_systems(String.t()) :: [map()] + defdelegate list_systems(map_id), to: Systems @doc "Get a specific system" - @spec get_system(String.t(), integer()) :: {:ok, any()} | {:error, :not_found} - def get_system(map_id, sid) do - MapSystemRepo.get_by_map_and_solar_system_id(map_id, sid) - end + @spec get_system(String.t(), integer()) :: {:ok, map()} | {:error, :not_found} + defdelegate get_system(map_id, system_id), to: Systems - @doc "Create or update a system in a map" - @spec create_system(String.t(), map()) :: {:ok, any()} | {:error, String.t()} - def create_system(map_id, params) do - with {:ok, %{id: char_id, user_id: user_id}} <- get_owner_character_id(map_id), - {:ok, system_id} <- fetch_system_id(params), - coords <- normalize_coordinates(params), - :ok <- Server.add_system(map_id, %{solar_system_id: system_id, coordinates: coords}, user_id, char_id), - {:ok, system} <- MapSystemRepo.get_by_map_and_solar_system_id(map_id, system_id) do - {:ok, system} - else - {:error, reason} when is_binary(reason) -> - {:error, reason} - _ -> - {:error, "Unable to create system"} - end - end + @doc "Create a system" + @spec create_system(String.t(), map()) :: {:ok, map()} | {:error, String.t()} + defdelegate create_system(map_id, params), to: Systems - @doc "Update attributes of an existing system" - @spec update_system(String.t(), integer(), map()) :: {:ok, any()} | {:error, String.t()} - def update_system(map_id, system_id, attrs) do - # Fetch current system to get its position if not provided - with {:ok, current_system} <- MapSystemRepo.get_by_map_and_solar_system_id(map_id, system_id), - x_raw <- Map.get(attrs, "position_x", Map.get(attrs, :position_x, current_system.position_x)), - y_raw <- Map.get(attrs, "position_y", Map.get(attrs, :position_y, current_system.position_y)), - {:ok, x} <- parse_int(x_raw, "position_x"), - {:ok, y} <- parse_int(y_raw, "position_y"), - coords = %{x: x, y: y}, - :ok <- apply_system_updates(map_id, system_id, attrs, coords), - {:ok, system} <- MapSystemRepo.get_by_map_and_solar_system_id(map_id, system_id) do - {:ok, system} - else - {:error, reason} when is_binary(reason) -> - {:error, reason} - _ -> - {:error, "Error updating system"} - end - end + @doc "Update a system" + @spec update_system(String.t(), integer(), map()) :: + {:ok, map()} | {:error, String.t()} + defdelegate update_system(map_id, system_id, attrs), to: Systems + @doc "Delete a system" @spec delete_system(String.t(), integer()) :: {:ok, integer()} | {:error, term()} - def delete_system(map_id, system_id) do - with {:ok, %{id: char_id, user_id: user_id}} <- get_owner_character_id(map_id), - {:ok, _system} <- MapSystemRepo.get_by_map_and_solar_system_id(map_id, system_id), - :ok <- Server.delete_systems(map_id, [system_id], user_id, char_id) do - {:ok, 1} - else - {:error, :not_found} -> {:error, :not_found} - _ -> - {:error, "Failed to delete system"} - end - end + defdelegate delete_system(map_id, system_id), to: Systems - @doc """ - Create a new connection if missing + @doc "Upsert systems and connections in batch" + @spec upsert_systems_and_connections(String.t(), [map()], [map()]) :: + {:ok, map()} | {:error, String.t()} + defdelegate upsert_systems_and_connections(map_id, systems, connections), to: Systems - Returns :ok on success - Returns {:skip, :exists} if connection already exists - Returns {:error, reason} on failure - """ - @spec create_connection(map(), String.t()) :: {:ok, any()} | {:skip, :exists} | {:error, String.t()} - def create_connection(attrs, map_id) when is_map(attrs) do - with {:ok, %{id: char_id}} <- get_owner_character_id(map_id) do - do_create_connection(attrs, map_id, char_id) - end - end + # -- Connections ----------------------------------------------------------- - @doc """ - Create a new connection if missing with explicit character ID - - Returns :ok on success - Returns {:skip, :exists} if connection already exists - Returns {:error, reason} on failure - """ - @spec create_connection(map(), String.t(), integer()) :: {:ok, any()} | {:skip, :exists} | {:error, String.t()} - def create_connection(attrs, map_id, char_id) when is_map(attrs), do: do_create_connection(attrs, map_id, char_id) - - defp do_create_connection(attrs, map_id, char_id) do - with {:ok, source} <- parse_int(attrs["solar_system_source"], "solar_system_source"), - {:ok, target} <- parse_int(attrs["solar_system_target"], "solar_system_target"), - info = build_connection_info(source, target, char_id, attrs["type"]), - :ok <- Server.add_connection(map_id, info), - {:ok, [conn | _]} <- MapConnectionRepo.get_by_locations(map_id, source, target) do - {:ok, conn} - else - {:ok, []} -> - {:ok, :created} - {:error, %Ash.Error.Invalid{errors: errors}} = err -> - if Enum.any?(errors, &is_unique_constraint_error?/1) do - {:skip, :exists} - else - err - end - {:error, _reason} = err -> - err - _ -> - {:error, "Failed to create connection"} - end - end - - defp build_connection_info(source, target, char_id, type) do - %{ - solar_system_source_id: source, - solar_system_target_id: target, - character_id: char_id, - type: parse_type(type) - } - end - - @doc "Delete an existing connection" - @spec delete_connection(String.t(), integer(), integer()) :: :ok | {:error, term()} - def delete_connection(map_id, src, tgt) do - case Server.delete_connection(map_id, %{solar_system_source_id: src, solar_system_target_id: tgt}) do - :ok -> :ok - {:error, :not_found} -> {:error, :not_found} - {:error, _reason} = err -> err - _ -> {:error, :unknown} - end - end - - # Helper to detect Ash 'not found' errors - defp is_not_found_error({:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]}}), do: true - defp is_not_found_error({:error, %Ash.Error.Invalid{errors: errors}}) when is_list(errors), do: Enum.any?(errors, &match?(%Ash.Error.Query.NotFound{}, &1)) - defp is_not_found_error(_), do: false - - @spec upsert_systems_and_connections(String.t(), [map()], [map()]) :: {:ok, map()} | {:error, String.t()} - def upsert_systems_and_connections(map_id, systems, connections) do - with {:ok, %{id: char_id}} <- get_owner_character_id(map_id) do - system_results = upsert_each(systems, fn sys -> create_system(map_id, sys) end, 0, 0) - connection_results = Enum.reduce(connections, %{created: 0, updated: 0, skipped: 0}, fn conn, acc -> - upsert_connection_branch(map_id, conn, char_id, acc) - end) - {:ok, format_upsert_results(system_results, {connection_results.created, connection_results.updated})} - else - {:error, reason} -> {:error, reason} - end - end - - # Private: Handles a single connection upsert branch for batch upsert - defp upsert_connection_branch(map_id, conn, char_id, acc) do - with {:ok, source} <- parse_int(conn["solar_system_source"], "solar_system_source"), - {:ok, target} <- parse_int(conn["solar_system_target"], "solar_system_target") do - case get_connection_by_systems(map_id, source, target) do - {:ok, existing_conn} when is_map(existing_conn) and not is_nil(existing_conn) -> - case update_connection(map_id, existing_conn.id, conn) do - {:ok, _} -> %{acc | updated: acc.updated + 1} - error -> - if is_not_found_error(error) do - case create_connection(conn, map_id, char_id) do - {:ok, _} -> %{acc | created: acc.created + 1} - {:skip, :exists} -> %{acc | updated: acc.updated + 1} - {:error, _} -> %{acc | skipped: acc.skipped + 1} - end - else - %{acc | skipped: acc.skipped + 1} - end - end - {:ok, _} -> - case create_connection(conn, map_id, char_id) do - {:ok, _} -> %{acc | created: acc.created + 1} - {:skip, :exists} -> %{acc | updated: acc.updated + 1} - {:error, _} -> %{acc | skipped: acc.skipped + 1} - end - {:error, :not_found} -> - case create_connection(conn, map_id, char_id) do - {:ok, _} -> %{acc | created: acc.created + 1} - {:skip, :exists} -> %{acc | updated: acc.updated + 1} - {:error, _} -> %{acc | skipped: acc.skipped + 1} - end - _ -> - %{acc | skipped: acc.skipped + 1} - end - else - {:error, _} -> - %{acc | skipped: acc.skipped + 1} - end - end - - # Helper to get a connection by source/target system IDs - def get_connection_by_systems(map_id, source, target) do - case WandererApp.Map.find_connection(map_id, source, target) do - {:ok, nil} -> - WandererApp.Map.find_connection(map_id, target, source) - {:ok, conn} -> - {:ok, conn} - end - end - - defp format_upsert_results({created_s, updated_s, _}, {created_c, updated_c}) do - %{ - systems: %{created: created_s, updated: updated_s}, - connections: %{created: created_c, updated: updated_c} - } - end - - @doc "Get connection by ID" - @spec get_connection(String.t(), String.t()) :: {:ok, map()} | {:error, String.t()} - def get_connection(map_id, id) do - case MapConnectionRepo.get_by_id(map_id, id) do - {:ok, %{} = conn} -> {:ok, conn} - {:error, _} -> {:error, "Connection not found"} - end - end - - # -- Helpers --------------------------------------------------------------- - - defp fetch_system_id(%{"solar_system_id" => id}), do: parse_int(id, "solar_system_id") - defp fetch_system_id(%{solar_system_id: id}) when not is_nil(id), do: parse_int(id, "solar_system_id") - defp fetch_system_id(%{"name" => name}) when is_binary(name) and name != "", do: find_by_name(name) - defp fetch_system_id(%{name: name}) when is_binary(name) and name != "", do: find_by_name(name) - defp fetch_system_id(_), do: {:error, "Missing system identifier (id or name)"} - - @doc """ - Find system ID by name - Uses EveDataService for lookup - """ - defp find_by_name(name) do - case WandererApp.EveDataService.find_system_id_by_name(name) do - {:ok, id} when is_integer(id) -> {:ok, id} - {:ok, _} -> - {:error, "Invalid system name: #{name}"} - {:error, reason} -> - {:error, "Failed to find system by name '#{name}': #{reason}"} - _ -> - {:error, "Unknown system name: #{name}"} - end - end - - defp parse_int(val, _field) when is_integer(val), do: {:ok, val} - defp parse_int(val, field) when is_binary(val) do - case Integer.parse(val) do - {i, _} -> {:ok, i} - _ -> {:error, "Invalid #{field}: #{val}"} - end - end - defp parse_int(nil, field), do: {:error, "Missing #{field}"} - defp parse_int(val, field), do: {:error, "Invalid #{field} type: #{inspect(val)}"} - - defp parse_type(val) when is_binary(val) do - case Integer.parse(val) do - {i, _} -> i - _ -> 0 - end - end - defp parse_type(val) when is_integer(val), do: val - defp parse_type(_), do: 0 - - defp normalize_coordinates(%{"coordinates" => %{"x" => x, "y" => y}}) when is_number(x) and is_number(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} - 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)) - } - end - - defp apply_system_updates(map_id, system_id, attrs, %{x: x, y: y}) do - with :ok <- Server.update_system_position(map_id, %{solar_system_id: system_id, position_x: round(x), position_y: round(y)}) do - attrs - |> Map.drop([:coordinates, :position_x, :position_y, :solar_system_id, - "coordinates", "position_x", "position_y", "solar_system_id"]) - |> Enum.reduce_while(:ok, fn {key, val}, _acc -> - case update_system_field(map_id, system_id, to_string(key), val) do - :ok -> {:cont, :ok} - {:error, _} = err -> {:halt, err} - end - end) - end - end - - defp update_system_field(map_id, system_id, field, val) do - case field do - "status" -> Server.update_system_status(map_id, %{solar_system_id: system_id, status: convert_status(val)}) - "description" -> Server.update_system_description(map_id, %{solar_system_id: system_id, description: val}) - "tag" -> Server.update_system_tag(map_id, %{solar_system_id: system_id, tag: val}) - "locked" -> - bool = val in [true, "true", 1, "1"] - Server.update_system_locked(map_id, %{solar_system_id: system_id, locked: bool}) - f when f in ["label", "labels"] -> - labels = - cond do - is_list(val) -> val - is_binary(val) -> String.split(val, ",", trim: true) - true -> [] - end - Server.update_system_labels(map_id, %{solar_system_id: system_id, labels: Enum.join(labels, ",")}) - "temporary_name" -> Server.update_system_temporary_name(map_id, %{solar_system_id: system_id, temporary_name: val}) - _ -> :ok - end - end - - defp convert_status("CLEAR"), do: 0 - defp convert_status("DANGEROUS"), do: 1 - defp convert_status("OCCUPIED"), do: 2 - defp convert_status("MASS_CRITICAL"), do: 3 - defp convert_status("TIME_CRITICAL"), do: 4 - defp convert_status("REINFORCED"), do: 5 - defp convert_status(i) when is_integer(i), do: i - defp convert_status(s) when is_binary(s) do - case Integer.parse(s) do - {i, _} -> i - _ -> 0 - end - end - defp convert_status(_), do: 0 - - defp upsert_each(list, fun, c, u), do: upsert_each(list, fun, c, u, 0) - defp upsert_each([], _fun, c, u, d), do: {c, u, d} - defp upsert_each([item | rest], fun, c, u, d) do - case fun.(item) do - {:ok, _} -> upsert_each(rest, fun, c + 1, u, d) - :ok -> upsert_each(rest, fun, c + 1, u, d) - {:skip, _} -> upsert_each(rest, fun, c, u + 1, d) - _ -> upsert_each(rest, fun, c, u, d + 1) - end - end - - @doc "Update an existing connection" - @spec update_connection(String.t(), String.t(), map()) :: {:ok, any()} | {:error, String.t()} - def update_connection(map_id, connection_id, attrs) do - with {:ok, conn} <- MapConnectionRepo.get_by_id(map_id, connection_id), - {:ok, %{id: char_id}} <- get_owner_character_id(map_id), - :ok <- validate_connection_update(conn, attrs), - :ok <- apply_connection_updates(map_id, conn, attrs, char_id), - {:ok, updated_conn} <- MapConnectionRepo.get_by_id(map_id, connection_id) do - {:ok, updated_conn} - else - {:error, reason} when is_binary(reason) -> - {:error, reason} - {:error, %Ash.Error.Invalid{} = ash_error} -> - {:error, ash_error} - _error -> - {:error, "Failed to update connection"} - end - end - - defp validate_connection_update(_conn, _attrs), do: :ok - - defp apply_connection_updates(map_id, conn, attrs, _char_id) do - with :ok <- maybe_update_mass_status(map_id, conn, Map.get(attrs, "mass_status", conn.mass_status)), - :ok <- maybe_update_ship_size_type(map_id, conn, Map.get(attrs, "ship_size_type", conn.ship_size_type)), - :ok <- maybe_update_type(map_id, conn, Map.get(attrs, "type", conn.type)) do - :ok - else - error -> - error - end - end - - defp maybe_update_mass_status(map_id, conn, value) when not is_nil(value) do - Server.update_connection_mass_status(map_id, %{ - solar_system_source_id: conn.solar_system_source, - solar_system_target_id: conn.solar_system_target, - mass_status: value - }) - end - defp maybe_update_mass_status(_map_id, _conn, nil), do: :ok - - defp maybe_update_ship_size_type(map_id, conn, value) when not is_nil(value) do - Server.update_connection_ship_size_type(map_id, %{ - solar_system_source_id: conn.solar_system_source, - solar_system_target_id: conn.solar_system_target, - ship_size_type: value - }) - end - defp maybe_update_ship_size_type(_map_id, _conn, nil), do: :ok - - defp maybe_update_type(map_id, conn, value) when not is_nil(value) do - Server.update_connection_type(map_id, %{ - solar_system_source_id: conn.solar_system_source, - solar_system_target_id: conn.solar_system_target, - type: value - }) - end - defp maybe_update_type(_map_id, _conn, nil), do: :ok - - @doc "List all connections for a map" + @doc "List all connections" @spec list_connections(String.t()) :: [map()] - def list_connections(map_id) do - case MapConnectionRepo.get_by_map(map_id) do - {:ok, connections} -> connections - _ -> [] - end - end + defdelegate list_connections(map_id), to: Connections - @doc "List connections for a map involving a specific system (source or target)" + @doc "List connections for a specific system" @spec list_connections(String.t(), integer()) :: [map()] - def list_connections(map_id, system_id) do - list_connections(map_id) - |> Enum.filter(fn conn -> - conn.solar_system_source == system_id or conn.solar_system_target == system_id - end) - end + defdelegate list_connections(map_id, system_id), to: Connections - # Helper to detect unique constraint errors in Ash error lists - defp is_unique_constraint_error?(%{constraint: :unique}), do: true - defp is_unique_constraint_error?(%{constraint: :unique_constraint}), do: true - defp is_unique_constraint_error?(_), do: false + @doc "Get a connection" + @spec get_connection(String.t(), String.t()) :: {:ok, map()} | {:error, String.t()} + defdelegate get_connection(map_id, connection_id), to: Connections + + @doc "Create a connection" + @spec create_connection(String.t(), map()) :: + {:ok, map()} | {:skip, :exists} | {:error, String.t()} + defdelegate create_connection(map_id, attrs), to: Connections + + @doc "Force-create a connection with explicit character ID" + @spec create_connection(String.t(), map(), integer()) :: + {:ok, map()} | {:skip, :exists} | {:error, String.t()} + defdelegate create_connection(map_id, attrs, char_id), to: Connections + + @doc "Update a connection" + @spec update_connection(String.t(), String.t(), map()) :: + {:ok, map()} | {:error, String.t()} + defdelegate update_connection(map_id, connection_id, attrs), to: Connections + + @doc "Delete a connection" + @spec delete_connection(String.t(), integer(), integer()) :: :ok | {:error, term()} + defdelegate delete_connection(map_id, src_id, tgt_id), to: Connections + + @doc "Get a connection by source and target system IDs" + @spec get_connection_by_systems(String.t(), integer(), integer()) :: {:ok, map()} | {:error, String.t()} + defdelegate get_connection_by_systems(map_id, source, target), to: Connections + + # -- Structures ------------------------------------------------------------ + + @doc "List all structures" + @spec list_structures(String.t()) :: [map()] + defdelegate list_structures(map_id), to: Structures + + @doc "Create a structure" + @spec create_structure(String.t(), map()) :: {:ok, map()} | {:error, String.t()} + defdelegate create_structure(map_id, params), to: Structures + + @doc "Update a structure" + @spec update_structure(String.t(), String.t(), map()) :: {:ok, map()} | {:error, String.t()} + defdelegate update_structure(map_id, struct_id, params), to: Structures + + @doc "Delete a structure" + @spec delete_structure(String.t(), String.t()) :: :ok | {:error, String.t()} + defdelegate delete_structure(map_id, struct_id), to: Structures + + # -- Signatures ------------------------------------------------------------ + + @doc "List all signatures" + @spec list_signatures(String.t()) :: [map()] + defdelegate list_signatures(map_id), to: Signatures + + @doc "Create a signature" + @spec create_signature(String.t(), map()) :: {:ok, map()} | {:error, String.t()} + defdelegate create_signature(map_id, params), to: Signatures + + @doc "Update a signature" + @spec update_signature(String.t(), String.t(), map()) :: + {:ok, map()} | {:error, String.t()} + defdelegate update_signature(map_id, sig_id, params), to: Signatures + + @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 end diff --git a/lib/wanderer_app/map/map_subscription_manager.ex b/lib/wanderer_app/map/map_subscription_manager.ex index 1574faec..80515d23 100644 --- a/lib/wanderer_app/map/map_subscription_manager.ex +++ b/lib/wanderer_app/map/map_subscription_manager.ex @@ -340,7 +340,7 @@ defmodule WandererApp.Map.SubscriptionManager do end) {:error, :no_active_subscription} -> - Logger.warn( + Logger.warning( "Cannot create license for map #{map.id}: No active subscription found" ) diff --git a/lib/wanderer_app/map/map_zkb_data_fetcher.ex b/lib/wanderer_app/map/map_zkb_data_fetcher.ex index 26744855..e8872107 100644 --- a/lib/wanderer_app/map/map_zkb_data_fetcher.ex +++ b/lib/wanderer_app/map/map_zkb_data_fetcher.ex @@ -196,7 +196,7 @@ defmodule WandererApp.Map.ZkbDataFetcher do end end - defp with_started_map(map_id, label \\ "operation", fun) when is_function(fun, 0) do + defp with_started_map(map_id, label, fun) when is_function(fun, 0) do if WandererApp.Cache.lookup!("map_#{map_id}:started", false) do fun.() else diff --git a/lib/wanderer_app/map/operations/connections.ex b/lib/wanderer_app/map/operations/connections.ex new file mode 100644 index 00000000..54638142 --- /dev/null +++ b/lib/wanderer_app/map/operations/connections.ex @@ -0,0 +1,259 @@ +defmodule WandererApp.Map.Operations.Connections do + @moduledoc """ + CRUD and batch upsert for map connections. + """ + + alias Ash.Error.Invalid + alias WandererApp.MapConnectionRepo + alias WandererApp.Map.Server + require Logger + + @spec list_connections(String.t()) :: [map()] | {:error, atom()} + def list_connections(map_id) do + with {:ok, conns} <- MapConnectionRepo.get_by_map(map_id) do + conns + else + {:error, err} -> + Logger.warning("[list_connections] Repo error: #{inspect(err)}") + {:error, :repo_error} + other -> + Logger.error("[list_connections] Unexpected repo result: #{inspect(other)}") + {:error, :unexpected_repo_result} + end + end + + @spec list_connections(String.t(), integer()) :: [map()] + def list_connections(map_id, system_id) do + list_connections(map_id) + |> Enum.filter(fn c -> + c.solar_system_source == system_id or c.solar_system_target == system_id + end) + end + + @spec get_connection(String.t(), String.t()) :: {:ok, map()} | {:error, String.t()} + def get_connection(map_id, conn_id) do + case MapConnectionRepo.get_by_id(map_id, conn_id) do + {:ok, conn} -> {:ok, conn} + _ -> {:error, "Connection not found"} + end + end + + @spec create_connection(Plug.Conn.t(), map()) :: {:ok, map()} | {:skip, :exists} | {:error, atom()} + def create_connection(%{assigns: %{map_id: map_id, owner_character_id: char_id}} = _conn, attrs) do + do_create(attrs, map_id, char_id) + end + + def create_connection(map_id, attrs, char_id) do + do_create(attrs, map_id, char_id) + end + + defp do_create(attrs, map_id, char_id) do + with {:ok, source} <- parse_int(attrs["solar_system_source"], "solar_system_source"), + {:ok, target} <- parse_int(attrs["solar_system_target"], "solar_system_target") do + info = %{ + solar_system_source_id: source, + solar_system_target_id: target, + character_id: char_id, + type: parse_type(attrs["type"]) + } + add_result = Server.add_connection(map_id, info) + case add_result do + :ok -> {:ok, :created} + {:ok, []} -> + Logger.warning("[do_create] Server.add_connection returned :ok, [] for map_id=#{inspect(map_id)}, source=#{inspect(source)}, target=#{inspect(target)}") + {:error, :inconsistent_state} + {:error, %Invalid{errors: errors}} = err -> + if Enum.any?(errors, &is_unique_constraint_error?/1), do: {:skip, :exists}, else: err + {:error, _} = err -> + Logger.error("[do_create] Server.add_connection error: #{inspect(err)}") + {:error, :server_error} + _ -> + Logger.error("[do_create] Unexpected add_result: #{inspect(add_result)}") + {:error, :unexpected_error} + end + else + {:ok, []} -> + Logger.warning("[do_create] Source or target system not found: attrs=#{inspect(attrs)}") + {:error, :inconsistent_state} + {:error, _} = err -> + Logger.error("[do_create] parse_int error: #{inspect(err)}, attrs=#{inspect(attrs)}") + {:error, :parse_error} + _ -> + Logger.error("[do_create] Unexpected error in preconditions: attrs=#{inspect(attrs)}") + {:error, :unexpected_precondition_error} + end + end + + @spec update_connection(Plug.Conn.t(), String.t(), map()) :: {:ok, map()} | {:error, atom()} + def update_connection(%{assigns: %{map_id: map_id, owner_character_id: char_id}} = _conn, conn_id, attrs) do + with {:ok, conn_struct} <- MapConnectionRepo.get_by_id(map_id, conn_id), + result <- ( + try do + _allowed_keys = [ + :mass_status, + :ship_size_type, + :type + ] + _update_map = + attrs + |> Enum.filter(fn {k, _v} -> k in ["mass_status", "ship_size_type", "type"] end) + |> Enum.map(fn {k, v} -> {String.to_atom(k), v} end) + |> Enum.into(%{}) + res = apply_connection_updates(map_id, conn_struct, attrs, char_id) + res + rescue + error -> + Logger.error("[update_connection] Exception: #{inspect(error)}") + {:error, :exception} + end + ), + :ok <- result, + {:ok, updated_conn} <- MapConnectionRepo.get_by_id(map_id, conn_id) do + {:ok, updated_conn} + else + {:error, err} -> {:error, err} + _ -> {:error, :unexpected_error} + end + end + def update_connection(_conn, _conn_id, _attrs), do: {:error, :missing_params} + + @spec delete_connection(Plug.Conn.t(), integer(), integer()) :: :ok | {:error, atom()} + def delete_connection(%{assigns: %{map_id: map_id}} = _conn, src, tgt) do + case Server.delete_connection(map_id, %{solar_system_source_id: src, solar_system_target_id: tgt}) do + :ok -> :ok + {:error, :not_found} -> + Logger.warning("[delete_connection] Connection not found: source=#{inspect(src)}, target=#{inspect(tgt)}") + {:error, :not_found} + {:error, _} = err -> + Logger.error("[delete_connection] Server error: #{inspect(err)}") + {:error, :server_error} + _ -> + Logger.error("[delete_connection] Unknown error") + {:error, :unknown} + end + end + def delete_connection(_conn, _src, _tgt), do: {:error, :missing_params} + + @doc "Batch upsert for connections" + @spec upsert_batch(Plug.Conn.t(), [map()]) :: %{created: integer(), updated: integer(), skipped: integer()} + def upsert_batch(%{assigns: %{map_id: map_id, owner_character_id: char_id}} = conn, conns) do + _assigns = %{map_id: map_id, char_id: char_id} + Enum.reduce(conns, %{created: 0, updated: 0, skipped: 0}, fn conn_attrs, acc -> + case upsert_single(conn, conn_attrs) do + {:ok, :created} -> %{acc | created: acc.created + 1} + {:ok, :updated} -> %{acc | updated: acc.updated + 1} + _ -> %{acc | skipped: acc.skipped + 1} + end + end) + end + def upsert_batch(_conn, _conns), do: %{created: 0, updated: 0, skipped: 0} + + @doc "Upsert a single connection" + @spec upsert_single(Plug.Conn.t(), map()) :: {:ok, :created | :updated} | {:error, atom()} + def upsert_single(%{assigns: %{map_id: map_id, owner_character_id: char_id}} = conn, conn_data) do + source = conn_data["solar_system_source"] || conn_data[:solar_system_source] + target = conn_data["solar_system_target"] || conn_data[:solar_system_target] + with {:ok, %{} = existing_conn} <- get_connection_by_systems(map_id, source, target), + {:ok, _} <- update_connection(conn, existing_conn.id, conn_data) do + {:ok, :updated} + else + {:ok, nil} -> + case create_connection(map_id, conn_data, char_id) do + {:ok, _} -> {:ok, :created} + {:skip, :exists} -> {:ok, :updated} + err -> {:error, err} + end + {:error, _} = err -> + Logger.warning("[upsert_single] Connection lookup error: #{inspect(err)}") + {:error, :lookup_error} + err -> + Logger.error("[upsert_single] Update failed: #{inspect(err)}") + {:error, :unexpected_error} + end + end + def upsert_single(_conn, _conn_data), do: {:error, :missing_params} + + @doc "Get a connection by source and target system IDs" + @spec get_connection_by_systems(String.t(), integer(), integer()) :: {:ok, map()} | {:error, String.t()} + def get_connection_by_systems(map_id, source, target) do + with {:ok, conn} <- WandererApp.Map.find_connection(map_id, source, target) do + if conn, do: {:ok, conn}, else: WandererApp.Map.find_connection(map_id, target, source) + else + {:error, reason} -> {:error, reason} + end + end + + # -- Helpers --------------------------------------------------------------- + + defp parse_int(val, _field) when is_integer(val), do: {:ok, val} + defp parse_int(val, field) when is_binary(val) do + case Integer.parse(val) do + {i, _} -> {:ok, i} + _ -> {:error, "Invalid #{field}: #{val}"} + end + end + defp parse_int(nil, field), do: {:error, "Missing #{field}"} + defp parse_int(val, field), do: {:error, "Invalid #{field} type: #{inspect(val)}"} + + defp parse_type(val) when is_integer(val), do: val + defp parse_type(val) when is_binary(val) do + case Integer.parse(val) do + {i, _} -> i + _ -> 0 + end + end + defp parse_type(_), do: 0 + + defp is_unique_constraint_error?(%{constraint: :unique}), do: true + defp is_unique_constraint_error?(%{constraint: :unique_constraint}), do: true + defp is_unique_constraint_error?(_), do: false + + defp apply_connection_updates(map_id, conn, attrs, _char_id) do + Enum.reduce_while(attrs, :ok, fn {key, val}, _acc -> + result = + case key do + "mass_status" -> maybe_update_mass_status(map_id, conn, val) + "ship_size_type" -> maybe_update_ship_size_type(map_id, conn, val) + "type" -> maybe_update_type(map_id, conn, val) + _ -> :ok + end + if result == :ok do + {:cont, :ok} + else + {:halt, result} + end + end) + |> case do + :ok -> :ok + err -> err + end + end + + defp maybe_update_mass_status(_map_id, _conn, nil), do: :ok + defp maybe_update_mass_status(map_id, conn, value) do + Server.update_connection_mass_status(map_id, %{ + solar_system_source_id: conn.solar_system_source, + solar_system_target_id: conn.solar_system_target, + mass_status: value + }) + end + + defp maybe_update_ship_size_type(_map_id, _conn, nil), do: :ok + defp maybe_update_ship_size_type(map_id, conn, value) do + Server.update_connection_ship_size_type(map_id, %{ + solar_system_source_id: conn.solar_system_source, + solar_system_target_id: conn.solar_system_target, + ship_size_type: value + }) + end + + defp maybe_update_type(_map_id, _conn, nil), do: :ok + defp maybe_update_type(map_id, conn, value) do + Server.update_connection_type(map_id, %{ + solar_system_source_id: conn.solar_system_source, + solar_system_target_id: conn.solar_system_target, + type: value + }) + end + +end diff --git a/lib/wanderer_app/map/operations/owner.ex b/lib/wanderer_app/map/operations/owner.ex new file mode 100644 index 00000000..a24fef5a --- /dev/null +++ b/lib/wanderer_app/map/operations/owner.ex @@ -0,0 +1,75 @@ +defmodule WandererApp.Map.Operations.Owner do + @moduledoc """ + Handles fetching and caching of the main character info for a map owner. + """ + + # Cache TTL in milliseconds (24 hours) + @owner_info_cache_ttl 86_400_000 + + alias WandererApp.{ + MapRepo, + MapCharacterSettingsRepo, + MapUserSettingsRepo, + Cache + } + alias WandererApp.Character + alias WandererApp.Character.TrackingUtils + + @spec get_owner_character_id(String.t()) :: {:ok, %{id: term(), user_id: term()}} | {:error, String.t()} + def get_owner_character_id(map_id) do + cache_key = "map_#{map_id}:owner_info" + + case Cache.lookup!(cache_key) do + nil -> + with {:ok, owner} <- fetch_map_owner(map_id), + {:ok, char_ids} <- fetch_character_ids(map_id), + {:ok, characters} <- load_characters(char_ids), + {:ok, user_settings} <- MapUserSettingsRepo.get(map_id, owner.id), + {:ok, main} <- TrackingUtils.get_main_character(user_settings, characters, characters) do + result = %{id: main.id, user_id: main.user_id} + Cache.insert(cache_key, result, ttl: @owner_info_cache_ttl) + {:ok, result} + else + {:error, msg} -> {:error, msg} + _ -> {:error, "Failed to resolve main character"} + end + + cached -> + {:ok, cached} + end + end + + defp fetch_map_owner(map_id) do + case MapRepo.get(map_id, [:owner]) do + {:ok, %{owner: %_{} = owner}} -> {:ok, owner} + {:ok, %{owner: nil}} -> {:error, "Map has no owner"} + {:error, _} -> {:error, "Map not found"} + end + end + + defp fetch_character_ids(map_id) do + case MapCharacterSettingsRepo.get_all_by_map(map_id) do + {:ok, settings} when is_list(settings) and settings != [] -> + {:ok, Enum.map(settings, & &1.character_id)} + + {:ok, []} -> + {:error, "No character settings found"} + + {:error, _} -> + {:error, "Failed to fetch character settings"} + end + end + + defp load_characters(ids) when is_list(ids) do + ids + |> Enum.map(&Character.get_character/1) + |> Enum.flat_map(fn + {:ok, ch} -> [ch] + _ -> [] + end) + |> case do + [] -> {:error, "No valid characters found"} + chars -> {:ok, chars} + end + end +end diff --git a/lib/wanderer_app/map/operations/signatures.ex b/lib/wanderer_app/map/operations/signatures.ex new file mode 100644 index 00000000..6e01ec48 --- /dev/null +++ b/lib/wanderer_app/map/operations/signatures.ex @@ -0,0 +1,114 @@ +defmodule WandererApp.Map.Operations.Signatures do + @moduledoc """ + CRUD for map signatures. + """ + + require Logger + alias WandererApp.Map.Operations + alias WandererApp.Api.{MapSystem, MapSystemSignature} + alias WandererApp.Map.Server + + @spec list_signatures(String.t()) :: [map()] + def list_signatures(map_id) do + systems = Operations.list_systems(map_id) + if systems != [] do + systems + |> Enum.flat_map(fn sys -> + with {:ok, sigs} <- MapSystemSignature.by_system_id(sys.id) do + sigs + else + err -> + Logger.error("[list_signatures] error: #{inspect(err)}") + [] + end + end) + else + [] + end + end + + @spec create_signature(Plug.Conn.t(), map()) :: {:ok, map()} | {:error, atom()} + def create_signature(%{assigns: %{map_id: map_id, owner_character_id: char_id, owner_user_id: user_id}} = _conn, %{"solar_system_id" => _solar_system_id} = params) do + attrs = Map.put(params, "character_eve_id", char_id) + case Server.update_signatures(map_id, %{ + added_signatures: [attrs], + updated_signatures: [], + removed_signatures: [], + solar_system_id: params["solar_system_id"], + character_id: char_id, + user_id: user_id, + delete_connection_with_sigs: false + }) do + :ok -> {:ok, attrs} + err -> + Logger.error("[create_signature] Unexpected error: #{inspect(err)}") + {:error, :unexpected_error} + end + end + + def create_signature(_conn, _params), do: {:error, :missing_params} + + @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}} = _conn, sig_id, params) do + with {:ok, sig} <- MapSystemSignature.by_id(sig_id), + {:ok, system} <- MapSystem.by_id(sig.system_id) do + base = %{ + "eve_id" => sig.eve_id, + "name" => sig.name, + "kind" => sig.kind, + "group" => sig.group, + "type" => sig.type, + "custom_info" => sig.custom_info, + "character_eve_id" => char_id, + "description" => sig.description, + "linked_system_id" => sig.linked_system_id + } + attrs = Map.merge(base, params) + :ok = Server.update_signatures(map_id, %{ + added_signatures: [], + updated_signatures: [attrs], + removed_signatures: [], + solar_system_id: system.solar_system_id, + character_id: char_id, + user_id: user_id, + delete_connection_with_sigs: false + }) + {:ok, attrs} + else + err -> + Logger.error("[update_signature] Unexpected error: #{inspect(err)}") + {:error, :unexpected_error} + end + end + + def update_signature(_conn, _sig_id, _params), do: {:error, :missing_params} + + @spec delete_signature(Plug.Conn.t(), String.t()) :: :ok | {:error, atom()} + def delete_signature(%{assigns: %{map_id: map_id, owner_character_id: char_id, owner_user_id: user_id}} = _conn, sig_id) do + with {:ok, sig} <- MapSystemSignature.by_id(sig_id), + {:ok, system} <- MapSystem.by_id(sig.system_id) do + removed = [%{ + "eve_id" => sig.eve_id, + "name" => sig.name, + "kind" => sig.kind, + "group" => sig.group + }] + :ok = Server.update_signatures(map_id, %{ + added_signatures: [], + updated_signatures: [], + removed_signatures: removed, + solar_system_id: system.solar_system_id, + character_id: char_id, + user_id: user_id, + delete_connection_with_sigs: false + }) + :ok + else + err -> + Logger.error("[delete_signature] Unexpected error: #{inspect(err)}") + {:error, :unexpected_error} + end + end + + def delete_signature(_conn, _sig_id), do: {:error, :missing_params} +end diff --git a/lib/wanderer_app/map/operations/structures.ex b/lib/wanderer_app/map/operations/structures.ex new file mode 100644 index 00000000..9948fae6 --- /dev/null +++ b/lib/wanderer_app/map/operations/structures.ex @@ -0,0 +1,104 @@ +defmodule WandererApp.Map.Operations.Structures do + @moduledoc """ + CRUD for map structures. + """ + + alias WandererApp.Map.Operations + alias WandererApp.Api.MapSystem + alias WandererApp.Api.MapSystemStructure + alias WandererApp.Structure + require Logger + + @spec list_structures(String.t()) :: [map()] + def list_structures(map_id) do + with systems when is_list(systems) and systems != [] <- ( + case Operations.list_systems(map_id) do + {:ok, systems} -> systems + systems when is_list(systems) -> systems + _ -> [] + end + ) do + systems + |> Enum.flat_map(fn sys -> + with {:ok, structs} <- MapSystemStructure.by_system_id(sys.id) do + structs + else + _other -> [] + end + end) + else + _ -> [] + end + end + + @spec create_structure(Plug.Conn.t(), map()) :: {:ok, map()} | {:error, atom()} + def create_structure(%{assigns: %{map_id: map_id, owner_character_id: char_id, owner_user_id: user_id}} = _conn, %{"solar_system_id" => _solar_system_id} = params) do + with {:ok, system} <- MapSystem.read_by_map_and_solar_system(%{map_id: map_id, solar_system_id: params["solar_system_id"]}), + attrs <- Map.put(prepare_attrs(params), "system_id", system.id), + :ok <- Structure.update_structures(system, [attrs], [], [], char_id, user_id), + name = Map.get(attrs, "name"), + structure_type_id = Map.get(attrs, "structureTypeId"), + struct when not is_nil(struct) <- + MapSystemStructure.by_system_id!(system.id) + |> Enum.find(fn s -> s.name == name and s.structure_type_id == structure_type_id end) do + {:ok, struct} + else + nil -> + Logger.warning("[create_structure] Structure not found after creation") + {:error, :structure_not_found} + err -> + Logger.error("[create_structure] Unexpected error: #{inspect(err)}") + {:error, :unexpected_error} + end + end + + def create_structure(_conn, _params), do: {:error, "missing params"} + + @spec update_structure(Plug.Conn.t(), String.t(), map()) :: {:ok, map()} | {:error, atom()} + def update_structure(%{assigns: %{map_id: map_id, owner_character_id: char_id, owner_user_id: user_id}} = _conn, struct_id, params) do + with {:ok, struct} <- MapSystemStructure.by_id(struct_id), + {:ok, system} <- MapSystem.read_by_map_and_solar_system(%{map_id: map_id, solar_system_id: struct.solar_system_id}) do + attrs = Map.merge(prepare_attrs(params), %{"id" => struct_id}) + :ok = Structure.update_structures(system, [], [attrs], [], char_id, user_id) + case MapSystemStructure.by_id(struct_id) do + {:ok, updated} -> {:ok, updated} + err -> + Logger.error("[update_structure] Unexpected error: #{inspect(err)}") + {:error, :unexpected_error} + end + else + err -> + Logger.error("[update_structure] Unexpected error: #{inspect(err)}") + {:error, :unexpected_error} + end + end + + def update_structure(_conn, _struct_id, _params), do: {:error, "missing params"} + + @spec delete_structure(Plug.Conn.t(), String.t()) :: :ok | {:error, atom()} + def delete_structure(%{assigns: %{map_id: _map_id, owner_character_id: char_id, owner_user_id: user_id}} = _conn, struct_id) do + with {:ok, struct} <- MapSystemStructure.by_id(struct_id), + {:ok, system} <- MapSystem.by_id(struct.system_id) do + :ok = Structure.update_structures(system, [], [], [%{"id" => struct_id}], char_id, user_id) + :ok + else + err -> + Logger.error("[delete_structure] Unexpected error: #{inspect(err)}") + {:error, :unexpected_error} + end + end + + def delete_structure(_conn, _struct_id), do: {:error, "missing params"} + + defp prepare_attrs(params) do + params + |> Enum.map(fn + {"structure_type", v} -> {"structureType", v} + {"structure_type_id", v} -> {"structureTypeId", v} + {"end_time", v} -> {"endTime", v} + {k, v} -> {k, v} + end) + |> Map.new() + |> Map.take(["name", "structureType", "structureTypeId", "status", "notes", "endTime"]) + end +end diff --git a/lib/wanderer_app/map/operations/systems.ex b/lib/wanderer_app/map/operations/systems.ex new file mode 100644 index 00000000..e11d3b1e --- /dev/null +++ b/lib/wanderer_app/map/operations/systems.ex @@ -0,0 +1,195 @@ +defmodule WandererApp.Map.Operations.Systems do + @moduledoc """ + CRUD and batch upsert for map systems. + """ + + alias WandererApp.MapSystemRepo + alias WandererApp.Map.Server + alias WandererApp.Map.Operations.Connections + require Logger + + @spec list_systems(String.t()) :: [map()] + def list_systems(map_id) do + with {:ok, systems} <- MapSystemRepo.get_visible_by_map(map_id) do + systems + else + _ -> [] + end + end + + @spec get_system(String.t(), integer()) :: {:ok, map()} | {:error, :not_found} + def get_system(map_id, system_id) do + MapSystemRepo.get_by_map_and_solar_system_id(map_id, system_id) + end + + @spec create_system(Plug.Conn.t(), map()) :: {:ok, map()} | {:error, atom()} + def create_system(%{assigns: %{map_id: map_id, owner_character_id: char_id, owner_user_id: user_id}} = _conn, params) do + do_create_system(map_id, user_id, char_id, params) + end + def create_system(_conn, _params), do: {:error, :missing_params} + + # Private helper for batch upsert + defp create_system_batch(%{map_id: map_id, user_id: user_id, char_id: char_id}, params) do + do_create_system(map_id, user_id, char_id, params) + end + + defp do_create_system(map_id, user_id, char_id, params) do + with {:ok, system_id} <- fetch_system_id(params), + coords <- normalize_coordinates(params), + :ok <- Server.add_system(map_id, %{solar_system_id: system_id, coordinates: coords}, user_id, char_id), + {:ok, system} <- MapSystemRepo.get_by_map_and_solar_system_id(map_id, system_id) do + {:ok, system} + else + {:error, reason} when is_binary(reason) -> + Logger.warning("[do_create_system] Expected error: #{inspect(reason)}") + {:error, :expected_error} + _ -> + Logger.error("[do_create_system] Unexpected error") + {:error, :unexpected_error} + end + end + + @spec update_system(Plug.Conn.t(), integer(), map()) :: {:ok, map()} | {:error, atom()} + def update_system(%{assigns: %{map_id: map_id}} = _conn, system_id, attrs) do + with {:ok, current} <- MapSystemRepo.get_by_map_and_solar_system_id(map_id, system_id), + x_raw <- Map.get(attrs, "position_x", Map.get(attrs, :position_x, current.position_x)), + y_raw <- Map.get(attrs, "position_y", Map.get(attrs, :position_y, current.position_y)), + {:ok, x} <- parse_int(x_raw, "position_x"), + {:ok, y} <- parse_int(y_raw, "position_y"), + coords = %{x: x, y: y}, + :ok <- apply_system_updates(map_id, system_id, attrs, coords), + {:ok, system} <- MapSystemRepo.get_by_map_and_solar_system_id(map_id, system_id) do + {:ok, system} + else + {:error, reason} when is_binary(reason) -> + Logger.warning("[update_system] Expected error: #{inspect(reason)}") + {:error, :expected_error} + _ -> + Logger.error("[update_system] Unexpected error") + {:error, :unexpected_error} + end + end + def update_system(_conn, _system_id, _attrs), do: {:error, :missing_params} + + @spec delete_system(Plug.Conn.t(), integer()) :: {:ok, integer()} | {:error, atom()} + def delete_system(%{assigns: %{map_id: map_id, owner_character_id: char_id, owner_user_id: user_id}} = _conn, system_id) do + with {:ok, _} <- MapSystemRepo.get_by_map_and_solar_system_id(map_id, system_id), + :ok <- Server.delete_systems(map_id, [system_id], user_id, char_id) do + {:ok, 1} + else + {:error, :not_found} -> + Logger.warning("[delete_system] System not found: #{inspect(system_id)}") + {:error, :not_found} + _ -> + Logger.error("[delete_system] Unexpected error") + {:error, :unexpected_error} + end + end + def delete_system(_conn, _system_id), do: {:error, :missing_params} + + @spec upsert_systems_and_connections(Plug.Conn.t(), [map()], [map()]) :: {:ok, map()} | {:error, atom()} + def upsert_systems_and_connections(%{assigns: %{map_id: map_id, owner_character_id: char_id, owner_user_id: user_id}} = conn, systems, connections) do + assigns = %{map_id: map_id, user_id: user_id, char_id: char_id} + {created_s, updated_s, _skipped_s} = upsert_each(systems, fn sys -> create_system_batch(assigns, sys) end, 0, 0, 0) + conn_results = + connections + |> Enum.reduce(%{created: 0, updated: 0, skipped: 0}, fn conn_data, acc -> + case Connections.upsert_single(conn, conn_data) do + {:ok, :created} -> %{acc | created: acc.created + 1} + {:ok, :updated} -> %{acc | updated: acc.updated + 1} + _ -> %{acc | skipped: acc.skipped + 1} + end + end) + {:ok, %{ + systems: %{created: created_s, updated: updated_s}, + connections: %{created: conn_results.created, updated: conn_results.updated} + }} + end + def upsert_systems_and_connections(_conn, _systems, _connections), do: {:error, :missing_params} + + # -- Internal Helpers ------------------------------------------------------- + + defp fetch_system_id(%{"solar_system_id" => id}), do: parse_int(id, "solar_system_id") + defp fetch_system_id(%{solar_system_id: id}) when not is_nil(id), do: parse_int(id, "solar_system_id") + defp fetch_system_id(_), do: {:error, "Missing system identifier (id)"} + + defp parse_int(val, _field) when is_integer(val), do: {:ok, val} + defp parse_int(val, field) when is_binary(val) do + case Integer.parse(val) do + {i, _} -> {:ok, i} + _ -> {:error, "Invalid #{field}: #{val}"} + end + end + defp parse_int(nil, field), do: {:error, "Missing #{field}"} + defp parse_int(val, field), do: {:error, "Invalid #{field} type: #{inspect(val)}"} + + defp normalize_coordinates(%{"coordinates" => %{"x" => x, "y" => y}}) when is_number(x) and is_number(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} + 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)) + } + end + + defp apply_system_updates(map_id, system_id, attrs, %{x: x, y: y}) do + with :ok <- Server.update_system_position(map_id, %{solar_system_id: system_id, position_x: round(x), position_y: round(y)}) do + attrs + |> Map.drop([:coordinates, :position_x, :position_y, :solar_system_id, + "coordinates", "position_x", "position_y", "solar_system_id"]) + |> Enum.reduce_while(:ok, fn {key, val}, _ -> + case update_system_field(map_id, system_id, to_string(key), val) do + :ok -> {:cont, :ok} + err -> {:halt, err} + end + end) + end + end + + defp update_system_field(map_id, system_id, field, val) do + case field do + "status" -> Server.update_system_status(map_id, %{solar_system_id: system_id, status: convert_status(val)}) + "description" -> Server.update_system_description(map_id, %{solar_system_id: system_id, description: val}) + "tag" -> Server.update_system_tag(map_id, %{solar_system_id: system_id, tag: val}) + "locked" -> + bool = val in [true, "true", 1, "1"] + Server.update_system_locked(map_id, %{solar_system_id: system_id, locked: bool}) + f when f in ["label", "labels"] -> + labels = cond do + is_list(val) -> val + is_binary(val) -> String.split(val, ",", trim: true) + true -> [] + end + Server.update_system_labels(map_id, %{solar_system_id: system_id, labels: Enum.join(labels, ",")}) + "temporary_name" -> Server.update_system_temporary_name(map_id, %{solar_system_id: system_id, temporary_name: val}) + _ -> :ok + end + end + + defp convert_status("CLEAR"), do: 0 + defp convert_status("DANGEROUS"), do: 1 + defp convert_status("OCCUPIED"), do: 2 + defp convert_status("MASS_CRITICAL"), do: 3 + defp convert_status("TIME_CRITICAL"), do: 4 + defp convert_status("REINFORCED"), do: 5 + defp convert_status(i) when is_integer(i), do: i + defp convert_status(s) when is_binary(s) do + case Integer.parse(s) do + {i, _} -> i + _ -> 0 + end + end + defp convert_status(_), do: 0 + + defp upsert_each([], _fun, c, u, d), do: {c, u, d} + defp upsert_each([item | rest], fun, c, u, d) do + case fun.(item) do + {:ok, _} -> upsert_each(rest, fun, c + 1, u, d) + :ok -> upsert_each(rest, fun, c + 1, u, d) + {:skip, _} -> upsert_each(rest, fun, c, u + 1, d) + _ -> upsert_each(rest, fun, c, u, d + 1) + end + end +end diff --git a/lib/wanderer_app/repositories/map_user_settings_repo.ex b/lib/wanderer_app/repositories/map_user_settings_repo.ex index e6d27b3d..d0d5a00e 100644 --- a/lib/wanderer_app/repositories/map_user_settings_repo.ex +++ b/lib/wanderer_app/repositories/map_user_settings_repo.ex @@ -48,6 +48,33 @@ defmodule WandererApp.MapUserSettingsRepo do end end + def get_hubs(map_id, user_id) do + case WandererApp.MapUserSettingsRepo.get(map_id, user_id) do + {:ok, user_settings} when not is_nil(user_settings) -> + {:ok, Map.get(user_settings, :hubs, [])} + + _ -> + {:ok, []} + end + end + + def update_hubs(map_id, user_id, hubs) do + get!(map_id, user_id) + |> case do + user_settings when not is_nil(user_settings) -> + user_settings + |> WandererApp.Api.MapUserSettings.update_hubs(%{hubs: hubs}) + + _ -> + WandererApp.Api.MapUserSettings.create!(%{ + map_id: map_id, + user_id: user_id, + settings: @default_form_data |> Jason.encode!() + }) + |> WandererApp.Api.MapUserSettings.update_hubs(%{hubs: hubs}) + end + end + def to_form_data(nil), do: {:ok, @default_form_data} def to_form_data(%{settings: settings} = _user_settings), do: {:ok, Jason.decode!(settings)} diff --git a/lib/wanderer_app/structures.ex b/lib/wanderer_app/structures.ex index 47c66a39..cdb1fbf8 100644 --- a/lib/wanderer_app/structures.ex +++ b/lib/wanderer_app/structures.ex @@ -7,7 +7,8 @@ defmodule WandererApp.Structure do alias WandererApp.Api.MapSystemStructure alias WandererApp.Character - def update_structures(system, added, updated, removed, main_character_eve_id) do + def update_structures(system, added, updated, removed, main_character_eve_id, user_id \\ nil) do + Logger.info("[Structure] update_structures called by user_id=#{inspect(user_id)}") added_structs = parse_structures(added, main_character_eve_id, system) |> Enum.map(&Map.delete(&1, :id)) @@ -105,7 +106,28 @@ defmodule WandererApp.Structure do # remove PK so Ash doesn't treat it as a new record updated_data = Map.delete(updated_data, :id) - new_record = MapSystemStructure.update(existing, updated_data) + # Merge update data with existing record to avoid nil required fields + merged_data = Map.merge(Map.from_struct(existing), updated_data, fn _k, v1, v2 -> if is_nil(v2), do: v1, else: v2 end) + # Only keep fields accepted by Ash update action + allowed_keys = [ + :system_id, + :solar_system_name, + :solar_system_id, + :structure_type_id, + :structure_type, + :character_eve_id, + :name, + :notes, + :owner_name, + :owner_ticker, + :owner_id, + :status, + :end_time + ] + filtered_data = Map.take(merged_data, allowed_keys) + Logger.info("[Structure] update_structures_in_db: calling update for id=#{existing.id} with: #{inspect(filtered_data)}") + new_record = MapSystemStructure.update(existing, filtered_data) + Logger.info("[Structure] update_structures_in_db: update result for id=#{existing.id}: #{inspect(new_record)}") Logger.debug(fn -> "[Structure] updated record =>\n" <> inspect(new_record, pretty: true) diff --git a/lib/wanderer_app/zkb/zkb_kills_preloader.ex b/lib/wanderer_app/zkb/zkb_kills_preloader.ex index 799b3e30..787261d6 100644 --- a/lib/wanderer_app/zkb/zkb_kills_preloader.ex +++ b/lib/wanderer_app/zkb/zkb_kills_preloader.ex @@ -162,7 +162,7 @@ defmodule WandererApp.Zkb.KillsPreloader do "[KillsPreloader] Starting #{pass_type} pass => #{length(unique_systems)} systems" ) - {final_state, kills_map} = + {final_state, _kills_map} = unique_systems |> Task.async_stream( fn {_map_id, system_id} -> diff --git a/lib/wanderer_app/zkb/zkb_supervisor.ex b/lib/wanderer_app/zkb/zkb_supervisor.ex index 67d705a1..2714376f 100644 --- a/lib/wanderer_app/zkb/zkb_supervisor.ex +++ b/lib/wanderer_app/zkb/zkb_supervisor.ex @@ -23,7 +23,9 @@ defmodule WandererApp.Zkb.Supervisor do }, opts: [ name: {:local, :zkb_kills_provider}, - mint_upgrade_opts: [Mint.WebSocket.PerMessageDeflate] + reconnect: true, + reconnect_after: 5_000, + max_reconnects: :infinity ] }, preloader_child diff --git a/lib/wanderer_app/zkb/zkills_provider/websocket.ex b/lib/wanderer_app/zkb/zkills_provider/websocket.ex index 0a8faa29..50fabea1 100644 --- a/lib/wanderer_app/zkb/zkills_provider/websocket.ex +++ b/lib/wanderer_app/zkb/zkills_provider/websocket.ex @@ -11,7 +11,6 @@ defmodule WandererApp.Zkb.KillsProvider.Websocket do use Retry @heartbeat_interval 1_000 - @max_esi_retries 3 # Called by `KillsProvider.handle_connect` def handle_connect(_status, _headers, %{connected: _} = state) do diff --git a/lib/wanderer_app_web/api_spec.ex b/lib/wanderer_app_web/api_spec.ex index 2d0a2fc6..00fbf959 100644 --- a/lib/wanderer_app_web/api_spec.ex +++ b/lib/wanderer_app_web/api_spec.ex @@ -27,6 +27,5 @@ defmodule WandererAppWeb.ApiSpec do }, security: [%{"bearerAuth" => []}] } - |> OpenApiSpex.resolve_schema_modules() end end diff --git a/lib/wanderer_app_web/controllers/common_api_controller.ex b/lib/wanderer_app_web/controllers/common_api_controller.ex index 02b4647f..4ab00b23 100644 --- a/lib/wanderer_app_web/controllers/common_api_controller.ex +++ b/lib/wanderer_app_web/controllers/common_api_controller.ex @@ -4,7 +4,6 @@ defmodule WandererAppWeb.CommonAPIController do alias WandererApp.CachedInfo alias WandererAppWeb.Helpers.APIUtils - alias WandererApp.EveDataService @system_static_response_schema %OpenApiSpex.Schema{ type: :object, @@ -113,11 +112,6 @@ defmodule WandererAppWeb.CommonAPIController do end end - @doc """ - Converts a system map to a JSON-friendly format. - - Takes only the fields that are needed for the API response. - """ defp static_system_to_json(system) do system |> Map.take([ @@ -142,12 +136,6 @@ defmodule WandererAppWeb.CommonAPIController do ]) end - @doc """ - Enhances system data with wormhole type information. - - If the system has static wormholes, adds detailed information about each static. - Otherwise, returns the original data unchanged. - """ defp enhance_with_static_details(data) do if data[:statics] && length(data[:statics]) > 0 do # Add the enhanced static details to the response @@ -158,11 +146,6 @@ defmodule WandererAppWeb.CommonAPIController do end end - @doc """ - Gets detailed information for each static wormhole. - - Uses the CachedInfo to get both wormhole type data and wormhole class data. - """ defp get_static_details(statics) do # Get wormhole data from CachedInfo {:ok, wormhole_types} = CachedInfo.get_wormhole_types() @@ -186,12 +169,6 @@ defmodule WandererAppWeb.CommonAPIController do end) end - @doc """ - Creates detailed wormhole information when the wormhole type is found. - - Includes information about the destination and properties of the wormhole. - Ensures that destination.id is always a string to match the OpenAPI schema. - """ defp create_wormhole_details(wh_type, classes_by_id) do # Get destination class info dest_class = Map.get(classes_by_id, wh_type.dest) @@ -213,11 +190,6 @@ defmodule WandererAppWeb.CommonAPIController do } end - @doc """ - Creates fallback information when a wormhole type is not found. - - Provides a placeholder structure with nil values for unknown wormhole types. - """ defp create_fallback_wormhole_details(static_name) do %{ name: static_name, diff --git a/lib/wanderer_app_web/controllers/map_api_controller.ex b/lib/wanderer_app_web/controllers/map_api_controller.ex index dce1c231..3679146e 100644 --- a/lib/wanderer_app_web/controllers/map_api_controller.ex +++ b/lib/wanderer_app_web/controllers/map_api_controller.ex @@ -19,7 +19,6 @@ defmodule WandererAppWeb.MapAPIController do # Basic entity schemas @character_schema ApiSchemas.character_schema() - @solar_system_schema ApiSchemas.solar_system_basic_schema() # Character tracking schemas @character_tracking_schema %OpenApiSpex.Schema{ diff --git a/lib/wanderer_app_web/controllers/map_connection_api_controller.ex b/lib/wanderer_app_web/controllers/map_connection_api_controller.ex index 70ea2505..20e39775 100644 --- a/lib/wanderer_app_web/controllers/map_connection_api_controller.ex +++ b/lib/wanderer_app_web/controllers/map_connection_api_controller.ex @@ -8,77 +8,145 @@ defmodule WandererAppWeb.MapConnectionAPIController do use WandererAppWeb, :controller use OpenApiSpex.ControllerSpecs + require Logger + alias OpenApiSpex.Schema alias WandererApp.Map, as: MapData alias WandererApp.Map.Operations alias WandererAppWeb.Helpers.APIUtils - alias WandererAppWeb.Schemas.{ApiSchemas, ResponseSchemas} + alias WandererAppWeb.Schemas.ResponseSchemas action_fallback WandererAppWeb.FallbackController # -- JSON Schemas -- - @map_connection_schema %Schema{ - type: :object, - properties: %{ - id: %Schema{type: :string, description: "Unique connection ID"}, - map_id: %Schema{type: :string, description: "Map UUID"}, - solar_system_source: %Schema{type: :integer, description: "Source system ID"}, - solar_system_target: %Schema{type: :integer, description: "Target system ID"}, - type: %Schema{type: :integer, description: "Connection type"}, - mass_status: %Schema{type: :integer, description: "Mass status (0-3)"}, - time_status: %Schema{type: :integer, description: "Time status (0-3)"}, - ship_size_type: %Schema{type: :integer, description: "Ship size limit (0-3)"}, - locked: %Schema{type: :boolean, description: "Locked flag"}, - custom_info: %Schema{type: :string, nullable: true, description: "Optional metadata"}, - wormhole_type: %Schema{type: :string, nullable: true, description: "Wormhole code"} - }, - required: ~w(id map_id solar_system_source solar_system_target)a - } - @connection_request_schema %Schema{ type: :object, properties: %{ solar_system_source: %Schema{type: :integer, description: "Source system ID"}, solar_system_target: %Schema{type: :integer, description: "Target system ID"}, - type: %Schema{type: :integer, description: "Connection type (default 0)"} + type: %Schema{type: :integer, description: "Connection type (default 0)"}, + mass_status: %Schema{type: :integer, description: "Mass status (0-3)", nullable: true}, + time_status: %Schema{type: :integer, description: "Time status (0-3)", nullable: true}, + ship_size_type: %Schema{type: :integer, description: "Ship size limit (0-3)", nullable: true}, + locked: %Schema{type: :boolean, description: "Locked flag", nullable: true}, + custom_info: %Schema{type: :string, nullable: true, description: "Optional metadata"}, + wormhole_type: %Schema{type: :string, nullable: true, description: "Wormhole code"} }, required: ~w(solar_system_source solar_system_target)a, - example: %{solar_system_source: 30_000_142, solar_system_target: 30_000_144, type: 0} + example: %{ + solar_system_source: 30_000_142, + solar_system_target: 30_000_144, + type: 0, + mass_status: 1, + time_status: 2, + ship_size_type: 1, + locked: false, + custom_info: "Frigate only", + wormhole_type: "C2" + } } - @batch_delete_schema %Schema{ + @list_response_schema %Schema{ type: :object, properties: %{ - connection_ids: %Schema{ + data: %Schema{ type: :array, - items: %Schema{type: :string, description: "Connection UUID"}, - description: "IDs to delete" + items: %Schema{ + type: :object, + properties: %{ + id: %Schema{type: :string}, + map_id: %Schema{type: :string}, + solar_system_source: %Schema{type: :integer}, + solar_system_target: %Schema{type: :integer}, + type: %Schema{type: :integer}, + mass_status: %Schema{type: :integer}, + time_status: %Schema{type: :integer}, + ship_size_type: %Schema{type: :integer}, + locked: %Schema{type: :boolean}, + custom_info: %Schema{type: :string, nullable: true}, + wormhole_type: %Schema{type: :string, nullable: true} + } + } } }, - required: ["connection_ids"] + example: %{ + data: [ + %{ + id: "conn-uuid-1", + map_id: "map-uuid-1", + solar_system_source: 30_000_142, + solar_system_target: 30_000_144, + type: 0, + mass_status: 1, + time_status: 2, + ship_size_type: 1, + locked: false, + custom_info: "Frigate only", + wormhole_type: "C2" + } + ] + } } - @list_response_schema ApiSchemas.data_wrapper(%Schema{type: :array, items: @map_connection_schema}) - @detail_response_schema ApiSchemas.data_wrapper(@map_connection_schema) - @batch_delete_response_schema ApiSchemas.data_wrapper( - %Schema{ - type: :object, - properties: %{deleted_count: %Schema{type: :integer, description: "Deleted count"}}, - required: ["deleted_count"] + @detail_response_schema %Schema{ + type: :object, + properties: %{ + data: %Schema{ + type: :object, + properties: %{ + id: %Schema{type: :string}, + map_id: %Schema{type: :string}, + solar_system_source: %Schema{type: :integer}, + solar_system_target: %Schema{type: :integer}, + type: %Schema{type: :integer}, + mass_status: %Schema{type: :integer}, + time_status: %Schema{type: :integer}, + ship_size_type: %Schema{type: :integer}, + locked: %Schema{type: :boolean}, + custom_info: %Schema{type: :string, nullable: true}, + wormhole_type: %Schema{type: :string, nullable: true} + } + } + }, + example: %{ + data: %{ + id: "conn-uuid-1", + map_id: "map-uuid-1", + solar_system_source: 30_000_142, + solar_system_target: 30_000_144, + type: 0, + mass_status: 1, + time_status: 2, + ship_size_type: 1, + locked: false, + custom_info: "Frigate only", + wormhole_type: "C2" + } } - ) + } # -- Actions -- operation :index, summary: "List Map Connections", parameters: [ - map_slug: [in: :path, type: :string], - map_id: [in: :path, type: :string], + map_identifier: [ + in: :path, + description: "Map identifier (UUID or slug). Provide either a UUID or a slug.", + type: :string, + required: true, + example: "00000000-0000-0000-0000-000000000000 or my-map-slug" + ], solar_system_source: [in: :query, type: :integer, required: false], solar_system_target: [in: :query, type: :integer, required: false] ], - responses: ResponseSchemas.standard_responses(@list_response_schema) + responses: [ + ok: { + "List Map Connections", + "application/json", + @list_response_schema + } + ] def index(%{assigns: %{map_id: map_id}} = conn, params) do with {:ok, src_filter} <- parse_optional(params, "solar_system_source"), {:ok, tgt_filter} <- parse_optional(params, "solar_system_target") do @@ -116,7 +184,18 @@ defmodule WandererAppWeb.MapConnectionAPIController do operation :show, summary: "Show Connection (by id or by source/target)", - parameters: [map_slug: [in: :path], map_id: [in: :path], id: [in: :path, required: false], solar_system_source: [in: :query, type: :integer, required: false], solar_system_target: [in: :query, type: :integer, required: false]], + parameters: [ + map_identifier: [ + in: :path, + description: "Map identifier (UUID or slug). Provide either a UUID or a slug.", + type: :string, + required: true, + example: "00000000-0000-0000-0000-000000000000 or my-map-slug" + ], + id: [in: :path, type: :string, required: false], + solar_system_source: [in: :query, type: :integer, required: false], + solar_system_target: [in: :query, type: :integer, required: false] + ], responses: ResponseSchemas.standard_responses(@detail_response_schema) def show(%{assigns: %{map_id: map_id}} = conn, %{"id" => id}) do case Operations.get_connection(map_id, id) do @@ -136,12 +215,20 @@ defmodule WandererAppWeb.MapConnectionAPIController do operation :create, summary: "Create Connection", - parameters: [map_slug: [in: :path], map_id: [in: :path], system_id: [in: :path]], + parameters: [ + map_identifier: [ + in: :path, + description: "Map identifier (UUID or slug). Provide either a UUID or a slug.", + type: :string, + required: true, + example: "00000000-0000-0000-0000-000000000000 or my-map-slug" + ], + system_id: [in: :path, type: :string, required: false] + ], request_body: {"Connection create", "application/json", @connection_request_schema}, responses: ResponseSchemas.create_responses(@detail_response_schema) def create(conn, params) do - map_id = conn.assigns[:map_id] - case Operations.create_connection(params, map_id) do + case Operations.create_connection(conn, params) do {:ok, conn_struct} when is_map(conn_struct) -> conn |> APIUtils.respond_data(APIUtils.connection_to_json(conn_struct), :created) @@ -157,73 +244,115 @@ defmodule WandererAppWeb.MapConnectionAPIController do conn |> put_status(:bad_request) |> json(%{error: reason}) - other -> + _other -> conn |> put_status(:internal_server_error) |> json(%{error: "Unexpected error"}) end end - def create(_, _), do: {:error, :bad_request} - operation :delete, summary: "Delete Connection (by id or by source/target)", - parameters: [map_slug: [in: :path], map_id: [in: :path], id: [in: :path, required: false], solar_system_source: [in: :query, type: :integer, required: false], solar_system_target: [in: :query, type: :integer, required: false]], + parameters: [ + map_identifier: [ + in: :path, + description: "Map identifier (UUID or slug). Provide either a UUID or a slug.", + type: :string, + required: true, + example: "00000000-0000-0000-0000-000000000000 or my-map-slug" + ], + id: [in: :path, type: :string, required: false], + solar_system_source: [in: :query, type: :integer, required: false], + solar_system_target: [in: :query, type: :integer, required: false] + ], responses: ResponseSchemas.delete_responses(nil) - def delete(%{assigns: %{map_id: map_id}} = conn, %{"id" => id}) do - case Operations.get_connection(map_id, id) do + def delete(%{assigns: %{map_id: _map_id}} = conn, %{"id" => id}) do + delete_connection_id(conn, id) + end + + def delete(%{assigns: %{map_id: _map_id}} = conn, %{"solar_system_source" => src, "solar_system_target" => tgt}) do + delete_by_systems(conn, src, tgt) + end + + # Private helpers for delete/2 + + defp delete_connection_id(conn, id) do + case Operations.get_connection(conn, id) do {:ok, conn_struct} -> - case MapData.remove_connection(map_id, conn_struct) do + source_id = conn_struct.solar_system_source + target_id = conn_struct.solar_system_target + case Operations.delete_connection(conn, source_id, target_id) do + :ok -> {:ok, conn_struct} + error -> error + end + _ -> {:error, :invalid_id} + end + end + + defp delete_by_systems(conn, src, tgt) do + with {:ok, source} <- APIUtils.parse_int(src), + {:ok, target} <- APIUtils.parse_int(tgt) do + do_delete_by_systems(conn, source, target, src, tgt) + else + {:error, :not_found} -> + Logger.error("[delete_connection] Connection not found for source=#{inspect(src)}, target=#{inspect(tgt)}") + {:error, :not_found} + {:error, reason} -> + Logger.error("[delete_connection] Error: #{inspect(reason)}") + {:error, reason} + error -> + Logger.error("[delete_connection] Unexpected error: #{inspect(error)}") + {:error, :internal_server_error} + end + end + + defp do_delete_by_systems(conn, source, target, src, tgt) do + map_id = conn.assigns.map_id + case Operations.get_connection_by_systems(map_id, source, target) do + {:ok, nil} -> + Logger.error("[delete_connection] No connection found for source=#{inspect(source)}, target=#{inspect(target)}") + try_reverse_delete(conn, source, target, src, tgt) + {:ok, conn_struct} -> + case Operations.delete_connection(conn, conn_struct.solar_system_source, conn_struct.solar_system_target) do :ok -> send_resp(conn, :no_content, "") error -> {:error, error} end - err -> err - end - end - def delete(%{assigns: %{map_id: map_id}} = conn, %{"solar_system_source" => src, "solar_system_target" => tgt}) do - with {:ok, source} <- APIUtils.parse_int(src), - {:ok, target} <- APIUtils.parse_int(tgt), - {:ok, conn_struct} <- Operations.get_connection_by_systems(map_id, source, target) do - case MapData.remove_connection(map_id, conn_struct) do - :ok -> send_resp(conn, :no_content, "") - error -> {:error, error} - end - else - err -> err + {:error, _} -> + try_reverse_delete(conn, source, target, src, tgt) end end - operation :batch_delete, - summary: "Batch Delete Connections", - parameters: [map_slug: [in: :path], map_id: [in: :path]], - request_body: {"Batch delete", "application/json", @batch_delete_schema}, - responses: ResponseSchemas.standard_responses(@batch_delete_response_schema), - deprecated: true, - description: "Deprecated. Use individual DELETE requests instead." - def batch_delete(%{assigns: %{map_id: map_id}} = conn, %{"connection_ids" => ids}) - when is_list(ids) do - deleted_count = - ids - |> Enum.map(&fetch_and_delete(map_id, &1)) - |> Enum.count(&(&1 == :ok)) - - APIUtils.respond_data(conn, %{deleted_count: deleted_count}) + defp try_reverse_delete(conn, source, target, src, tgt) do + map_id = conn.assigns.map_id + case Operations.get_connection_by_systems(map_id, target, source) do + {:ok, nil} -> + Logger.error("[delete_connection] No connection found for source=#{inspect(target)}, target=#{inspect(source)}") + {:error, :not_found} + {:ok, conn_struct} -> + case Operations.delete_connection(conn, conn_struct.solar_system_source, conn_struct.solar_system_target) do + :ok -> send_resp(conn, :no_content, "") + error -> {:error, error} + end + {:error, reason} -> + Logger.error("[delete_connection] Connection not found for source=#{inspect(src)}, target=#{inspect(tgt)} (both orders)") + {:error, reason} + end end - def batch_delete(_, _), do: {:error, :bad_request} - - # -- Legacy route -- - @deprecated "Use GET /api/maps/:map_identifier/systems/:system_id/connections instead" - operation :list_all_connections, - summary: "List All Connections (Legacy)", - deprecated: true, - parameters: [map_id: [in: :query]], - responses: ResponseSchemas.standard_responses(@list_response_schema) - defdelegate list_all_connections(conn, params), to: __MODULE__, as: :index - operation :update, summary: "Update Connection (by id or by source/target)", - parameters: [map_slug: [in: :path], map_id: [in: :path], id: [in: :path, required: false], solar_system_source: [in: :query, type: :integer, required: false], solar_system_target: [in: :query, type: :integer, required: false]], + parameters: [ + map_identifier: [ + in: :path, + description: "Map identifier (UUID or slug). Provide either a UUID or a slug.", + type: :string, + required: true, + example: "00000000-0000-0000-0000-000000000000 or my-map-slug" + ], + id: [in: :path, type: :string, required: false], + solar_system_source: [in: :query, type: :integer, required: false], + solar_system_target: [in: :query, type: :integer, required: false] + ], request_body: {"Connection update", "application/json", @connection_request_schema}, responses: ResponseSchemas.standard_responses(@detail_response_schema) def update(%{assigns: %{map_id: map_id}} = conn, %{"id" => id}) do @@ -233,11 +362,9 @@ defmodule WandererAppWeb.MapConnectionAPIController do |> Map.take(allowed_fields) |> Enum.reject(fn {_k, v} -> is_nil(v) end) |> Enum.into(%{}) - case Operations.update_connection(map_id, id, attrs) do - {:ok, updated_conn} -> APIUtils.respond_data(conn, APIUtils.connection_to_json(updated_conn)) - err -> err - end + update_by_id(conn, map_id, id, attrs) end + def update(%{assigns: %{map_id: map_id}} = conn, %{"solar_system_source" => src, "solar_system_target" => tgt}) do allowed_fields = ["mass_status", "ship_size_type", "locked", "custom_info", "type"] attrs = @@ -245,42 +372,82 @@ defmodule WandererAppWeb.MapConnectionAPIController do |> Map.take(allowed_fields) |> Enum.reject(fn {_k, v} -> is_nil(v) end) |> Enum.into(%{}) + update_by_systems(conn, map_id, src, tgt, attrs) + end + + # Private helpers for update/2 + + defp update_by_id(conn, _map_id, id, attrs) do + case Operations.update_connection(conn, id, attrs) do + {:ok, updated_conn} -> APIUtils.respond_data(conn, APIUtils.connection_to_json(updated_conn)) + err -> err + end + end + + defp update_by_systems(conn, _map_id, src, tgt, attrs) do + require Logger with {:ok, source} <- APIUtils.parse_int(src), - {:ok, target} <- APIUtils.parse_int(tgt), - {:ok, conn_struct} <- Operations.get_connection_by_systems(map_id, source, target), - {:ok, updated_conn} <- Operations.update_connection(map_id, conn_struct.id, attrs) do - APIUtils.respond_data(conn, APIUtils.connection_to_json(updated_conn)) + {:ok, target} <- APIUtils.parse_int(tgt) do + do_update_by_systems(conn, source, target, src, tgt, attrs) else {:error, :not_found} -> + Logger.error("[update_connection] Connection not found for source=#{inspect(src)}, target=#{inspect(tgt)}") {:error, :not_found} {:error, reason} -> + Logger.error("[update_connection] Error: #{inspect(reason)}") {:error, reason} error -> + Logger.error("[update_connection] Unexpected error: #{inspect(error)}") {:error, :internal_server_error} end end - # -- Helpers -- - - defp involves_system?(%{"solar_system_source" => s, "solar_system_target" => t}, id), - do: s == id or t == id - - defp involves_system?(%{solar_system_source: s, solar_system_target: t}, id), - do: s == id or t == id - - defp fetch_and_delete(map_id, id) do - case Operations.get_connection(map_id, id) do - {:ok, conn_struct} -> MapData.remove_connection(map_id, conn_struct) - _ -> :error + defp do_update_by_systems(conn, source, target, src, tgt, attrs) do + map_id = conn.assigns.map_id + case Operations.get_connection_by_systems(map_id, source, target) do + {:ok, nil} -> + Logger.error("[update_connection] No connection found for source=#{inspect(source)}, target=#{inspect(target)}") + try_reverse_update(conn, source, target, src, tgt, attrs) + {:ok, conn_struct} -> + do_update_connection(conn, conn_struct.id, attrs) + {:error, _} -> + try_reverse_update(conn, source, target, src, tgt, attrs) end end - defp fetch_connection!(map_id, id) do - MapData.list_connections!(map_id) - |> Enum.find(&(&1.id == id)) - |> case do - nil -> raise "Connection #{id} not found" - conn -> conn + defp try_reverse_update(conn, source, target, src, tgt, attrs) do + map_id = conn.assigns.map_id + case Operations.get_connection_by_systems(map_id, target, source) do + {:ok, nil} -> + Logger.error("[update_connection] No connection found for source=#{inspect(target)}, target=#{inspect(source)}") + {:error, :not_found} + {:ok, conn_struct} -> + do_update_connection(conn, conn_struct.id, attrs) + {:error, reason} -> + Logger.error("[update_connection] Connection not found for source=#{inspect(src)}, target=#{inspect(tgt)} (both orders)") + {:error, reason} end end + + defp do_update_connection(conn, id, attrs) do + case Operations.update_connection(conn, id, attrs) do + {:ok, updated_conn} -> APIUtils.respond_data(conn, APIUtils.connection_to_json(updated_conn)) + {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]}} -> + Logger.error("[update_connection] Ash update NotFound for id=#{id}") + {:error, :not_found} + err -> err + end + end + + @deprecated "Use GET /api/maps/:map_identifier/systems instead" + operation :list_all_connections, + summary: "List All Connections (Legacy)", + deprecated: true, + parameters: [map_id: [in: :query]], + responses: ResponseSchemas.standard_responses(@list_response_schema) + def list_all_connections(%{assigns: %{map_id: map_id}} = conn, _params) do + connections = Operations.list_connections(map_id) + data = Enum.map(connections, &APIUtils.connection_to_json/1) + APIUtils.respond_data(conn, data) + end end diff --git a/lib/wanderer_app_web/controllers/map_system_api_controller.ex b/lib/wanderer_app_web/controllers/map_system_api_controller.ex index 7a047575..a89cff92 100644 --- a/lib/wanderer_app_web/controllers/map_system_api_controller.ex +++ b/lib/wanderer_app_web/controllers/map_system_api_controller.ex @@ -77,40 +77,143 @@ defmodule WandererAppWeb.MapSystemAPIController do labels: %Schema{type: :array, items: %Schema{type: :string}, nullable: true, description: "Labels"} }, example: %{ - position_x: 100.5, - position_y: 200.3, - visible: true + solar_system_name: "Jita", + position_x: 101.0, + position_y: 202.0, + visible: false, + status: "active", + tag: "HQ", + locked: true } } - @list_response_schema ApiSchemas.data_wrapper(%Schema{type: :array, items: @map_system_schema}) - @detail_response_schema ApiSchemas.data_wrapper(@map_system_schema) - @delete_response_schema ApiSchemas.data_wrapper(%Schema{ - type: :object, - properties: %{deleted: %Schema{type: :boolean, description: "Deleted flag"}}, - required: ["deleted"] - }) - - @batch_response_schema ApiSchemas.data_wrapper(%Schema{ + @map_connection_schema %Schema{ type: :object, properties: %{ - systems: %Schema{ - type: :object, - properties: %{created: %Schema{type: :integer}, updated: %Schema{type: :integer}}, - required: ~w(created updated)a - }, - connections: %Schema{ + id: %Schema{type: :string, description: "Connection UUID"}, + map_id: %Schema{type: :string, description: "Map UUID"}, + solar_system_source: %Schema{type: :integer}, + solar_system_target: %Schema{type: :integer}, + type: %Schema{type: :integer}, + mass_status: %Schema{type: :integer, nullable: true}, + time_status: %Schema{type: :integer, nullable: true}, + ship_size_type: %Schema{type: :integer, nullable: true}, + locked: %Schema{type: :boolean}, + custom_info: %Schema{type: :string, nullable: true}, + wormhole_type: %Schema{type: :string, nullable: true} + }, + required: ~w(id map_id solar_system_source solar_system_target)a + } + + @list_response_schema %Schema{ + type: :object, + properties: %{ + data: %Schema{ type: :object, properties: %{ - created: %Schema{type: :integer}, - updated: %Schema{type: :integer}, - deleted: %Schema{type: :integer} - }, - required: ~w(created updated deleted)a + systems: %Schema{type: :array, items: @map_system_schema}, + connections: %Schema{type: :array, items: @map_connection_schema} + } } }, - required: ~w(systems connections)a - }) + example: %{ + data: %{ + systems: [ + %{ + id: "sys-uuid-1", + map_id: "map-uuid-1", + solar_system_id: 30_000_142, + solar_system_name: "Jita", + region_name: "The Forge", + position_x: 100.5, + position_y: 200.3, + status: "active", + visible: true, + description: "Trade hub", + tag: "HQ", + locked: false, + temporary_name: nil, + labels: ["market", "hub"] + } + ], + connections: [ + %{ + id: "conn-uuid-1", + map_id: "map-uuid-1", + solar_system_source: 30_000_142, + solar_system_target: 30_000_144, + type: 0, + mass_status: 1, + time_status: 2, + ship_size_type: 1, + locked: false, + custom_info: "Frigate only", + wormhole_type: "C2" + } + ] + } + } + } + + @detail_response_schema %Schema{ + type: :object, + properties: %{ + data: @map_system_schema + }, + example: %{ + data: %{ + id: "sys-uuid-1", + map_id: "map-uuid-1", + solar_system_id: 30_000_142, + solar_system_name: "Jita", + region_name: "The Forge", + position_x: 100.5, + position_y: 200.3, + status: "active", + visible: true, + description: "Trade hub", + tag: "HQ", + locked: false, + temporary_name: nil, + labels: ["market", "hub"] + } + } + } + + @delete_response_schema %Schema{ + type: :object, + properties: %{deleted: %Schema{type: :boolean, description: "Deleted flag"}}, + required: ["deleted"], + example: %{deleted: true} + } + + @batch_response_schema %Schema{ + type: :object, + properties: %{ + data: %Schema{ + type: :object, + properties: %{ + systems: %Schema{ + type: :object, + properties: %{created: %Schema{type: :integer}, updated: %Schema{type: :integer}}, + required: ~w(created updated)a + }, + connections: %Schema{ + type: :object, + properties: %{created: %Schema{type: :integer}, updated: %Schema{type: :integer}, deleted: %Schema{type: :integer}}, + required: ~w(created updated deleted)a + } + }, + required: ~w(systems connections)a + } + }, + example: %{ + data: %{ + systems: %{created: 2, updated: 1}, + connections: %{created: 1, updated: 0, deleted: 1} + } + } + } @batch_delete_schema %Schema{ type: :object, @@ -127,14 +230,19 @@ defmodule WandererAppWeb.MapSystemAPIController do nullable: true } }, - required: ["system_ids"] + required: ["system_ids"], + example: %{ + system_ids: [30_000_142, 30_000_143], + connection_ids: ["conn-uuid-1", "conn-uuid-2"] + } } - @batch_delete_response_schema ApiSchemas.data_wrapper(%Schema{ + @batch_delete_response_schema %Schema{ type: :object, properties: %{deleted_count: %Schema{type: :integer, description: "Deleted count"}}, - required: ["deleted_count"] - }) + required: ["deleted_count"], + example: %{deleted_count: 2} + } @batch_request_schema ApiSchemas.data_wrapper(%Schema{ type: :object, @@ -154,6 +262,24 @@ defmodule WandererAppWeb.MapSystemAPIController do }, required: ~w(solar_system_source solar_system_target)a }} + }, + example: %{ + systems: [ + %{ + solar_system_id: 30_000_142, + solar_system_name: "Jita", + position_x: 100.5, + position_y: 200.3, + visible: true + } + ], + connections: [ + %{ + solar_system_source: 30_000_142, + solar_system_target: 30_000_144, + type: 0 + } + ] } }) @@ -161,8 +287,22 @@ defmodule WandererAppWeb.MapSystemAPIController do operation :index, summary: "List Map Systems and Connections", - parameters: [map_slug: [in: :path], map_id: [in: :path]], - responses: ResponseSchemas.standard_responses(@list_response_schema) + parameters: [ + map_identifier: [ + in: :path, + description: "Map identifier (UUID or slug). Provide either a UUID or a slug.", + type: :string, + required: true, + example: "00000000-0000-0000-0000-000000000000 or my-map-slug" + ] + ], + responses: [ + ok: { + "List Map Systems and Connections", + "application/json", + @list_response_schema + } + ] def index(%{assigns: %{map_id: map_id}} = conn, _params) do systems = Operations.list_systems(map_id) |> Enum.map(&APIUtils.map_system_to_json/1) connections = Operations.list_connections(map_id) |> Enum.map(&APIUtils.connection_to_json/1) @@ -171,7 +311,16 @@ defmodule WandererAppWeb.MapSystemAPIController do operation :show, summary: "Show Map System", - parameters: [map_slug: [in: :path], map_id: [in: :path], id: [in: :path]], + parameters: [ + map_identifier: [ + in: :path, + description: "Map identifier (UUID or slug). Provide either a UUID or a slug.", + type: :string, + required: true, + example: "00000000-0000-0000-0000-000000000000 or my-map-slug" + ], + id: [in: :path, type: :string, required: true] + ], responses: ResponseSchemas.standard_responses(@detail_response_schema) def show(%{assigns: %{map_id: map_id}} = conn, %{"id" => id}) do with {:ok, system_id} <- APIUtils.parse_int(id), @@ -182,14 +331,23 @@ defmodule WandererAppWeb.MapSystemAPIController do operation :create, summary: "Upsert Systems and Connections (batch or single)", + parameters: [ + map_identifier: [ + in: :path, + description: "Map identifier (UUID or slug). Provide either a UUID or a slug.", + type: :string, + required: true, + example: "00000000-0000-0000-0000-000000000000 or my-map-slug" + ] + ], request_body: {"Systems+Connections upsert", "application/json", @batch_request_schema}, responses: ResponseSchemas.standard_responses(@batch_response_schema) - def create(%{assigns: %{map_id: map_id}} = conn, params) do + def create(conn, params) do systems = Map.get(params, "systems", []) connections = Map.get(params, "connections", []) - with {:ok, result} <- Operations.upsert_systems_and_connections(map_id, systems, connections) do - APIUtils.respond_data(conn, result) - else + case Operations.upsert_systems_and_connections(conn, systems, connections) do + {:ok, result} -> + APIUtils.respond_data(conn, result) error -> error end @@ -197,43 +355,46 @@ defmodule WandererAppWeb.MapSystemAPIController do operation :update, summary: "Update System", - parameters: [map_slug: [in: :path], map_id: [in: :path], id: [in: :path]], + parameters: [ + map_identifier: [ + in: :path, + description: "Map identifier (UUID or slug). Provide either a UUID or a slug.", + type: :string, + required: true, + example: "00000000-0000-0000-0000-000000000000 or my-map-slug" + ], + id: [in: :path, type: :string, required: true] + ], request_body: {"System update request", "application/json", @system_update_schema}, responses: ResponseSchemas.update_responses(@detail_response_schema) - def update(%{assigns: %{map_id: map_id}} = conn, %{"id" => id} = params) do + def update(conn, %{"id" => id} = params) do with {:ok, sid} <- APIUtils.parse_int(id), {:ok, attrs} <- APIUtils.extract_update_params(params), update_attrs = Map.put(attrs, "solar_system_id", sid), - {:ok, system} <- Operations.update_system(map_id, sid, update_attrs) do + {:ok, system} <- Operations.update_system(conn, sid, update_attrs) do APIUtils.respond_data(conn, APIUtils.map_system_to_json(system)) end end operation :delete, summary: "Batch Delete Systems and Connections", + parameters: [ + map_identifier: [ + in: :path, + description: "Map identifier (UUID or slug). Provide either a UUID or a slug.", + type: :string, + required: true, + example: "00000000-0000-0000-0000-000000000000 or my-map-slug" + ] + ], request_body: {"Batch delete", "application/json", @batch_delete_schema}, responses: ResponseSchemas.standard_responses(@batch_delete_response_schema) - def delete(%{assigns: %{map_id: map_id}} = conn, params) do + def delete(conn, params) do system_ids = Map.get(params, "system_ids", []) connection_ids = Map.get(params, "connection_ids", []) - deleted_systems = Enum.map(system_ids, fn id -> - case APIUtils.parse_int(id) do - {:ok, sid} -> Operations.delete_system(map_id, sid) - _ -> {:error, :invalid_id} - end - end) - - deleted_connections = Enum.map(connection_ids, fn id -> - case Operations.get_connection(map_id, id) do - {:ok, conn_struct} -> - case WandererApp.Map.Server.delete_connection(map_id, conn_struct) do - :ok -> {:ok, conn_struct} - error -> error - end - _ -> {:error, :invalid_id} - end - end) + deleted_systems = Enum.map(system_ids, &delete_system_id(conn, &1)) + deleted_connections = Enum.map(connection_ids, &delete_connection_id(conn, &1)) systems_deleted = Enum.count(deleted_systems, &match?({:ok, _}, &1)) connections_deleted = Enum.count(deleted_connections, &match?({:ok, _}, &1)) @@ -242,13 +403,42 @@ defmodule WandererAppWeb.MapSystemAPIController do APIUtils.respond_data(conn, %{deleted_count: deleted_count}) end + defp delete_system_id(conn, id) do + case APIUtils.parse_int(id) do + {:ok, sid} -> Operations.delete_system(conn, sid) + _ -> {:error, :invalid_id} + end + end + + defp delete_connection_id(conn, id) do + case Operations.get_connection(conn, id) do + {:ok, conn_struct} -> + source_id = conn_struct.solar_system_source + target_id = conn_struct.solar_system_target + case Operations.delete_connection(conn, source_id, target_id) do + :ok -> {:ok, conn_struct} + error -> error + end + _ -> {:error, :invalid_id} + end + end + operation :delete_single, summary: "Delete a single Map System", - parameters: [map_slug: [in: :path], map_id: [in: :path], id: [in: :path]], + parameters: [ + map_identifier: [ + in: :path, + description: "Map identifier (UUID or slug). Provide either a UUID or a slug.", + type: :string, + required: true, + example: "00000000-0000-0000-0000-000000000000 or my-map-slug" + ], + id: [in: :path, type: :string, required: true] + ], responses: ResponseSchemas.standard_responses(@delete_response_schema) - def delete_single(%{assigns: %{map_id: map_id}} = conn, %{"id" => id}) do + def delete_single(conn, %{"id" => id}) do with {:ok, sid} <- APIUtils.parse_int(id), - {:ok, _} <- Operations.delete_system(map_id, sid) do + {:ok, _} <- Operations.delete_system(conn, sid) do APIUtils.respond_data(conn, %{deleted: true}) else {:error, :not_found} -> @@ -284,15 +474,4 @@ defmodule WandererAppWeb.MapSystemAPIController do responses: ResponseSchemas.standard_responses(@detail_response_schema) defdelegate show_system(conn, params), to: __MODULE__, as: :show - @deprecated "Use GET /api/maps/:map_identifier/systems instead" - operation :list_all_connections, - summary: "List All Connections (Legacy)", - deprecated: true, - parameters: [map_id: [in: :query]], - responses: ResponseSchemas.standard_responses(@list_response_schema) - def list_all_connections(%{assigns: %{map_id: map_id}} = conn, _params) do - connections = Operations.list_connections(map_id) - data = Enum.map(connections, &APIUtils.connection_to_json/1) - APIUtils.respond_data(conn, data) - end end diff --git a/lib/wanderer_app_web/controllers/map_system_signature_api_controller.ex b/lib/wanderer_app_web/controllers/map_system_signature_api_controller.ex new file mode 100644 index 00000000..09c76523 --- /dev/null +++ b/lib/wanderer_app_web/controllers/map_system_signature_api_controller.ex @@ -0,0 +1,169 @@ +defmodule WandererAppWeb.MapSystemSignatureAPIController do + use WandererAppWeb, :controller + use OpenApiSpex.ControllerSpecs + + alias WandererApp.Api.MapSystemSignature + alias WandererApp.Map.Operations, as: MapOperations + + @moduledoc """ + API controller for managing map system signatures. + """ + + # Inlined OpenAPI schema for a map system signature + @signature_schema %OpenApiSpex.Schema{ + title: "MapSystemSignature", + type: :object, + properties: %{ + id: %OpenApiSpex.Schema{type: :string, format: :uuid}, + system_id: %OpenApiSpex.Schema{type: :string, format: :uuid}, + eve_id: %OpenApiSpex.Schema{type: :string}, + character_eve_id: %OpenApiSpex.Schema{type: :string}, + name: %OpenApiSpex.Schema{type: :string, nullable: true}, + description: %OpenApiSpex.Schema{type: :string, nullable: true}, + type: %OpenApiSpex.Schema{type: :string, nullable: true}, + linked_system_id: %OpenApiSpex.Schema{type: :integer, nullable: true}, + kind: %OpenApiSpex.Schema{type: :string, nullable: true}, + group: %OpenApiSpex.Schema{type: :string, nullable: true}, + custom_info: %OpenApiSpex.Schema{type: :string, nullable: true}, + updated: %OpenApiSpex.Schema{type: :integer, nullable: true}, + inserted_at: %OpenApiSpex.Schema{type: :string, format: :date_time}, + updated_at: %OpenApiSpex.Schema{type: :string, format: :date_time} + }, + required: [ + :id, :system_id, :eve_id, :character_eve_id + ], + example: %{ + id: "sig-uuid-1", + system_id: "sys-uuid-1", + eve_id: "ABC-123", + character_eve_id: "123456789", + name: "Wormhole K162", + description: "Leads to unknown space", + type: "Wormhole", + linked_system_id: 30000144, + kind: "cosmic_signature", + group: "wormhole", + custom_info: "Fresh", + updated: 1, + inserted_at: "2025-04-30T10:00:00Z", + updated_at: "2025-04-30T10:00:00Z" + } + } + + @doc """ + List all signatures for a map. + """ + operation :index, + summary: "List all signatures for a map", + parameters: [ + map_identifier: [in: :path, description: "Map identifier (UUID or slug)", type: :string, required: true] + ], + responses: [ok: {"List of signatures", "application/json", %OpenApiSpex.Schema{ + type: :object, + properties: %{ + data: %OpenApiSpex.Schema{ + type: :array, + items: @signature_schema + } + }, + example: %{ + data: [@signature_schema.example] + } + }}] + def index(conn, _params) do + map_id = conn.assigns.map_id + signatures = MapOperations.list_signatures(map_id) + json(conn, %{data: signatures}) + end + + @doc """ + Show a single signature by ID. + """ + operation :show, + summary: "Show a single signature by ID", + 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: {"Signature", "application/json", %OpenApiSpex.Schema{ + type: :object, + properties: %{data: @signature_schema}, + example: %{data: @signature_schema.example} + }}] + def show(conn, %{"id" => id}) do + map_id = conn.assigns.map_id + case MapSystemSignature.by_id(id) do + {:ok, signature} -> + case WandererApp.Api.MapSystem.by_id(signature.system_id) do + {:ok, system} when system.map_id == map_id -> + json(conn, %{data: signature}) + _ -> + conn |> put_status(:not_found) |> json(%{error: "Signature not found"}) + end + _ -> conn |> put_status(:not_found) |> json(%{error: "Signature not found"}) + end + end + + @doc """ + Create a new signature. + """ + operation :create, + summary: "Create a new signature", + parameters: [ + map_identifier: [in: :path, description: "Map identifier (UUID or slug)", type: :string, required: true] + ], + request_body: {"Signature", "application/json", @signature_schema}, + responses: [created: {"Created signature", "application/json", %OpenApiSpex.Schema{ + type: :object, + properties: %{data: @signature_schema}, + example: %{data: @signature_schema.example} + }}] + def create(conn, params) do + case MapOperations.create_signature(conn, params) do + {:ok, sig} -> conn |> put_status(:created) |> json(%{data: sig}) + {:error, error} -> conn |> put_status(:unprocessable_entity) |> json(%{error: error}) + end + end + + @doc """ + Update a signature by ID. + """ + operation :update, + summary: "Update a signature by ID", + 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: {"Signature update", "application/json", @signature_schema}, + responses: [ok: {"Updated signature", "application/json", %OpenApiSpex.Schema{ + type: :object, + properties: %{data: @signature_schema}, + example: %{data: @signature_schema.example} + }}] + def update(conn, %{"id" => id} = params) do + case MapOperations.update_signature(conn, id, params) do + {:ok, sig} -> json(conn, %{data: sig}) + {:error, error} -> conn |> put_status(:unprocessable_entity) |> json(%{error: error}) + end + end + + @doc """ + Delete a signature by ID. + """ + operation :delete, + summary: "Delete a signature by ID", + 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: [no_content: {"Deleted", "application/json", %OpenApiSpex.Schema{ + type: :object, + example: %{} + }}] + def delete(conn, %{"id" => id}) do + case MapOperations.delete_signature(conn, id) do + :ok -> send_resp(conn, :no_content, "") + {:error, error} -> conn |> put_status(:unprocessable_entity) |> json(%{error: error}) + end + end +end diff --git a/lib/wanderer_app_web/controllers/map_system_structure_api_controller.ex b/lib/wanderer_app_web/controllers/map_system_structure_api_controller.ex new file mode 100644 index 00000000..857b8434 --- /dev/null +++ b/lib/wanderer_app_web/controllers/map_system_structure_api_controller.ex @@ -0,0 +1,192 @@ +defmodule WandererAppWeb.MapSystemStructureAPIController do + use WandererAppWeb, :controller + use OpenApiSpex.ControllerSpecs + + alias WandererApp.Api.MapSystemStructure + alias OpenApiSpex.Schema + alias WandererApp.Map.Operations, as: MapOperations + + @moduledoc """ + API controller for managing map system structures. + Includes legacy structure-timers endpoint (deprecated). + """ + + # Inlined OpenAPI schema for a map system structure + @structure_schema %Schema{ + title: "MapSystemStructure", + type: :object, + properties: %{ + id: %Schema{type: :string, format: :uuid}, + system_id: %Schema{type: :string, format: :uuid}, + solar_system_name: %Schema{type: :string}, + solar_system_id: %Schema{type: :integer}, + structure_type_id: %Schema{type: :string}, + structure_type: %Schema{type: :string}, + character_eve_id: %Schema{type: :string}, + name: %Schema{type: :string}, + notes: %Schema{type: :string, nullable: true}, + owner_name: %Schema{type: :string, nullable: true}, + owner_ticker: %Schema{type: :string, nullable: true}, + owner_id: %Schema{type: :string, nullable: true}, + status: %Schema{type: :string, nullable: true}, + end_time: %Schema{type: :string, format: :date_time, nullable: true}, + inserted_at: %Schema{type: :string, format: :date_time}, + updated_at: %Schema{type: :string, format: :date_time} + }, + required: [ + :id, :system_id, :solar_system_name, :solar_system_id, :structure_type_id, :structure_type, :character_eve_id, :name + ], + example: %{ + id: "struct-uuid-1", + system_id: "sys-uuid-1", + solar_system_name: "Jita", + solar_system_id: 30000142, + structure_type_id: "35832", + structure_type: "Astrahus", + character_eve_id: "123456789", + name: "Jita Trade Hub", + notes: "Main market structure", + owner_name: "Wanderer Corp", + owner_ticker: "WANDR", + owner_id: "corp-uuid-1", + status: "anchoring", + end_time: "2025-05-01T12:00:00Z", + inserted_at: "2025-04-30T10:00:00Z", + updated_at: "2025-04-30T10:00:00Z" + } + } + + @doc """ + List all structures for a map. + """ + operation :index, + summary: "List all structures for a map", + parameters: [ + map_identifier: [in: :path, description: "Map identifier (UUID or slug)", type: :string, required: true] + ], + responses: [ok: {"List of structures", "application/json", %OpenApiSpex.Schema{ + type: :object, + properties: %{ + data: %OpenApiSpex.Schema{ + type: :array, + items: @structure_schema + } + }, + example: %{ + data: [@structure_schema.example] + } + }}] + def index(conn, _params) do + map_id = conn.assigns.map_id + structures = MapOperations.list_structures(map_id) + json(conn, %{data: structures}) + end + + @doc """ + Show a single structure by ID. + """ + operation :show, + summary: "Show a single structure by ID", + parameters: [ + map_identifier: [in: :path, description: "Map identifier (UUID or slug)", type: :string, required: true], + id: [in: :path, description: "Structure UUID", type: :string, required: true] + ], + responses: [ok: {"Structure", "application/json", %OpenApiSpex.Schema{ + type: :object, + properties: %{data: @structure_schema}, + example: %{data: @structure_schema.example} + }}] + def show(conn, %{"id" => id}) do + map_id = conn.assigns.map_id + case MapSystemStructure.by_id(id) do + {:ok, structure} -> + case WandererApp.Api.MapSystem.by_id(structure.system_id) do + {:ok, system} when system.map_id == map_id -> + json(conn, %{data: structure}) + _ -> + conn |> put_status(:not_found) |> json(%{error: "Structure not found"}) + end + _ -> conn |> put_status(:not_found) |> json(%{error: "Structure not found"}) + end + end + + @doc """ + Create a new structure. + """ + operation :create, + summary: "Create a new structure", + parameters: [ + map_identifier: [in: :path, description: "Map identifier (UUID or slug)", type: :string, required: true] + ], + request_body: {"Structure", "application/json", @structure_schema}, + responses: [created: {"Created structure", "application/json", %OpenApiSpex.Schema{ + type: :object, + properties: %{data: @structure_schema}, + example: %{data: @structure_schema.example} + }}] + def create(conn, params) do + case MapOperations.create_structure(conn, params) do + {:ok, struct} -> conn |> put_status(:created) |> json(%{data: struct}) + {:error, error} -> conn |> put_status(:unprocessable_entity) |> json(%{error: error}) + end + end + + @doc """ + Update a structure by ID. + """ + operation :update, + summary: "Update a structure by ID", + parameters: [ + map_identifier: [in: :path, description: "Map identifier (UUID or slug)", type: :string, required: true], + id: [in: :path, description: "Structure UUID", type: :string, required: true] + ], + request_body: {"Structure update", "application/json", @structure_schema}, + responses: [ok: {"Updated structure", "application/json", %OpenApiSpex.Schema{ + type: :object, + properties: %{data: @structure_schema}, + example: %{data: @structure_schema.example} + }}] + def update(conn, %{"id" => id} = params) do + case MapOperations.update_structure(conn, id, params) do + {:ok, struct} -> json(conn, %{data: struct}) + {:error, error} -> conn |> put_status(:unprocessable_entity) |> json(%{error: error}) + end + end + + @doc """ + Delete a structure by ID. + """ + operation :delete, + summary: "Delete a structure by ID", + parameters: [ + map_identifier: [in: :path, description: "Map identifier (UUID or slug)", type: :string, required: true], + id: [in: :path, description: "Structure UUID", type: :string, required: true] + ], + responses: [no_content: {"Deleted", "application/json", %OpenApiSpex.Schema{ + type: :object, + example: %{} + }}] + def delete(conn, %{"id" => id}) do + case MapOperations.delete_structure(conn, id) do + :ok -> send_resp(conn, :no_content, "") + {:error, error} -> conn |> put_status(:unprocessable_entity) |> json(%{error: error}) + end + end + + @doc """ + @deprecated "Use /structures instead. This endpoint will be removed in a future release." + Legacy: Get structure timers for a map. + """ + operation :structure_timers, + summary: "Get structure timers for a map (Legacy)", + deprecated: true, + parameters: [ + map_identifier: [in: :path, description: "Map identifier (UUID or slug)", type: :string, required: true] + ], + responses: [ok: {"Structure timers", "application/json", %Schema{type: :array, items: %Schema{type: :object}}}] + def structure_timers(conn, _params) do + map_id = conn.assigns.map_id + structures = MapOperations.list_structures(map_id) + json(conn, %{data: structures}) + end +end diff --git a/lib/wanderer_app_web/controllers/plugs/assign_map_owner.ex b/lib/wanderer_app_web/controllers/plugs/assign_map_owner.ex new file mode 100644 index 00000000..278a52f0 --- /dev/null +++ b/lib/wanderer_app_web/controllers/plugs/assign_map_owner.ex @@ -0,0 +1,21 @@ +defmodule WandererAppWeb.Plugs.AssignMapOwner do + import Plug.Conn + + alias WandererApp.Map.Operations + + def init(opts), do: opts + + def call(conn, _opts) do + map_id = conn.assigns[:map_id] + case Operations.get_owner_character_id(map_id) do + {:ok, %{id: char_id, user_id: user_id}} -> + conn + |> assign(:owner_character_id, char_id) + |> assign(:owner_user_id, user_id) + _ -> + conn + |> assign(:owner_character_id, nil) + |> assign(:owner_user_id, nil) + end + end +end diff --git a/lib/wanderer_app_web/helpers/api_utils.ex b/lib/wanderer_app_web/helpers/api_utils.ex index 1050956e..0aafa074 100644 --- a/lib/wanderer_app_web/helpers/api_utils.ex +++ b/lib/wanderer_app_web/helpers/api_utils.ex @@ -172,7 +172,14 @@ defmodule WandererAppWeb.Helpers.APIUtils do mass_status = normalized_params["mass_status"] || 0 time_status = normalized_params["time_status"] || 0 ship_size_type = normalized_params["ship_size_type"] || 0 - locked = normalized_params["locked"] || false + # Coerce to boolean; accept "true"/"false", 1/0, etc. + locked = + case normalized_params["locked"] do + val when val in [true, "true", 1, "1"] -> true + val when val in [false, "false", 0, "0"] -> false + nil -> false + other -> other # keep unknowns for caller-side validation + end custom_info = normalized_params["custom_info"] wormhole_type = normalized_params["wormhole_type"] diff --git a/lib/wanderer_app_web/live/map/event_handlers/map_characters_event_handler.ex b/lib/wanderer_app_web/live/map/event_handlers/map_characters_event_handler.ex index f6b0b8d2..5f192394 100644 --- a/lib/wanderer_app_web/live/map/event_handlers/map_characters_event_handler.ex +++ b/lib/wanderer_app_web/live/map/event_handlers/map_characters_event_handler.ex @@ -83,12 +83,11 @@ defmodule WandererAppWeb.MapCharactersEventHandler do socket |> assign(has_tracked_characters?: user_character_eve_ids |> Enum.empty?() |> Kernel.not()) |> MapEventHandler.push_map_event( - "init", + "map_updated", %{ main_character_eve_id: main_character_eve_id, following_character_eve_id: following_character_eve_id, - user_characters: user_character_eve_ids, - reset: false + user_characters: user_character_eve_ids } ) end @@ -183,31 +182,32 @@ defmodule WandererAppWeb.MapCharactersEventHandler do } = socket ) when character_eve_id != following_character_eve_id do - settings = case map_user_settings do - nil -> nil - %{settings: settings} -> settings - end + settings = + case map_user_settings do + nil -> nil + %{settings: settings} -> settings + end - {:ok, user_settings} = - WandererApp.MapUserSettingsRepo.create_or_update(map_id, current_user_id, settings) + {:ok, user_settings} = + WandererApp.MapUserSettingsRepo.create_or_update(map_id, current_user_id, settings) - {:ok, map_user_settings} = - user_settings - |> WandererApp.Api.MapUserSettings.update_following_character(%{ - following_character_eve_id: "#{character_eve_id}" - }) + {:ok, map_user_settings} = + user_settings + |> WandererApp.Api.MapUserSettings.update_following_character(%{ + following_character_eve_id: "#{character_eve_id}" + }) - {:ok, tracking_data} = - WandererApp.Character.TrackingUtils.build_tracking_data(map_id, current_user_id) + {:ok, tracking_data} = + WandererApp.Character.TrackingUtils.build_tracking_data(map_id, current_user_id) - Process.send_after(self(), %{event: :refresh_user_characters}, 50) + Process.send_after(self(), %{event: :refresh_user_characters}, 50) - {:reply, %{data: tracking_data}, - socket - |> assign( - map_user_settings: map_user_settings, - following_character_eve_id: "#{character_eve_id}" - )} + {:reply, %{data: tracking_data}, + socket + |> assign( + map_user_settings: map_user_settings, + following_character_eve_id: "#{character_eve_id}" + )} end def handle_ui_event( @@ -223,46 +223,47 @@ defmodule WandererAppWeb.MapCharactersEventHandler do } = socket ) when not is_nil(character_eve_id) and character_eve_id != main_character_eve_id do - settings = case map_user_settings do - nil -> nil - %{settings: settings} -> settings - end + settings = + case map_user_settings do + nil -> nil + %{settings: settings} -> settings + end - {:ok, user_settings} = - WandererApp.MapUserSettingsRepo.create_or_update(map_id, current_user_id, settings) + {:ok, user_settings} = + WandererApp.MapUserSettingsRepo.create_or_update(map_id, current_user_id, settings) - {:ok, map_user_settings} = - user_settings - |> WandererApp.Api.MapUserSettings.update_main_character(%{ - main_character_eve_id: "#{character_eve_id}" - }) + {:ok, map_user_settings} = + user_settings + |> WandererApp.Api.MapUserSettings.update_main_character(%{ + main_character_eve_id: "#{character_eve_id}" + }) - {:ok, tracking_data} = - WandererApp.Character.TrackingUtils.build_tracking_data(map_id, current_user_id) + {:ok, tracking_data} = + WandererApp.Character.TrackingUtils.build_tracking_data(map_id, current_user_id) - {main_character_id, main_character_eve_id} = - WandererApp.Character.TrackingUtils.get_main_character( - map_user_settings, - current_user_characters, - current_user_characters - ) - |> case do - {:ok, main_character} when not is_nil(main_character) -> - {main_character.id, main_character.eve_id} + {main_character_id, main_character_eve_id} = + WandererApp.Character.TrackingUtils.get_main_character( + map_user_settings, + current_user_characters, + current_user_characters + ) + |> case do + {:ok, main_character} when not is_nil(main_character) -> + {main_character.id, main_character.eve_id} - _ -> - {nil, nil} - end + _ -> + {nil, nil} + end - Process.send_after(self(), %{event: :refresh_user_characters}, 50) + Process.send_after(self(), %{event: :refresh_user_characters}, 50) - {:reply, %{data: tracking_data}, - socket - |> assign( - map_user_settings: map_user_settings, - main_character_id: main_character_id, - main_character_eve_id: main_character_eve_id - )} + {:reply, %{data: tracking_data}, + socket + |> assign( + map_user_settings: map_user_settings, + main_character_id: main_character_id, + main_character_eve_id: main_character_eve_id + )} end def handle_ui_event(event, body, socket), @@ -334,7 +335,7 @@ defmodule WandererAppWeb.MapCharactersEventHandler do defp handle_tracking_event({:track_characters, map_characters, track_character}, socket, map_id) do :ok = - WandererApp.Character.TrackingUtils.track_characters( + WandererApp.Character.TrackingUtils.track( map_characters, map_id, track_character, diff --git a/lib/wanderer_app_web/live/map/event_handlers/map_core_event_handler.ex b/lib/wanderer_app_web/live/map/event_handlers/map_core_event_handler.ex index d3d68aa3..245da91c 100644 --- a/lib/wanderer_app_web/live/map/event_handlers/map_core_event_handler.ex +++ b/lib/wanderer_app_web/live/map/event_handlers/map_core_event_handler.ex @@ -57,7 +57,7 @@ defmodule WandererAppWeb.MapCoreEventHandler do case track_character do false -> :ok = - WandererApp.Character.TrackingUtils.untrack_characters( + WandererApp.Character.TrackingUtils.untrack( map_characters, map_id, self() @@ -67,7 +67,7 @@ defmodule WandererAppWeb.MapCoreEventHandler do _ -> :ok = - WandererApp.Character.TrackingUtils.track_characters( + WandererApp.Character.TrackingUtils.track( map_characters, map_id, true, @@ -517,6 +517,7 @@ defmodule WandererAppWeb.MapCoreEventHandler do defp map_start( %{ assigns: %{ + current_user: current_user, needs_tracking_setup: needs_tracking_setup, main_character_id: main_character_id, main_character_eve_id: main_character_eve_id, @@ -548,7 +549,7 @@ defmodule WandererAppWeb.MapCoreEventHandler do map_data = map_id - |> get_map_data() + |> get_map_data(current_user.id, is_subscription_active) socket = socket @@ -591,11 +592,19 @@ defmodule WandererAppWeb.MapCoreEventHandler do end end - defp get_map_data(map_id) do + defp get_map_data(map_id, current_user_id, is_subscription_active) do {:ok, hubs} = map_id |> WandererApp.Map.list_hubs() + {:ok, hubs_limit} = map_id |> WandererApp.Map.get_hubs_limit() {:ok, connections} = map_id |> WandererApp.Map.list_connections() {:ok, systems} = map_id |> WandererApp.Map.list_systems() + {:ok, user_hubs} = + if is_subscription_active do + WandererApp.MapUserSettingsRepo.get_hubs(map_id, current_user_id) + else + {:ok, []} + end + system_static_infos = systems |> Enum.map(&WandererApp.CachedInfo.get_system_static_info!(&1.solar_system_id)) @@ -607,6 +616,8 @@ defmodule WandererAppWeb.MapCoreEventHandler do system_static_infos: system_static_infos |> Enum.map(&MapEventHandler.map_ui_system_static_info/1), hubs: hubs, + hubs_limit: hubs_limit, + user_hubs: user_hubs, connections: connections |> Enum.map(&MapEventHandler.map_ui_connection/1) } end diff --git a/lib/wanderer_app_web/live/map/event_handlers/map_kills_event_handler.ex b/lib/wanderer_app_web/live/map/event_handlers/map_kills_event_handler.ex index f03ca754..eabf77ce 100644 --- a/lib/wanderer_app_web/live/map/event_handlers/map_kills_event_handler.ex +++ b/lib/wanderer_app_web/live/map/event_handlers/map_kills_event_handler.ex @@ -22,13 +22,12 @@ defmodule WandererAppWeb.MapKillsEventHandler do socket |> MapEventHandler.push_map_event( - "init", + "map_updated", %{ kills: kills |> Enum.filter(fn {_, kills} -> kills > 0 end) - |> Enum.map(&map_ui_kill/1), - reset: false + |> Enum.map(&map_ui_kill/1) } ) end @@ -45,11 +44,14 @@ defmodule WandererAppWeb.MapKillsEventHandler do ) end - def handle_server_event(%{event: :detailed_kills_updated, payload: payload}, %{ - assigns: %{ - map_id: map_id - } - } = socket) do + def handle_server_event( + %{event: :detailed_kills_updated, payload: payload}, + %{ + assigns: %{ + map_id: map_id + } + } = socket + ) do case WandererApp.Map.is_subscription_active?(map_id) do {:ok, true} -> socket @@ -57,7 +59,9 @@ defmodule WandererAppWeb.MapKillsEventHandler do "detailed_kills_updated", payload ) - _ -> socket + + _ -> + socket end end diff --git a/lib/wanderer_app_web/live/map/event_handlers/map_routes_event_handler.ex b/lib/wanderer_app_web/live/map/event_handlers/map_routes_event_handler.ex index 17ed30d9..1cc405fc 100644 --- a/lib/wanderer_app_web/live/map/event_handlers/map_routes_event_handler.ex +++ b/lib/wanderer_app_web/live/map/event_handlers/map_routes_event_handler.ex @@ -3,7 +3,7 @@ defmodule WandererAppWeb.MapRoutesEventHandler do use Phoenix.Component require Logger - alias WandererAppWeb.{MapEventHandler, MapCoreEventHandler} + alias WandererAppWeb.{MapEventHandler, MapCoreEventHandler, MapSystemsEventHandler} def handle_server_event( %{ @@ -24,6 +24,25 @@ defmodule WandererAppWeb.MapRoutesEventHandler do } ) + def handle_server_event( + %{ + event: :user_routes, + payload: {solar_system_id, %{routes: routes, systems_static_data: systems_static_data}} + }, + socket + ), + do: + socket + |> MapEventHandler.push_map_event( + "user_routes", + %{ + 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) @@ -49,6 +68,39 @@ defmodule WandererAppWeb.MapRoutesEventHandler do {:noreply, socket} end + def handle_ui_event( + "get_user_routes", + %{"system_id" => solar_system_id, "routes_settings" => routes_settings} = _event, + %{ + assigns: %{ + map_id: map_id, + map_loaded?: true, + current_user: current_user, + is_subscription_active?: is_subscription_active? + } + } = socket + ) do + Task.async(fn -> + if is_subscription_active? do + {:ok, hubs} = WandererApp.MapUserSettingsRepo.get_hubs(map_id, current_user.id) + + {:ok, routes} = + WandererApp.Maps.find_routes( + map_id, + hubs, + solar_system_id, + get_routes_settings(routes_settings) + ) + + {:user_routes, {solar_system_id, routes}} + else + {:user_routes, {solar_system_id, %{routes: [], systems_static_data: []}}} + end + end) + + {:noreply, socket} + end + def handle_ui_event( "set_autopilot_waypoint", %{ diff --git a/lib/wanderer_app_web/live/map/event_handlers/map_signatures_event_handler.ex b/lib/wanderer_app_web/live/map/event_handlers/map_signatures_event_handler.ex index 6279812b..d8cc6240 100644 --- a/lib/wanderer_app_web/live/map/event_handlers/map_signatures_event_handler.ex +++ b/lib/wanderer_app_web/live/map/event_handlers/map_signatures_event_handler.ex @@ -96,7 +96,11 @@ defmodule WandererAppWeb.MapSignaturesEventHandler do end) {:noreply, - socket |> MapEventHandler.push_map_event("init", %{system_signatures: system_signatures})} + socket + |> MapEventHandler.push_map_event( + "map_updated", + %{system_signatures: system_signatures} + )} end def handle_ui_event( diff --git a/lib/wanderer_app_web/live/map/event_handlers/map_structures_event_handler.ex b/lib/wanderer_app_web/live/map/event_handlers/map_structures_event_handler.ex index 7cd95ba2..3ffba293 100644 --- a/lib/wanderer_app_web/live/map/event_handlers/map_structures_event_handler.ex +++ b/lib/wanderer_app_web/live/map/event_handlers/map_structures_event_handler.ex @@ -6,7 +6,7 @@ defmodule WandererAppWeb.MapStructuresEventHandler do alias WandererApp.Api.MapSystem alias WandererApp.Structure - alias WandererAppWeb.{MapEventHandler, MapCoreEventHandler} + alias WandererAppWeb. MapCoreEventHandler def handle_server_event(%{event: :structures_updated, payload: _solar_system_id}, socket) do socket diff --git a/lib/wanderer_app_web/live/map/event_handlers/map_systems_event_handler.ex b/lib/wanderer_app_web/live/map/event_handlers/map_systems_event_handler.ex index 4883e8ea..13258520 100644 --- a/lib/wanderer_app_web/live/map/event_handlers/map_systems_event_handler.ex +++ b/lib/wanderer_app_web/live/map/event_handlers/map_systems_event_handler.ex @@ -184,6 +184,97 @@ defmodule WandererAppWeb.MapSystemsEventHandler do {:noreply, socket} end + def handle_ui_event( + "get_user_hubs", + _event, + %{ + assigns: %{ + map_id: map_id, + current_user: current_user + } + } = + socket + ) do + {:ok, hubs} = WandererApp.MapUserSettingsRepo.get_hubs(map_id, current_user.id) + + {:reply, %{hubs: hubs}, socket} + end + + def handle_ui_event( + "add_user_hub", + %{"system_id" => solar_system_id} = _event, + %{ + assigns: %{ + map_id: map_id, + current_user: current_user + } + } = + socket + ) do + {:ok, map} = map_id |> WandererApp.Map.get_map() + hubs_limit = map |> Map.get(:hubs_limit, 20) + + {:ok, hubs} = WandererApp.MapUserSettingsRepo.get_hubs(map_id, current_user.id) + + if hubs |> Enum.count() < hubs_limit do + hubs = hubs ++ ["#{solar_system_id}"] + + {:ok, _} = + WandererApp.MapUserSettingsRepo.update_hubs( + map_id, + current_user.id, + hubs + ) + + {:noreply, socket |> MapEventHandler.push_map_event( + "map_updated", + %{user_hubs: hubs} + )} + else + + {:noreply, socket |> MapEventHandler.push_map_event( + "map_updated", + %{user_hubs: hubs} + )} + end + end + + def handle_ui_event( + "delete_user_hub", + %{"system_id" => solar_system_id} = _event, + %{ + assigns: %{ + map_id: map_id, + current_user: current_user + } + } = + socket + ) do + {:ok, hubs} = WandererApp.MapUserSettingsRepo.get_hubs(map_id, current_user.id) + + case hubs |> Enum.member?("#{solar_system_id}") do + true -> + hubs = hubs |> Enum.reject(fn hub -> hub == "#{solar_system_id}" end) + + {:ok, _} = + WandererApp.MapUserSettingsRepo.update_hubs( + map_id, + current_user.id, + hubs + ) + + {:noreply, socket |> MapEventHandler.push_map_event( + "map_updated", + %{user_hubs: hubs} + )} + _ -> + {:noreply, socket |> MapEventHandler.push_map_event( + "map_updated", + %{user_hubs: hubs} + )} + end + end + def handle_ui_event( "update_system_position", position, diff --git a/lib/wanderer_app_web/live/map/map_event_handler.ex b/lib/wanderer_app_web/live/map/map_event_handler.ex index fecbdcc2..b4ed7b30 100644 --- a/lib/wanderer_app_web/live/map/map_event_handler.ex +++ b/lib/wanderer_app_web/live/map/map_event_handler.ex @@ -56,7 +56,10 @@ defmodule WandererAppWeb.MapEventHandler do "update_system_locked", "update_system_tag", "update_system_temporary_name", - "update_system_status" + "update_system_status", + "get_user_hubs", + "add_user_hub", + "delete_user_hub" ] @map_system_comments_events [ @@ -98,11 +101,13 @@ defmodule WandererAppWeb.MapEventHandler do ] @map_routes_events [ - :routes + :routes, + :user_routes ] @map_routes_ui_events [ "get_routes", + "get_user_routes", "set_autopilot_waypoint" ] @@ -287,6 +292,8 @@ defmodule WandererAppWeb.MapEventHandler do def push_map_event(socket, _type, _body), do: socket + def map_ui_character_stat(nil), do: nil + def map_ui_character_stat(character), do: character diff --git a/lib/wanderer_app_web/router.ex b/lib/wanderer_app_web/router.ex index 8e2ebd86..66b240e7 100644 --- a/lib/wanderer_app_web/router.ex +++ b/lib/wanderer_app_web/router.ex @@ -169,6 +169,7 @@ defmodule WandererAppWeb.Router do pipeline :api_map do plug WandererAppWeb.Plugs.CheckMapApiKey plug WandererAppWeb.Plugs.CheckMapSubscription + plug WandererAppWeb.Plugs.AssignMapOwner end pipeline :api_kills do @@ -209,9 +210,9 @@ defmodule WandererAppWeb.Router do # Deprecated routes - use /api/maps/:map_identifier/systems instead get "/systems", MapSystemAPIController, :list_systems get "/system", MapSystemAPIController, :show_system - get "/connections", MapSystemAPIController, :list_all_connections + get "/connections", MapConnectionAPIController, :list_all_connections get "/characters", MapAPIController, :list_tracked_characters - get "/structure-timers", MapAPIController, :show_structure_timers + get "/structure-timers", MapSystemStructureAPIController, :structure_timers get "/character-activity", MapAPIController, :character_activity get "/user_characters", MapAPIController, :user_characters @@ -230,6 +231,9 @@ defmodule WandererAppWeb.Router do delete "/systems", MapSystemAPIController, :delete resources "/systems", MapSystemAPIController, only: [:index, :show, :create, :update, :delete] resources "/connections", MapConnectionAPIController, only: [:index, :show, :create, :update, :delete], param: "id" + resources "/structures", MapSystemStructureAPIController, except: [:new, :edit] + get "/structure-timers", MapSystemStructureAPIController, :structure_timers + resources "/signatures", MapSystemSignatureAPIController, except: [:new, :edit] end diff --git a/lib/wanderer_app_web/schemas/response_schemas.ex b/lib/wanderer_app_web/schemas/response_schemas.ex index 060f9ac6..e12b6ec1 100644 --- a/lib/wanderer_app_web/schemas/response_schemas.ex +++ b/lib/wanderer_app_web/schemas/response_schemas.ex @@ -6,7 +6,6 @@ defmodule WandererAppWeb.Schemas.ResponseSchemas do HTTP response schemas for OpenAPI documentation. """ - alias OpenApiSpex.Schema alias WandererAppWeb.Schemas.ApiSchemas # Standard response status codes diff --git a/mix.exs b/mix.exs index c35523e0..dd148aab 100644 --- a/mix.exs +++ b/mix.exs @@ -3,7 +3,7 @@ defmodule WandererApp.MixProject do @source_url "https://github.com/wanderer-industries/wanderer" - @version "1.61.2" + @version "1.62.3" def project do [ diff --git a/priv/posts/2025/04-30-systems-connections-api.md b/priv/posts/2025/05-07-systems-connections-api.md similarity index 56% rename from priv/posts/2025/04-30-systems-connections-api.md rename to priv/posts/2025/05-07-systems-connections-api.md index 0173b493..5db6e718 100644 --- a/priv/posts/2025/04-30-systems-connections-api.md +++ b/priv/posts/2025/05-07-systems-connections-api.md @@ -479,6 +479,420 @@ This script demonstrates a practical application of the batch operations endpoin --- +## Structures Endpoints + +### 1. List Structures + +```bash +GET /api/maps/:map_identifier/structures +``` + +- **Description:** Retrieves all structures for the specified map. +- **Authentication:** Requires Map API Token. +- **Parameters:** + - `map_identifier` (required) — the map's slug or UUID. + +#### Example Request + +```bash +curl -H "Authorization: Bearer " \ + "https://wanderer.example.com/api/maps/your-map-slug/structures" +``` + +#### Example Response + +```json +{ + "data": [ + { + "id": "", + "system_id": "", + "solar_system_id": 30000142, + "solar_system_name": "Jita", + "structure_type_id": "35832", + "structure_type": "Astrahus", + "character_eve_id": "123456789", + "name": "Jita Trade Hub", + "notes": "Main market structure", + "owner_name": "Wanderer Corp", + "owner_ticker": "WANDR", + "owner_id": "corp-uuid-1", + "status": "anchoring", + "end_time": "2025-05-01T12:00:00Z", + "inserted_at": "2025-04-30T10:00:00Z", + "updated_at": "2025-04-30T10:00:00Z" + } + ] +} +``` + +### 2. Show Structure + +```bash +GET /api/maps/:map_identifier/structures/:id +``` + +- **Description:** Retrieves details for a specific structure. +- **Authentication:** Requires Map API Token. +- **Parameters:** + - `map_identifier` (required) — the map's slug or UUID. + - `id` (required) — the structure's UUID. + +#### Example Request + +```bash +curl -H "Authorization: Bearer " \ + "https://wanderer.example.com/api/maps/your-map-slug/structures/" +``` + +#### Example Response + +```json +{ + "data": { + "id": "", + "system_id": "", + "solar_system_id": 30000142, + "solar_system_name": "Jita", + "structure_type_id": "35832", + "structure_type": "Astrahus", + "character_eve_id": "123456789", + "name": "Jita Trade Hub", + "notes": "Main market structure", + "owner_name": "Wanderer Corp", + "owner_ticker": "WANDR", + "owner_id": "corp-uuid-1", + "status": "anchoring", + "end_time": "2025-05-01T12:00:00Z", + "inserted_at": "2025-04-30T10:00:00Z", + "updated_at": "2025-04-30T10:00:00Z" + } +} +``` + +### 3. Create Structure + +```bash +POST /api/maps/:map_identifier/structures +``` + +- **Description:** Creates a new structure. +- **Authentication:** Requires Map API Token. +- **Parameters:** + - `map_identifier` (required) — the map's slug or UUID. + +#### Example Request + +```bash +curl -X POST \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "solar_system_id": 30000142, + "solar_system_name": "Jita", + "structure_type_id": "35832", + "structure_type": "Astrahus", + "character_eve_id": "123456789", + "name": "Jita Trade Hub", + "notes": "Main market structure", + "owner_name": "Wanderer Corp", + "owner_ticker": "WANDR", + "owner_id": "corp-uuid-1", + "status": "anchoring", + "end_time": "2025-05-01T12:00:00Z" + }' \ + "https://wanderer.example.com/api/maps/your-map-slug/structures" +``` + +#### Example Response + +```json +{ + "data": { + "id": "", + "system_id": "", + "solar_system_id": 30000142, + "solar_system_name": "Jita", + "structure_type_id": "35832", + "structure_type": "Astrahus", + "character_eve_id": "123456789", + "name": "Jita Trade Hub", + "notes": "Main market structure", + "owner_name": "Wanderer Corp", + "owner_ticker": "WANDR", + "owner_id": "corp-uuid-1", + "status": "anchoring", + "end_time": "2025-05-01T12:00:00Z", + "inserted_at": "2025-04-30T10:00:00Z", + "updated_at": "2025-04-30T10:00:00Z" + } +} +``` + +### 4. Update Structure + +```bash +PUT /api/maps/:map_identifier/structures/:id +``` + +- **Description:** Updates an existing structure. +- **Authentication:** Requires Map API Token. +- **Parameters:** + - `map_identifier` (required) — the map's slug or UUID. + - `id` (required) — the structure's UUID. + +#### Example Request + +```bash +curl -X PUT \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "status": "anchored", + "notes": "Updated via API" + }' \ + "https://wanderer.example.com/api/maps/your-map-slug/structures/" +``` + +#### Example Response + +```json +{ + "data": { + "id": "", + "status": "anchored", + "notes": "Updated via API" + // ... other fields ... + } +} +``` + +### 5. Delete Structure + +```bash +DELETE /api/maps/:map_identifier/structures/:id +``` + +- **Description:** Deletes a specific structure. +- **Authentication:** Requires Map API Token. +- **Parameters:** + - `map_identifier` (required) — the map's slug or UUID. + - `id` (required) — the structure's UUID. + +#### Example Request + +```bash +curl -X DELETE \ + -H "Authorization: Bearer " \ + "https://wanderer.example.com/api/maps/your-map-slug/structures/" +``` + +--- + +## Signatures Endpoints + +### 1. List Signatures + +```bash +GET /api/maps/:map_identifier/signatures +``` + +- **Description:** Retrieves all signatures for the specified map. +- **Authentication:** Requires Map API Token. +- **Parameters:** + - `map_identifier` (required) — the map's slug or UUID. + +#### Example Request + +```bash +curl -H "Authorization: Bearer " \ + "https://wanderer.example.com/api/maps/your-map-slug/signatures" +``` + +#### Example Response + +```json +{ + "data": [ + { + "id": "", + "system_id": "", + "eve_id": "ABC-123", + "name": "Wormhole K162", + "description": "Leads to unknown space", + "type": "Wormhole", + "linked_system_id": 30000144, + "kind": "cosmic_signature", + "group": "wormhole", + "custom_info": "Fresh", + "solar_system_id": 31001394, + "solar_system_name": "J214811", + "character_eve_id": "123456789", + "inserted_at": "2025-04-30T10:00:00Z", + "updated_at": "2025-04-30T10:00:00Z" + } + ] +} +``` + +### 2. Show Signature + +```bash +GET /api/maps/:map_identifier/signatures/:id +``` + +- **Description:** Retrieves details for a specific signature. +- **Authentication:** Requires Map API Token. +- **Parameters:** + - `map_identifier` (required) — the map's slug or UUID. + - `id` (required) — the signature's UUID. + +#### Example Request + +```bash +curl -H "Authorization: Bearer " \ + "https://wanderer.example.com/api/maps/your-map-slug/signatures/" +``` + +#### Example Response + +```json +{ + "data": { + "id": "", + "system_id": "", + "eve_id": "ABC-123", + "name": "Wormhole K162", + "description": "Leads to unknown space", + "type": "Wormhole", + "linked_system_id": 30000144, + "kind": "cosmic_signature", + "group": "wormhole", + "custom_info": "Fresh", + "solar_system_id": 31001394, + "solar_system_name": "J214811", + "character_eve_id": "123456789", + "inserted_at": "2025-04-30T10:00:00Z", + "updated_at": "2025-04-30T10:00:00Z" + } +} +``` + +### 3. Create Signature + +```bash +POST /api/maps/:map_identifier/signatures +``` + +- **Description:** Creates a new signature. +- **Authentication:** Requires Map API Token. +- **Parameters:** + - `map_identifier` (required) — the map's slug or UUID. + +#### Example Request + +```bash +curl -X POST \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "eve_id": "ABC-123", + "name": "Wormhole K162", + "description": "Leads to unknown space", + "type": "Wormhole", + "linked_system_id": 30000144, + "kind": "cosmic_signature", + "group": "wormhole", + "custom_info": "Fresh", + "solar_system_id": 31001394, + "solar_system_name": "J214811" + }' \ + "https://wanderer.example.com/api/maps/your-map-slug/signatures" +``` + +#### Example Response + +```json +{ + "data": { + "id": "", + "eve_id": "ABC-123", + "name": "Wormhole K162", + "description": "Leads to unknown space", + "type": "Wormhole", + "linked_system_id": 30000144, + "kind": "cosmic_signature", + "group": "wormhole", + "custom_info": "Fresh", + "solar_system_id": 31001394, + "solar_system_name": "J214811", + "character_eve_id": "123456789", + "inserted_at": "2025-04-30T10:00:00Z", + "updated_at": "2025-04-30T10:00:00Z" + } +} +``` + +### 4. Update Signature + +```bash +PUT /api/maps/:map_identifier/signatures/:id +``` + +- **Description:** Updates an existing signature. +- **Authentication:** Requires Map API Token. +- **Parameters:** + - `map_identifier` (required) — the map's slug or UUID. + - `id` (required) — the signature's UUID. + +#### Example Request + +```bash +curl -X PUT \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "description": "Updated via API", + "custom_info": "Updated info" + }' \ + "https://wanderer.example.com/api/maps/your-map-slug/signatures/" +``` + +#### Example Response + +```json +{ + "data": { + "id": "", + "description": "Updated via API", + "custom_info": "Updated info" + // ... other fields ... + } +} +``` + +### 5. Delete Signature + +```bash +DELETE /api/maps/:map_identifier/signatures/:id +``` + +- **Description:** Deletes a specific signature. +- **Authentication:** Requires Map API Token. +- **Parameters:** + - `map_identifier` (required) — the map's slug or UUID. + - `id` (required) — the signature's UUID. + +#### Example Request + +```bash +curl -X DELETE \ + -H "Authorization: Bearer " \ + "https://wanderer.example.com/api/maps/your-map-slug/signatures/" +``` + +--- + ## Conclusion These endpoints provide powerful tools for managing your map's systems and connections programmatically. Key features include: diff --git a/priv/repo/migrations/20250327074524_add_map_user_settings_hubs.exs b/priv/repo/migrations/20250327074524_add_map_user_settings_hubs.exs new file mode 100644 index 00000000..f52ae4e0 --- /dev/null +++ b/priv/repo/migrations/20250327074524_add_map_user_settings_hubs.exs @@ -0,0 +1,21 @@ +defmodule WandererApp.Repo.Migrations.AddMapUserSettingsHubs do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:map_user_settings_v1) do + add :hubs, {:array, :text}, default: [] + end + end + + def down do + alter table(:map_user_settings_v1) do + remove :hubs + end + end +end diff --git a/priv/resource_snapshots/repo/map_user_settings_v1/20250327074524.json b/priv/resource_snapshots/repo/map_user_settings_v1/20250327074524.json new file mode 100644 index 00000000..098695e2 --- /dev/null +++ b/priv/resource_snapshots/repo/map_user_settings_v1/20250327074524.json @@ -0,0 +1,129 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "primary_key?": true, + "references": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "settings", + "type": "text" + }, + { + "allow_nil?": true, + "default": "[]", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "hubs", + "type": [ + "array", + "text" + ] + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": true, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "map_user_settings_v1_map_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "maps_v1" + }, + "size": null, + "source": "map_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": true, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "map_user_settings_v1_user_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "user_v1" + }, + "size": null, + "source": "user_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "4E38AE137F667F0707C49D15D5B79653DDF6F8A248D1845713200DE4E1C144F0", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "map_user_settings_v1_uniq_map_user_index", + "keys": [ + { + "type": "atom", + "value": "map_id" + }, + { + "type": "atom", + "value": "user_id" + } + ], + "name": "uniq_map_user", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.WandererApp.Repo", + "schema": null, + "table": "map_user_settings_v1" +} \ No newline at end of file diff --git a/test/manual/api/run_tests.sh b/test/manual/api/run_tests.sh deleted file mode 100755 index bd6d16d6..00000000 --- a/test/manual/api/run_tests.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -# test/manual/api/run_tests.sh -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -# Main entry point for all manual API tests -# Usage: ./run_tests.sh [all|create|update|delete|-v] - -if [ $# -eq 0 ]; then - echo "Running all improved API tests..." - "$SCRIPT_DIR/improved_api_tests.sh" - exit $? -fi - -# Pass through any arguments to improved_api_tests.sh -"$SCRIPT_DIR/improved_api_tests.sh" "$@" -exit $? diff --git a/test/manual/api/structure_signature_api_tests.sh b/test/manual/api/structure_signature_api_tests.sh new file mode 100755 index 00000000..063f7efb --- /dev/null +++ b/test/manual/api/structure_signature_api_tests.sh @@ -0,0 +1,462 @@ +#!/bin/bash +# test/manual/api/structure_signature_api_tests.sh +# ─── Manual API Tests for Map Structure and Signature APIs ──────────────── +# +# Usage: +# ./structure_signature_api_tests.sh # Run all tests with menu selection +# ./structure_signature_api_tests.sh create # Run only creation tests +# ./structure_signature_api_tests.sh update # Run only update tests +# ./structure_signature_api_tests.sh delete # Run only deletion tests +# ./structure_signature_api_tests.sh -v # Run in verbose mode +# +source "$(dirname "$0")/utils.sh" + +echo "DEBUG: Script started" + +#set -x # Enable shell debug output + +VERBOSE=${VERBOSE:-false} + +trap 'echo -e "\n❌ ERROR: Script failed at line $LINENO. Last command: $BASH_COMMAND" >&2' ERR + +while getopts "vh" opt; do + case $opt in + v) + VERBOSE=true + ;; + h) + echo "Usage: $0 [-v] [-h] [all|create|update|delete]" + echo " -v Verbose mode (show detailed test output)" + echo " -h Show this help message" + echo " all Run all tests (default with menu)" + echo " create Run only creation tests" + echo " update Run only update tests" + echo " delete Run only deletion tests" + exit 0 + ;; + \?) + echo "Invalid option: -$OPTARG" >&2 + echo "Use -h for help" + exit 1 + ;; + esac +done +shift $((OPTIND-1)) +COMMAND=${1:-"all"} + +STRUCTURES_FILE="/tmp/wanderer_test_structures.txt" +SIGNATURES_FILE="/tmp/wanderer_test_signatures.txt" +CREATED_STRUCTURE_IDS="" +CREATED_SIGNATURE_IDS="" + +save_structures() { + echo "DEBUG: Entering save_structures" + if ! echo "$CREATED_STRUCTURE_IDS" > "$STRUCTURES_FILE"; then + echo "ERROR: Failed to write to $STRUCTURES_FILE" >&2 + exit 1 + fi + echo "DEBUG: Successfully wrote to $STRUCTURES_FILE" + if [[ "$VERBOSE" == "true" ]]; then echo "Saved $(wc -w < "$STRUCTURES_FILE") structures to $STRUCTURES_FILE"; fi +} +load_structures() { + if [ -f "$STRUCTURES_FILE" ]; then + CREATED_STRUCTURE_IDS=$(cat "$STRUCTURES_FILE") + if [[ "$VERBOSE" == "true" ]]; then echo "Loaded $(wc -w < "$STRUCTURES_FILE") structures from $STRUCTURES_FILE"; fi + else + CREATED_STRUCTURE_IDS="" + fi +} +save_signatures() { + echo "$CREATED_SIGNATURE_IDS" > "$SIGNATURES_FILE" + if [[ "$VERBOSE" == "true" ]]; then echo "Saved $(wc -w < "$SIGNATURES_FILE") signatures to $SIGNATURES_FILE"; fi +} +load_signatures() { + if [ -f "$SIGNATURES_FILE" ]; then + CREATED_SIGNATURE_IDS=$(cat "$SIGNATURES_FILE") + if [[ "$VERBOSE" == "true" ]]; then echo "Loaded $(wc -w < "$SIGNATURES_FILE") signatures from $SIGNATURES_FILE"; fi + else + CREATED_SIGNATURE_IDS="" + fi +} +add_to_list() { + local list="$1" + local item="$2" + if [ -z "$list" ]; then + echo "$item" + else + echo "$list $item" + fi +} + +# Fetch the first available system (ID and name) from the API +get_first_system() { + local raw=$(make_request GET "$API_BASE_URL/api/maps/$MAP_SLUG/systems") + local status=$(parse_status "$raw") + if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then + local response=$(parse_response "$raw") + # Try .data as array + local count=$(echo "$response" | jq -er 'if (.data | type == "array") then (.data | length) else 0 end' 2>/dev/null) + for i in $(seq 0 $((count-1))); do + local uuid=$(echo "$response" | jq -er ".data[$i].id // empty" 2>/dev/null) + local eve_id=$(echo "$response" | jq -er ".data[$i].solar_system_id // empty" 2>/dev/null) + local name=$(echo "$response" | jq -er ".data[$i].name // .data[$i].solar_system_name // empty" 2>/dev/null) + if [[ -n "$uuid" && -n "$eve_id" && -n "$name" ]]; then + echo "$uuid:$eve_id:$name" + return 0 + fi + done + # Try .data.systems as array + local count2=$(echo "$response" | jq -er 'if (.data.systems | type == "array") then (.data.systems | length) else 0 end' 2>/dev/null) + for i in $(seq 0 $((count2-1))); do + local uuid=$(echo "$response" | jq -er ".data.systems[$i].id // empty" 2>/dev/null) + local eve_id=$(echo "$response" | jq -er ".data.systems[$i].solar_system_id // empty" 2>/dev/null) + local name=$(echo "$response" | jq -er ".data.systems[$i].name // .data.systems[$i].solar_system_name // empty" 2>/dev/null) + if [[ -n "$uuid" && -n "$eve_id" && -n "$name" ]]; then + echo "$uuid:$eve_id:$name" + return 0 + fi + done + echo "ERROR: No valid system found in API response. Available systems:" >&2 + echo "$response" | jq '.' >&2 + exit 1 + else + echo "ERROR: Failed to fetch systems (status $status)" >&2 + exit 1 + fi +} + +# ─── STRUCTURE TESTS ───────────────────────────────────────────── +create_structure() { + local sys_info=$(get_first_system) + local system_uuid=$(echo "$sys_info" | cut -d: -f1) + local eve_system_id=$(echo "$sys_info" | cut -d: -f2) + local system_name=$(echo "$sys_info" | cut -d: -f3-) + echo "==== Creating Structure in system $system_name ($eve_system_id, $system_uuid) ====" + local payload=$(jq -n --arg sid "$eve_system_id" --arg name "$system_name" '{ + system_id: "sys-uuid-1", + solar_system_name: $name, + solar_system_id: ($sid|tonumber), + structure_type_id: "35832", + structure_type: "Astrahus", + character_eve_id: "123456789", + name: "Jita Trade Hub", + notes: "Main market structure", + owner_name: "Wanderer Corp", + owner_ticker: "WANDR", + owner_id: "corp-uuid-1", + status: "anchoring", + end_time: "2025-05-05T12:00:00Z" + }') + local raw=$(make_request POST "$API_BASE_URL/api/maps/$MAP_SLUG/structures" "$payload") + local status=$(parse_status "$raw") + if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then + local id=$(parse_response "$raw" | jq -r '.data.id') + CREATED_STRUCTURE_IDS=$(add_to_list "$CREATED_STRUCTURE_IDS" "$id") + echo "✅ Created structure with ID: $id" + else + echo -e "\n❌ ERROR: Failed to create structure. Status: $status" >&2 + if [[ "$VERBOSE" == "true" ]]; then echo "Response: $(parse_response "$raw")" >&2; fi + exit 1 + fi + save_structures + echo "DEBUG: End of create_structure, about to return" +} + +list_structures() { + echo "==== Listing Structures ====" + local raw=$(make_request GET "$API_BASE_URL/api/maps/$MAP_SLUG/structures") + local status=$(parse_status "$raw") + if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then + local count=$(parse_response "$raw" | jq '.data | length') + echo "✅ Listed $count structures" + if [[ "$VERBOSE" == "true" ]]; then echo "$(parse_response "$raw")" | jq '.'; fi + else + echo -e "\n❌ ERROR: Failed to list structures. Status: $status" >&2 + if [[ "$VERBOSE" == "true" ]]; then echo "Response: $(parse_response "$raw")" >&2; fi + exit 1 + fi +} + +show_structure() { + load_structures + local id=$(echo "$CREATED_STRUCTURE_IDS" | awk '{print $1}') + if [ -z "$id" ]; then + echo -e "\n❌ ERROR: No structure ID found. Run creation first." >&2 + exit 1 + fi + echo "==== Show Structure $id ====" + local raw=$(make_request GET "$API_BASE_URL/api/maps/$MAP_SLUG/structures/$id") + local status=$(parse_status "$raw") + if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then + local data=$(parse_response "$raw") + local name=$(echo "$data" | jq -r '.data.name') + local status_val=$(echo "$data" | jq -r '.data.status') + local notes=$(echo "$data" | jq -r '.data.notes') + echo "✅ Showed structure $id: name='$name', status='$status_val', notes='$notes'" + if [[ "$VERBOSE" == "true" ]]; then echo "$data" | jq '.'; fi + else + echo -e "\n❌ ERROR: Failed to show structure $id. Status: $status" >&2 + if [[ "$VERBOSE" == "true" ]]; then echo "Response: $(parse_response "$raw")" >&2; fi + exit 1 + fi +} + +update_structure() { + load_structures + local id=$(echo "$CREATED_STRUCTURE_IDS" | awk '{print $1}') + if [ -z "$id" ]; then + echo -e "\n❌ ERROR: No structure ID found. Run creation first." >&2 + exit 1 + fi + echo "==== Updating Structure $id ====" + local payload=$(jq -n '{status: "anchored", notes: "Updated via test"}') + local raw=$(make_request PUT "$API_BASE_URL/api/maps/$MAP_SLUG/structures/$id" "$payload") + local status=$(parse_status "$raw") + if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then + echo "✅ Updated structure $id" + else + echo -e "\n❌ ERROR: Failed to update structure $id. Status: $status" >&2 + if [[ "$VERBOSE" == "true" ]]; then echo "Response: $(parse_response "$raw")" >&2; fi + exit 1 + fi +} + +delete_structure() { + load_structures + local id=$(echo "$CREATED_STRUCTURE_IDS" | awk '{print $1}') + if [ -z "$id" ]; then + echo -e "\n❌ ERROR: No structure ID found. Run creation first." >&2 + exit 1 + fi + echo "==== Deleting Structure $id ====" + local raw=$(make_request DELETE "$API_BASE_URL/api/maps/$MAP_SLUG/structures/$id") + local status=$(parse_status "$raw") + if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then + echo "✅ Deleted structure $id" + CREATED_STRUCTURE_IDS="" + save_structures + else + echo -e "\n❌ ERROR: Failed to delete structure $id. Status: $status" >&2 + if [[ "$VERBOSE" == "true" ]]; then echo "Response: $(parse_response "$raw")" >&2; fi + exit 1 + fi +} + +# ─── SIGNATURE TESTS ───────────────────────────────────────────── +create_signature() { + local sys_info=$(get_first_system) + echo "DEBUG: sys_info='$sys_info'" + local system_uuid=$(echo "$sys_info" | cut -d: -f1) + local system_id=$(echo "$sys_info" | cut -d: -f2) + local system_name=$(echo "$sys_info" | cut -d: -f3-) + echo "DEBUG: system_id='$system_id' (should be a number like 31001394)" + if [[ -z "$system_id" ]]; then + echo "ERROR: system_id is empty. sys_info='$sys_info'" >&2 + exit 1 + fi + # Generate a unique, valid-looking eve_id (e.g., ABC-123) + local eve_id=$(cat /dev/urandom | tr -dc 'A-Z' | fold -w 3 | head -n 1)-$(shuf -i 100-999 -n 1) + echo "==== Creating Signature in system $system_name ($system_id, $system_uuid) with eve_id $eve_id ====" + local payload=$(jq -n --arg sid "$system_id" --arg name "$system_name" --arg eve_id "$eve_id" '{ + eve_id: $eve_id, + name: "Wormhole K162", + description: "Leads to unknown space", + type: "Wormhole", + linked_system_id: 30000144, + kind: "cosmic_signature", + group: "wormhole", + custom_info: "Fresh", + solar_system_id: ($sid|tonumber), + solar_system_name: $name + }') + echo "DEBUG: payload=$payload" + local raw=$(make_request POST "$API_BASE_URL/api/maps/$MAP_SLUG/signatures" "$payload") + local status=$(parse_status "$raw") + if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then + # Now list signatures and find the one with this eve_id + local list_raw=$(make_request GET "$API_BASE_URL/api/maps/$MAP_SLUG/signatures") + local id=$(parse_response "$list_raw" | jq -r --arg eve_id "$eve_id" '.data[] | select(.eve_id == $eve_id) | .id' | head -n 1) + if [[ -z "$id" ]]; then + echo "❌ ERROR: Created signature not found in list (eve_id: $eve_id)" >&2 + exit 1 + fi + CREATED_SIGNATURE_IDS=$(add_to_list "$CREATED_SIGNATURE_IDS" "$id") + save_signatures + echo "✅ Created signature with eve_id: $eve_id and ID: $id" + else + echo "❌ ERROR: Failed to create signature (status $status)" >&2 + echo "$raw" | parse_response | jq . >&2 + exit 1 + fi +} + +list_signatures() { + echo "==== Listing Signatures ====" + local raw=$(make_request GET "$API_BASE_URL/api/maps/$MAP_SLUG/signatures") + local status=$(parse_status "$raw") + if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then + local count=$(parse_response "$raw" | jq '.data | length') + echo "✅ Listed $count signatures" + if [[ "$VERBOSE" == "true" ]]; then echo "$(parse_response "$raw")" | jq '.'; fi + else + echo -e "\n❌ ERROR: Failed to list signatures. Status: $status" >&2 + if [[ "$VERBOSE" == "true" ]]; then echo "Response: $(parse_response "$raw")" >&2; fi + exit 1 + fi +} + +show_signature() { + load_signatures + local id=$(echo "$CREATED_SIGNATURE_IDS" | awk '{print $1}') + if [ -z "$id" ]; then + echo -e "\n❌ ERROR: No signature ID found. Run creation first." >&2 + exit 1 + fi + echo "==== Show Signature $id ====" + local raw=$(make_request GET "$API_BASE_URL/api/maps/$MAP_SLUG/signatures/$id") + local status=$(parse_status "$raw") + if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then + local data=$(parse_response "$raw") + local eve_id=$(echo "$data" | jq -r '.data.eve_id') + local name=$(echo "$data" | jq -r '.data.name') + local description=$(echo "$data" | jq -r '.data.description') + local custom_info=$(echo "$data" | jq -r '.data.custom_info') + echo "✅ Showed signature $id: eve_id='$eve_id', name='$name', description='$description', custom_info='$custom_info'" + if [[ "$VERBOSE" == "true" ]]; then echo "$data" | jq '.'; fi + else + echo -e "\n❌ ERROR: Failed to show signature $id. Status: $status" >&2 + if [[ "$VERBOSE" == "true" ]]; then echo "Response: $(parse_response "$raw")" >&2; fi + exit 1 + fi +} + +update_signature() { + load_signatures + local id=$(echo "$CREATED_SIGNATURE_IDS" | awk '{print $1}') + if [ -z "$id" ]; then + echo -e "\n❌ ERROR: No signature ID found. Run creation first." >&2 + exit 1 + fi + # Get the EVE system ID for the update payload + local sys_info=$(get_first_system) + local system_id=$(echo "$sys_info" | cut -d: -f2) + echo "==== Updating Signature $id ====" + local payload=$(jq -n --arg sid "$system_id" '{description: "Updated via test", custom_info: "Updated info", solar_system_id: ($sid|tonumber) }') + local raw=$(make_request PUT "$API_BASE_URL/api/maps/$MAP_SLUG/signatures/$id" "$payload") + local status=$(parse_status "$raw") + if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then + echo "✅ Updated signature $id" + else + echo -e "\n❌ ERROR: Failed to update signature $id. Status: $status" >&2 + if [[ "$VERBOSE" == "true" ]]; then echo "Response: $(parse_response "$raw")" >&2; fi + exit 1 + fi +} + +delete_signature() { + load_signatures + local id=$(echo "$CREATED_SIGNATURE_IDS" | awk '{print $1}') + if [ -z "$id" ]; then + echo -e "\n❌ ERROR: No signature ID found. Run creation first." >&2 + exit 1 + fi + echo "==== Deleting Signature $id ====" + local raw=$(make_request DELETE "$API_BASE_URL/api/maps/$MAP_SLUG/signatures/$id") + local status=$(parse_status "$raw") + if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then + echo "✅ Deleted signature $id" + CREATED_SIGNATURE_IDS="" + save_signatures + else + echo -e "\n❌ ERROR: Failed to delete signature $id. Status: $status" >&2 + if [[ "$VERBOSE" == "true" ]]; then echo "Response: $(parse_response "$raw")" >&2; fi + exit 1 + fi +} + +show_menu() { + echo "===== Map Structure & Signature API Tests =====" + echo "1. Run all tests in sequence (with pauses)" + echo "2. Create structure" + echo "3. List structures" + echo "4. Show structure" + echo "5. Update structure" + echo "6. Delete structure" + echo "7. Create signature" + echo "8. List signatures" + echo "9. Show signature" + echo "10. Update signature" + echo "11. Delete signature" + echo "12. Exit" + echo "===============================================" + echo "Enter your choice [1-12]: " +} + +case "$COMMAND" in + "all") + if [ -t 0 ]; then + while true; do + show_menu + read -r choice + case $choice in + 1) + create_structure + echo "DEBUG: After calling create_structure in menu, exit code $?" + echo "DEBUG: After create_structure, exit code $?"; read -p "Press Enter to continue..." + list_structures; echo "DEBUG: After list_structures, exit code $?"; read -p "Press Enter to continue..." + show_structure; echo "DEBUG: After show_structure, exit code $?"; read -p "Press Enter to continue..." + update_structure; echo "DEBUG: After update_structure, exit code $?"; read -p "Press Enter to continue..." + show_structure; echo "DEBUG: After show_structure (post-update), exit code $?"; read -p "Press Enter to continue..." + delete_structure; echo "DEBUG: After delete_structure, exit code $?"; read -p "Press Enter to continue..." + create_signature; echo "DEBUG: After create_signature, exit code $?"; read -p "Press Enter to continue..." + list_signatures; echo "DEBUG: After list_signatures, exit code $?"; read -p "Press Enter to continue..." + show_signature; echo "DEBUG: After show_signature, exit code $?"; read -p "Press Enter to continue..." + update_signature; echo "DEBUG: After update_signature, exit code $?"; read -p "Press Enter to continue..." + show_signature; echo "DEBUG: After show_signature (post-update), exit code $?"; read -p "Press Enter to continue..." + delete_signature; echo "DEBUG: After delete_signature, exit code $?"; read -p "Press Enter to continue..." + echo "All tests completed." + show_menu + read -r choice + continue + ;; + 2) create_structure ;; + 3) list_structures ;; + 4) show_structure ;; + 5) update_structure ;; + 6) delete_structure ;; + 7) create_signature ;; + 8) list_signatures ;; + 9) show_signature ;; + 10) update_signature ;; + 11) delete_signature ;; + 12) + read -p "Clean up any remaining test data before exiting? (y/n): " confirm + if [[ "$confirm" =~ ^[Yy] ]]; then + delete_structure + delete_signature + fi + exit 0 + ;; + *) echo "Invalid option. Please try again." ;; + esac + done + else + create_structure; list_structures; show_structure; update_structure; show_structure; delete_structure + create_signature; list_signatures; show_signature; update_signature; show_signature; delete_signature + fi + ;; + "create") + create_structure; create_signature ;; + "update") + update_structure; update_signature ;; + "delete") + delete_structure; delete_signature ;; + *) + echo "Invalid command: $COMMAND" + echo "Use -h for help" + exit 1 + ;; +esac + +exit 0 +echo "DEBUG: End of script reached" \ No newline at end of file diff --git a/test/manual/api/system_api_tests.sh b/test/manual/api/system_api_tests.sh index 5cdfe95b..e69e59dc 100755 --- a/test/manual/api/system_api_tests.sh +++ b/test/manual/api/system_api_tests.sh @@ -220,9 +220,7 @@ create_connections() { time_status: 0, ship_size_type: 1, wormhole_type: "K162", - count_of_passage: 0, - locked: false, - custom_info: "Test connection" + count_of_passage: 0 }') # Send create request to dedicated endpoint @@ -244,7 +242,7 @@ create_connections() { echo "Total connections created via dedicated endpoint: $connection_count/$total_connections" save_connections - # Validate actual state after dedicated connection creation + # Always validate actual state after connection creation echo "Validating connections after dedicated creation:" list_systems_and_connections @@ -307,6 +305,17 @@ create_connections() { echo "[SCRIPT] Batch upsert response: $response" + # Debug: List all connections after batch upsert + echo "[SCRIPT] Listing all connections after batch upsert:" + local list_raw=$(make_request GET "$API_BASE_URL/api/maps/$MAP_SLUG/systems") + local list_status=$(parse_status "$list_raw") + if [[ "$list_status" =~ ^2[0-9][0-9]$ ]]; then + local list_response=$(parse_response "$list_raw") + echo "$list_response" | jq -c '.data.connections[] | {id: .id, source: .solar_system_source, target: .solar_system_target, mass_status: .mass_status, ship_size_type: .ship_size_type, type: .type}' + else + echo "[SCRIPT] Failed to list connections after batch upsert. Status: $list_status" + fi + # Add batch system IDs to CREATED_SYSTEM_IDS for i in $(seq 0 $((num_batch_systems-1))); do IFS=':' read -r system_id _ <<< "${BATCH_EVE_SYSTEMS[$i]}" @@ -419,9 +428,7 @@ update_connections() { --argjson ship "$ship" \ '{ mass_status: $mass, - ship_size_type: $ship, - locked: false, - custom_info: "Updated via PATCH" + ship_size_type: $ship }') # Try source/target update @@ -465,8 +472,7 @@ update_connections() { solar_system_source: $source, solar_system_target: $target, mass_status: $mass, - ship_size_type: $ship, - custom_info: "Batch updated" + ship_size_type: $ship }') done batch_connections+="]" diff --git a/test/manual/api/utils.sh b/test/manual/api/utils.sh index 1d0d857d..a5598cb1 100755 --- a/test/manual/api/utils.sh +++ b/test/manual/api/utils.sh @@ -164,4 +164,4 @@ cleanup_map_systems() { done fi } -trap cleanup_map_systems EXIT +#trap cleanup_map_systems EXIT