Compare commits

..

26 Commits

Author SHA1 Message Date
CI
32fe6395a1 chore: release version v1.50.0
Some checks are pending
Build / 🚀 Deploy to test env (fly.io) (push) Waiting to run
Build / Manual Approval (push) Blocked by required conditions
Build / 🛠 Build (1.17, 18.x, 27) (push) Blocked by required conditions
Build / 🛠 Build Docker Images (linux/amd64) (push) Blocked by required conditions
Build / 🛠 Build Docker Images (linux/arm64) (push) Blocked by required conditions
Build / merge (push) Blocked by required conditions
Build / 🏷 Create Release (push) Blocked by required conditions
2025-02-17 00:09:15 +00:00
guarzo
5f506bf4b2 feat: allow addition of characters to acl without preregistration (#176) 2025-02-17 03:52:47 +04:00
CI
0127ebfe46 chore: release version v1.49.0
Some checks failed
Build / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build / Manual Approval (push) Has been cancelled
Build / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build / merge (push) Has been cancelled
Build / 🏷 Create Release (push) Has been cancelled
2025-02-15 08:37:43 +00:00
guarzo
8c5366fd9b feat: add api for acl management (#171) 2025-02-15 12:16:42 +04:00
CI
dbcad892a9 chore: release version v1.48.1
Some checks failed
Build / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build / Manual Approval (push) Has been cancelled
Build / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build / merge (push) Has been cancelled
Build / 🏷 Create Release (push) Has been cancelled
2025-02-13 17:40:15 +00:00
Dmitry Popov
6da3096db1 chore: release version v1.48.0 2025-02-13 18:04:28 +01:00
CI
cd8efcd6e3 chore: release version v1.48.0
Some checks are pending
Build / 🚀 Deploy to test env (fly.io) (push) Waiting to run
Build / Manual Approval (push) Blocked by required conditions
Build / 🛠 Build (1.17, 18.x, 27) (push) Blocked by required conditions
Build / 🛠 Build Docker Images (linux/amd64) (push) Blocked by required conditions
Build / 🛠 Build Docker Images (linux/arm64) (push) Blocked by required conditions
Build / merge (push) Blocked by required conditions
Build / 🏷 Create Release (push) Blocked by required conditions
2025-02-12 22:46:57 +00:00
Dmitry Popov
b52471ae5e chore: release version v1.47.6 2025-02-12 23:37:54 +01:00
Dmitry Popov
438fecb61f chore: release version v1.47.6 2025-02-12 23:07:45 +01:00
guarzo
70b589a359 System Kills cleanup (#166)
* fix: styling and count of kills tooltip
2025-02-13 01:23:36 +04:00
guarzo
cf7069b3b2 feat: autosize local character tooltip and increase hover target (#165) 2025-02-13 01:11:40 +04:00
CI
b2198e469e chore: release version v1.47.6
Some checks are pending
Build / 🚀 Deploy to test env (fly.io) (push) Waiting to run
Build / Manual Approval (push) Blocked by required conditions
Build / 🛠 Build (1.17, 18.x, 27) (push) Blocked by required conditions
Build / 🛠 Build Docker Images (linux/amd64) (push) Blocked by required conditions
Build / 🛠 Build Docker Images (linux/arm64) (push) Blocked by required conditions
Build / merge (push) Blocked by required conditions
Build / 🏷 Create Release (push) Blocked by required conditions
2025-02-12 09:47:56 +00:00
Dmitry Popov
8ab337e8e7 chore: release version v1.47.5 2025-02-12 10:17:35 +01:00
CI
51878ab503 chore: release version v1.47.5 2025-02-12 07:55:37 +00:00
guarzo
401dfad298 fix: sync kills count bookmark and the kills widget (#160)
Some checks are pending
Build / 🚀 Deploy to test env (fly.io) (push) Waiting to run
Build / Manual Approval (push) Blocked by required conditions
Build / 🛠 Build (1.17, 18.x, 27) (push) Blocked by required conditions
Build / 🛠 Build Docker Images (linux/amd64) (push) Blocked by required conditions
Build / 🛠 Build Docker Images (linux/arm64) (push) Blocked by required conditions
Build / 🛠 Build Docker Images (linux/arm64/v8) (push) Blocked by required conditions
Build / merge (push) Blocked by required conditions
Build / 🏷 Create Release (push) Blocked by required conditions
* fix: lazy load kills widget
2025-02-12 03:15:16 +04:00
CI
18cff7d312 chore: release version v1.47.4
Some checks are pending
Build / 🚀 Deploy to test env (fly.io) (push) Waiting to run
Build / Manual Approval (push) Blocked by required conditions
Build / 🛠 Build (1.17, 18.x, 27) (push) Blocked by required conditions
Build / 🛠 Build Docker Images (linux/amd64) (push) Blocked by required conditions
Build / 🛠 Build Docker Images (linux/arm64) (push) Blocked by required conditions
Build / 🛠 Build Docker Images (linux/arm64/v8) (push) Blocked by required conditions
Build / merge (push) Blocked by required conditions
Build / 🏷 Create Release (push) Blocked by required conditions
2025-02-11 11:10:34 +00:00
Dmitry Popov
7896de00d6 chore: release version v1.47.3 2025-02-11 12:02:42 +01:00
CI
3b079505c3 chore: release version v1.47.3 2025-02-11 10:20:28 +00:00
Dmitry Popov
5b972b03e5 Revert "fix: lazy load kills widget (#157)" (#158)
This reverts commit b29e57b3a4.
2025-02-11 14:20:01 +04:00
CI
79b284c46d chore: release version v1.47.2 2025-02-11 08:31:23 +00:00
guarzo
b29e57b3a4 fix: lazy load kills widget (#157)
* fix: lazy load kills widget

* fix: updates for eslint and pr feedback
2025-02-11 12:28:24 +04:00
CI
c6f4baeee3 chore: release version v1.47.1
Some checks failed
Build / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build / 🛠 Build Docker Images (linux/arm64/v8) (push) Has been cancelled
Build / merge (push) Has been cancelled
Build / 🏷 Create Release (push) Has been cancelled
2025-02-09 16:27:54 +00:00
Dmitry Popov
6d341be072 Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-02-09 17:27:27 +01:00
Dmitry Popov
2437ec9c84 fix(Connections): Fixed connections auto-refresh after update 2025-02-09 17:27:22 +01:00
CI
7e692b5805 chore: release version v1.47.0
Some checks are pending
Build / 🚀 Deploy to test env (fly.io) (push) Waiting to run
Build / 🛠 Build (1.17, 18.x, 27) (push) Waiting to run
Build / 🛠 Build Docker Images (linux/amd64) (push) Blocked by required conditions
Build / 🛠 Build Docker Images (linux/arm64) (push) Blocked by required conditions
Build / 🛠 Build Docker Images (linux/arm64/v8) (push) Blocked by required conditions
Build / merge (push) Blocked by required conditions
Build / 🏷 Create Release (push) Blocked by required conditions
2025-02-09 10:13:17 +00:00
Dmitry Popov
01b7370ecd feat(Map): Added check for active map subscription to using Map APIs 2025-02-09 11:12:43 +01:00
53 changed files with 1926 additions and 902 deletions

View File

@@ -1,7 +1,12 @@
{
"name": "wanderer-dev",
"dockerComposeFile": ["./docker-compose.yml"],
"extensions": ["jakebecker.elixir-ls"],
"extensions": [
"jakebecker.elixir-ls",
"JakeBecker.elixir-ls",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"
],
"service": "wanderer",
"workspaceFolder": "/app",
"shutdownAction": "stopCompose",

View File

@@ -7,4 +7,5 @@ export EVE_CLIENT_WITH_WALLET_SECRET="<EVE_CLIENT_WITH_WALLET_SECRET>"
export GIT_SHA="1111"
export WANDERER_INVITES="false"
export WANDERER_PUBLIC_API_DISABLED="false"
export WANDERER_CHARACTER_API_DISABLED="false"
export WANDERER_ZKILL_PRELOAD_DISABLED="false"

View File

@@ -1,8 +1,6 @@
name: Build
on:
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled]
push:
branches:
- main
@@ -41,8 +39,28 @@ jobs:
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
manual-approval:
name: Manual Approval
runs-on: ubuntu-latest
needs: deploy-test
if: success()
permissions:
issues: write
steps:
- name: Await Manual Approval
uses: trstringer/manual-approval@v1
with:
secret: ${{ github.TOKEN }}
approvers: DmitryPopov
minimum-approvals: 1
issue-title: "Manual Approval Required for Release"
issue-body: "Please approve or deny the deployment."
build:
name: 🛠 Build
needs: manual-approval
runs-on: ubuntu-22.04
if: ${{ (github.ref == 'refs/heads/main') && github.event_name == 'push' }}
permissions:
@@ -140,7 +158,6 @@ jobs:
platform:
- linux/amd64
- linux/arm64
- linux/arm64/v8
steps:
- name: Prepare
run: |

View File

@@ -2,6 +2,95 @@
<!-- changelog -->
## [v1.50.0](https://github.com/wanderer-industries/wanderer/compare/v1.49.0...v1.50.0) (2025-02-17)
### Features:
* allow addition of characters to acl without preregistration (#176)
## [v1.49.0](https://github.com/wanderer-industries/wanderer/compare/v1.48.1...v1.49.0) (2025-02-15)
### Features:
* add api for acl management (#171)
## [v1.48.1](https://github.com/wanderer-industries/wanderer/compare/v1.48.0...v1.48.1) (2025-02-13)
## [v1.48.0](https://github.com/wanderer-industries/wanderer/compare/v1.47.6...v1.48.0) (2025-02-12)
### Features:
* autosize local character tooltip and increase hover target (#165)
## [v1.47.6](https://github.com/wanderer-industries/wanderer/compare/v1.47.5...v1.47.6) (2025-02-12)
## [v1.47.5](https://github.com/wanderer-industries/wanderer/compare/v1.47.4...v1.47.5) (2025-02-12)
### Bug Fixes:
* sync kills count bookmark and the kills widget (#160)
* lazy load kills widget
## [v1.47.4](https://github.com/wanderer-industries/wanderer/compare/v1.47.3...v1.47.4) (2025-02-11)
## [v1.47.3](https://github.com/wanderer-industries/wanderer/compare/v1.47.2...v1.47.3) (2025-02-11)
## [v1.47.2](https://github.com/wanderer-industries/wanderer/compare/v1.47.1...v1.47.2) (2025-02-11)
### Bug Fixes:
* lazy load kills widget (#157)
* lazy load kills widget
* updates for eslint and pr feedback
## [v1.47.1](https://github.com/wanderer-industries/wanderer/compare/v1.47.0...v1.47.1) (2025-02-09)
### Bug Fixes:
* Connections: Fixed connections auto-refresh after update
## [v1.47.0](https://github.com/wanderer-industries/wanderer/compare/v1.46.1...v1.47.0) (2025-02-09)
### Features:
* Map: Added check for active map subscription to using Map APIs
## [v1.46.1](https://github.com/wanderer-industries/wanderer/compare/v1.46.0...v1.46.1) (2025-02-09)

View File

@@ -936,3 +936,66 @@ body > div:first-of-type {
width: 16px;
height: 16px;
}
.verticalTabsContainer {
display: flex;
width: 100%;
min-height: 300px;
}
.verticalTabsContainer .p-tabview {
width: 100%;
display: flex;
align-items: flex-start;
}
.verticalTabsContainer .p-tabview-panels {
padding: 6px 1rem !important;
flex-grow: 1;
height: 100%;
}
.verticalTabsContainer .p-tabview-nav-container {
border-right: none;
height: 100%;
}
.verticalTabsContainer .p-tabview-nav {
flex-direction: column;
width: 150px;
min-height: 100%;
border: none;
}
.verticalTabsContainer .p-tabview-nav li {
width: 100%;
border-right: 4px solid var(--surface-hover);
background-color: var(--surface-card);
transition:
background-color 200ms,
border-right-color 200ms;
}
.verticalTabsContainer .p-tabview-nav li:hover {
background-color: var(--surface-hover);
border-right: 4px solid var(--surface-100);
}
.verticalTabsContainer .p-tabview-nav li .p-tabview-nav-link {
transition: color 200ms;
justify-content: flex-end;
padding: 10px;
background-color: initial;
border: none;
color: var(--gray-400);
border-radius: initial;
font-weight: 400;
margin: 0;
}
.verticalTabsContainer .p-tabview-nav li.p-tabview-selected {
background-color: var(--surface-50);
border-right: 4px solid var(--primary-color);
}
.verticalTabsContainer .p-tabview-nav li.p-tabview-selected .p-tabview-nav-link {
font-weight: 600;
color: var(--primary-color);
}
.verticalTabsContainer .p-tabview-nav li.p-tabview-selected:hover {
border-right: 4px solid var(--primary-color);
}
.verticalTabsContainer .p-tabview-panel {
flex-grow: 1;
}

View File

@@ -84,7 +84,6 @@ interface MapCompProps {
onCommand: OutCommandHandler;
onSelectionChange: OnMapSelectionChange;
onManualDelete(systems: string[]): void;
canRemoveConnection?(connectionId: string): boolean;
onConnectionInfoClick?(e: SolarSystemConnection): void;
onAddSystem?: OnMapAddSystemCallback;
onSelectionContextMenu?: NodeSelectionMouseHandler;
@@ -114,9 +113,8 @@ const MapComp = ({
isSoftBackground,
theme,
onAddSystem,
canRemoveConnection,
}: MapCompProps) => {
const { getEdge, getNode, getNodes } = useReactFlow();
const { getNode, getNodes } = useReactFlow();
const [nodes, , onNodesChange] = useNodesState<Node<SolarSystemRawType>>(initialNodes);
const [edges, , onEdgesChange] = useEdgesState<Edge<SolarSystemConnection>>(initialEdges);
@@ -224,40 +222,6 @@ const MapComp = ({
[getNode, getNodes, onManualDelete, onNodesChange],
);
const handleEdgesChange = useCallback(
(changes: EdgeChange[]) => {
const nextChanges = changes.reduce((acc, change) => {
if (change.type !== 'remove') {
return [...acc, change];
}
if (canRemoveConnection?.(change.id)) {
return [...acc, change];
}
const edge = getEdge(change.id);
if (!edge) {
return [...acc, change];
}
const sourceNode = getNode(edge.source);
const targetNode = getNode(edge.target);
if (!sourceNode || !targetNode) {
return [...acc, change];
}
if (sourceNode.data.locked || targetNode.data.locked) {
return acc;
}
return [...acc, change];
}, [] as EdgeChange[]);
onEdgesChange(nextChanges);
},
[canRemoveConnection, getEdge, getNode, onEdgesChange],
);
useEffect(() => {
update(x => ({
...x,
@@ -273,7 +237,7 @@ const MapComp = ({
nodes={nodes}
edges={edges}
onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
// TODO we need save into session all of this
// and on any action do either

View File

@@ -1,7 +1,7 @@
import { SystemKillsContent } from '../../../mapInterface/widgets/SystemKills/SystemKillsContent/SystemKillsContent';
import { useKillsCounter } from '../../hooks/useKillsCounter';
import { WdTooltipWrapper } from '@/hooks/Mapper/components/ui-kit/WdTooltipWrapper';
import { WithChildren, WithClassName } from '@/hooks/Mapper/types/common.ts';
import { WithChildren, WithClassName } from '@/hooks/Mapper/types/common';
type TooltipSize = 'xs' | 'sm' | 'md' | 'lg';
@@ -20,11 +20,18 @@ export const KillsCounter = ({ killsCount, systemId, className, children, size =
if (!killsCount || detailedKills.length === 0 || !systemId || isLoading) return null;
const tooltipContent = (
<SystemKillsContent kills={detailedKills} systemNameMap={systemNameMap} compact={true} onlyOneSystem={true} />
<div style={{ width: '100%', minWidth: '300px', overflow: 'hidden' }}>
<SystemKillsContent
kills={detailedKills}
systemNameMap={systemNameMap}
onlyOneSystem={true}
autoSize={true}
limit={killsCount}
/>
</div>
);
return (
// @ts-ignore
<WdTooltipWrapper content={tooltipContent} className={className} size={size} interactive={true}>
{children}
</WdTooltipWrapper>

View File

@@ -4,6 +4,12 @@
z-index: 3;
}
.hoverTarget {
padding: 0.5rem;
margin: -0.5rem;
display: inline-block;
}
.localCounter {
mix-blend-mode: screen;
display: flex;

View File

@@ -24,13 +24,12 @@ export const LocalCounter = ({ localCounterCharacters, hasUserCharacters, showIc
return (
<div
style={{
width: '300px',
overflowX: 'hidden',
overflowY: 'auto',
height: '300px',
width: '100%',
minWidth: '300px',
overflow: 'hidden',
}}
>
<LocalCharactersList items={localCounterCharacters} itemTemplate={itemTemplate} itemSize={26} />
<LocalCharactersList items={localCounterCharacters} itemTemplate={itemTemplate} itemSize={26} autoSize={true} />
</div>
);
}, [localCounterCharacters, itemTemplate]);
@@ -45,19 +44,16 @@ export const LocalCounter = ({ localCounterCharacters, hasUserCharacters, showIc
[classes.Pathfinder]: theme === AvailableThemes.pathfinder,
})}
>
<WdTooltipWrapper
// @ts-ignore
content={pilotTooltipContent}
position={TooltipPosition.right}
offset={0}
>
<div
className={clsx(classes.localCounter, {
[classes.hasUserCharacters]: hasUserCharacters,
})}
>
{showIcon && <i className="pi pi-users" />}
<span>{localCounterCharacters.length}</span>
<WdTooltipWrapper content={pilotTooltipContent} position={TooltipPosition.right} offset={0} interactive={true}>
<div className={clsx(classes.hoverTarget)}>
<div
className={clsx(classes.localCounter, {
[classes.hasUserCharacters]: hasUserCharacters,
})}
>
{showIcon && <i className="pi pi-users" />}
<span>{localCounterCharacters.length}</span>
</div>
</div>
</WdTooltipWrapper>
</div>

View File

@@ -90,11 +90,7 @@ $tooltip-bg: #202020;
&.eve-system-status-home {
border: 1px solid var(--eve-solar-system-status-color-home-dark30);
background-image: linear-gradient(
45deg,
var(--eve-solar-system-status-color-background),
transparent
);
background-image: linear-gradient(45deg, var(--eve-solar-system-status-color-background), transparent);
&.selected {
border-color: var(--eve-solar-system-status-color-home);
}
@@ -102,11 +98,7 @@ $tooltip-bg: #202020;
&.eve-system-status-friendly {
border: 1px solid var(--eve-solar-system-status-color-friendly-dark20);
background-image: linear-gradient(
275deg,
var(--eve-solar-system-status-friendly-dark30),
transparent
);
background-image: linear-gradient(275deg, var(--eve-solar-system-status-friendly-dark30), transparent);
&.selected {
border-color: var(--eve-solar-system-status-color-friendly-dark5);
}
@@ -121,27 +113,15 @@ $tooltip-bg: #202020;
}
&.eve-system-status-warning {
background-image: linear-gradient(
275deg,
var(--eve-solar-system-status-warning),
transparent
);
background-image: linear-gradient(275deg, var(--eve-solar-system-status-warning), transparent);
}
&.eve-system-status-dangerous {
background-image: linear-gradient(
275deg,
var(--eve-solar-system-status-dangerous),
transparent
);
background-image: linear-gradient(275deg, var(--eve-solar-system-status-dangerous), transparent);
}
&.eve-system-status-target {
background-image: linear-gradient(
275deg,
var(--eve-solar-system-status-target),
transparent
);
background-image: linear-gradient(275deg, var(--eve-solar-system-status-target), transparent);
}
}
@@ -254,7 +234,7 @@ $tooltip-bg: #202020;
font-size: 11px;
font-weight: 500;
text-shadow: 0 0 2px rgba(231, 146, 52, 0.73);
color: var(--rf-tag-color, #38BDF8);
color: var(--rf-tag-color, #38bdf8);
}
/* Firefox kostyl */
@@ -263,7 +243,6 @@ $tooltip-bg: #202020;
font-weight: bold;
}
}
}
.BottomRow {
@@ -376,4 +355,3 @@ $tooltip-bg: #202020;
}
}
}

View File

@@ -4,7 +4,7 @@ import { Handle, NodeProps, Position } from 'reactflow';
import clsx from 'clsx';
import classes from './SolarSystemNodeDefault.module.scss';
import { PrimeIcons } from 'primereact/api';
import { useLocalCounter, useSolarSystemNode } from '../../hooks/useSolarSystemLogic';
import { useLocalCounter, useSolarSystemNode, useNodeKillsCount } from '../../hooks/useSolarSystemLogic';
import {
EFFECT_BACKGROUND_STYLES,
MARKER_BOOKMARK_BG_STYLES,
@@ -18,6 +18,7 @@ import { KillsCounter } from './SolarSystemKillsCounter';
export const SolarSystemNodeDefault = memo((props: NodeProps<MapSolarSystemType>) => {
const nodeVars = useSolarSystemNode(props);
const { localCounterCharacters } = useLocalCounter(nodeVars);
const localKillsCount = useNodeKillsCount(nodeVars.solarSystemId, nodeVars.killsCount);
return (
<>
@@ -35,9 +36,9 @@ export const SolarSystemNodeDefault = memo((props: NodeProps<MapSolarSystemType>
</div>
)}
{nodeVars.killsCount && nodeVars.killsCount > 0 && nodeVars.solarSystemId && (
{localKillsCount && localKillsCount > 0 && nodeVars.solarSystemId && (
<KillsCounter
killsCount={nodeVars.killsCount}
killsCount={localKillsCount}
systemId={nodeVars.solarSystemId}
size="lg"
killsActivityType={nodeVars.killsActivityType}

View File

@@ -4,7 +4,7 @@ import { Handle, NodeProps, Position } from 'reactflow';
import clsx from 'clsx';
import classes from './SolarSystemNodeTheme.module.scss';
import { PrimeIcons } from 'primereact/api';
import { useLocalCounter, useSolarSystemNode } from '../../hooks/useSolarSystemLogic';
import { useLocalCounter, useNodeKillsCount, useSolarSystemNode } from '../../hooks/useSolarSystemLogic';
import {
EFFECT_BACKGROUND_STYLES,
MARKER_BOOKMARK_BG_STYLES,
@@ -18,6 +18,7 @@ import { KillsCounter } from './SolarSystemKillsCounter';
export const SolarSystemNodeTheme = memo((props: NodeProps<MapSolarSystemType>) => {
const nodeVars = useSolarSystemNode(props);
const { localCounterCharacters } = useLocalCounter(nodeVars);
const localKillsCount = useNodeKillsCount(nodeVars.solarSystemId, nodeVars.killsCount);
return (
<>
@@ -35,9 +36,9 @@ export const SolarSystemNodeTheme = memo((props: NodeProps<MapSolarSystemType>)
</div>
)}
{nodeVars.killsCount && nodeVars.killsCount > 0 && nodeVars.solarSystemId && (
{localKillsCount && localKillsCount > 0 && nodeVars.solarSystemId && (
<KillsCounter
killsCount={nodeVars.killsCount}
killsCount={localKillsCount}
systemId={nodeVars.solarSystemId}
size="lg"
killsActivityType={nodeVars.killsActivityType}

View File

@@ -10,5 +10,6 @@ export const convertSystem2Node = (sys: SolarSystemRawType): Node => {
position: sys.position,
data: sys,
draggable: !sys.locked,
deletable: !sys.locked,
};
};

View File

@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { MapSolarSystemType } from '../map.types';
import { NodeProps } from 'reactflow';
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
@@ -10,8 +10,9 @@ import { isWormholeSpace } from '@/hooks/Mapper/components/map/helpers/isWormhol
import { getSystemClassStyles, prepareUnsplashedChunks } from '@/hooks/Mapper/components/map/helpers';
import { sortWHClasses } from '@/hooks/Mapper/helpers';
import { LabelsManager } from '@/hooks/Mapper/utils/labelsManager';
import { CharacterTypeRaw, OutCommand, SystemSignature } from '@/hooks/Mapper/types';
import { CharacterTypeRaw, Commands, OutCommand, SystemSignature } from '@/hooks/Mapper/types';
import { LABELS_INFO, LABELS_ORDER } from '@/hooks/Mapper/components/map/constants';
import { useMapEventListener } from '@/hooks/Mapper/events';
export type LabelInfo = {
id: string;
@@ -195,7 +196,6 @@ export function useSolarSystemNode(props: NodeProps<MapSolarSystemType>): SolarS
kind: s.kind,
name: s.name,
group: s.group,
sig_id: s.eve_id, // Add a unique key property
})) as UnsplashedSignatureType[],
);
}, [isShowUnsplashedSignatures, systemSigs]);
@@ -281,3 +281,25 @@ export interface SolarSystemNodeVars {
classTitle: string | null;
temporaryName?: string | null;
}
export function useNodeKillsCount(systemId: number | string, initialKillsCount: number | null): number | null {
const [killsCount, setKillsCount] = useState<number | null>(initialKillsCount);
useEffect(() => {
setKillsCount(initialKillsCount);
}, [initialKillsCount]);
useMapEventListener(event => {
if (event.name === Commands.killsUpdated && event.data?.toString() === systemId.toString()) {
//@ts-ignore
if (event.payload && typeof event.payload.kills === 'number') {
// @ts-ignore
setKillsCount(event.payload.kills);
}
return true;
}
return false;
});
return killsCount;
}

View File

@@ -3,11 +3,13 @@ import { VirtualScroller, VirtualScrollerTemplateOptions } from 'primereact/virt
import clsx from 'clsx';
import { CharItemProps } from './types';
type LocalCharactersListProps = {
export type LocalCharactersListProps = {
items: Array<CharItemProps>;
itemSize: number;
itemTemplate: (char: CharItemProps, options: VirtualScrollerTemplateOptions) => React.ReactNode;
containerClassName?: string;
style?: React.CSSProperties;
autoSize?: boolean;
};
export const LocalCharactersList = ({
@@ -15,7 +17,19 @@ export const LocalCharactersList = ({
itemSize,
itemTemplate,
containerClassName,
style = {},
autoSize = false,
}: LocalCharactersListProps) => {
const computedHeight = autoSize ? `${Math.max(items.length, 1) * itemSize}px` : style.height || '100%';
const localStyle: React.CSSProperties = {
...style,
height: computedHeight,
width: '100%',
boxSizing: 'border-box',
overflowX: 'hidden',
};
return (
<VirtualScroller
items={items}
@@ -23,6 +37,8 @@ export const LocalCharactersList = ({
orientation="vertical"
className={clsx('w-full h-full', containerClassName)}
itemTemplate={itemTemplate}
autoSize={autoSize}
style={localStyle}
/>
);
};

View File

@@ -7,8 +7,9 @@ import { useKillsWidgetSettings } from './hooks/useKillsWidgetSettings';
import { useSystemKills } from './hooks/useSystemKills';
import { KillsSettingsDialog } from './components/SystemKillsSettingsDialog';
import { isWormholeSpace } from '@/hooks/Mapper/components/map/helpers/isWormholeSpace';
import { SolarSystemRawType } from '@/hooks/Mapper/types';
export const SystemKills: React.FC = () => {
export const SystemKills: React.FC = React.memo(() => {
const {
data: { selectedSystems, systems, isSubscriptionActive },
outCommand,
@@ -25,6 +26,16 @@ export const SystemKills: React.FC = () => {
return map;
}, [systems]);
const systemBySolarSystemId = useMemo(() => {
const map: Record<number, SolarSystemRawType> = {};
systems.forEach(sys => {
if (sys.system_static_info?.solar_system_id != null) {
map[sys.system_static_info.solar_system_id] = sys;
}
});
return map;
}, [systems]);
const [settings] = useKillsWidgetSettings();
const visible = settings.showAll;
@@ -40,78 +51,59 @@ export const SystemKills: React.FC = () => {
const filteredKills = useMemo(() => {
if (!settings.whOnly || !visible) return kills;
return kills.filter(kill => {
const system = systems.find(
sys => sys.system_static_info.solar_system_id === kill.solar_system_id
);
const system = systemBySolarSystemId[kill.solar_system_id];
if (!system) {
console.warn(`System with id ${kill.solar_system_id} not found.`);
return false;
}
return isWormholeSpace(system.system_static_info.system_class);
});
}, [kills, settings.whOnly, systems]);
}, [kills, settings.whOnly, systemBySolarSystemId, visible]);
return (
<div className="h-full flex flex-col min-h-0">
<div className="flex flex-col flex-1 min-h-0">
<Widget
label={
<KillsHeader
systemId={systemId}
onOpenSettings={() => setSettingsDialogVisible(true)}
/>
}
>
<div className="relative h-full">
{!isSubscriptionActive ? (
<div className="absolute inset-0 flex items-center justify-center">
<span className="select-none text-center text-stone-400/80 text-sm">
Kills available with &#39;Active&#39; map subscription only (contact map administrators)
</span>
</div>
) : isNothingSelected ? (
<div className="absolute inset-0 flex items-center justify-center">
<span className="select-none text-center text-stone-400/80 text-sm">
No system selected (or toggle Show all systems)
</span>
</div>
) : showLoading ? (
<div className="absolute inset-0 flex items-center justify-center">
<span className="select-none text-center text-stone-400/80 text-sm">
Loading Kills...
</span>
</div>
) : error ? (
<div className="absolute inset-0 flex items-center justify-center">
<span className="select-none text-center text-red-400 text-sm">
{error}
</span>
</div>
) : !filteredKills || filteredKills.length === 0 ? (
<div className="absolute inset-0 flex items-center justify-center">
<span className="select-none text-center text-stone-400/80 text-sm">
No kills found
</span>
</div>
) : (
<div className="h-full overflow-y-auto">
<SystemKillsContent
key={settings.compact ? 'compact' : 'normal'}
kills={filteredKills}
systemNameMap={systemNameMap}
compact={settings.compact}
onlyOneSystem={!visible}
/>
</div>
)}
</div>
<Widget label={<KillsHeader systemId={systemId} onOpenSettings={() => setSettingsDialogVisible(true)} />}>
{!isSubscriptionActive ? (
<div className="w-full h-full flex items-center justify-center">
<span className="select-none text-center text-stone-400/80 text-sm">
Kills available with &#39;Active&#39; map subscription only (contact map administrators)
</span>
</div>
) : isNothingSelected ? (
<div className="w-full h-full flex items-center justify-center">
<span className="select-none text-center text-stone-400/80 text-sm">
No system selected (or toggle Show all systems)
</span>
</div>
) : showLoading ? (
<div className="w-full h-full flex items-center justify-center">
<span className="select-none text-center text-stone-400/80 text-sm">Loading Kills...</span>
</div>
) : error ? (
<div className="w-full h-full flex items-center justify-center">
<span className="select-none text-center text-red-400 text-sm">{error}</span>
</div>
) : !filteredKills || filteredKills.length === 0 ? (
<div className="w-full h-full flex items-center justify-center">
<span className="select-none text-center text-stone-400/80 text-sm">No kills found</span>
</div>
) : (
<div className="w-full h-full" style={{ height: '100%' }}>
<SystemKillsContent
kills={filteredKills}
systemNameMap={systemNameMap}
onlyOneSystem={!visible}
timeRange={settings.timeRange}
/>
</div>
)}
</Widget>
</div>
<KillsSettingsDialog
visible={settingsDialogVisible}
setVisible={setSettingsDialogVisible}
/>
{settingsDialogVisible && <KillsSettingsDialog visible setVisible={setSettingsDialogVisible} />}
</div>
);
};
});
SystemKills.displayName = 'SystemKills';

View File

@@ -1,16 +1,14 @@
.TableRowCompact {
height: 8px;
max-height: 8px;
font-size: 12px !important;
line-height: 8px;
.wrapper {
overflow-x: hidden;
box-sizing: border-box;
}
.Table {
font-size: 12px;
border-collapse: collapse;
.scrollerContent {
width: 100%;
box-sizing: border-box;
overflow-x: hidden;
}
.Tooltip {
white-space: pre-line;
line-height: 1.2rem;
.VirtualScroller {
height: 100% !important;
}

View File

@@ -1,50 +1,91 @@
import React, { useMemo } from 'react';
import React, { useMemo, useRef, useEffect, useState } from 'react';
import clsx from 'clsx';
import { DetailedKill } from '@/hooks/Mapper/types/kills';
import { KillRow } from '../components/SystemKillsRow';
import { VirtualScroller } from 'primereact/virtualscroller';
import { useSystemKillsItemTemplate } from '../hooks/useSystemKillsItemTemplate';
import classes from './SystemKillsContent.module.scss';
interface SystemKillsContentProps {
export const ITEM_HEIGHT = 35;
export const CONTENT_MARGINS = 5;
export interface SystemKillsContentProps {
kills: DetailedKill[];
systemNameMap: Record<string, string>;
compact?: boolean;
onlyOneSystem?: boolean;
autoSize?: boolean;
timeRange?: number;
limit?: number;
}
export const SystemKillsContent: React.FC<SystemKillsContentProps> = ({
kills,
systemNameMap,
compact = false,
onlyOneSystem = false,
autoSize = false,
timeRange = 4,
limit,
}) => {
const sortedKills = useMemo(() => {
return [...kills].sort((a, b) => {
const timeA = a.kill_time ? new Date(a.kill_time).getTime() : 0;
const timeB = b.kill_time ? new Date(b.kill_time).getTime() : 0;
return timeB - timeA;
});
}, [kills]);
const processedKills = useMemo(() => {
const sortedKills = kills
.filter(k => k.kill_time)
.sort((a, b) => new Date(b.kill_time!).getTime() - new Date(a.kill_time!).getTime());
if (limit !== undefined) {
return sortedKills.slice(0, limit);
} else {
const now = Date.now();
const cutoff = now - timeRange * 60 * 60 * 1000;
return sortedKills.filter(k => new Date(k.kill_time!).getTime() >= cutoff);
}
}, [kills, timeRange, limit]);
const computedHeight = autoSize ? Math.max(processedKills.length, 1) * ITEM_HEIGHT + CONTENT_MARGINS : undefined;
const containerRef = useRef<HTMLDivElement>(null);
const scrollerRef = useRef<VirtualScroller | null>(null);
const [containerHeight, setContainerHeight] = useState<number>(0);
useEffect(() => {
if (!autoSize && containerRef.current) {
const measure = () => {
const newHeight = containerRef.current?.clientHeight || 0;
setContainerHeight(newHeight);
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(containerRef.current);
window.addEventListener('resize', measure);
return () => {
observer.disconnect();
window.removeEventListener('resize', measure);
};
}
}, [autoSize]);
const itemTemplate = useSystemKillsItemTemplate(systemNameMap, onlyOneSystem);
const scrollerHeight = autoSize ? `${computedHeight}px` : containerHeight ? `${containerHeight}px` : '100%';
return (
<div
className={clsx(
'flex flex-col w-full text-stone-200 text-xs transition-all duration-300',
compact ? 'p-1' : 'p-1'
)}
>
{sortedKills.map(kill => {
const systemIdStr = String(kill.solar_system_id);
const systemName = systemNameMap[systemIdStr] || `System ${systemIdStr}`;
return (
<KillRow
key={kill.killmail_id}
killDetails={kill}
systemName={systemName}
isCompact={compact}
onlyOneSystem={onlyOneSystem}
/>
);
})}
<div ref={autoSize ? undefined : containerRef} className={clsx('w-full h-full', classes.wrapper)}>
<VirtualScroller
ref={autoSize ? undefined : scrollerRef}
items={processedKills}
itemSize={ITEM_HEIGHT}
itemTemplate={itemTemplate}
autoSize={autoSize}
scrollWidth="100%"
style={{ height: scrollerHeight }}
className={clsx('w-full h-full custom-scrollbar select-none overflow-x-hidden overflow-y-auto', {
[classes.VirtualScroller]: !autoSize,
})}
pt={{
content: {
className: classes.scrollerContent,
},
}}
/>
</div>
);
};

View File

@@ -1,268 +0,0 @@
import React from 'react';
import clsx from 'clsx';
import { DetailedKill } from '@/hooks/Mapper/types/kills';
import {
formatISK,
formatTimeMixed,
zkillLink,
getAttackerSubscript,
buildVictimImageUrls,
buildAttackerImageUrls,
getPrimaryLogoAndTooltip,
getAttackerPrimaryImageAndTooltip,
} from '../helpers';
import { VictimRowSubInfo } from './VictimRowSubInfo';
import { WdTooltipWrapper } from '../../../../ui-kit/WdTooltipWrapper';
import classes from './SystemKillRow.module.scss';
import { TooltipPosition } from '@/hooks/Mapper/components/ui-kit';
export interface FullKillRowProps {
killDetails: DetailedKill;
systemName: string;
onlyOneSystem: boolean;
}
export const FullKillRow: React.FC<FullKillRowProps> = ({
killDetails,
systemName,
onlyOneSystem,
}) => {
const {
killmail_id = 0,
// Victim data
victim_char_name = '',
victim_alliance_ticker = '',
victim_corp_ticker = '',
victim_ship_name = '',
victim_char_id = 0,
victim_corp_id = 0,
victim_alliance_id = 0,
victim_ship_type_id = 0,
victim_corp_name = '',
victim_alliance_name = '',
// Attacker data
final_blow_char_id = 0,
final_blow_char_name = '',
final_blow_alliance_ticker = '',
final_blow_corp_ticker = '',
final_blow_corp_name = '',
final_blow_alliance_name = '',
final_blow_corp_id = 0,
final_blow_alliance_id = 0,
final_blow_ship_name = '',
final_blow_ship_type_id = 0,
total_value = 0,
kill_time = '',
} = killDetails || {};
const attackerIsNpc = final_blow_char_id === 0;
const victimAffiliation = victim_alliance_ticker || victim_corp_ticker || null;
const attackerAffiliation = attackerIsNpc
? ''
: final_blow_alliance_ticker || final_blow_corp_ticker || '';
const killValueFormatted =
total_value != null && total_value > 0 ? `${formatISK(total_value)} ISK` : null;
const killTimeAgo = kill_time ? formatTimeMixed(kill_time) : '0h ago';
// Build victim images
const {
victimPortraitUrl,
victimCorpLogoUrl,
victimAllianceLogoUrl,
victimShipUrl,
} = buildVictimImageUrls({
victim_char_id,
victim_ship_type_id,
victim_corp_id,
victim_alliance_id,
});
// Build attacker images
const {
attackerPortraitUrl,
attackerCorpLogoUrl,
attackerAllianceLogoUrl,
} = buildAttackerImageUrls({
final_blow_char_id,
final_blow_corp_id,
final_blow_alliance_id,
});
// Primary image for victim
const { url: victimPrimaryImageUrl, tooltip: victimPrimaryTooltip } =
getPrimaryLogoAndTooltip(
victimAllianceLogoUrl,
victimCorpLogoUrl,
victim_alliance_name,
victim_corp_name,
'Victim'
);
// Primary image for attacker
const { url: attackerPrimaryImageUrl, tooltip: attackerPrimaryTooltip } =
getAttackerPrimaryImageAndTooltip(
attackerIsNpc,
attackerAllianceLogoUrl,
attackerCorpLogoUrl,
final_blow_alliance_name,
final_blow_corp_name,
final_blow_ship_type_id
);
const attackerSubscript = getAttackerSubscript(killDetails);
return (
<div
className={clsx(
classes.killRowContainer,
'w-full text-sm py-1 px-2',
'flex flex-col sm:flex-row'
)}
>
<div className="w-full flex flex-col sm:flex-row items-start gap-2">
{/* Victim Section */}
<div className="flex items-start gap-1 min-w-0">
{victimShipUrl && (
<div className="relative shrink-0 w-12 h-12 sm:w-14 sm:h-14 overflow-hidden">
<a
href={zkillLink('kill', killmail_id)}
target="_blank"
rel="noopener noreferrer"
className="block w-full h-full"
>
<img
src={victimShipUrl}
alt="VictimShip"
className={clsx(
classes.killRowImage,
'w-full h-full object-contain'
)}
/>
</a>
</div>
)}
{victimPrimaryImageUrl && (
<WdTooltipWrapper
content={victimPrimaryTooltip}
position={TooltipPosition.top}
>
<div className="relative shrink-0 w-12 h-12 sm:w-14 sm:h-14 overflow-hidden">
<a
href={zkillLink('kill', killmail_id)}
target="_blank"
rel="noopener noreferrer"
className="block w-full h-full"
>
<img
src={victimPrimaryImageUrl}
alt="VictimPrimaryLogo"
className={clsx(
classes.killRowImage,
'w-full h-full object-contain'
)}
/>
</a>
</div>
</WdTooltipWrapper>
)}
<VictimRowSubInfo
victimCharName={victim_char_name}
victimCharacterId={victim_char_id}
victimPortraitUrl={victimPortraitUrl}
/>
<div className="flex flex-col text-stone-200 leading-4 min-w-0 overflow-hidden">
<div className="truncate font-semibold">
{victim_char_name}
{victimAffiliation && (
<span className="ml-1 text-stone-400">/ {victimAffiliation}</span>
)}
</div>
<div className="truncate text-stone-300">
{victim_ship_name}
{killValueFormatted && (
<>
<span className="ml-1 text-stone-400">/</span>
<span className="ml-1 text-green-400">{killValueFormatted}</span>
</>
)}
</div>
<div className="truncate text-stone-400">
{!onlyOneSystem && systemName && <span>{systemName}</span>}
</div>
</div>
</div>
{/* Attacker Section */}
<div className="flex items-start gap-1 min-w-0 sm:ml-auto">
<div className="flex flex-col items-end leading-4 min-w-0 overflow-hidden text-right">
{!attackerIsNpc && (
<div className="truncate font-semibold">
{final_blow_char_name}
{attackerAffiliation && (
<span className="ml-1 text-stone-400">/ {attackerAffiliation}</span>
)}
</div>
)}
{!attackerIsNpc && final_blow_ship_name && (
<div className="truncate text-stone-300">{final_blow_ship_name}</div>
)}
<div className="truncate text-red-400">{killTimeAgo}</div>
</div>
{(!attackerIsNpc && attackerPortraitUrl && final_blow_char_id > 0) && (
<div className="relative shrink-0 w-12 h-12 sm:w-14 sm:h-14 overflow-hidden">
<a
href={zkillLink('character', final_blow_char_id)}
target="_blank"
rel="noopener noreferrer"
className="block w-full h-full"
>
<img
src={attackerPortraitUrl}
alt="AttackerPortrait"
className={clsx(
classes.killRowImage,
'w-full h-full object-contain'
)}
/>
</a>
</div>
)}
{attackerPrimaryImageUrl && (
<WdTooltipWrapper
content={attackerPrimaryTooltip}
position={TooltipPosition.top}
>
<div className="relative shrink-0 w-12 h-12 sm:w-14 sm:h-14 overflow-hidden">
<a
href={zkillLink('kill', killmail_id)}
target="_blank"
rel="noopener noreferrer"
className="block w-full h-full"
>
<img
src={attackerPrimaryImageUrl}
alt={attackerIsNpc ? 'NpcShip' : 'AttackerPrimaryLogo'}
className={clsx(
classes.killRowImage,
'w-full h-full object-contain'
)}
/>
{attackerSubscript && (
<span
className={clsx(
attackerSubscript.cssClass,
classes.attackerCountLabel
)}
>
{attackerSubscript.label}
</span>
)}
</a>
</div>
</WdTooltipWrapper>
)}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,20 @@
import { DetailedKill } from '@/hooks/Mapper/types/kills';
import { VirtualScrollerTemplateOptions } from 'primereact/virtualscroller';
import { KillRow } from './SystemKillsRow';
import clsx from 'clsx';
export function KillItemTemplate(
systemNameMap: Record<string, string>,
onlyOneSystem: boolean,
kill: DetailedKill,
options: VirtualScrollerTemplateOptions,
) {
const systemIdStr = String(kill.solar_system_id);
const systemName = systemNameMap[systemIdStr] || `System ${systemIdStr}`;
return (
<div style={{ height: `${options.props.itemSize}px` }} className={clsx({ 'bg-gray-900': options.odd })}>
<KillRow killDetails={kill} systemName={systemName} onlyOneSystem={onlyOneSystem} />
</div>
);
}

View File

@@ -11,8 +11,8 @@ import {
getPrimaryLogoAndTooltip,
getAttackerPrimaryImageAndTooltip,
} from '../helpers';
import { WdTooltipWrapper } from '../../../../ui-kit/WdTooltipWrapper';
import classes from './SystemKillRow.module.scss';
import { WdTooltipWrapper } from '@/hooks/Mapper/components/ui-kit';
import classes from './KillRowDetail.module.scss';
import { TooltipPosition } from '@/hooks/Mapper/components/ui-kit';
export interface CompactKillRowProps {
@@ -21,15 +21,10 @@ export interface CompactKillRowProps {
onlyOneSystem: boolean;
}
export const CompactKillRow: React.FC<CompactKillRowProps> = ({
killDetails,
systemName,
onlyOneSystem,
}) => {
export const KillRowDetail: React.FC<CompactKillRowProps> = ({ killDetails, systemName, onlyOneSystem }) => {
const {
killmail_id = 0,
// Victim
// Victim data
victim_char_name = 'Unknown Pilot',
victim_alliance_ticker = '',
victim_corp_ticker = '',
@@ -40,8 +35,7 @@ export const CompactKillRow: React.FC<CompactKillRowProps> = ({
victim_corp_id = 0,
victim_alliance_id = 0,
victim_ship_type_id = 0,
// Attacker
// Attacker data
final_blow_char_id = 0,
final_blow_char_name = '',
final_blow_alliance_ticker = '',
@@ -51,72 +45,64 @@ export const CompactKillRow: React.FC<CompactKillRowProps> = ({
final_blow_corp_id = 0,
final_blow_corp_name = '',
final_blow_ship_type_id = 0,
kill_time = '',
total_value = 0,
} = killDetails || {};
const attackerIsNpc = final_blow_char_id === 0;
// Tickers & strings
const victimAffiliationTicker =
victim_alliance_ticker || victim_corp_ticker || 'No Ticker';
const killValueFormatted =
total_value != null && total_value > 0 ? `${formatISK(total_value)} ISK` : null;
const attackerName = attackerIsNpc ? '' : final_blow_char_name;
const attackerTicker = attackerIsNpc
? ''
: final_blow_alliance_ticker || final_blow_corp_ticker || '';
// Define victim affiliation ticker.
const victimAffiliationTicker = victim_alliance_ticker || victim_corp_ticker || 'No Ticker';
const killValueFormatted = total_value != null && total_value > 0 ? `${formatISK(total_value)} ISK` : null;
const killTimeAgo = kill_time ? formatTimeMixed(kill_time) : '0h ago';
const attackerSubscript = getAttackerSubscript(killDetails);
// Victim images, including the ship
const {
victimCorpLogoUrl,
victimAllianceLogoUrl,
victimShipUrl,
} = buildVictimImageUrls({
const { victimCorpLogoUrl, victimAllianceLogoUrl, victimShipUrl } = buildVictimImageUrls({
victim_char_id,
victim_ship_type_id,
victim_corp_id,
victim_alliance_id,
});
// Attacker corp/alliance
const { attackerCorpLogoUrl, attackerAllianceLogoUrl } = buildAttackerImageUrls({
final_blow_char_id,
final_blow_corp_id,
final_blow_alliance_id,
});
// Victim corp/alliance logo
const { url: victimPrimaryLogoUrl, tooltip: victimPrimaryTooltip } =
getPrimaryLogoAndTooltip(
victimAllianceLogoUrl,
victimCorpLogoUrl,
victim_alliance_name,
victim_corp_name,
'Victim'
);
const { url: victimPrimaryLogoUrl, tooltip: victimPrimaryTooltip } = getPrimaryLogoAndTooltip(
victimAllianceLogoUrl,
victimCorpLogoUrl,
victim_alliance_name,
victim_corp_name,
'Victim',
);
// Attacker corp/alliance or NPC ship
const { url: attackerPrimaryImageUrl, tooltip: attackerPrimaryTooltip } =
getAttackerPrimaryImageAndTooltip(
attackerIsNpc,
attackerAllianceLogoUrl,
attackerCorpLogoUrl,
final_blow_alliance_name,
final_blow_corp_name,
final_blow_ship_type_id
);
const { url: attackerPrimaryImageUrl, tooltip: attackerPrimaryTooltip } = getAttackerPrimaryImageAndTooltip(
attackerIsNpc,
attackerAllianceLogoUrl,
attackerCorpLogoUrl,
final_blow_alliance_name,
final_blow_corp_name,
final_blow_ship_type_id,
);
// Define attackerTicker to use the alliance ticker if available, otherwise the corp ticker.
const attackerTicker = attackerIsNpc ? '' : final_blow_alliance_ticker || final_blow_corp_ticker || '';
// For the attacker image link: if the attacker is not an NPC, link to the character page; otherwise, link to the kill page.
const attackerLink = attackerIsNpc ? zkillLink('kill', killmail_id) : zkillLink('character', final_blow_char_id);
return (
<div
className={clsx(
'h-10 flex items-center border-b border-stone-800',
'text-xs whitespace-nowrap overflow-hidden leading-none'
'text-xs whitespace-nowrap overflow-hidden leading-none',
)}
>
{/* Victim Section */}
<div className="flex items-center gap-1">
{victimShipUrl && (
<div className="relative shrink-0 w-8 h-8 overflow-hidden">
@@ -128,20 +114,14 @@ export const CompactKillRow: React.FC<CompactKillRowProps> = ({
>
<img
src={victimShipUrl}
alt="VictimShip"
className={clsx(
classes.killRowImage,
'w-full h-full object-contain'
)}
alt="Victim Ship"
className={clsx(classes.killRowImage, 'w-full h-full object-contain')}
/>
</a>
</div>
)}
{victimPrimaryLogoUrl && (
<WdTooltipWrapper
content={victimPrimaryTooltip}
position={TooltipPosition.top}
>
<WdTooltipWrapper content={victimPrimaryTooltip} position={TooltipPosition.top}>
<a
href={zkillLink('kill', killmail_id)}
target="_blank"
@@ -150,17 +130,14 @@ export const CompactKillRow: React.FC<CompactKillRowProps> = ({
>
<img
src={victimPrimaryLogoUrl}
alt="VictimPrimaryLogo"
className={clsx(
classes.killRowImage,
'w-full h-full object-contain'
)}
alt="Victim Primary Logo"
className={clsx(classes.killRowImage, 'w-full h-full object-contain')}
/>
</a>
</WdTooltipWrapper>
)}
</div>
<div className="flex flex-col ml-2 min-w-0 overflow-hidden leading-[1rem]">
<div className="flex flex-col ml-2 flex-1 min-w-0 overflow-hidden leading-[1rem]">
<div className="truncate text-stone-200">
{victim_char_name}
<span className="text-stone-400"> / {victimAffiliationTicker}</span>
@@ -176,20 +153,17 @@ export const CompactKillRow: React.FC<CompactKillRowProps> = ({
</div>
</div>
<div className="flex items-center ml-auto gap-2">
<div className="flex flex-col items-end min-w-0 overflow-hidden text-right leading-[1rem]">
{!attackerIsNpc && (attackerName || attackerTicker) && (
<div className="flex flex-col items-end flex-1 min-w-0 overflow-hidden text-right leading-[1rem]">
{!attackerIsNpc && (final_blow_char_name || attackerTicker) && (
<div className="truncate text-stone-200">
{attackerName}
{attackerTicker && (
<span className="ml-1 text-stone-400">/ {attackerTicker}</span>
)}
{final_blow_char_name}
{!attackerIsNpc && attackerTicker && <span className="ml-1 text-stone-400">/ {attackerTicker}</span>}
</div>
)}
<div className="truncate text-stone-400">
{!onlyOneSystem && systemName ? (
<>
{systemName} /{' '}
<span className="ml-1 text-red-400">{killTimeAgo}</span>
{systemName} / <span className="ml-1 text-red-400">{killTimeAgo}</span>
</>
) : (
<span className="text-red-400">{killTimeAgo}</span>
@@ -197,30 +171,24 @@ export const CompactKillRow: React.FC<CompactKillRowProps> = ({
</div>
</div>
{attackerPrimaryImageUrl && (
<WdTooltipWrapper
content={attackerPrimaryTooltip}
position={TooltipPosition.top}
>
<WdTooltipWrapper content={attackerPrimaryTooltip} position={TooltipPosition.top}>
<a
href={zkillLink('kill', killmail_id)}
href={attackerLink}
target="_blank"
rel="noopener noreferrer"
className="relative block shrink-0 w-8 h-8 overflow-hidden"
>
<img
src={attackerPrimaryImageUrl}
alt={attackerIsNpc ? 'NpcShip' : 'AttackerPrimaryLogo'}
className={clsx(
classes.killRowImage,
'w-full h-full object-contain'
)}
alt={attackerIsNpc ? 'NPC Ship' : 'Attacker Primary Logo'}
className={clsx(classes.killRowImage, 'w-full h-full object-contain')}
/>
{attackerSubscript && (
<span
className={clsx(
classes.attackerCountLabel,
attackerSubscript.cssClass,
'text-[0.6rem] leading-none px-[2px]'
'text-[0.6rem] leading-none px-[2px]',
)}
>
{attackerSubscript.label}

View File

@@ -1,24 +1,15 @@
import React from 'react';
import { DetailedKill } from '@/hooks/Mapper/types/kills';
import { CompactKillRow } from './CompactKillRow';
import { FullKillRow } from './FullKillRow';
import { KillRowDetail } from './KillRowDetail.tsx';
export interface KillRowProps {
killDetails: DetailedKill;
systemName: string;
isCompact?: boolean;
onlyOneSystem?: boolean;
}
export const KillRow: React.FC<KillRowProps> = ({
killDetails,
systemName,
isCompact = false,
onlyOneSystem = false,
}) => {
if (isCompact) {
return <CompactKillRow killDetails={killDetails} systemName={systemName} onlyOneSystem={onlyOneSystem} />;
}
return <FullKillRow killDetails={killDetails} systemName={systemName} onlyOneSystem={onlyOneSystem} />;
const KillRowComponent: React.FC<KillRowProps> = ({ killDetails, systemName, onlyOneSystem = false }) => {
return <KillRowDetail killDetails={killDetails} systemName={systemName} onlyOneSystem={onlyOneSystem} />;
};
export const KillRow = React.memo(KillRowComponent);

View File

@@ -1,13 +1,14 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Dialog } from 'primereact/dialog';
import { Button } from 'primereact/button';
import { WdImgButton, SystemView, TooltipPosition } from '@/hooks/Mapper/components/ui-kit';
import { WdImgButton } from '@/hooks/Mapper/components/ui-kit';
import { PrimeIcons } from 'primereact/api';
import { useKillsWidgetSettings } from '../hooks/useKillsWidgetSettings';
import {
AddSystemDialog,
SearchOnSubmitCallback,
} from '@/hooks/Mapper/components/mapInterface/components/AddSystemDialog';
import { SystemView, TooltipPosition } from '@/hooks/Mapper/components/ui-kit';
interface KillsSettingsDialogProps {
visible: boolean;
@@ -17,36 +18,27 @@ interface KillsSettingsDialogProps {
export const KillsSettingsDialog: React.FC<KillsSettingsDialogProps> = ({ visible, setVisible }) => {
const [globalSettings, setGlobalSettings] = useKillsWidgetSettings();
const localRef = useRef({
compact: globalSettings.compact,
showAll: globalSettings.showAll,
whOnly: globalSettings.whOnly,
excludedSystems: globalSettings.excludedSystems || [],
timeRange: globalSettings.timeRange,
});
const [, forceRender] = useState(0);
const [addSystemDialogVisible, setAddSystemDialogVisible] = useState(false);
useEffect(() => {
if (visible) {
localRef.current = {
compact: globalSettings.compact,
showAll: globalSettings.showAll,
whOnly: globalSettings.whOnly,
excludedSystems: globalSettings.excludedSystems || [],
timeRange: globalSettings.timeRange,
};
forceRender(n => n + 1);
}
}, [visible, globalSettings]);
const handleCompactChange = useCallback((checked: boolean) => {
localRef.current = {
...localRef.current,
compact: checked,
};
forceRender(n => n + 1);
}, []);
const handleWHChange = useCallback((checked: boolean) => {
localRef.current = {
...localRef.current,
@@ -55,6 +47,14 @@ export const KillsSettingsDialog: React.FC<KillsSettingsDialogProps> = ({ visibl
forceRender(n => n + 1);
}, []);
const handleTimeRangeChange = useCallback((newTimeRange: number) => {
localRef.current = {
...localRef.current,
timeRange: newTimeRange,
};
forceRender(n => n + 1);
}, []);
const handleRemoveSystem = useCallback((sysId: number) => {
localRef.current = {
...localRef.current,
@@ -88,22 +88,11 @@ export const KillsSettingsDialog: React.FC<KillsSettingsDialogProps> = ({ visibl
const localData = localRef.current;
const excluded = localData.excludedSystems || [];
const timeRangeOptions = [4, 12, 24];
return (
<Dialog header="Kills Settings" visible={visible} style={{ width: '440px' }} draggable={false} onHide={handleHide}>
<div className="flex flex-col gap-3 p-2.5">
<div className="flex items-center gap-2">
<input
type="checkbox"
id="kills-compact-mode"
checked={localData.compact}
onChange={e => handleCompactChange(e.target.checked)}
/>
<label htmlFor="kills-compact-mode" className="cursor-pointer">
Use compact mode
</label>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
@@ -111,11 +100,30 @@ export const KillsSettingsDialog: React.FC<KillsSettingsDialogProps> = ({ visibl
checked={localData.whOnly}
onChange={e => handleWHChange(e.target.checked)}
/>
<label htmlFor="kills-wh-only-mode" className="cursor-pointer">
<label htmlFor="kills-wormhole-only-mode" className="cursor-pointer">
Only show wormhole kills
</label>
</div>
<div className="flex flex-col gap-1">
<span className="text-sm">Time Range:</span>
<div className="flex flex-wrap gap-2">
{timeRangeOptions.map(option => (
<label key={option} className="cursor-pointer flex items-center gap-1">
<input
type="radio"
name="timeRange"
value={option}
checked={localData.timeRange === option}
onChange={() => handleTimeRangeChange(option)}
/>
<span className="text-sm">{option} Hours</span>
</label>
))}
</div>
</div>
{/* Excluded Systems */}
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<label className="text-sm text-stone-400">Excluded Systems</label>
@@ -128,8 +136,7 @@ export const KillsSettingsDialog: React.FC<KillsSettingsDialogProps> = ({ visibl
{excluded.length === 0 && <div className="text-stone-500 text-xs italic">No systems excluded.</div>}
{excluded.map(sysId => (
<div key={sysId} className="flex items-center justify-between border-b border-stone-600 py-1 px-1 text-xs">
<SystemView systemId={sysId.toString()} hideRegion compact/>
<SystemView systemId={sysId.toString()} hideRegion />
<WdImgButton
className={PrimeIcons.TRASH}
onClick={() => handleRemoveSystem(sysId)}

View File

@@ -1,39 +0,0 @@
import React from 'react';
import clsx from 'clsx';
import { zkillLink } from '../helpers';
import classes from './SystemKillRow.module.scss';
interface VictimRowSubInfoProps {
victimCharacterId: number | null;
victimPortraitUrl: string | null;
victimCharName?: string;
}
export const VictimRowSubInfo: React.FC<VictimRowSubInfoProps> = ({
victimCharacterId = 0,
victimPortraitUrl,
victimCharName,
}) => {
if (!victimPortraitUrl || !victimCharacterId || victimCharacterId <= 0) {
return null;
}
return (
<div className="flex items-start gap-1">
<div className="relative shrink-0 w-12 h-12 sm:w-14 sm:h-14 overflow-hidden">
<a
href={zkillLink('character', victimCharacterId)}
target="_blank"
rel="noopener noreferrer"
className="block w-full h-full"
>
<img
src={victimPortraitUrl}
alt={victimCharName || 'Victim Portrait'}
className={clsx(classes.killRowImage, 'w-full h-full object-contain')}
/>
</a>
</div>
</div>
);
};

View File

@@ -2,19 +2,19 @@ import { useMemo, useCallback } from 'react';
import useLocalStorageState from 'use-local-storage-state';
export interface KillsWidgetSettings {
compact: boolean;
showAll: boolean;
whOnly: boolean;
excludedSystems: number[];
version: number;
timeRange: number;
}
export const DEFAULT_KILLS_WIDGET_SETTINGS: KillsWidgetSettings = {
compact: true,
showAll: false,
whOnly: true,
excludedSystems: [],
version: 0,
version: 2,
timeRange: 1,
};
function mergeWithDefaults(settings?: Partial<KillsWidgetSettings>): KillsWidgetSettings {

View File

@@ -0,0 +1,13 @@
// useSystemKillsItemTemplate.tsx
import { useCallback } from 'react';
import { VirtualScrollerTemplateOptions } from 'primereact/virtualscroller';
import { DetailedKill } from '@/hooks/Mapper/types/kills';
import { KillItemTemplate } from '../components/KillItemTemplate';
export function useSystemKillsItemTemplate(systemNameMap: Record<string, string>, onlyOneSystem: boolean) {
return useCallback(
(kill: DetailedKill, options: VirtualScrollerTemplateOptions) =>
KillItemTemplate(systemNameMap, onlyOneSystem, kill, options),
[systemNameMap, onlyOneSystem],
);
}

View File

@@ -33,7 +33,7 @@ export const MapWrapper = () => {
const {
update,
outCommand,
data: { selectedConnections, selectedSystems, hubs, systems, connections, linkSignatureToSystem },
data: { selectedConnections, selectedSystems, hubs, systems, linkSignatureToSystem },
interfaceSettings: {
isShowMenu,
isShowMinimap = STORED_INTERFACE_DEFAULT_VALUES.isShowMinimap,
@@ -56,8 +56,8 @@ export const MapWrapper = () => {
const [openAddSystem, setOpenAddSystem] = useState<XYPosition | null>(null);
const [selectedConnection, setSelectedConnection] = useState<SolarSystemConnection | null>(null);
const ref = useRef({ selectedConnections, selectedSystems, systemContextProps, systems, connections, deleteSystems });
ref.current = { selectedConnections, selectedSystems, systemContextProps, systems, connections, deleteSystems };
const ref = useRef({ selectedConnections, selectedSystems, systemContextProps, systems, deleteSystems });
ref.current = { selectedConnections, selectedSystems, systemContextProps, systems, deleteSystems };
useMapEventListener(event => {
runCommand(event);
@@ -125,11 +125,6 @@ export const MapWrapper = () => {
setOpenAddSystem(coordinates);
}, []);
const canRemoveConnection = useCallback((connectionId: string) => {
const { connections } = ref.current;
return !connections.some(x => x.id === connectionId);
}, []);
const handleSubmitAddSystem: SearchOnSubmitCallback = useCallback(
async item => {
if (ref.current.systems.some(x => x.system_static_info.solar_system_id === item.value)) {
@@ -166,7 +161,6 @@ export const MapWrapper = () => {
isSoftBackground={isSoftBackground}
theme={theme}
onAddSystem={onAddSystem}
canRemoveConnection={canRemoveConnection}
/>
{openSettings != null && (

View File

@@ -21,7 +21,6 @@ export interface DetailedKill {
victim_ship_type_id?: number | null;
victim_ship_name?: string;
final_blow_char_id?: number | null;
final_blow_char_name?: string;
final_blow_corp_id?: number | null;

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -53,6 +53,11 @@ public_api_disabled =
|> get_var_from_path_or_env("WANDERER_PUBLIC_API_DISABLED", "false")
|> String.to_existing_atom()
character_api_disabled =
config_dir
|> get_var_from_path_or_env("WANDERER_CHARACTER_API_DISABLED", "false")
|> String.to_existing_atom()
zkill_preload_disabled =
config_dir
|> get_var_from_path_or_env("WANDERER_ZKILL_PRELOAD_DISABLED", "false")
@@ -123,6 +128,7 @@ config :wanderer_app,
corp_id: System.get_env("WANDERER_CORP_ID", "-1") |> String.to_integer(),
corp_wallet: System.get_env("WANDERER_CORP_WALLET", ""),
public_api_disabled: public_api_disabled,
character_api_disabled: character_api_disabled,
zkill_preload_disabled: zkill_preload_disabled,
map_subscriptions_enabled: map_subscriptions_enabled,
map_connection_auto_expire_hours: map_connection_auto_expire_hours,

View File

@@ -12,7 +12,6 @@ defmodule WandererApp.Api.AccessList do
code_interface do
define(:create, action: :create)
define(:available, action: :available)
define(:new, action: :new)
define(:read, action: :read)
@@ -39,7 +38,8 @@ defmodule WandererApp.Api.AccessList do
end
create :new do
accept [:name, :description, :owner_id]
# Added :api_key to the accepted attributes
accept [:name, :description, :owner_id, :api_key]
primary?(true)
argument :owner_id, :uuid, allow_nil?: false
@@ -48,7 +48,7 @@ defmodule WandererApp.Api.AccessList do
end
update :update do
accept [:name, :description, :owner_id]
accept [:name, :description, :owner_id, :api_key]
primary?(true)
end
@@ -68,6 +68,10 @@ defmodule WandererApp.Api.AccessList do
allow_nil? true
end
attribute :api_key, :string do
allow_nil? true
end
create_timestamp(:inserted_at)
update_timestamp(:updated_at)
end

View File

@@ -11,6 +11,7 @@ defmodule WandererApp.Env do
def invites, do: get_key(:invites, false)
def map_subscriptions_enabled?, do: get_key(:map_subscriptions_enabled, false)
def public_api_disabled?, do: get_key(:public_api_disabled, false)
def character_api_disabled?, do: get_key(:character_api_disabled, false)
def zkill_preload_disabled?, do: get_key(:zkill_preload_disabled, false)
def wallet_tracking_enabled?, do: get_key(:wallet_tracking_enabled, false)
def admins, do: get_key(:admins, [])

View File

@@ -295,4 +295,34 @@ defmodule WandererApp.Maps do
character_eve_ids |> Enum.any?(fn eve_id -> eve_id in acl_roles_eve_ids end)
end
def check_user_can_delete_map(map_slug, current_user) do
map_slug
|> WandererApp.Api.Map.get_map_by_slug()
|> Ash.load([:owner, :acls, :user_permissions], actor: current_user)
|> case do
{:ok,
%{
user_permissions: user_permissions,
owner_id: owner_id
} = map} ->
user_permissions =
WandererApp.Permissions.get_map_permissions(
user_permissions,
owner_id,
current_user.characters |> Enum.map(& &1.id)
)
case user_permissions.delete_map do
true ->
{:ok, map}
_ ->
{:error, :not_authorized}
end
error ->
{:error, error}
end
end
end

View File

@@ -64,6 +64,18 @@ defmodule WandererAppWeb.Layouts do
"""
end
def donate_container(assigns) do
~H"""
<.link
href="https://www.patreon.com/WandererLtd"
target="_blank"
class="flex flex-col p-4 items-center absolute bottom-52 left-1 gap-2 tooltip tooltip-right text-gray-400 hover:text-white"
>
<.icon name="hero-banknotes-solid" class="h-4 w-4" />
</.link>
"""
end
def feedback_container(assigns) do
~H"""
<.link

View File

@@ -17,6 +17,7 @@
/>
</aside>
<.ping_container rtt_class={@rtt_class} />
<.donate_container />
<.feedback_container />
<.new_version_banner app_version={@app_version} />
</div>

View File

@@ -0,0 +1,181 @@
defmodule WandererAppWeb.MapAccessListAPIController do
@moduledoc """
API endpoints for managing Access Lists.
Endpoints:
- GET /api/map/acls?map_id=... or ?slug=... (list ACLs)
- POST /api/map/acls (create ACL)
- GET /api/acls/:id (show ACL)
- PUT /api/acls/:id (update ACL)
ACL members are managed via a separate controller.
"""
use WandererAppWeb, :controller
alias WandererApp.Api.{AccessList, Character}
# Do not alias Map—to avoid conflicts—use the full module name: WandererApp.Map.
alias WandererAppWeb.UtilAPIController, as: Util
import Ash.Query
# List ACLs for a given map (returns reduced info: no api_key, no members, and includes owner_eve_id)
def index(conn, params) do
case Util.fetch_map_id(params) do
{:ok, map_identifier} ->
with {:ok, map} <- get_map(map_identifier) do
acls = map.acls || []
json(conn, %{data: Enum.map(acls, &acl_to_list_json/1)})
else
{:error, :map_not_found} ->
conn |> put_status(:not_found) |> json(%{error: "Map not found"})
{:error, error} ->
conn |> put_status(:internal_server_error) |> json(%{error: inspect(error)})
end
{:error, msg} ->
conn |> put_status(:bad_request) |> json(%{error: msg})
end
end
# Create a new ACL for a map
def create(conn, params) do
with {:ok, map_identifier} <- Util.fetch_map_id(params),
{:ok, map} <- get_map(map_identifier),
%{"acl" => acl_params} <- params,
owner_eve_id when is_binary(owner_eve_id) <- Map.get(acl_params, "owner_eve_id"),
{:ok, character} <- find_character_by_eve_id(owner_eve_id),
{:ok, new_api_key} <- {:ok, UUID.uuid4()},
{:ok, new_params} <- {:ok,
acl_params
|> Map.delete("owner_eve_id")
|> Map.put("owner_id", character.id)
|> Map.put("api_key", new_api_key)
},
{:ok, new_acl} <- AccessList.new(new_params),
{:ok, _} <- {:ok, associate_acl_with_map(map, new_acl)}
do
json(conn, %{data: acl_to_json(new_acl)})
else
error ->
conn |> put_status(:bad_request) |> json(%{error: inspect(error)})
end
end
# Show a specific ACL (with members)
def show(conn, %{"id" => id}) do
query = AccessList |> Ash.Query.new() |> filter(id == ^id)
case WandererApp.Api.read(query) do
{:ok, [acl]} ->
case Ash.load(acl, :members) do
{:ok, loaded_acl} -> json(conn, %{data: acl_to_json(loaded_acl)})
{:error, error} -> conn |> put_status(:internal_server_error) |> json(%{error: "Failed to load ACL members: #{inspect(error)}"})
end
{:ok, []} ->
conn |> put_status(:not_found) |> json(%{error: "ACL not found"})
{:error, error} ->
conn |> put_status(:internal_server_error) |> json(%{error: "Error reading ACL: #{inspect(error)}"})
end
end
# Update an ACL (if needed)
def update(conn, %{"id" => id, "acl" => acl_params}) do
with {:ok, acl} <- AccessList.by_id(id),
{:ok, updated_acl} <- AccessList.update(acl, acl_params),
{:ok, updated_acl} <- Ash.load(updated_acl, :members) do
json(conn, %{data: acl_to_json(updated_acl)})
else
{:error, error} ->
conn |> put_status(:bad_request) |> json(%{error: "Failed to update ACL: #{inspect(error)}"})
end
end
# Helper to get the map (using your module WandererApp.Map)
defp get_map(map_identifier) do
# Assuming Util.fetch_map_id returns a map id.
case WandererApp.Map.get_map(map_identifier) do
{:ok, map} -> {:ok, map}
other -> other
end
end
# Helper to convert an ACL to full JSON (for detail views)
defp acl_to_json(acl) do
members =
case acl.members do
%Ash.NotLoaded{} -> []
list when is_list(list) -> Enum.map(list, &member_to_json/1)
_ -> []
end
%{
id: acl.id,
name: acl.name,
description: acl.description,
owner_id: acl.owner_id,
api_key: acl.api_key,
inserted_at: acl.inserted_at,
updated_at: acl.updated_at,
members: members
}
end
defp acl_to_list_json(acl) do
full_acl =
case AccessList.by_id(acl.id) do
{:ok, loaded_acl} -> loaded_acl
_ -> acl
end
owner_eve_id =
case find_character_by_id(full_acl.owner_id) do
{:ok, character} -> character.eve_id
_ -> nil
end
%{
id: full_acl.id,
name: full_acl.name,
description: full_acl.description,
owner_eve_id: owner_eve_id,
inserted_at: full_acl.inserted_at,
updated_at: full_acl.updated_at
}
end
defp member_to_json(member) do
%{
id: member.id,
name: member.name,
role: member.role,
eve_character_id: member.eve_character_id,
inserted_at: member.inserted_at,
updated_at: member.updated_at
}
end
# Helper to find a character by external EVE id (used in create action)
defp find_character_by_eve_id(eve_id) do
query = Character |> Ash.Query.new() |> filter(eve_id == ^eve_id)
case WandererApp.Api.read(query) do
{:ok, [character]} -> {:ok, character}
{:ok, []} -> {:error, "owner_eve_id does not match any existing character"}
other -> other
end
end
# Helper to find a character by internal id (used in acl_to_list_json)
defp find_character_by_id(id) do
query = Character |> Ash.Query.new() |> filter(id == ^id)
case WandererApp.Api.read(query) do
{:ok, [character]} -> {:ok, character}
{:ok, []} -> {:error, "Character not found"}
other -> other
end
end
# Associate the new ACL with the map by updating the map's acls list.
defp associate_acl_with_map(map, new_acl) do
current_acls = map.acls || []
updated_acls = current_acls ++ [new_acl]
case WandererApp.Map.update_map(map.map_id, %{acls: updated_acls}) do
_ -> :ok
end
end
end

View File

@@ -0,0 +1,153 @@
defmodule WandererAppWeb.AccessListMemberAPIController do
@moduledoc """
Handles creation, role updates, and deletion of individual ACL members.
"""
use WandererAppWeb, :controller
alias WandererApp.Api.AccessListMember
import Ash.Query
@doc """
POST /api/acls/:acl_id/members
Creates a new member for the given ACL.
Request Body example:
{
"member": {
"eve_character_id": "CHARACTER_EXTERNAL_EVE_ID",
"role": "viewer" // optional; defaults to "viewer" if not provided
}
}
Behavior:
The controller looks up the character via the external API using its external EVE id (eve_id),
injects the character's name into the membership, and creates the membership record.
"""
def create(conn, %{"acl_id" => acl_id, "member" => member_params}) do
with eve_id when not is_nil(eve_id) <- Map.get(member_params, "eve_character_id"),
{:ok, character_info} <- WandererApp.Esi.get_character_info(eve_id),
name when is_binary(name) <- Map.get(character_info, "name") do
member_params = Map.put(member_params, "name", name)
merged_params = Map.put(member_params, "access_list_id", acl_id)
case AccessListMember.create(merged_params) do
{:ok, new_member} ->
json(conn, %{data: member_to_json(new_member)})
{:error, error} ->
conn
|> put_status(:bad_request)
|> json(%{error: "Failed to create member: #{inspect(error)}"})
end
else
nil ->
conn
|> put_status(:bad_request)
|> json(%{error: "Missing eve_character_id in member payload"})
{:error, error} ->
conn
|> put_status(:bad_request)
|> json(%{error: "Failed to lookup character: #{inspect(error)}"})
_ ->
conn
|> put_status(:bad_request)
|> json(%{error: "Unexpected error during character lookup"})
end
end
@doc """
PUT /api/acls/:acl_id/members/:member_id
Updates a single ACL members role based on the external EVE ID provided in the URL.
Request Body example:
{
"member": {
"role": "admin"
}
}
"""
def update_role(conn, %{"acl_id" => acl_id, "member_id" => eve_id, "member" => member_params}) do
membership_query =
AccessListMember
|> Ash.Query.new()
|> filter(eve_character_id == ^eve_id)
|> filter(access_list_id == ^acl_id)
case WandererApp.Api.read(membership_query) do
{:ok, [membership]} ->
case AccessListMember.update_role(membership, member_params) do
{:ok, updated_membership} ->
json(conn, %{data: member_to_json(updated_membership)})
{:error, error} ->
conn
|> put_status(:bad_request)
|> json(%{error: inspect(error)})
end
{:ok, []} ->
conn
|> put_status(:not_found)
|> json(%{error: "Membership not found for given ACL and eve_character_id"})
{:error, error} ->
conn
|> put_status(:internal_server_error)
|> json(%{error: inspect(error)})
end
end
@doc """
DELETE /api/acls/:acl_id/members/:member_id
Deletes a member from an ACL based on the external EVE ID provided in the URL.
"""
def delete(conn, %{"acl_id" => acl_id, "member_id" => eve_id}) do
membership_query =
AccessListMember
|> Ash.Query.new()
|> filter(eve_character_id == ^eve_id)
|> filter(access_list_id == ^acl_id)
case WandererApp.Api.read(membership_query) do
{:ok, [membership]} ->
case AccessListMember.destroy(membership) do
:ok ->
json(conn, %{ok: true})
{:error, error} ->
conn
|> put_status(:bad_request)
|> json(%{error: inspect(error)})
end
{:ok, []} ->
conn
|> put_status(:not_found)
|> json(%{error: "Membership not found for given ACL and eve_character_id"})
{:error, error} ->
conn
|> put_status(:internal_server_error)
|> json(%{error: inspect(error)})
end
end
# ---------------------------------------------------------------------------
# Private Helpers
# ---------------------------------------------------------------------------
defp member_to_json(member) do
%{
id: member.id,
name: member.name,
role: member.role,
eve_character_id: member.eve_character_id,
inserted_at: member.inserted_at,
updated_at: member.updated_at
}
end
end

View File

@@ -0,0 +1,39 @@
defmodule WandererAppWeb.CharactersAPIController do
@moduledoc """
Exposes an endpoint for listing ALL characters in the database
Endpoint:
GET /api/characters
"""
use WandererAppWeb, :controller
alias WandererApp.Api.Character
@doc """
GET /api/characters
Lists ALL characters in the database
Returns an array of objects, each with `id`, `eve_id`, `name`, etc.
"""
def index(conn, _params) do
case WandererApp.Api.read(Character) do
{:ok, characters} ->
result =
characters
|> Enum.map(&%{
id: &1.id,
eve_id: &1.eve_id,
name: &1.name,
corporation_name: &1.corporation_name,
alliance_name: &1.alliance_name
})
json(conn, %{data: result})
{:error, error} ->
conn
|> put_status(:internal_server_error)
|> json(%{error: inspect(error)})
end
end
end

View File

@@ -0,0 +1,55 @@
defmodule WandererAppWeb.Plugs.CheckAclApiKey do
@moduledoc """
A plug that checks the "Authorization: Bearer <token>" header
against the ACLs stored api_key.
"""
import Plug.Conn
alias WandererApp.Repo
alias WandererApp.Api.AccessList
def init(opts), do: opts
def call(conn, _opts) do
header = get_req_header(conn, "authorization") |> List.first()
case header do
"Bearer " <> incoming_token ->
acl_id = conn.params["id"] || conn.params["acl_id"]
if acl_id do
case Repo.get(AccessList, acl_id) do
nil ->
conn
|> send_resp(404, "ACL not found")
|> halt()
acl ->
cond do
is_nil(acl.api_key) ->
conn
|> send_resp(401, "Unauthorized (no API key set for ACL)")
|> halt()
acl.api_key == incoming_token ->
conn
true ->
conn
|> send_resp(401, "Unauthorized (invalid API key for ACL)")
|> halt()
end
end
else
conn
|> send_resp(400, "ACL ID not provided")
|> halt()
end
_ ->
conn
|> send_resp(401, "Missing or invalid 'Bearer' token")
|> halt()
end
end
end

View File

@@ -0,0 +1,15 @@
defmodule WandererAppWeb.Plugs.CheckCharacterApiDisabled do
import Plug.Conn
def init(opts), do: opts
def call(conn, _opts) do
if WandererApp.Env.character_api_disabled?() do
conn
|> send_resp(403, "Character API is disabled")
|> halt()
else
conn
end
end
end

View File

@@ -5,6 +5,7 @@ defmodule WandererAppWeb.Plugs.CheckMapApiKey do
"""
import Plug.Conn
alias WandererAppWeb.UtilAPIController, as: Util
def init(opts), do: opts
@@ -13,27 +14,19 @@ defmodule WandererAppWeb.Plugs.CheckMapApiKey do
case header do
"Bearer " <> incoming_token ->
case fetch_map_id(conn.query_params) do
{:ok, map_id} ->
case WandererApp.Api.Map.by_id(map_id) do
{:ok, map} ->
if map.public_api_key == incoming_token do
conn
else
conn
|> send_resp(401, "Unauthorized (invalid token for map)")
|> halt()
end
{:error, _reason} ->
conn
|> send_resp(404, "Map not found")
|> halt()
case fetch_map(conn.query_params) do
{:ok, map} ->
if map.public_api_key == incoming_token do
conn
else
conn
|> send_resp(401, "Unauthorized (invalid token for map)")
|> halt()
end
{:error, msg} ->
{:error, _reason} ->
conn
|> send_resp(400, msg)
|> send_resp(404, "Map not found")
|> halt()
end
@@ -44,19 +37,13 @@ defmodule WandererAppWeb.Plugs.CheckMapApiKey do
end
end
defp fetch_map_id(%{"map_id" => mid}) when is_binary(mid) and mid != "" do
{:ok, mid}
end
defp fetch_map(query_params) do
case Util.fetch_map_id(query_params) do
{:ok, map_id} ->
WandererApp.Api.Map.by_id(map_id)
defp fetch_map_id(%{"slug" => slug}) when is_binary(slug) and slug != "" do
case WandererApp.Api.Map.get_map_by_slug(slug) do
{:ok, map} ->
{:ok, map.id}
{:error, _reason} ->
{:error, "No map found for slug=#{slug}"}
error ->
error
end
end
defp fetch_map_id(_), do: {:error, "Must provide either ?map_id=UUID or ?slug=SLUG"}
end

View File

@@ -0,0 +1,46 @@
defmodule WandererAppWeb.Plugs.CheckMapSubscription do
@moduledoc """
A plug that checks the Map has active subscription
Halts with 401 if no active subscription.
"""
import Plug.Conn
def init(opts), do: opts
def call(conn, _opts) do
case fetch_map_id(conn.query_params) do
{:ok, map_id} ->
{:ok, is_subscription_active} = map_id |> WandererApp.Map.is_subscription_active?()
if is_subscription_active do
conn
else
conn
|> send_resp(401, "Unauthorized (map subscription not active)")
|> halt()
end
{:error, msg} ->
conn
|> send_resp(400, msg)
|> halt()
end
end
defp fetch_map_id(%{"map_id" => mid}) when is_binary(mid) and mid != "" do
{:ok, mid}
end
defp fetch_map_id(%{"slug" => slug}) when is_binary(slug) and slug != "" do
case WandererApp.Api.Map.get_map_by_slug(slug) do
{:ok, map} ->
{:ok, map.id}
{:error, _reason} ->
{:error, "No map found for slug=#{slug}"}
end
end
defp fetch_map_id(_), do: {:error, "Must provide either ?map_id=UUID or ?slug=SLUG"}
end

View File

@@ -314,6 +314,24 @@ defmodule WandererAppWeb.AccessListsLive do
end
end
def handle_event("generate-api-key", _params, socket) do
new_api_key = UUID.uuid4()
new_params = Map.put(socket.assigns.form.params || %{}, "api_key", new_api_key)
form = AshPhoenix.Form.validate(socket.assigns.form, new_params)
{:noreply, assign(socket, form: form)}
end
@impl true
def handle_event("noop", _, socket) do
{:noreply, socket}
end
@impl true
def handle_event(event, body, socket) do
Logger.warning(fn -> "unhandled event: #{event} #{inspect(body)}" end)
{:noreply, socket}
end
@impl true
def handle_info(
{"update_role", %{member_id: member_id, role: role}},
@@ -328,17 +346,6 @@ defmodule WandererAppWeb.AccessListsLive do
{:noreply, socket |> maybe_update_role(member, role_atom, access_list)}
end
@impl true
def handle_event("noop", _, socket) do
{:noreply, socket}
end
@impl true
def handle_event(event, body, socket) do
Logger.warning(fn -> "unhandled event: #{event} #{inspect(body)}" end)
{:noreply, socket}
end
@impl true
def handle_info({:search, text}, socket) do
active_character_id =

View File

@@ -142,6 +142,50 @@
placeholder="Select an owner"
options={Enum.map(@characters, fn character -> {character.label, character.id} end)}
/>
<!-- Divider between above inputs and the API key section -->
<hr class="my-4 border-gray-600" />
<!-- API Key Section with grid layout -->
<div class="mt-2">
<label class="block text-sm font-medium text-gray-200 mb-1">ACL API key</label>
<div class="grid grid-cols-12 gap-2">
<div class="col-span-7">
<.input
type="text"
field={f[:api_key]}
placeholder="No API Key yet"
readonly
class="w-full"
/>
</div>
<div class="col-span-3">
<.button
type="button"
phx-click="generate-api-key"
class="p-button p-component p-button-primary w-full"
style="min-width: 0;"
>
<span class="p-button-label">Generate</span>
</.button>
</div>
<div class="col-span-2">
<.button
type="button"
phx-hook="CopyToClipboard"
id="copy-acl-api-key"
data-url={f[:api_key].value}
disabled={is_nil(f[:api_key].value) or f[:api_key].value == ""}
class={"p-button p-component w-full " <> if(is_nil(f[:api_key].value) or f[:api_key].value == "", do: "p-disabled", else: "")}
>
<span class="p-button-label">Copy</span>
</.button>
</div>
</div>
</div>
<hr class="my-4 border-gray-600" />
<div class="modal-action">
<.button class="mt-2" type="submit" phx-disable-with="Saving...">
<%= (@live_action == :create && "Create") || "Save" %>

View File

@@ -10,57 +10,38 @@ defmodule WandererAppWeb.MapAuditLive do
def mount(
%{"slug" => map_slug, "period" => period, "activity" => activity} = _params,
_session,
socket
%{assigns: %{current_user: current_user}} = socket
) do
current_user = socket.assigns.current_user
map_slug
|> WandererApp.Api.Map.get_map_by_slug()
|> Ash.load([:acls, :user_permissions], actor: current_user)
WandererApp.Maps.check_user_can_delete_map(map_slug, current_user)
|> case do
{:ok,
%{
id: map_id,
user_permissions: user_permissions,
name: map_name,
owner_id: owner_id
name: map_name
} = _map} ->
user_permissions =
WandererApp.Permissions.get_map_permissions(
user_permissions,
owner_id,
current_user.characters |> Enum.map(& &1.id)
)
{:ok, is_subscription_active} = map_id |> WandererApp.Map.is_subscription_active?()
case user_permissions.delete_map do
true ->
{:ok, is_subscription_active} = map_id |> WandererApp.Map.is_subscription_active?()
{:ok,
socket
|> assign(
map_id: map_id,
map_name: map_name,
map_slug: map_slug,
map_subscription_active: is_subscription_active,
activity: activity,
can_undo_types: [:systems_removed],
period: period || "1H",
page: 1,
per_page: 25,
end_of_stream?: false
)
|> stream(:activity, [])}
_ ->
{:ok,
socket
|> put_flash(:error, "You don't have an access.")
|> push_navigate(to: ~p"/maps")}
end
{:ok,
socket
|> assign(
map_id: map_id,
map_name: map_name,
map_slug: map_slug,
map_subscription_active: is_subscription_active,
activity: activity,
can_undo_types: [:systems_removed],
period: period || "1H",
page: 1,
per_page: 25,
end_of_stream?: false
)
|> stream(:activity, [])}
_ ->
{:ok, socket}
{:ok,
socket
|> put_flash(:error, "You don't have an access.")
|> push_navigate(to: ~p"/maps")}
end
end

View File

@@ -37,7 +37,14 @@ defmodule WandererAppWeb.MapsLive do
@impl true
def mount(_params, _session, socket) do
{:ok, socket |> assign(maps: [], characters: [], location: nil)}
{:ok,
socket
|> assign(
maps: [],
characters: [],
location: nil,
map_subscriptions: []
)}
end
@impl true
@@ -88,99 +95,119 @@ defmodule WandererAppWeb.MapsLive do
end
end
defp apply_action(socket, :edit, %{"slug" => map_slug} = _params, url) do
map =
map_slug
|> WandererApp.Api.Map.get_map_by_slug!()
|> Ash.load!([:owner, :acls])
|> map_map()
defp apply_action(
%{assigns: %{current_user: current_user}} = socket,
:edit,
%{"slug" => map_slug} = _params,
url
) do
WandererApp.Maps.check_user_can_delete_map(map_slug, current_user)
|> case do
{:ok, map} ->
map = map |> map_map()
socket
|> assign(:active_page, :maps)
|> assign(:uri, URI.parse(url) |> Map.put(:path, ~p"/"))
|> assign(:page_title, "Maps - Edit")
|> assign(:scopes, ["wormholes", "stargates", "none", "all"])
|> assign(:map_slug, map_slug)
|> assign(
:characters,
[map.owner |> map_character() | socket.assigns.characters] |> Enum.uniq()
)
|> assign(
:form,
map |> AshPhoenix.Form.for_update(:update, forms: [auto?: true])
)
|> load_access_lists()
socket
|> assign(:active_page, :maps)
|> assign(:uri, URI.parse(url) |> Map.put(:path, ~p"/"))
|> assign(:page_title, "Maps - Edit")
|> assign(:scopes, ["wormholes", "stargates", "none", "all"])
|> assign(:map_slug, map_slug)
|> assign(
:characters,
[map.owner |> map_character() | socket.assigns.characters] |> Enum.uniq()
)
|> assign(
:form,
map |> AshPhoenix.Form.for_update(:update, forms: [auto?: true])
)
|> load_access_lists()
_ ->
socket
|> put_flash(:error, "You don't have an access.")
|> push_navigate(to: ~p"/maps")
end
end
defp apply_action(socket, :settings, %{"slug" => map_slug} = _params, _url) do
map =
map_slug
|> WandererApp.Api.Map.get_map_by_slug!()
|> Ash.load!([:owner, :acls])
defp apply_action(
%{assigns: %{current_user: current_user}} = socket,
:settings,
%{"slug" => map_slug} = _params,
_url
) do
WandererApp.Maps.check_user_can_delete_map(map_slug, current_user)
|> case do
{:ok, map} ->
{:ok, export_settings} =
map
|> WandererApp.Map.Server.get_export_settings()
{:ok, export_settings} =
map
|> WandererApp.Map.Server.get_export_settings()
{:ok, map_balance} = WandererApp.Map.SubscriptionManager.get_balance(map)
{:ok, map_balance} = WandererApp.Map.SubscriptionManager.get_balance(map)
{:ok, map_subscriptions} =
WandererApp.Map.SubscriptionManager.get_map_subscriptions(map.id)
{:ok, map_subscriptions} = WandererApp.Map.SubscriptionManager.get_map_subscriptions(map.id)
subscription_form = %{
"plan" => "omega",
"period" => "1",
"characters_limit" => "100",
"hubs_limit" => "10",
"auto_renew?" => true
}
subscription_form = %{
"plan" => "omega",
"period" => "1",
"characters_limit" => "100",
"hubs_limit" => "10",
"auto_renew?" => true
}
{:ok, options_form_data} = WandererApp.MapRepo.options_to_form_data(map)
{:ok, options_form_data} = WandererApp.MapRepo.options_to_form_data(map)
{:ok, estimated_price, discount} =
WandererApp.Map.SubscriptionManager.estimate_price(subscription_form, false)
{:ok, estimated_price, discount} =
WandererApp.Map.SubscriptionManager.estimate_price(subscription_form, false)
socket
|> assign(:active_page, :maps)
|> assign(:page_title, "Maps - Settings")
|> assign(:map_slug, map_slug)
|> assign(:map_id, map.id)
|> assign(:public_api_key, map.public_api_key)
|> assign(:map, map)
|> assign(
export_settings: export_settings |> _get_export_map_data(),
import_form: to_form(%{}),
importing: false,
show_settings?: true,
is_topping_up?: false,
active_settings_tab: "general",
is_adding_subscription?: false,
selected_subscription: nil,
options_form: options_form_data |> to_form(),
map_subscriptions: map_subscriptions,
subscription_form: subscription_form |> to_form(),
estimated_price: estimated_price,
discount: discount,
map_balance: map_balance,
topup_form: %{} |> to_form(),
subscription_plans: ["omega", "advanced"],
subscription_periods: [
{"1 Month", "1"},
{"3 Months", "3"},
{"6 Months", "6"},
{"1 Year", "12"}
],
layout_options: [
{"Left To Right", "left_to_right"},
{"Top To Bottom", "top_to_bottom"}
]
)
|> allow_upload(:settings,
accept: ~w(.json),
max_entries: 1,
max_file_size: 10_000_000,
auto_upload: true,
progress: &handle_progress/3
)
socket
|> assign(:active_page, :maps)
|> assign(:page_title, "Maps - Settings")
|> assign(:map_slug, map_slug)
|> assign(:map_id, map.id)
|> assign(:public_api_key, map.public_api_key)
|> assign(:map, map)
|> assign(
export_settings: export_settings |> _get_export_map_data(),
import_form: to_form(%{}),
importing: false,
show_settings?: true,
is_topping_up?: false,
active_settings_tab: "general",
is_adding_subscription?: false,
selected_subscription: nil,
options_form: options_form_data |> to_form(),
map_subscriptions: map_subscriptions,
subscription_form: subscription_form |> to_form(),
estimated_price: estimated_price,
discount: discount,
map_balance: map_balance,
topup_form: %{} |> to_form(),
subscription_plans: ["omega", "advanced"],
subscription_periods: [
{"1 Month", "1"},
{"3 Months", "3"},
{"6 Months", "6"},
{"1 Year", "12"}
],
layout_options: [
{"Left To Right", "left_to_right"},
{"Top To Bottom", "top_to_bottom"}
]
)
|> allow_upload(:settings,
accept: ~w(.json),
max_entries: 1,
max_file_size: 10_000_000,
auto_upload: true,
progress: &handle_progress/3
)
_ ->
socket
|> put_flash(:error, "You don't have an access.")
|> push_navigate(to: ~p"/maps")
end
end
defp allow_map_creation(),

View File

@@ -7,7 +7,7 @@
class="card h-[250px] rounded-none bg-gradient-to-l from-stone-950 to-stone-900 hover:text-white transform transition duration-500"
patch={~p"/maps/new"}
>
<div class="card-body justify-center items-center ">
<div class="card-body justify-center items-center">
<.icon name="hero-plus-solid" class="w-20 h-20" />
<h3 class="card-title text-center text-md">Create Map</h3>
</div>
@@ -125,6 +125,7 @@
<% end %>
</main>
</div>
<.modal
:if={@is_connected? && @live_action in [:create, :edit]}
title={"#{(@live_action == :create && "Create") || "Edit"} Map"}
@@ -185,7 +186,7 @@
</.modal>
<.modal
:if={@live_action in [:settings]}
:if={@live_action in [:settings] && not is_nil(assigns[:map])}
title="Map Settings"
class="!min-w-[700px]"
id="map-settings-modal"
@@ -194,7 +195,7 @@
>
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-2">
<div class="_verticalTabsContainer_1o01l_2">
<div class="verticalTabsContainer">
<div class="p-tabview p-component" data-pc-name="tabview" data-pc-section="root">
<div class="p-tabview-nav-container" data-pc-section="navcontainer">
<div class="p-tabview-nav-content" data-pc-section="navcontent">
@@ -256,9 +257,7 @@
:if={@map_subscriptions_enabled?}
class={[
"p-unselectable-text",
classes(
"p-tabview-selected p-highlight": @active_settings_tab == "subscription"
)
classes("p-tabview-selected p-highlight": @active_settings_tab == "subscription")
]}
role="presentation"
data-pc-name=""
@@ -306,21 +305,11 @@
</span>
</a>
</li>
<li
aria-hidden="true"
role="presentation"
class="p-tabview-ink-bar"
data-pc-section="inkbar"
style="width: 146px; left: 0px;"
>
</li>
<li
:if={not WandererApp.Env.public_api_disabled?()}
class={[
"p-unselectable-text",
classes(
"p-tabview-selected p-highlight": @active_settings_tab == "public_api"
)
classes("p-tabview-selected p-highlight": @active_settings_tab == "public_api")
]}
role="presentation"
data-pc-name=""
@@ -422,10 +411,7 @@
</div>
<div
:if={
@active_settings_tab == "public_api" and
not WandererApp.Env.public_api_disabled?()
}
:if={@active_settings_tab == "public_api" and not WandererApp.Env.public_api_disabled?()}
class="p-6"
>
<h2 class="text-lg font-semibold mb-4">Public API</h2>
@@ -447,29 +433,28 @@
/>
</div>
<div class="flex items-center gap-2">
<.button class="btn btn-primary rounded-md" phx-click="generate-map-api-key">
Generate
<.button
type="button"
phx-click="generate-map-api-key"
class="p-button p-component p-button-primary"
style="min-width: 120px;"
>
<span class="p-button-label">Generate</span>
</.button>
<.button
type="button"
phx-hook="CopyToClipboard"
id="copy-map-api-key"
data-url={@public_api_key}
disabled={is_nil(@public_api_key)}
class={
if is_nil(@public_api_key) do
"copy-link btn rounded-md transition-colors duration-300
bg-gray-500 hover:bg-gray-500 text-gray-300 cursor-not-allowed"
else
"copy-link btn rounded-md transition-colors duration-300
bg-blue-600 hover:bg-blue-700 text-white cursor-pointer"
end
}
class={"p-button p-component " <> if(is_nil(@public_api_key), do: "p-disabled", else: "")}
>
Copy
<span class="p-button-label">Copy</span>
</.button>
</div>
</div>
</div>
<div :if={@active_settings_tab == "balance"}>
<div class="stats w-full bg-primary text-primary-content">
<div class="stat">
@@ -695,10 +680,8 @@
<.button
:if={@active_settings_tab == "subscription" && not @is_adding_subscription?}
type="button"
disabled={
@map_subscriptions |> Enum.at(0) |> Map.get(:status) == :active &&
@map_subscriptions |> Enum.at(0) |> Map.get(:plan) != :alpha
}
disabled={@map_subscriptions |> Enum.at(0) |> Map.get(:status) == :active &&
@map_subscriptions |> Enum.at(0) |> Map.get(:plan) != :alpha}
phx-click="add_subscription"
>
Add subscription

View File

@@ -4,6 +4,7 @@ defmodule WandererAppWeb.Router do
use Plug.ErrorHandler
import PlugDynamic.Builder
import Logger
import WandererAppWeb.UserAuth,
warn: false,
@@ -24,7 +25,6 @@ defmodule WandererAppWeb.Router do
@font_src ~w('self' https://fonts.gstatic.com data: https://web.ccpgamescdn.com https://w.appzi.io )
@script_src ~w('self' )
pipeline :admin_bauth do
plug :admin_basic_auth
end
@@ -112,12 +112,21 @@ defmodule WandererAppWeb.Router do
pipeline :api_map do
plug WandererAppWeb.Plugs.CheckMapApiKey
plug WandererAppWeb.Plugs.CheckMapSubscription
end
pipeline :api_kills do
plug WandererAppWeb.Plugs.CheckApiDisabled
end
pipeline :api_character do
plug WandererAppWeb.Plugs.CheckCharacterApiDisabled
end
pipeline :api_acl do
plug WandererAppWeb.Plugs.CheckAclApiKey
end
scope "/api/map/systems-kills", WandererAppWeb do
pipe_through [:api, :api_map, :api_kills]
@@ -126,67 +135,77 @@ defmodule WandererAppWeb.Router do
scope "/api/map", WandererAppWeb do
pipe_through [:api, :api_map]
# GET /api/map/systems?map_id=... or ?slug=...
get "/systems", MapAPIController, :list_systems
# GET /api/map/system?id=... plus either map_id=... or slug=...
get "/system", MapAPIController, :show_system
# GET /api/map/characters?map_id=... or slug=...
get "/characters", MapAPIController, :tracked_characters_with_info
# GET /api/map/structure-timers?map_id=... or slug=... and optionally ?system_id=...
get "/structure-timers", MapAPIController, :show_structure_timers
get "/acls", MapAccessListAPIController, :index
post "/acls", MapAccessListAPIController, :create
end
scope "/api/characters", WandererAppWeb do
pipe_through [:api, :api_character]
get "/", CharactersAPIController, :index
end
scope "/api/acls", WandererAppWeb do
pipe_through [:api, :api_acl]
get "/:id", MapAccessListAPIController, :show
put "/:id", MapAccessListAPIController, :update
post "/:acl_id/members", AccessListMemberAPIController, :create
put "/:acl_id/members/:member_id", AccessListMemberAPIController, :update_role
delete "/:acl_id/members/:member_id", AccessListMemberAPIController, :delete
end
scope "/api/common", WandererAppWeb do
pipe_through [:api]
# GET /api/common/system-static-info?id=...
get "/system-static-info", CommonAPIController, :show_system_static
end
#
# Browser / blog stuff
#
scope "/", WandererAppWeb do
pipe_through [:browser, :blog, :redirect_if_user_is_authenticated]
get "/welcome", BlogController, :index
end
scope "/contacts", WandererAppWeb do
pipe_through [:browser, :blog]
get "/", BlogController, :contacts
end
scope "/changelog", WandererAppWeb do
pipe_through [:browser, :blog]
get "/", BlogController, :changelog
end
scope "/news", WandererAppWeb do
pipe_through [:browser, :blog]
get "/:slug", BlogController, :show
get "/", BlogController, :list
end
scope "/license", WandererAppWeb do
pipe_through [:browser, :blog]
get "/", BlogController, :license
end
#
# Auth
#
scope "/auth", WandererAppWeb do
pipe_through :browser
get "/signout", AuthController, :signout
get "/:provider", AuthController, :request
get "/:provider/callback", AuthController, :callback
end
#
# Admin
#
scope "/admin", WandererAppWeb do
pipe_through(:browser)
pipe_through(:admin_bauth)
@@ -208,53 +227,49 @@ defmodule WandererAppWeb.Router do
)
end
#
# Additional routes / Live sessions
#
scope "/", WandererAppWeb do
pipe_through(:browser)
get "/", RedirectController, :redirect_authenticated
get("/last", MapsController, :last)
get "/last", MapsController, :last
live_session :authenticated,
on_mount: [
{WandererAppWeb.UserAuth, :ensure_authenticated},
WandererAppWeb.Nav
] do
live("/access-lists/new", AccessListsLive, :create)
live("/access-lists/:id/edit", AccessListsLive, :edit)
live("/access-lists/:id/add-members", AccessListsLive, :add_members)
live("/access-lists/:id", AccessListsLive, :members)
live("/access-lists", AccessListsLive, :index)
live("/coming-soon", ComingLive, :index)
live("/tracking/:slug", CharactersTrackingLive, :characters)
live("/tracking", CharactersTrackingLive, :index)
live("/characters", CharactersLive, :index)
live("/characters/authorize", CharactersLive, :authorize)
live("/maps/new", MapsLive, :create)
live("/maps/:slug/edit", MapsLive, :edit)
live("/maps/:slug/settings", MapsLive, :settings)
live("/maps", MapsLive, :index)
live("/profile", ProfileLive, :index)
live("/profile/deposit", ProfileLive, :deposit)
live("/profile/subscribe", ProfileLive, :subscribe)
live("/:slug/audit", MapAuditLive, :index)
live("/:slug", MapLive, :index)
live "/access-lists/new", AccessListsLive, :create
live "/access-lists/:id/edit", AccessListsLive, :edit
live "/access-lists/:id/add-members", AccessListsLive, :add_members
live "/access-lists/:id", AccessListsLive, :members
live "/access-lists", AccessListsLive, :index
live "/coming-soon", ComingLive, :index
live "/tracking/:slug", CharactersTrackingLive, :characters
live "/tracking", CharactersTrackingLive, :index
live "/characters", CharactersLive, :index
live "/characters/authorize", CharactersLive, :authorize
live "/maps/new", MapsLive, :create
live "/maps/:slug/edit", MapsLive, :edit
live "/maps/:slug/settings", MapsLive, :settings
live "/maps", MapsLive, :index
live "/profile", ProfileLive, :index
live "/profile/deposit", ProfileLive, :deposit
live "/profile/subscribe", ProfileLive, :subscribe
live "/:slug/audit", MapAuditLive, :index
live "/:slug", MapLive, :index
end
end
# Enable LiveDashboard and Swoosh mailbox preview in development
if Application.compile_env(:wanderer_app, :dev_routes) do
# If you want to use the LiveDashboard in production, you should put
# it behind authentication and allow only admins to access it.
# If your application does not have an admins-only section yet,
# you can use Plug.BasicAuth to set up some basic authentication
# as long as you are also using SSL (which you should anyway).
import Phoenix.LiveDashboard.Router
scope "/dev" do
pipe_through(:browser)
error_tracker_dashboard("/errors", as: :error_tracker_dev_dashboard)
live_dashboard("/dashboard", metrics: WandererAppWeb.Telemetry)
end
end

View File

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

View File

@@ -0,0 +1,425 @@
%{
title: "User Guide: Characters & ACL API Endpoints",
author: "Wanderer Team",
cover_image_uri: "/images/news/02-20-acl-api/generate-key.png",
tags: ~w(acl characters guide interface),
description: "Learn how to retrieve and manage Access Lists and Characters through the Wanderer public APIs. This guide covers available endpoints, request examples, and sample responses."
}
---
## Introduction
Wanderers expanded public API now lets you retrieve **all characters** in the system and manage “Access Lists” (ACLs) for controlling visibility or permissions. These endpoints allow you to:
- Fetch a list of **all** EVE characters known to the system.
- List ACLs for a given map.
- Create new ACLs for maps (with automatic API key generation).
- Update existing ACLs.
- Add, remove, and change the roles of ACL members.
This guide provides step-by-step instructions, request/response examples, and details on how to authenticate each call.
---
## Authentication
Unless otherwise noted, these endpoints require a valid **Bearer** token. Pass it in the `Authorization` header:
```bash
Authorization: Bearer <REDACTED_TOKEN>
```
If the token is missing or invalid, youll receive a `401 Unauthorized` error.
_(No API key is required for some “common” endpoints, but ACL- and character-related endpoints require a valid token.)_
There are two types of tokens in use:
1. **Map API Token:** Available in the map settings. This token is used for map-specific endpoints (e.g. listing ACLs for a map and creating ACLs).
![Generate Map API Key](/images/news/01-05-map-public-api/generate-key.png "Generate Map API Key")
2. **ACL API Token:** Available in the create/edit ACL screen. This token is used for ACL member management endpoints.
![Generate ACL API Key](/images/news/02-20-acl-api/generate-key.png "Generate ACL API Key")
---
## Endpoints Overview
### 1. List **All** Characters
```bash
GET /api/characters
```
- **Description:** Returns a list of **all** characters known to Wanderer.
- **Toggle:** Controlled by the environment variable `WANDERER_CHARACTER_API_DISABLED` (default is `false`).
- **Example Request:**
```bash
curl -H "Authorization: Bearer <REDACTED_TOKEN>" \
"https://wanderer.example.com/api/characters"
```
- **Example Response (redacted):**
```json
{
"data": [
{
"id": "b374d9e6-47a7-4e20-85ad-d608809827b5",
"name": "Some Character",
"eve_id": "2122825111",
"corporation_name": "School of Applied Knowledge",
"alliance_name": null
},
{
"id": "6963bee6-eaa1-40e2-8200-4bc2fcbd7350",
"name": "Other Character",
"eve_id": "2122019111",
"corporation_name": "Some Corporation",
"alliance_name": null
}
...
]
}
```
Use the `eve_id` when referencing a character in ACL operations.
---
### 2. List ACLs for a Given Map
```bash
GET /api/map/acls?map_id=<UUID>
GET /api/map/acls?slug=<map-slug>
```
- **Description:** Lists all ACLs associated with a map, specified by either `map_id` (UUID) or `slug`.
- **Authentication:** Requires the Map API Token (available in map settings).
- **Example Request (using slug):**
```bash
curl -H "Authorization: Bearer <REDACTED_TOKEN>" \
"https://wanderer.example.com/api/map/acls?slug=mapname"
```
- **Example Response (redacted):**
```json
{
"data": [
{
"id": "19712899-ec3a-47b1-b73b-2bae221c5513",
"name": "aclName",
"description": null,
"owner_eve_id": "11111111111",
"inserted_at": "2025-02-13T03:32:25.144403Z",
"updated_at": "2025-02-13T03:32:25.144403Z",
}
]
}
```
---
### 3. Show a Specific ACL (Including Members)
```bash
GET /api/acls/:id
```
- **Description:** Fetches a single ACL by ID, with its members preloaded.
- **Authentication:** Requires the ACL API Token.
- **Example Request:**
```bash
curl -H "Authorization: Bearer <REDACTED_TOKEN>" \
"https://wanderer.example.com/api/acls/19712899-ec3a-47b1-b73b-2bae221c5513"
```
- **Example Response (redacted):**
```json
{
"data": {
"id": "19712899-ec3a-47b1-b73b-2bae221c5513",
"name": "aclName",
"description": null,
"owner_id": "d43a9083-2705-40c9-a314-f7f412346661",
"members": [
{
"id": "8d63ab1e-b44f-4e81-8227-8fb8d928dad8",
"name": "Other Character",
"role": "admin",
"inserted_at": "2025-02-13T03:33:32.332598Z",
"updated_at": "2025-02-13T03:33:36.644520Z"
},
...
]
}
}
```
---
### 4. Create a New ACL Associated with a Map
```bash
POST /api/map/acls
```
- **Description:** Creates a new ACL for a map and generates a new ACL API key. The map record tracks its ACLs.
- **Required Query Parameter:** Either `map_id` (UUID) or `slug` (map slug).
- **Request Body Example:**
```json
{
"acl": {
"name": "New ACL",
"description": "Optional description",
"owner_eve_id": "EXTERNAL_EVE_ID"
}
}
```
- `owner_eve_id` must be the external EVE id (the `eve_id` from `/api/characters`).
- **Example Request (using map slug):**
```bash
curl -X POST \
-H "Authorization: Bearer <MAP_API_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"acl": {
"name": "New ACL",
"description": "Optional description",
"owner_eve_id": "EXTERNAL_EVE_ID"
}
}' \
"https://wanderer.example.com/api/map/acls?slug=mapname"
```
- **Example Request (using map UUID):**
```bash
curl -X POST \
-H "Authorization: Bearer <MAP_API_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"acl": {
"name": "New ACL",
"description": "Optional description",
"owner_eve_id": "EXTERNAL_EVE_ID"
}
}' \
"https://wanderer.example.com/api/map/acls?map_id=YOUR_MAP_UUID"
```
- **Example Response (redacted):**
```json
{
"data": {
"id": "NEW_ACL_UUID",
"name": "New ACL",
"description": "Optional description",
"owner_id": "OWNER_ID",
"api_key": "GENERATED_ACL_API_KEY",
"inserted_at": "2025-02-14T17:00:00Z",
"updated_at": "2025-02-14T17:00:00Z",
"members": []
}
}
```
---
### 5. Update an ACL
```bash
PUT /api/acls/:id
```
- **Description:** Updates an existing ACL (e.g. name, description, api_key).
The update endpoint fetches the ACL record first and then applies the update.
- **Authentication:** Requires the ACL API Token.
- **Example Request:**
```bash
curl -X PUT \
-H "Authorization: Bearer <ACL_API_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"acl": {
"name": "Updated ACL Name",
"description": "This is the updated description",
"api_key": "EXISTING_ACL_API_KEY"
}
}' \
"https://wanderer.example.com/api/acls/ACL_UUID"
```
- **Example Response (redacted):**
```json
{
"data": {
"id": "ACL_UUID",
"name": "Updated ACL Name",
"description": "This is the updated description",
"owner_id": "OWNER_ID",
"api_key": "EXISTING_ACL_API_KEY",
"inserted_at": "2025-02-14T16:49:13.423556Z",
"updated_at": "2025-02-14T17:22:51.343784Z",
"members": []
}
}
```
---
### 6. Add a Member to an ACL
```bash
POST /api/acls/:acl_id/members
```
- **Description:** Adds a new member (character, corporation, or alliance) to the specified ACL.
- **Authentication:** Requires the ACL API Token.
- **Request Body Example:**
```json
{
"member": {
"name": "New Member",
"eve_character_id": "EXTERNAL_EVE_ID",
"role": "viewer"
}
}
```
- `eve_character_id` is the characters external EVE id.
- **Example Request:**
```bash
curl -X POST \
-H "Authorization: Bearer <ACL_API_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"member": {
"name": "New Member",
"eve_character_id": "EXTERNAL_EVE_ID",
"role": "viewer"
}
}' \
"https://wanderer.example.com/api/acls/ACL_UUID/members"
```
- **Example Response (redacted):**
```json
{
"data": {
"id": "MEMBERSHIP_UUID",
"name": "New Member",
"role": "viewer",
"inserted_at": "...",
"updated_at": "..."
}
}
```
---
### 7. Change a Members Role
```bash
PUT /api/acls/:acl_id/members/:member_id
```
- **Description:** Updates an ACL members role (e.g. from `viewer` to `admin`).
The `:member_id` is the external EVE id of the character.
- **Authentication:** Requires the ACL API Token.
- **Request Body Example:**
```json
{
"member": {
"role": "admin"
}
}
```
- **Example Request:**
```bash
curl -X PUT \
-H "Authorization: Bearer <ACL_API_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"member": {
"role": "admin"
}
}' \
"https://wanderer.example.com/api/acls/ACL_UUID/members/EXTERNAL_EVE_ID"
```
- **Example Response (redacted):**
```json
{
"data": {
"id": "MEMBERSHIP_UUID",
"name": "New Member",
"role": "admin",
...
}
}
```
---
### 8. Remove a Member from an ACL
```bash
DELETE /api/acls/:acl_id/members/:member_id
```
- **Description:** Removes the member with the specified external EVE id from the ACL.
- **Authentication:** Requires the ACL API Token.
- **Example Request:**
```bash
curl -X DELETE \
-H "Authorization: Bearer <ACL_API_TOKEN>" \
"https://wanderer.example.com/api/acls/ACL_UUID/members/EXTERNAL_EVE_ID"
```
- **Example Response:**
```json
{ "ok": true }
```
---
## Conclusion
This guide outlines how to:
1. **List** all characters (`GET /api/characters`) so you can pick a valid character to add to your ACL.
2. **List** ACLs for a specified map (`GET /api/map/acls?map_id=<UUID>` or `?slug=<map-slug>`).
3. **Show** ACL details, including its members (`GET /api/acls/:id`).
4. **Create** a new ACL for a map (`POST /api/map/acls`), which generates a new ACL API key.
5. **Update** an existing ACL (`PUT /api/acls/:id`).
6. **Add** members (characters, corporations, alliances) to an ACL (`POST /api/acls/:acl_id/members`).
7. **Change** a members role (`PUT /api/acls/:acl_id/members/:member_id`).
8. **Remove** a member from an ACL (`DELETE /api/acls/:acl_id/members/:member_id`).
By following these request patterns, you can manage your ACL resources in a fully programmatic fashion. If you have any questions, feel free to reach out to the Wanderer Team.
Fly safe,
**WANDERER TEAM**

View File

@@ -0,0 +1,21 @@
defmodule WandererApp.Repo.Migrations.AddAclApiKey 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(:access_lists_v1) do
add :api_key, :text
end
end
def down do
alter table(:access_lists_v1) do
remove :api_key
end
end
end

View File

@@ -0,0 +1,108 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"primary_key?": true,
"references": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "name",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "description",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "api_key",
"type": "text"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "inserted_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "updated_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "access_lists_v1_owner_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "character_v1"
},
"size": null,
"source": "owner_id",
"type": "uuid"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "5118AF0DEBEEED63DC30565ECFFEDF682876FAD476AF2796E973C6883E4054E0",
"identities": [],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.WandererApp.Repo",
"schema": null,
"table": "access_lists_v1"
}