mirror of
https://github.com/wanderer-industries/wanderer
synced 2025-11-28 20:13:24 +00:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32fe6395a1 | ||
|
|
5f506bf4b2 | ||
|
|
0127ebfe46 | ||
|
|
8c5366fd9b | ||
|
|
dbcad892a9 | ||
|
|
6da3096db1 | ||
|
|
cd8efcd6e3 | ||
|
|
b52471ae5e | ||
|
|
438fecb61f | ||
|
|
70b589a359 | ||
|
|
cf7069b3b2 | ||
|
|
b2198e469e | ||
|
|
8ab337e8e7 | ||
|
|
51878ab503 | ||
|
|
401dfad298 | ||
|
|
18cff7d312 | ||
|
|
7896de00d6 | ||
|
|
3b079505c3 | ||
|
|
5b972b03e5 | ||
|
|
79b284c46d | ||
|
|
b29e57b3a4 | ||
|
|
c6f4baeee3 | ||
|
|
6d341be072 | ||
|
|
2437ec9c84 | ||
|
|
7e692b5805 | ||
|
|
01b7370ecd |
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
23
.github/workflows/build.yml
vendored
23
.github/workflows/build.yml
vendored
@@ -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: |
|
||||
|
||||
89
CHANGELOG.md
89
CHANGELOG.md
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.hoverTarget {
|
||||
padding: 0.5rem;
|
||||
margin: -0.5rem;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.localCounter {
|
||||
mix-blend-mode: screen;
|
||||
display: flex;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -10,5 +10,6 @@ export const convertSystem2Node = (sys: SolarSystemRawType): Node => {
|
||||
position: sys.position,
|
||||
data: sys,
|
||||
draggable: !sys.locked,
|
||||
deletable: !sys.locked,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 'Active' 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 'Active' map subscription only (contact map administrators)
|
||||
</span>
|
||||
</div>
|
||||
) : isNothingSelected ? (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<span className="select-none text-center text-stone-400/80 text-sm">
|
||||
No system selected (or toggle “Show all systems”)
|
||||
</span>
|
||||
</div>
|
||||
) : showLoading ? (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<span className="select-none text-center text-stone-400/80 text-sm">Loading Kills...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<span className="select-none text-center text-red-400 text-sm">{error}</span>
|
||||
</div>
|
||||
) : !filteredKills || filteredKills.length === 0 ? (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<span className="select-none text-center text-stone-400/80 text-sm">No kills found</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full" style={{ height: '100%' }}>
|
||||
<SystemKillsContent
|
||||
kills={filteredKills}
|
||||
systemNameMap={systemNameMap}
|
||||
onlyOneSystem={!visible}
|
||||
timeRange={settings.timeRange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Widget>
|
||||
</div>
|
||||
|
||||
<KillsSettingsDialog
|
||||
visible={settingsDialogVisible}
|
||||
setVisible={setSettingsDialogVisible}
|
||||
/>
|
||||
{settingsDialogVisible && <KillsSettingsDialog visible setVisible={setSettingsDialogVisible} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
SystemKills.displayName = 'SystemKills';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
BIN
assets/static/images/news/02-20-acl-api/generate-acl-key.png
Executable file
BIN
assets/static/images/news/02-20-acl-api/generate-acl-key.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, [])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
/>
|
||||
</aside>
|
||||
<.ping_container rtt_class={@rtt_class} />
|
||||
<.donate_container />
|
||||
<.feedback_container />
|
||||
<.new_version_banner app_version={@app_version} />
|
||||
</div>
|
||||
|
||||
181
lib/wanderer_app_web/controllers/access_list_api_controller.ex
Normal file
181
lib/wanderer_app_web/controllers/access_list_api_controller.ex
Normal 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
|
||||
@@ -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 member’s 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
|
||||
39
lib/wanderer_app_web/controllers/character_api_controller.ex
Normal file
39
lib/wanderer_app_web/controllers/character_api_controller.ex
Normal 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
|
||||
55
lib/wanderer_app_web/controllers/plugs/check_acl_api_key.ex
Normal file
55
lib/wanderer_app_web/controllers/plugs/check_acl_api_key.ex
Normal file
@@ -0,0 +1,55 @@
|
||||
defmodule WandererAppWeb.Plugs.CheckAclApiKey do
|
||||
@moduledoc """
|
||||
A plug that checks the "Authorization: Bearer <token>" header
|
||||
against the ACL’s 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 =
|
||||
|
||||
@@ -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" %>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
2
mix.exs
2
mix.exs
@@ -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
|
||||
[
|
||||
|
||||
425
priv/posts/2025/02-20-acl-api.md
Normal file
425
priv/posts/2025/02-20-acl-api.md
Normal 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
|
||||
|
||||
Wanderer’s 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, you’ll 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).
|
||||
|
||||

|
||||
|
||||
2. **ACL API Token:** Available in the create/edit ACL screen. This token is used for ACL member management endpoints.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 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 character’s 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 Member’s Role
|
||||
|
||||
```bash
|
||||
PUT /api/acls/:acl_id/members/:member_id
|
||||
```
|
||||
|
||||
- **Description:** Updates an ACL member’s 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 member’s 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**
|
||||
21
priv/repo/migrations/20250213182400_add_acl_api_key.exs
Normal file
21
priv/repo/migrations/20250213182400_add_acl_api_key.exs
Normal 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
|
||||
108
priv/resource_snapshots/repo/access_lists_v1/20250213182400.json
Normal file
108
priv/resource_snapshots/repo/access_lists_v1/20250213182400.json
Normal 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"
|
||||
}
|
||||
Reference in New Issue
Block a user