Compare commits

...

4 Commits

Author SHA1 Message Date
calli f49cc61da4 up deps and improve login 2026-04-14 22:26:17 +03:00
calli c37578c4e5 add drag for characters inside an account card. also minimize for easier reorder 2026-04-14 22:14:39 +03:00
calli 48da721980 fix launchpad storage calculations 2026-04-14 22:08:26 +03:00
calli bf31a7e2cb add matrix space link 2026-02-24 19:47:18 +02:00
11 changed files with 1305 additions and 1083 deletions
+3 -1
View File
@@ -2,7 +2,9 @@
Simple tool to track your PI planet extractors. Login with your characters and enjoy the PI! Simple tool to track your PI planet extractors. Login with your characters and enjoy the PI!
Any questions, feedback or suggestions are welcome at [EVE PI Discord](https://discord.gg/bCdXzU8PHK) Any questions, feedback or suggestions are welcome at
[EVE PI Matrix](https://matrix.to/#/#eve-pi:calli.fi)
[EVE PI Discord](https://discord.gg/bCdXzU8PHK)
## Partner code ## Partner code
+1104 -1033
View File
File diff suppressed because it is too large Load Diff
+154 -24
View File
@@ -7,6 +7,8 @@ import { useContext, useState, useEffect } from "react";
import { PlanRow } from "./PlanRow"; import { PlanRow } from "./PlanRow";
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import { DragDropContext, Droppable, Draggable, DropResult } from "@hello-pangea/dnd";
import { planetCalculations } from "@/planets"; import { planetCalculations } from "@/planets";
import { EvePraisalResult } from "@/eve-praisal"; import { EvePraisalResult } from "@/eve-praisal";
import { STORAGE_IDS, PI_SCHEMATICS, PI_PRODUCT_VOLUMES, STORAGE_CAPACITIES } from "@/const"; import { STORAGE_IDS, PI_SCHEMATICS, PI_PRODUCT_VOLUMES, STORAGE_CAPACITIES } from "@/const";
@@ -99,6 +101,7 @@ const calculatePlanetDetails = (planet: PlanetWithInfo, piPrices: EvePraisalResu
const fillRate = storageCapacity > 0 ? (totalVolume / storageCapacity) * 100 : 0; const fillRate = storageCapacity > 0 ? (totalVolume / storageCapacity) * 100 : 0;
return { return {
pin_id: storage.pin_id,
type: storageType, type: storageType,
type_id: storage.type_id, type_id: storage.type_id,
capacity: storageCapacity, capacity: storageCapacity,
@@ -241,6 +244,69 @@ export const AccountCard = ({ characters, isCollapsed: propIsCollapsed }: { char
const theme = useTheme(); const theme = useTheme();
const [localIsCollapsed, setLocalIsCollapsed] = useState(false); const [localIsCollapsed, setLocalIsCollapsed] = useState(false);
const { planMode, piPrices, alertMode, balanceThreshold, minExtractionRate } = useContext(SessionContext); const { planMode, piPrices, alertMode, balanceThreshold, minExtractionRate } = useContext(SessionContext);
const accountName = characters.length > 0 ? (characters[0].account ?? "-") : "-";
const [characterOrder, setCharacterOrder] = useState<number[]>(() => {
try {
const saved = localStorage.getItem(`characterOrder-${accountName}`);
if (saved) {
const parsed: number[] = JSON.parse(saved);
const ids = characters.map(c => c.character.characterId);
const valid = parsed.filter(id => ids.includes(id));
const newIds = ids.filter(id => !valid.includes(id));
return [...valid, ...newIds];
}
} catch (_) { /* ignore corrupt localStorage */ }
return characters.map(c => c.character.characterId);
});
useEffect(() => {
const ids = characters.map(c => c.character.characterId);
setCharacterOrder(prev => {
const valid = prev.filter(id => ids.includes(id));
const newIds = ids.filter(id => !valid.includes(id));
return [...valid, ...newIds];
});
}, [characters]);
useEffect(() => {
if (characterOrder.length > 0) {
localStorage.setItem(`characterOrder-${accountName}`, JSON.stringify(characterOrder));
}
}, [characterOrder, accountName]);
const [collapsedCharacters, setCollapsedCharacters] = useState<Set<number>>(() => {
try {
const saved = localStorage.getItem(`collapsedCharacters-${accountName}`);
if (saved) return new Set<number>(JSON.parse(saved));
} catch (_) { /* ignore corrupt localStorage */ }
return new Set<number>();
});
const toggleCharacterCollapsed = (characterId: number) => {
setCollapsedCharacters(prev => {
const next = new Set(prev);
if (next.has(characterId)) next.delete(characterId);
else next.add(characterId);
localStorage.setItem(`collapsedCharacters-${accountName}`, JSON.stringify(Array.from(next)));
return next;
});
};
const orderedCharacters = characterOrder
.map(id => characters.find(c => c.character.characterId === id))
.filter((c): c is AccessToken => c !== undefined);
const handleCharacterDragEnd = (result: DropResult) => {
if (!result.destination) return;
const items = Array.from(characterOrder);
const [moved] = items.splice(result.source.index, 1);
items.splice(result.destination.index, 0, moved);
setCharacterOrder(items);
};
const DragDropContextComponent = DragDropContext as any;
const DroppableComponent = Droppable as any;
const DraggableComponent = Draggable as any;
const { monthlyEstimate, storageValue, planetCount, characterCount, runningExtractors, totalExtractors } = calculateAccountTotals(characters, piPrices); const { monthlyEstimate, storageValue, planetCount, characterCount, runningExtractors, totalExtractors } = calculateAccountTotals(characters, piPrices);
// Calculate planet details and alert states for each planet // Calculate planet details and alert states for each planet
@@ -420,30 +486,94 @@ export const AccountCard = ({ characters, isCollapsed: propIsCollapsed }: { char
{localIsCollapsed ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />} {localIsCollapsed ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
</IconButton> </IconButton>
</Box> </Box>
{!localIsCollapsed && characters.map((c) => ( {!localIsCollapsed && (
<Stack <DragDropContextComponent onDragEnd={handleCharacterDragEnd}>
key={c.character.characterId} <DroppableComponent droppableId={`characters-${accountName}`} direction="vertical">
direction="row" {(provided: any) => (
alignItems="flex-start" <Box ref={provided.innerRef} {...provided.droppableProps}>
> {orderedCharacters.map((c, index) => (
<CharacterRow character={c} /> <DraggableComponent
{planMode ? ( key={c.character.characterId}
<PlanRow character={c} /> draggableId={`char-${c.character.characterId}`}
) : ( index={index}
<PlanetaryInteractionRow >
character={c} {(provided: any, snapshot: any) => (
planetDetails={c.planets.reduce((acc, planet) => { <Stack
const details = planetDetails[`${c.character.characterId}-${planet.planet_id}`]; ref={provided.innerRef}
acc[planet.planet_id] = { {...provided.draggableProps}
...details, direction="row"
visibility: getAlertVisibility(details.alertState) alignItems="flex-start"
}; sx={{
return acc; opacity: snapshot.isDragging ? 0.8 : 1,
}, {} as Record<number, PlanetCalculations & { visibility: string }>)} backgroundColor: snapshot.isDragging ? theme.palette.action.hover : 'transparent',
/> borderRadius: 1,
)} }}
</Stack> >
))} <Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
pt: 1,
gap: 0.5,
}}
>
<Box
{...provided.dragHandleProps}
sx={{
display: 'flex',
alignItems: 'center',
cursor: 'grab',
px: 0.5,
color: theme.palette.text.disabled,
'&:hover': { color: theme.palette.text.secondary },
'&:active': { cursor: 'grabbing' },
}}
>
</Box>
<IconButton
size="small"
onClick={() => toggleCharacterCollapsed(c.character.characterId)}
sx={{
p: 0.25,
color: theme.palette.text.disabled,
'&:hover': { color: theme.palette.text.secondary },
transform: collapsedCharacters.has(c.character.characterId) ? 'rotate(0deg)' : 'rotate(90deg)',
transition: 'transform 0.2s ease-in-out',
}}
>
<ChevronRightIcon fontSize="small" />
</IconButton>
</Box>
<CharacterRow character={c} />
{!collapsedCharacters.has(c.character.characterId) && (
planMode ? (
<PlanRow character={c} />
) : (
<PlanetaryInteractionRow
character={c}
planetDetails={c.planets.reduce((acc, planet) => {
const details = planetDetails[`${c.character.characterId}-${planet.planet_id}`];
acc[planet.planet_id] = {
...details,
visibility: getAlertVisibility(details.alertState)
};
return acc;
}, {} as Record<number, PlanetCalculations & { visibility: string }>)}
/>
)
)}
</Stack>
)}
</DraggableComponent>
))}
{provided.placeholder}
</Box>
)}
</DroppableComponent>
</DragDropContextComponent>
)}
</Box> </Box>
</Paper> </Paper>
); );
+4 -11
View File
@@ -19,7 +19,6 @@ export const LoginDialog = ({
DEFAULT_SCOPES_TO_SELECT DEFAULT_SCOPES_TO_SELECT
); );
const [ssoUrl, setSsoUrl] = useState<string | undefined>(undefined); const [ssoUrl, setSsoUrl] = useState<string | undefined>(undefined);
const [loginUrl, setLoginUrl] = useState<string | undefined>(undefined);
const { EVE_SSO_CLIENT_ID, EVE_SSO_CALLBACK_URL } = const { EVE_SSO_CLIENT_ID, EVE_SSO_CALLBACK_URL } =
useContext(SessionContext); useContext(SessionContext);
@@ -30,15 +29,6 @@ export const LoginDialog = ({
}); });
}, []); }, []);
useEffect(() => {
if (!ssoUrl || selectedScopes.length === 0) return;
loginParameters(
selectedScopes,
EVE_SSO_CLIENT_ID,
EVE_SSO_CALLBACK_URL
).then((res) => setLoginUrl(ssoUrl + "?" + res));
}, [selectedScopes, ssoUrl, EVE_SSO_CLIENT_ID, EVE_SSO_CALLBACK_URL]);
return ( return (
<Dialog open={open} onClose={closeDialog}> <Dialog open={open} onClose={closeDialog}>
<DialogTitle>Select scopes to login with</DialogTitle> <DialogTitle>Select scopes to login with</DialogTitle>
@@ -59,8 +49,11 @@ export const LoginDialog = ({
<DialogActions> <DialogActions>
<Button <Button
variant="contained" variant="contained"
disabled={!ssoUrl || selectedScopes.length === 0}
onClick={() => { onClick={() => {
window.open(loginUrl, "_self"); if (!ssoUrl) return;
const params = loginParameters(selectedScopes, EVE_SSO_CLIENT_ID, EVE_SSO_CALLBACK_URL);
window.open(ssoUrl + "?" + params, "_self");
}} }}
> >
Login Login
@@ -438,7 +438,7 @@ export const PlanetTableRow = ({
.map((storage, idx) => { .map((storage, idx) => {
const fillRate = storage.fillRate; const fillRate = storage.fillRate;
const color = fillRate > 90 ? '#ff0000' : fillRate > 80 ? '#ffa500' : fillRate > 60 ? '#ffd700' : 'inherit'; const color = fillRate > 90 ? '#ff0000' : fillRate > 80 ? '#ffa500' : fillRate > 60 ? '#ffd700' : 'inherit';
const contents = planet.info.pins.find(p => p.type_id === storage.type_id)?.contents || []; const contents = planet.info.pins.find(p => p.pin_id === storage.pin_id)?.contents || [];
return ( return (
<React.Fragment key={`storage-${character.character.characterId}-${planet.planet_id}-${storage.type}-${idx}`}> <React.Fragment key={`storage-${character.character.characterId}-${planet.planet_id}-${storage.type}-${idx}`}>
+12 -2
View File
@@ -3,7 +3,7 @@ import "@fontsource/roboto/300.css";
import "@fontsource/roboto/400.css"; import "@fontsource/roboto/400.css";
import "@fontsource/roboto/500.css"; import "@fontsource/roboto/500.css";
import "@fontsource/roboto/700.css"; import "@fontsource/roboto/700.css";
import { memo, useCallback, useEffect, useState, Suspense } from "react"; import { memo, useCallback, useEffect, useRef, useState, Suspense } from "react";
import { AccessToken, CharacterUpdate, Env, PlanetWithInfo } from "../types"; import { AccessToken, CharacterUpdate, Env, PlanetWithInfo } from "../types";
import { MainGrid } from "./components/MainGrid"; import { MainGrid } from "./components/MainGrid";
import { refreshToken } from "@/esi-sso"; import { refreshToken } from "@/esi-sso";
@@ -37,6 +37,7 @@ const processInBatches = async <T, R>(
const Home = () => { const Home = () => {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const callbackHandled = useRef(false);
const [characters, setCharacters] = useState<AccessToken[]>([]); const [characters, setCharacters] = useState<AccessToken[]>([]);
const [sessionReady, setSessionReady] = useState(false); const [sessionReady, setSessionReady] = useState(false);
const [environment, setEnvironment] = useState<Env | undefined>(undefined); const [environment, setEnvironment] = useState<Env | undefined>(undefined);
@@ -93,7 +94,16 @@ const Home = () => {
characters: AccessToken[], characters: AccessToken[],
): Promise<AccessToken[]> => { ): Promise<AccessToken[]> => {
const code = searchParams?.get("code"); const code = searchParams?.get("code");
if (code) { const returnedState = searchParams?.get("state");
if (code && !callbackHandled.current) {
callbackHandled.current = true;
const expectedState = localStorage.getItem("oauth_state");
localStorage.removeItem("oauth_state");
if (!expectedState || returnedState !== expectedState) {
console.error("OAuth state mismatch — possible CSRF attack");
window.history.replaceState(null, "", "/");
return Promise.resolve(characters);
}
window.history.replaceState(null, "", "/"); window.history.replaceState(null, "", "/");
const res = await fetch(`api/token?code=${code}`); const res = await fetch(`api/token?code=${code}`);
const newCharacter: AccessToken = await res.json(); const newCharacter: AccessToken = await res.json();
+4 -2
View File
@@ -37,17 +37,19 @@ export const revokeToken = async (
}); });
}; };
export const loginParameters = async ( export const loginParameters = (
selectedScopes: string[], selectedScopes: string[],
EVE_SSO_CLIENT_ID: string, EVE_SSO_CLIENT_ID: string,
EVE_SSO_CALLBACK_URL: string, EVE_SSO_CALLBACK_URL: string,
) => { ) => {
const state = crypto.randomUUID();
localStorage.setItem("oauth_state", state);
return new URLSearchParams({ return new URLSearchParams({
response_type: "code", response_type: "code",
redirect_uri: EVE_SSO_CALLBACK_URL, redirect_uri: EVE_SSO_CALLBACK_URL,
client_id: EVE_SSO_CLIENT_ID, client_id: EVE_SSO_CLIENT_ID,
scope: selectedScopes.join(" "), scope: selectedScopes.join(" "),
state: "asfe", state,
}).toString(); }).toString();
}; };
+18 -5
View File
@@ -9,11 +9,25 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
}); });
try { try {
const praisalRequest: { quantity: number; type_id: number }[] = JSON.parse( const parsed = JSON.parse(req.body);
req.body
if (!Array.isArray(parsed)) {
return res.status(400).json({ error: 'Invalid input' });
}
const praisalRequest: { quantity: number; type_id: number }[] = parsed.filter(
(item): item is { quantity: number; type_id: number } =>
item !== null &&
typeof item === 'object' &&
typeof item.quantity === 'number' &&
Number.isFinite(item.quantity) &&
item.quantity >= 0 &&
typeof item.type_id === 'number' &&
Number.isInteger(item.type_id) &&
item.type_id > 0
); );
logger.info({ logger.info({
event: 'praisal_request_parsed', event: 'praisal_request_parsed',
items: praisalRequest.length items: praisalRequest.length
}); });
@@ -27,10 +41,9 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
return res.json(praisal); return res.json(praisal);
} catch (e) { } catch (e) {
logger.error({ logger.error({
event: 'praisal_request_failed', event: 'praisal_request_failed',
error: e, error: e,
body: req.body
}); });
return res.status(500).json({ error: 'Failed to get praisal' }); return res.status(500).json({ error: 'Failed to get praisal' });
} }
+2 -4
View File
@@ -20,9 +20,8 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
return res.status(404).end(); return res.status(404).end();
} }
logger.info({ logger.info({
event: 'token_request_start', event: 'token_request_start',
code: code
}); });
const params = new URLSearchParams({ const params = new URLSearchParams({
@@ -88,11 +87,10 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
}; };
return res.json(token); return res.json(token);
} catch (e) { } catch (e) {
logger.error({ logger.error({
event: 'token_request_failed', event: 'token_request_failed',
reason: 'api_error', reason: 'api_error',
error: e, error: e,
code: code
}); });
return res.status(500).end(); return res.status(500).end();
} }
+2
View File
@@ -31,6 +31,7 @@ const CACHE_DURATION_MS = 60_000; // 1 minute
const CACHE_STORAGE_KEY = "planet_cache"; const CACHE_STORAGE_KEY = "planet_cache";
const loadCacheFromStorage = (): Map<string, CachedPlanetData> => { const loadCacheFromStorage = (): Map<string, CachedPlanetData> => {
if (typeof window === "undefined") return new Map();
try { try {
const stored = localStorage.getItem(CACHE_STORAGE_KEY); const stored = localStorage.getItem(CACHE_STORAGE_KEY);
if (stored) { if (stored) {
@@ -44,6 +45,7 @@ const loadCacheFromStorage = (): Map<string, CachedPlanetData> => {
}; };
const saveCacheToStorage = (cache: Map<string, CachedPlanetData>) => { const saveCacheToStorage = (cache: Map<string, CachedPlanetData>) => {
if (typeof window === "undefined") return;
try { try {
const obj = Object.fromEntries(cache); const obj = Object.fromEntries(cache);
localStorage.setItem(CACHE_STORAGE_KEY, JSON.stringify(obj)); localStorage.setItem(CACHE_STORAGE_KEY, JSON.stringify(obj));
+1
View File
@@ -6,6 +6,7 @@ export interface StorageContent {
} }
export interface StorageInfo { export interface StorageInfo {
pin_id: number;
type: string; type: string;
type_id: number; type_id: number;
capacity: number; capacity: number;