First working version of EVE-PI
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
.env*
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.github
|
||||||
|
*.md
|
||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
docker
|
||||||
|
scripts
|
||||||
|
.next
|
4
.env.example
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Populate these env variables from the app you create at https://developers.eveonline.com/
|
||||||
|
EVE_SSO_CLIENT_ID=Client ID
|
||||||
|
EVE_SSO_SECRET=Secret Key
|
||||||
|
EVE_SSO_CALLBACK_URL=Callback URL (This should be the domain you are hosting at or if run locally it should be http://localhost:3000)
|
3
.eslintrc.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "next/core-web-vitals"
|
||||||
|
}
|
36
.gitignore
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
.env
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
51
Dockerfile
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
FROM node:18-alpine AS base
|
||||||
|
|
||||||
|
# Install dependencies only when needed
|
||||||
|
FROM base AS deps
|
||||||
|
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies based on the preferred package manager
|
||||||
|
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
|
||||||
|
RUN \
|
||||||
|
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
|
||||||
|
elif [ -f package-lock.json ]; then npm ci; \
|
||||||
|
elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i --frozen-lockfile; \
|
||||||
|
else echo "Lockfile not found." && exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production image, copy all the files and run next
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
|
||||||
|
# Automatically leverage output traces to reduce image size
|
||||||
|
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENV PORT 3000
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
49
README.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Callis PI tool
|
||||||
|
|
||||||
|
Simple tool to track your PI planet extractors. Login with your characters and hit refresh.
|
||||||
|
|
||||||
|
## [Avanto hosted PI tool](https://pi.avanto.tk)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Features:
|
||||||
|
|
||||||
|
- Group characters to account groups by clickin on the character icon
|
||||||
|
- Track amount of planets
|
||||||
|
- Track extractor status
|
||||||
|
- When the cycle will end
|
||||||
|
- Highlight the planet if extractor has stopped or has not been started.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
All eve sso information is stored in your browser and refresh token is encrypted with apps EVE SSO secret. Backend processes only the token exchange, refresh and revoke that need the EVE_SSO_SECRET. Everything else is handled in frontend.
|
||||||
|
|
||||||
|
## EVE SSO Callback
|
||||||
|
|
||||||
|
Callback is handled by the SPA so when running you should point to the domain. There is no separate callback path.
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
To run the app you need to create a EVE SSO application here: https://developers.eveonline.com/
|
||||||
|
|
||||||
|
You will need these env variables from the application settings:
|
||||||
|
|
||||||
|
```
|
||||||
|
EVE_SSO_CLIENT_ID=Client ID
|
||||||
|
EVE_SSO_SECRET=Secret Key
|
||||||
|
EVE_SSO_CALLBACK_URL=Callback URL (This should be the domain you are hosting at or if run locally it should be http://localhost:3000)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run locally
|
||||||
|
|
||||||
|
1. Create .env file in the directory root and populate with env variables you get from the EVE app you created. Example env file: .env.example
|
||||||
|
2. run `npm run dev`
|
||||||
|
|
||||||
|
## Run the container
|
||||||
|
|
||||||
|
1. Populate the environment variables in .env file
|
||||||
|
2. Run 'docker-compose up
|
||||||
|
|
||||||
|
## Hosting
|
||||||
|
|
||||||
|
Easiest way to host is deploy the app through Vercel https://vercel.com. Login with github, point to eve-pi repository, setup the env variables and the app should work out of the box.
|
13
docker-compose.yml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
version: "2.1"
|
||||||
|
services:
|
||||||
|
eve-pi:
|
||||||
|
build: .
|
||||||
|
container_name: eve-pi
|
||||||
|
environment:
|
||||||
|
- EVE_SSO_CLIENT_ID=${EVE_SSO_CLIENT_ID}
|
||||||
|
- EVE_SSO_CALLBACK_URL=${EVE_SSO_CALLBACK_URL}
|
||||||
|
- EVE_SSO_SECRET=${EVE_SSO_SECRET}
|
||||||
|
ports:
|
||||||
|
- 3000:3000
|
||||||
|
restart: unless-stopped
|
BIN
images/eve-pi.png
Normal file
After Width: | Height: | Size: 792 KiB |
16
next.config.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "images.evetech.net",
|
||||||
|
port: "",
|
||||||
|
pathname: "/**",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
output: "standalone",
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
8066
package-lock.json
generated
Normal file
37
package.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "eve-pi",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint",
|
||||||
|
"esi-swagger": "npx swagger-typescript-api -p https://esi.evetech.net/latest/swagger.json -o ./src -n esi-api.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@emotion/react": "^11.11.1",
|
||||||
|
"@emotion/styled": "^11.11.0",
|
||||||
|
"@fontsource/roboto": "^5.0.3",
|
||||||
|
"@mui/icons-material": "^5.11.16",
|
||||||
|
"@mui/material": "^5.13.5",
|
||||||
|
"@types/node": "20.3.1",
|
||||||
|
"@types/react": "18.2.12",
|
||||||
|
"@types/react-dom": "18.2.5",
|
||||||
|
"autoprefixer": "10.4.14",
|
||||||
|
"crypto-js": "^4.1.1",
|
||||||
|
"eslint": "8.42.0",
|
||||||
|
"eslint-config-next": "13.4.5",
|
||||||
|
"luxon": "^3.3.0",
|
||||||
|
"next": "13.4.5",
|
||||||
|
"react": "18.2.0",
|
||||||
|
"react-countdown": "^2.3.5",
|
||||||
|
"react-dom": "18.2.0",
|
||||||
|
"sharp": "^0.32.1",
|
||||||
|
"typescript": "5.1.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/crypto-js": "^4.1.1",
|
||||||
|
"@types/luxon": "^3.3.0"
|
||||||
|
}
|
||||||
|
}
|
BIN
public/barren.png
Normal file
After Width: | Height: | Size: 47 KiB |
BIN
public/gas.png
Normal file
After Width: | Height: | Size: 41 KiB |
BIN
public/ice.png
Normal file
After Width: | Height: | Size: 47 KiB |
BIN
public/lava.png
Normal file
After Width: | Height: | Size: 54 KiB |
BIN
public/noplanet.png
Normal file
After Width: | Height: | Size: 50 KiB |
BIN
public/oceanic.png
Normal file
After Width: | Height: | Size: 43 KiB |
BIN
public/plasma.png
Normal file
After Width: | Height: | Size: 56 KiB |
BIN
public/stopped.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
public/storm.png
Normal file
After Width: | Height: | Size: 46 KiB |
BIN
public/temperate.png
Normal file
After Width: | Height: | Size: 51 KiB |
35
src/app/components/Account/AccountCard.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { AccessToken } from "@/types";
|
||||||
|
import { Box, Stack, Typography } from "@mui/material";
|
||||||
|
import { CharacterRow } from "../Characters/CharacterRow";
|
||||||
|
import { PlanetaryInteractionRow } from "../PlanetaryInteraction/PlanetaryInteractionRow";
|
||||||
|
|
||||||
|
export const AccountCard = ({
|
||||||
|
characters,
|
||||||
|
sessionReady,
|
||||||
|
}: {
|
||||||
|
characters: AccessToken[];
|
||||||
|
sessionReady: boolean;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
background: "#262626",
|
||||||
|
padding: 1,
|
||||||
|
borderRadius: 1,
|
||||||
|
margin: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography paddingLeft={2}>Account: {characters[0].account}</Typography>
|
||||||
|
{characters.map((c) => (
|
||||||
|
<Stack
|
||||||
|
key={c.character.characterId}
|
||||||
|
direction="row"
|
||||||
|
alignItems="flex-start"
|
||||||
|
>
|
||||||
|
<CharacterRow character={c} />
|
||||||
|
{sessionReady && <PlanetaryInteractionRow character={c} />}
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
70
src/app/components/Characters/CharacterDialog.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { Button, Dialog, DialogActions, DialogTitle } from "@mui/material";
|
||||||
|
import { AccessToken, CharacterUpdate } from "../../../types";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import { revokeToken } from "@/esi-sso";
|
||||||
|
|
||||||
|
export const CharacterDialog = ({
|
||||||
|
character,
|
||||||
|
closeDialog,
|
||||||
|
deleteCharacter,
|
||||||
|
updateCharacter,
|
||||||
|
}: {
|
||||||
|
character: AccessToken | undefined;
|
||||||
|
closeDialog: () => void;
|
||||||
|
deleteCharacter: (character: AccessToken) => void;
|
||||||
|
updateCharacter: (characer: AccessToken, update: CharacterUpdate) => void;
|
||||||
|
}) => {
|
||||||
|
const [account, setAccount] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (character?.account) setAccount(character.account);
|
||||||
|
}, [character]);
|
||||||
|
|
||||||
|
const logout = (character: AccessToken) => {
|
||||||
|
revokeToken(character)
|
||||||
|
.then()
|
||||||
|
.catch((e) => console.log("Logout failed"));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={character !== undefined} onClose={closeDialog}>
|
||||||
|
<DialogTitle>{character && character.character.name}</DialogTitle>
|
||||||
|
<TextField
|
||||||
|
id="outlined-basic"
|
||||||
|
label="Account name"
|
||||||
|
variant="outlined"
|
||||||
|
value={account ?? ""}
|
||||||
|
sx={{ margin: 1 }}
|
||||||
|
onChange={(event) => setAccount(event.target.value)}
|
||||||
|
/>
|
||||||
|
<DialogActions>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
character && updateCharacter(character, { account });
|
||||||
|
closeDialog();
|
||||||
|
}}
|
||||||
|
variant="contained"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
character && deleteCharacter(character);
|
||||||
|
character && logout(character);
|
||||||
|
}}
|
||||||
|
variant="contained"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
closeDialog();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
57
src/app/components/Characters/CharacterRow.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useContext, useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import { styled } from "@mui/material/styles";
|
||||||
|
import React from "react";
|
||||||
|
import { CharacterDialog } from "./CharacterDialog";
|
||||||
|
import { AccessToken } from "@/types";
|
||||||
|
import { Box } from "@mui/material";
|
||||||
|
import { EVE_IMAGE_URL } from "@/const";
|
||||||
|
import { CharacterContext } from "@/app/context/Context";
|
||||||
|
|
||||||
|
const StackItem = styled(Stack)(({ theme }) => ({
|
||||||
|
...theme.typography.body2,
|
||||||
|
padding: theme.spacing(2),
|
||||||
|
textAlign: "left",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const CharacterRow = ({ character }: { character: AccessToken }) => {
|
||||||
|
const [selectedCharacter, setSelectedCharacter] = useState<
|
||||||
|
AccessToken | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
|
const { deleteCharacter, updateCharacter } = useContext(CharacterContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StackItem
|
||||||
|
key={character.character.characterId}
|
||||||
|
alignItems="flex-start"
|
||||||
|
justifyContent="flex-start"
|
||||||
|
>
|
||||||
|
<CharacterDialog
|
||||||
|
character={selectedCharacter}
|
||||||
|
deleteCharacter={deleteCharacter}
|
||||||
|
updateCharacter={updateCharacter}
|
||||||
|
closeDialog={() => setSelectedCharacter(undefined)}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
onClick={() => setSelectedCharacter(character)}
|
||||||
|
display="flex"
|
||||||
|
flexDirection="column"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={`${EVE_IMAGE_URL}/characters/${character.character.characterId}/portrait?size=64`}
|
||||||
|
alt=""
|
||||||
|
width={120}
|
||||||
|
height={120}
|
||||||
|
style={{ marginBottom: "0.2rem" }}
|
||||||
|
/>
|
||||||
|
{character.character.name}
|
||||||
|
</Box>
|
||||||
|
</StackItem>
|
||||||
|
);
|
||||||
|
};
|
22
src/app/components/Login/LoginButton.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Button } from "@mui/material";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { LoginDialog } from "./LoginDialog";
|
||||||
|
|
||||||
|
export const LoginButton = () => {
|
||||||
|
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => setLoginDialogOpen(true)}
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
<LoginDialog
|
||||||
|
open={loginDialogOpen}
|
||||||
|
closeDialog={() => setLoginDialogOpen(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
71
src/app/components/Login/LoginDialog.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import DialogTitle from "@mui/material/DialogTitle";
|
||||||
|
import Dialog from "@mui/material/Dialog";
|
||||||
|
import Button from "@mui/material/Button";
|
||||||
|
import { Box, DialogActions } from "@mui/material";
|
||||||
|
import { useContext, useEffect, useState } from "react";
|
||||||
|
import { eveSwagger, loginParameters } from "@/esi-sso";
|
||||||
|
import { SessionContext } from "@/app/context/Context";
|
||||||
|
|
||||||
|
export const LoginDialog = ({
|
||||||
|
open,
|
||||||
|
closeDialog,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
closeDialog: () => void;
|
||||||
|
}) => {
|
||||||
|
const [scopes] = useState<string[]>(["esi-planets.manage_planets.v1"]);
|
||||||
|
const [selectedScopes, setSelectedScopes] = useState<string[]>([
|
||||||
|
"esi-planets.manage_planets.v1",
|
||||||
|
]);
|
||||||
|
const [ssoUrl, setSsoUrl] = useState<string | undefined>(undefined);
|
||||||
|
const [loginUrl, setLoginUrl] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
const { EVE_SSO_CLIENT_ID, EVE_SSO_CALLBACK_URL } =
|
||||||
|
useContext(SessionContext);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
eveSwagger().then((json) => {
|
||||||
|
setSsoUrl(json.securityDefinitions.evesso.authorizationUrl);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Dialog open={open} onClose={closeDialog}>
|
||||||
|
<DialogTitle>Select scopes to login with</DialogTitle>
|
||||||
|
{scopes.map((scope) => (
|
||||||
|
<Box key={scope} padding={1}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedScopes.some((v) => v === scope)}
|
||||||
|
onChange={() => {
|
||||||
|
selectedScopes.some((v) => v === scope)
|
||||||
|
? setSelectedScopes(selectedScopes.filter((s) => s !== scope))
|
||||||
|
: setSelectedScopes([...selectedScopes, scope]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{scope}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
<DialogActions>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => {
|
||||||
|
window.open(loginUrl, "_self");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
<Button onClick={closeDialog}>Close</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
16
src/app/components/Login/RefreshButton.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { SessionContext } from "@/app/context/Context";
|
||||||
|
import { Button } from "@mui/material";
|
||||||
|
import { useContext } from "react";
|
||||||
|
|
||||||
|
export const RefreshButton = () => {
|
||||||
|
const { refreshSession } = useContext(SessionContext);
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
variant="contained"
|
||||||
|
onClick={refreshSession}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
45
src/app/components/MainGrid.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { useContext } from "react";
|
||||||
|
import { Box, Grid, Stack } from "@mui/material";
|
||||||
|
import { LoginButton } from "./Login/LoginButton";
|
||||||
|
import { RefreshButton } from "./Login/RefreshButton";
|
||||||
|
import { AccountCard } from "./Account/AccountCard";
|
||||||
|
import { AccessToken } from "@/types";
|
||||||
|
import { CharacterContext } from "../context/Context";
|
||||||
|
|
||||||
|
interface Grouped {
|
||||||
|
[key: string]: AccessToken[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MainGrid = ({ sessionReady }: { sessionReady: boolean }) => {
|
||||||
|
const { characters } = useContext(CharacterContext);
|
||||||
|
const groupByAccount = characters.reduce<Grouped>((group, character) => {
|
||||||
|
const { account } = character;
|
||||||
|
group[account ?? ""] = group[account ?? ""] ?? [];
|
||||||
|
group[account ?? ""].push(character);
|
||||||
|
return group;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ flexGrow: 1 }}>
|
||||||
|
<Grid container spacing={1}>
|
||||||
|
<Grid item xs={2}>
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<LoginButton />
|
||||||
|
<RefreshButton />
|
||||||
|
</Stack>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
<Grid container spacing={1}>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
{Object.values(groupByAccount).map((g, id) => (
|
||||||
|
<AccountCard
|
||||||
|
key={`account-${id}-${g[0].account}`}
|
||||||
|
characters={g}
|
||||||
|
sessionReady={sessionReady}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
32
src/app/components/PlanetaryInteraction/NoPlanetCard.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Stack, Typography, styled } from "@mui/material";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
const StackItem = styled(Stack)(({ theme }) => ({
|
||||||
|
...theme.typography.body2,
|
||||||
|
padding: 0,
|
||||||
|
textAlign: "left",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const NoPlanetCard = ({}: {}) => {
|
||||||
|
return (
|
||||||
|
<StackItem alignItems="flex-start" className="poop" height="100%">
|
||||||
|
<Image
|
||||||
|
src={`/noplanet.png`}
|
||||||
|
alt=""
|
||||||
|
width={120}
|
||||||
|
height={120}
|
||||||
|
style={{ marginBottom: "0.2rem" }}
|
||||||
|
/>
|
||||||
|
<Image
|
||||||
|
width={64}
|
||||||
|
height={64}
|
||||||
|
src={`/stopped.png`}
|
||||||
|
alt=""
|
||||||
|
style={{ position: "absolute" }}
|
||||||
|
/>
|
||||||
|
<Typography>No planet</Typography>
|
||||||
|
</StackItem>
|
||||||
|
);
|
||||||
|
};
|
146
src/app/components/PlanetaryInteraction/PlanetCard.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { Stack, Typography, styled } from "@mui/material";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { AccessToken, Planet } from "@/types";
|
||||||
|
import { Api } from "@/esi-api";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { DateTime } from "luxon";
|
||||||
|
import { EXTRACTOR_TYPE_IDS } from "@/const";
|
||||||
|
import Countdown from "react-countdown";
|
||||||
|
|
||||||
|
const StackItem = styled(Stack)(({ theme }) => ({
|
||||||
|
...theme.typography.body2,
|
||||||
|
padding: 0,
|
||||||
|
textAlign: "left",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}));
|
||||||
|
|
||||||
|
export interface PlanetInfo {
|
||||||
|
links: {
|
||||||
|
destination_pin_id: number;
|
||||||
|
link_level: number;
|
||||||
|
source_pin_id: number;
|
||||||
|
}[];
|
||||||
|
pins: {
|
||||||
|
contents?: {
|
||||||
|
amount: number;
|
||||||
|
type_id: number;
|
||||||
|
}[];
|
||||||
|
expiry_time?: string;
|
||||||
|
extractor_details?: {
|
||||||
|
cycle_time?: number;
|
||||||
|
head_radius?: number;
|
||||||
|
heads: {
|
||||||
|
head_id: number;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
}[];
|
||||||
|
product_type_id?: number;
|
||||||
|
qty_per_cycle?: number;
|
||||||
|
};
|
||||||
|
factory_details?: {
|
||||||
|
schematic_id: number;
|
||||||
|
};
|
||||||
|
install_time?: string;
|
||||||
|
last_cycle_start?: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
pin_id: number;
|
||||||
|
schematic_id?: number;
|
||||||
|
type_id: number;
|
||||||
|
}[];
|
||||||
|
routes: {
|
||||||
|
content_type_id: number;
|
||||||
|
destination_pin_id: number;
|
||||||
|
quantity: number;
|
||||||
|
route_id: number;
|
||||||
|
source_pin_id: number;
|
||||||
|
waypoints?: number[];
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PlanetCard = ({
|
||||||
|
planet,
|
||||||
|
character,
|
||||||
|
}: {
|
||||||
|
planet: Planet;
|
||||||
|
character: AccessToken;
|
||||||
|
}) => {
|
||||||
|
const [planetInfo, setPlanetInfo] = useState<PlanetInfo | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
const extractors =
|
||||||
|
(planetInfo &&
|
||||||
|
planetInfo.pins
|
||||||
|
.filter((p) => EXTRACTOR_TYPE_IDS.some((e) => e === p.type_id))
|
||||||
|
.map((p) => p.expiry_time)) ??
|
||||||
|
[];
|
||||||
|
const getPlanet = async (
|
||||||
|
character: AccessToken,
|
||||||
|
planet: Planet
|
||||||
|
): Promise<PlanetInfo> => {
|
||||||
|
const api = new Api();
|
||||||
|
const planetInfo = (
|
||||||
|
await api.characters.getCharactersCharacterIdPlanetsPlanetId(
|
||||||
|
character.character.characterId,
|
||||||
|
planet.planet_id,
|
||||||
|
{
|
||||||
|
token: character.access_token,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
).data;
|
||||||
|
return planetInfo;
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
getPlanet(character, planet).then(setPlanetInfo);
|
||||||
|
}, [planet, character]);
|
||||||
|
return (
|
||||||
|
<StackItem alignItems="flex-start" className="poop" height="100%">
|
||||||
|
<Image
|
||||||
|
src={`/${planet.planet_type}.png`}
|
||||||
|
alt=""
|
||||||
|
width={120}
|
||||||
|
height={120}
|
||||||
|
style={{ marginBottom: "0.2rem" }}
|
||||||
|
/>
|
||||||
|
{extractors.some((e) => {
|
||||||
|
if (!e) return true;
|
||||||
|
const dateExtractor = DateTime.fromISO(e);
|
||||||
|
const dateNow = DateTime.now();
|
||||||
|
return dateExtractor < dateNow;
|
||||||
|
}) && (
|
||||||
|
<Image
|
||||||
|
width={64}
|
||||||
|
height={64}
|
||||||
|
src={`/stopped.png`}
|
||||||
|
alt=""
|
||||||
|
style={{ position: "absolute" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{extractors.map((e, idx) => {
|
||||||
|
const inPast = () => {
|
||||||
|
if (!e) return true;
|
||||||
|
const dateExtractor = DateTime.fromISO(e);
|
||||||
|
const dateNow = DateTime.now();
|
||||||
|
return dateExtractor < dateNow;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Typography
|
||||||
|
key={`${e}-${idx}-${character.character.characterId}`}
|
||||||
|
color={inPast() ? "red" : "white"}
|
||||||
|
>
|
||||||
|
{e ? (
|
||||||
|
<Countdown
|
||||||
|
overtime={true}
|
||||||
|
date={DateTime.fromISO(e).toMillis()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
"STOPPED"
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</StackItem>
|
||||||
|
);
|
||||||
|
};
|
@@ -0,0 +1,57 @@
|
|||||||
|
import { Api } from "@/esi-api";
|
||||||
|
import { AccessToken, Planet } from "@/types";
|
||||||
|
import { Stack, styled } from "@mui/material";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { PlanetCard } from "./PlanetCard";
|
||||||
|
import { NoPlanetCard } from "./NoPlanetCard";
|
||||||
|
|
||||||
|
const StackItem = styled(Stack)(({ theme }) => ({
|
||||||
|
...theme.typography.body2,
|
||||||
|
padding: theme.spacing(2),
|
||||||
|
textAlign: "left",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const getPlanets = async (character: AccessToken): Promise<Planet[]> => {
|
||||||
|
const api = new Api();
|
||||||
|
const planets = (
|
||||||
|
await api.characters.getCharactersCharacterIdPlanets(
|
||||||
|
character.character.characterId,
|
||||||
|
{
|
||||||
|
token: character.access_token,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
).data;
|
||||||
|
return planets;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PlanetaryInteractionRow = ({
|
||||||
|
character,
|
||||||
|
}: {
|
||||||
|
character: AccessToken;
|
||||||
|
}) => {
|
||||||
|
const [planets, setPlanets] = useState<Planet[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
getPlanets(character).then(setPlanets).catch(console.log);
|
||||||
|
}, [character]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StackItem>
|
||||||
|
<Stack spacing={2} direction="row" flexWrap="wrap">
|
||||||
|
{planets.map((planet) => (
|
||||||
|
<PlanetCard
|
||||||
|
key={`${character.character.characterId}-${planet.planet_id}`}
|
||||||
|
planet={planet}
|
||||||
|
character={character}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{Array.from(Array(6 - planets.length).keys()).map((i, id) => (
|
||||||
|
<NoPlanetCard
|
||||||
|
key={`${character.character.characterId}-no-planet-${id}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</StackItem>
|
||||||
|
);
|
||||||
|
};
|
26
src/app/context/Context.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { AccessToken, CharacterUpdate } from "@/types";
|
||||||
|
import { Dispatch, SetStateAction, createContext } from "react";
|
||||||
|
|
||||||
|
export const CharacterContext = createContext<{
|
||||||
|
characters: AccessToken[];
|
||||||
|
deleteCharacter: (character: AccessToken) => void;
|
||||||
|
updateCharacter: (character: AccessToken, update: CharacterUpdate) => void;
|
||||||
|
}>({
|
||||||
|
characters: [],
|
||||||
|
deleteCharacter: () => {},
|
||||||
|
updateCharacter: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SessionContext = createContext<{
|
||||||
|
sessionReady: boolean;
|
||||||
|
refreshSession: () => void;
|
||||||
|
setSessionReady: Dispatch<SetStateAction<boolean>>;
|
||||||
|
EVE_SSO_CALLBACK_URL: string;
|
||||||
|
EVE_SSO_CLIENT_ID: string;
|
||||||
|
}>({
|
||||||
|
sessionReady: false,
|
||||||
|
refreshSession: () => {},
|
||||||
|
setSessionReady: () => {},
|
||||||
|
EVE_SSO_CALLBACK_URL: "",
|
||||||
|
EVE_SSO_CLIENT_ID: "",
|
||||||
|
});
|
BIN
src/app/favicon.ico
Normal file
After Width: | Height: | Size: 25 KiB |
23
src/app/globals.css
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
:root {
|
||||||
|
--foreground-rgb: 0, 0, 0;
|
||||||
|
--background-start-rgb: 214, 219, 220;
|
||||||
|
--background-end-rgb: 255, 255, 255;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--foreground-rgb: 255, 255, 255;
|
||||||
|
--background-start-rgb: 0, 0, 0;
|
||||||
|
--background-end-rgb: 0, 0, 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
color: rgb(var(--foreground-rgb));
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
transparent,
|
||||||
|
rgb(var(--background-end-rgb))
|
||||||
|
)
|
||||||
|
rgb(var(--background-start-rgb));
|
||||||
|
}
|
21
src/app/layout.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import "./globals.css";
|
||||||
|
import { Inter } from "next/font/google";
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "EVE PI",
|
||||||
|
description: "Lets PI!",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body className={inter.className}>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
124
src/app/page.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
"use client";
|
||||||
|
import "@fontsource/roboto/300.css";
|
||||||
|
import "@fontsource/roboto/400.css";
|
||||||
|
import "@fontsource/roboto/500.css";
|
||||||
|
import "@fontsource/roboto/700.css";
|
||||||
|
import { memo, useCallback, useEffect, useState } from "react";
|
||||||
|
import { AccessToken, CharacterUpdate, Env } from "../types";
|
||||||
|
import { MainGrid } from "./components/MainGrid";
|
||||||
|
import { refreshToken } from "@/esi-sso";
|
||||||
|
import { CharacterContext, SessionContext } from "./context/Context";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
|
const Home = () => {
|
||||||
|
const [characters, setCharacters] = useState<AccessToken[]>([]);
|
||||||
|
const [sessionReady, setSessionReady] = useState(false);
|
||||||
|
const [environment, setEnvironment] = useState<Env | undefined>(undefined);
|
||||||
|
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const code = searchParams && searchParams.get("code");
|
||||||
|
|
||||||
|
// Initialize SSO env
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("api/env")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((j) => {
|
||||||
|
setEnvironment({
|
||||||
|
EVE_SSO_CLIENT_ID: j.EVE_SSO_CLIENT_ID,
|
||||||
|
EVE_SSO_CALLBACK_URL: j.EVE_SSO_CALLBACK_URL,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Memoize chracter state manipulations
|
||||||
|
const addCharacter = useCallback((character: AccessToken) => {
|
||||||
|
setCharacters((chars) => [
|
||||||
|
...chars.filter(
|
||||||
|
(c) => c.character.characterId !== character.character.characterId
|
||||||
|
),
|
||||||
|
character,
|
||||||
|
]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const deleteCharacter = useCallback(
|
||||||
|
(character: AccessToken) => {
|
||||||
|
setCharacters(
|
||||||
|
characters.filter(
|
||||||
|
(c) => character.character.characterId !== c.character.characterId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[characters]
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateCharacter = useCallback(
|
||||||
|
(character: AccessToken, updates: CharacterUpdate) => {
|
||||||
|
setCharacters(
|
||||||
|
characters.map((c) => {
|
||||||
|
if (c.character.characterId === character.character.characterId)
|
||||||
|
return {
|
||||||
|
...c,
|
||||||
|
...(updates.account ? { account: updates.account } : {}),
|
||||||
|
};
|
||||||
|
return c;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[characters]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle EVE SSO callback
|
||||||
|
useEffect(() => {
|
||||||
|
if (code) {
|
||||||
|
window.history.replaceState(null, "", "/");
|
||||||
|
fetch(`api/token?code=${code}`)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then(addCharacter)
|
||||||
|
.catch();
|
||||||
|
}
|
||||||
|
}, [code, addCharacter]);
|
||||||
|
|
||||||
|
// Initialise saved characters
|
||||||
|
useEffect(() => {
|
||||||
|
const localStorageCharacters = localStorage.getItem("characters");
|
||||||
|
if (localStorageCharacters) {
|
||||||
|
const characterArray: AccessToken[] = JSON.parse(localStorageCharacters);
|
||||||
|
setCharacters(characterArray);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Update saved characters to local storage on state change
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem("characters", JSON.stringify(characters));
|
||||||
|
}, [characters]);
|
||||||
|
|
||||||
|
const refreshSession = () => {
|
||||||
|
Promise.all(characters.map((c) => refreshToken(c)))
|
||||||
|
.then(setCharacters)
|
||||||
|
.finally(() => setSessionReady(true));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SessionContext.Provider
|
||||||
|
value={{
|
||||||
|
sessionReady,
|
||||||
|
setSessionReady,
|
||||||
|
refreshSession,
|
||||||
|
EVE_SSO_CALLBACK_URL: environment?.EVE_SSO_CALLBACK_URL ?? "",
|
||||||
|
EVE_SSO_CLIENT_ID: environment?.EVE_SSO_CLIENT_ID ?? "",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CharacterContext.Provider
|
||||||
|
value={{
|
||||||
|
characters,
|
||||||
|
deleteCharacter,
|
||||||
|
updateCharacter,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MainGrid sessionReady={sessionReady} />
|
||||||
|
</CharacterContext.Provider>
|
||||||
|
</SessionContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default memo(Home);
|
4
src/const.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export const EVE_IMAGE_URL = "https://images.evetech.net";
|
||||||
|
export const EXTRACTOR_TYPE_IDS = [
|
||||||
|
2848, 3060, 3061, 3062, 3063, 3064, 3067, 3068,
|
||||||
|
];
|
19278
src/esi-api.ts
Normal file
55
src/esi-sso.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { AccessToken } from "./types";
|
||||||
|
|
||||||
|
export const refreshToken = async (
|
||||||
|
character: AccessToken
|
||||||
|
): Promise<AccessToken> => {
|
||||||
|
return fetch(`api/refresh`, {
|
||||||
|
method: "POST",
|
||||||
|
mode: "cors",
|
||||||
|
cache: "no-cache",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
redirect: "error",
|
||||||
|
referrerPolicy: "no-referrer",
|
||||||
|
body: JSON.stringify(character),
|
||||||
|
}).then((res) => res.json());
|
||||||
|
};
|
||||||
|
|
||||||
|
export const revokeToken = async (
|
||||||
|
character: AccessToken
|
||||||
|
): Promise<Response> => {
|
||||||
|
return fetch(`api/revoke`, {
|
||||||
|
method: "POST",
|
||||||
|
mode: "cors",
|
||||||
|
cache: "no-cache",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
redirect: "error",
|
||||||
|
referrerPolicy: "no-referrer",
|
||||||
|
body: JSON.stringify(character),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loginParameters = async (
|
||||||
|
selectedScopes: string[],
|
||||||
|
EVE_SSO_CLIENT_ID: string,
|
||||||
|
EVE_SSO_CALLBACK_URL: string
|
||||||
|
) => {
|
||||||
|
return new URLSearchParams({
|
||||||
|
response_type: "code",
|
||||||
|
redirect_uri: EVE_SSO_CALLBACK_URL,
|
||||||
|
client_id: EVE_SSO_CLIENT_ID,
|
||||||
|
scope: selectedScopes.join(" "),
|
||||||
|
state: "asfe",
|
||||||
|
}).toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const eveSwagger = async () => {
|
||||||
|
return fetch("https://esi.evetech.net/latest/swagger.json").then((res) =>
|
||||||
|
res.json()
|
||||||
|
);
|
||||||
|
};
|
13
src/pages/api/env.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
|
if (req.method === "GET") {
|
||||||
|
const EVE_SSO_CALLBACK_URL = process.env.EVE_SSO_CALLBACK_URL;
|
||||||
|
const EVE_SSO_CLIENT_ID = process.env.EVE_SSO_CLIENT_ID;
|
||||||
|
res.json({ EVE_SSO_CLIENT_ID, EVE_SSO_CALLBACK_URL });
|
||||||
|
} else {
|
||||||
|
res.status(404).end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default handler;
|
62
src/pages/api/refresh.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { AccessToken } from "@/types";
|
||||||
|
import { extractCharacterFromToken } from "@/utils";
|
||||||
|
import { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import crypto from "crypto-js";
|
||||||
|
|
||||||
|
const EVE_SSO_TOKEN_URL = "https://login.eveonline.com/v2/oauth/token";
|
||||||
|
const EVE_SSO_CLIENT_ID = process.env.EVE_SSO_CLIENT_ID ?? "";
|
||||||
|
const EVE_SSO_SECRET = process.env.EVE_SSO_SECRET ?? "";
|
||||||
|
|
||||||
|
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
|
if (req.method === "POST") {
|
||||||
|
const accessToken: AccessToken = req.body;
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: crypto.AES.decrypt(
|
||||||
|
accessToken.refresh_token,
|
||||||
|
EVE_SSO_SECRET
|
||||||
|
).toString(crypto.enc.Utf8),
|
||||||
|
}).toString();
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
Authorization: `Basic ${Buffer.from(
|
||||||
|
`${EVE_SSO_CLIENT_ID}:${EVE_SSO_SECRET}`
|
||||||
|
).toString("base64")}`,
|
||||||
|
Host: "login.eveonline.com",
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(EVE_SSO_TOKEN_URL, {
|
||||||
|
method: "POST",
|
||||||
|
body: params,
|
||||||
|
headers,
|
||||||
|
}).then((res) => res.json());
|
||||||
|
const character = extractCharacterFromToken(response);
|
||||||
|
|
||||||
|
const token: AccessToken = {
|
||||||
|
access_token: response.access_token,
|
||||||
|
token_type: response.token_type,
|
||||||
|
refresh_token: crypto.AES.encrypt(
|
||||||
|
response.refresh_token,
|
||||||
|
EVE_SSO_SECRET
|
||||||
|
).toString(),
|
||||||
|
expires_at: Date.now() + response.expires_in * 1000,
|
||||||
|
character,
|
||||||
|
needsLogin: false,
|
||||||
|
account: accessToken.account,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("Refresh", character.name, character.characterId);
|
||||||
|
|
||||||
|
return res.json(token);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
res.json({ ...accessToken, needsLogin: true });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
res.status(404).end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default handler;
|
51
src/pages/api/revoke.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { AccessToken } from "@/types";
|
||||||
|
import { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import crypto from "crypto-js";
|
||||||
|
|
||||||
|
const EVE_SSO_REVOKE_URL = "https://login.eveonline.com/v2/oauth/revoke";
|
||||||
|
const EVE_SSO_CLIENT_ID = process.env.EVE_SSO_CLIENT_ID ?? "";
|
||||||
|
const EVE_SSO_SECRET = process.env.EVE_SSO_SECRET ?? "";
|
||||||
|
|
||||||
|
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
|
if (req.method === "POST") {
|
||||||
|
const accessToken: AccessToken = req.body;
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: crypto.AES.decrypt(
|
||||||
|
accessToken.refresh_token,
|
||||||
|
EVE_SSO_SECRET
|
||||||
|
).toString(crypto.enc.Utf8),
|
||||||
|
}).toString();
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
Authorization: `Basic ${Buffer.from(
|
||||||
|
`${EVE_SSO_CLIENT_ID}:${EVE_SSO_SECRET}`
|
||||||
|
).toString("base64")}`,
|
||||||
|
Host: "login.eveonline.com",
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(EVE_SSO_REVOKE_URL, {
|
||||||
|
method: "POST",
|
||||||
|
body: params,
|
||||||
|
headers,
|
||||||
|
}).then((res) => res.json());
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"Revoke",
|
||||||
|
accessToken.character.name,
|
||||||
|
accessToken.character.characterId
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.end();
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
return res.status(500).end();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
res.status(404).end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default handler;
|
56
src/pages/api/token.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { AccessToken } from "@/types";
|
||||||
|
import { extractCharacterFromToken } from "@/utils";
|
||||||
|
import { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import crypto from "crypto-js";
|
||||||
|
|
||||||
|
const EVE_SSO_TOKEN_URL = "https://login.eveonline.com/v2/oauth/token";
|
||||||
|
const EVE_SSO_CLIENT_ID = process.env.EVE_SSO_CLIENT_ID ?? "";
|
||||||
|
const EVE_SSO_SECRET = process.env.EVE_SSO_SECRET ?? "";
|
||||||
|
|
||||||
|
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
|
if (req.method === "GET") {
|
||||||
|
const code = req.query.code as string;
|
||||||
|
if (!code || code === undefined) return res.status(404).end();
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: code,
|
||||||
|
}).toString();
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
Authorization: `Basic ${Buffer.from(
|
||||||
|
`${EVE_SSO_CLIENT_ID}:${EVE_SSO_SECRET}`
|
||||||
|
).toString("base64")}`,
|
||||||
|
Host: "login.eveonline.com",
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(EVE_SSO_TOKEN_URL, {
|
||||||
|
method: "POST",
|
||||||
|
body: params,
|
||||||
|
headers,
|
||||||
|
}).then((res) => res.json());
|
||||||
|
|
||||||
|
const character = extractCharacterFromToken(response);
|
||||||
|
|
||||||
|
console.log("Login", character.name, character.characterId);
|
||||||
|
|
||||||
|
const token: AccessToken = {
|
||||||
|
access_token: response.access_token,
|
||||||
|
token_type: response.token_type,
|
||||||
|
refresh_token: crypto.AES.encrypt(
|
||||||
|
response.refresh_token,
|
||||||
|
EVE_SSO_SECRET
|
||||||
|
).toString(),
|
||||||
|
expires_at: Date.now() + response.expires_in * 1000,
|
||||||
|
character,
|
||||||
|
needsLogin: false,
|
||||||
|
account: "-",
|
||||||
|
};
|
||||||
|
res.json(token);
|
||||||
|
} else {
|
||||||
|
res.status(404).end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default handler;
|
41
src/types.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
export interface AccessToken {
|
||||||
|
access_token: string;
|
||||||
|
expires_at: number;
|
||||||
|
token_type: "Bearer";
|
||||||
|
refresh_token: string;
|
||||||
|
character: Character;
|
||||||
|
account: string;
|
||||||
|
needsLogin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Character {
|
||||||
|
name: string;
|
||||||
|
characterId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CharacterUpdate {
|
||||||
|
account?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Planet {
|
||||||
|
last_update: string;
|
||||||
|
num_pins: number;
|
||||||
|
owner_id: number;
|
||||||
|
planet_id: number;
|
||||||
|
planet_type:
|
||||||
|
| "temperate"
|
||||||
|
| "barren"
|
||||||
|
| "oceanic"
|
||||||
|
| "ice"
|
||||||
|
| "gas"
|
||||||
|
| "lava"
|
||||||
|
| "storm"
|
||||||
|
| "plasma";
|
||||||
|
solar_system_id: number;
|
||||||
|
upgrade_level: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Env {
|
||||||
|
EVE_SSO_CALLBACK_URL: string;
|
||||||
|
EVE_SSO_CLIENT_ID: string;
|
||||||
|
}
|
13
src/utils.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { AccessToken, Character } from "./types";
|
||||||
|
|
||||||
|
export const extractCharacterFromToken = (token: AccessToken): Character => {
|
||||||
|
const decodedToken = parseJwt(token.access_token);
|
||||||
|
return {
|
||||||
|
name: decodedToken.name,
|
||||||
|
characterId: decodedToken.sub.split(":")[2],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseJwt = (token: string) => {
|
||||||
|
return JSON.parse(Buffer.from(token.split(".")[1], "base64").toString());
|
||||||
|
};
|
28
tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es5",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|