feat: ability to load characters and their skills from ESI (#25)
This can track multiple characters, and tries to make as few as possible ESI calls. In the localstorage, we keep track of all the characters and their refresh-tokens. Access-tokens and skills are not stored in the localstorage, which means that every reload, this information is fetched from ESI again.
This commit is contained in:
@@ -19,7 +19,8 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eveshipfit/dogma-engine": "^2.2.1",
|
||||
"clsx": "^2.0.0"
|
||||
"clsx": "^2.0.0",
|
||||
"jwt-decode": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/preset-env": "^7.23.3",
|
||||
|
||||
25
src/EsiCharacterSelection/EsiCharacterSelection.module.css
Normal file
25
src/EsiCharacterSelection/EsiCharacterSelection.module.css
Normal file
@@ -0,0 +1,25 @@
|
||||
.character {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.character > select {
|
||||
background-color: #1d1d1d;
|
||||
color: #c5c5c5;
|
||||
height: 24px;
|
||||
width: calc(100% - 20px);
|
||||
}
|
||||
|
||||
.character > button {
|
||||
background-color: #1d1d1d;
|
||||
color: #c5c5c5;
|
||||
cursor: pointer;
|
||||
height: 24px;
|
||||
text-align: center;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.character > button.noCharacter {
|
||||
text-align: left;
|
||||
padding-left: 5px;
|
||||
width: 100%;
|
||||
}
|
||||
28
src/EsiCharacterSelection/EsiCharacterSelection.stories.tsx
Normal file
28
src/EsiCharacterSelection/EsiCharacterSelection.stories.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { Decorator, Meta, StoryObj } from '@storybook/react';
|
||||
import React from "react";
|
||||
|
||||
import { EsiProvider } from '../EsiProvider';
|
||||
import { EsiCharacterSelection } from './';
|
||||
|
||||
const meta: Meta<typeof EsiCharacterSelection> = {
|
||||
component: EsiCharacterSelection,
|
||||
tags: ['autodocs'],
|
||||
title: 'Component/EsiCharacterSelection',
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof EsiCharacterSelection>;
|
||||
|
||||
const withEsiProvider: Decorator<Record<string, never>> = (Story) => {
|
||||
return (
|
||||
<EsiProvider>
|
||||
<Story />
|
||||
</EsiProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
},
|
||||
decorators: [withEsiProvider],
|
||||
};
|
||||
34
src/EsiCharacterSelection/EsiCharacterSelection.tsx
Normal file
34
src/EsiCharacterSelection/EsiCharacterSelection.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from "react";
|
||||
|
||||
import { EsiContext } from "../EsiProvider";
|
||||
|
||||
import styles from "./EsiCharacterSelection.module.css";
|
||||
|
||||
/**
|
||||
* Character selection for EsiProvider.
|
||||
*
|
||||
* It shows both a dropdown for all the characters that the EsiProvider knows,
|
||||
* and a button to add another character.
|
||||
*/
|
||||
export const EsiCharacterSelection = () => {
|
||||
const esi = React.useContext(EsiContext);
|
||||
|
||||
if (Object.keys(esi.characters ?? {}).length === 0) {
|
||||
return <div className={styles.character}>
|
||||
<button className={styles.noCharacter} onClick={esi.login}>
|
||||
Login to load characters skills and fits
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
return <div className={styles.character}>
|
||||
<select onChange={e => esi.changeCharacter(e.target.value)} value={esi.currentCharacter}>
|
||||
{Object.entries(esi.characters).map(([id, name]) => {
|
||||
return <option key={id} value={id}>{name.name}</option>
|
||||
})}
|
||||
</select>
|
||||
<button onClick={esi.login} title="Add another character">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
};
|
||||
1
src/EsiCharacterSelection/index.ts
Normal file
1
src/EsiCharacterSelection/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { EsiCharacterSelection } from "./EsiCharacterSelection";
|
||||
20
src/EsiProvider/EsiAccessToken.tsx
Normal file
20
src/EsiProvider/EsiAccessToken.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
export async function getAccessToken(refreshToken: string): Promise<string | undefined> {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch('https://esi.eveship.fit/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
refresh_token: refreshToken,
|
||||
}),
|
||||
});
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (response.status !== 201) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.access_token;
|
||||
};
|
||||
42
src/EsiProvider/EsiProvider.stories.tsx
Normal file
42
src/EsiProvider/EsiProvider.stories.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import React from "react";
|
||||
|
||||
import { EsiContext, EsiProvider } from './';
|
||||
|
||||
const meta: Meta<typeof EsiProvider> = {
|
||||
component: EsiProvider,
|
||||
tags: ['autodocs'],
|
||||
title: 'Provider/EsiProvider',
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof EsiProvider>;
|
||||
|
||||
const TestEsi = () => {
|
||||
const esi = React.useContext(EsiContext);
|
||||
|
||||
if (!esi.loaded) {
|
||||
return (
|
||||
<div>
|
||||
Esi: loading<br/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
Esi: loaded<br/>
|
||||
<pre>{JSON.stringify(esi, null, 2)}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
},
|
||||
render: (args) => (
|
||||
<EsiProvider {...args}>
|
||||
<TestEsi />
|
||||
</EsiProvider>
|
||||
),
|
||||
};
|
||||
277
src/EsiProvider/EsiProvider.tsx
Normal file
277
src/EsiProvider/EsiProvider.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
import React from "react";
|
||||
import { getAccessToken } from "./EsiAccessToken";
|
||||
import { getSkills } from "./EsiSkills";
|
||||
|
||||
export interface EsiCharacter {
|
||||
name: string;
|
||||
skills?: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface Esi {
|
||||
loaded?: boolean;
|
||||
characters: Record<string, EsiCharacter>;
|
||||
currentCharacter?: string;
|
||||
|
||||
changeCharacter: (character: string) => void;
|
||||
login: () => void;
|
||||
}
|
||||
|
||||
interface EsiPrivate {
|
||||
loaded?: boolean;
|
||||
refreshTokens: Record<string, string>;
|
||||
accessTokens: Record<string, string>;
|
||||
}
|
||||
|
||||
interface JwtPayload {
|
||||
name: string;
|
||||
sub: string;
|
||||
}
|
||||
|
||||
export const EsiContext = React.createContext<Esi>({
|
||||
loaded: undefined,
|
||||
characters: {},
|
||||
changeCharacter: () => {},
|
||||
login: () => {},
|
||||
});
|
||||
|
||||
export interface EsiProps {
|
||||
/** Children that can use this provider. */
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const useLocalStorage = function <T>(key: string, initialValue: T) {
|
||||
const [storedValue, setStoredValue] = React.useState<T>(() => {
|
||||
if (typeof window === 'undefined') return initialValue;
|
||||
|
||||
const item = window.localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : initialValue;
|
||||
});
|
||||
|
||||
const setValue = React.useCallback((value: T | ((val: T) => T)) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (storedValue == value) return;
|
||||
|
||||
const valueToStore = value instanceof Function ? value(storedValue) : value;
|
||||
setStoredValue(valueToStore);
|
||||
|
||||
if (valueToStore === undefined) {
|
||||
window.localStorage.removeItem(key);
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
||||
}, [key, storedValue]);
|
||||
|
||||
return [ storedValue, setValue ] as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps track (in local storage) of ESI characters and their refresh token.
|
||||
*/
|
||||
export const EsiProvider = (props: EsiProps) => {
|
||||
const [esi, setEsi] = React.useState<Esi>({
|
||||
loaded: undefined,
|
||||
characters: {},
|
||||
changeCharacter: () => {},
|
||||
login: () => {},
|
||||
});
|
||||
const [esiPrivate, setEsiPrivate] = React.useState<EsiPrivate>({
|
||||
loaded: undefined,
|
||||
refreshTokens: {},
|
||||
accessTokens: {},
|
||||
});
|
||||
|
||||
const [characters, setCharacters] = useLocalStorage<Record<string, EsiCharacter>>('characters', {});
|
||||
const [refreshTokens, setRefreshTokens] = useLocalStorage('refreshTokens', {});
|
||||
const [currentCharacter, setCurrentCharacter] = useLocalStorage<string | undefined>('currentCharacter', undefined);
|
||||
|
||||
const changeCharacter = React.useCallback((character: string) => {
|
||||
setCurrentCharacter(character);
|
||||
|
||||
setEsi((oldEsi: Esi) => {
|
||||
return {
|
||||
...oldEsi,
|
||||
currentCharacter: character,
|
||||
};
|
||||
});
|
||||
}, [setCurrentCharacter]);
|
||||
|
||||
const login = React.useCallback(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.location.href = "https://esi.eveship.fit/";
|
||||
}, []);
|
||||
|
||||
const ensureAccessToken = React.useCallback(async (characterId: string): Promise<string | undefined> => {
|
||||
if (esiPrivate.accessTokens[characterId]) {
|
||||
return esiPrivate.accessTokens[characterId];
|
||||
}
|
||||
|
||||
const accessToken = await getAccessToken(esiPrivate.refreshTokens[characterId]);
|
||||
if (accessToken === undefined) {
|
||||
console.log('Failed to get access token');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/* New access token; store for later use. */
|
||||
setEsiPrivate((oldEsiPrivate: EsiPrivate) => {
|
||||
return {
|
||||
...oldEsiPrivate,
|
||||
accessToken: {
|
||||
...oldEsiPrivate.accessTokens,
|
||||
[characterId]: accessToken,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return accessToken;
|
||||
}, [esiPrivate.accessTokens, esiPrivate.refreshTokens]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const characterId = esi.currentCharacter;
|
||||
if (characterId === undefined) return;
|
||||
/* Skills already fetched? We won't do it again till the user reloads. */
|
||||
if (esi.characters[characterId]?.skills !== undefined) return;
|
||||
|
||||
ensureAccessToken(characterId).then((accessToken) => {
|
||||
if (accessToken === undefined) return;
|
||||
|
||||
getSkills(characterId, accessToken).then((skills) => {
|
||||
if (skills === undefined) return;
|
||||
|
||||
setEsi((oldEsi: Esi) => {
|
||||
return {
|
||||
...oldEsi,
|
||||
characters: {
|
||||
...oldEsi.characters,
|
||||
[characterId]: {
|
||||
...oldEsi.characters[characterId],
|
||||
skills,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/* We only update when currentCharacter changes, and ignore all others. */
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [esi.currentCharacter]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
async function loginCharacter(code: string) {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch('https://esi.eveship.fit/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
code: code,
|
||||
}),
|
||||
});
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (response.status !== 201) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
/* Decode the access-token as it contains the name and character id. */
|
||||
const jwt = jwtDecode<JwtPayload>(data.access_token);
|
||||
if (!jwt.name || !jwt.sub?.startsWith("CHARACTER:EVE:")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const accessToken = data.access_token;
|
||||
const refreshToken = data.refresh_token;
|
||||
const name = jwt.name;
|
||||
const characterId = jwt.sub.slice("CHARACTER:EVE:".length);
|
||||
|
||||
/* Update the local storage with the new character. */
|
||||
setCharacters((oldCharacters: Record<string, EsiCharacter>) => {
|
||||
return {
|
||||
...oldCharacters,
|
||||
[characterId]: {
|
||||
name: name,
|
||||
},
|
||||
};
|
||||
});
|
||||
setRefreshTokens((oldRefreshTokens: Record<string, string>) => {
|
||||
return {
|
||||
...oldRefreshTokens,
|
||||
[characterId]: refreshToken,
|
||||
};
|
||||
});
|
||||
setCurrentCharacter(characterId);
|
||||
|
||||
/* Update the current render with the new character. */
|
||||
setEsi((oldEsi: Esi) => {
|
||||
return {
|
||||
...oldEsi,
|
||||
characters: {
|
||||
...oldEsi.characters,
|
||||
[characterId]: {
|
||||
name: name,
|
||||
},
|
||||
},
|
||||
currentCharacter: characterId,
|
||||
};
|
||||
});
|
||||
setEsiPrivate((oldEsiPrivate: EsiPrivate) => {
|
||||
return {
|
||||
...oldEsiPrivate,
|
||||
refreshTokens: {
|
||||
...oldEsiPrivate.refreshTokens,
|
||||
[characterId]: refreshToken,
|
||||
},
|
||||
accessToken: {
|
||||
...oldEsiPrivate.accessTokens,
|
||||
[characterId]: accessToken,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function startup() {
|
||||
setEsi({
|
||||
loaded: true,
|
||||
characters,
|
||||
currentCharacter,
|
||||
changeCharacter,
|
||||
login,
|
||||
});
|
||||
setEsiPrivate({
|
||||
loaded: true,
|
||||
refreshTokens,
|
||||
accessTokens: {},
|
||||
});
|
||||
|
||||
/* Check if this was a login request. */
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const code = urlParams.get('code');
|
||||
if (code) {
|
||||
/* Remove the code from the URL. */
|
||||
window.history.replaceState(null, "", window.location.pathname + window.location.hash);
|
||||
|
||||
if (!await loginCharacter(code)) {
|
||||
console.log('Failed to login character');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
startup();
|
||||
|
||||
/* This should only on first start. */
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return <EsiContext.Provider value={esi}>
|
||||
{props.children}
|
||||
</EsiContext.Provider>
|
||||
};
|
||||
25
src/EsiProvider/EsiSkills.tsx
Normal file
25
src/EsiProvider/EsiSkills.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
|
||||
export async function getSkills(characterId: string, accessToken: string): Promise<Record<string, number> | undefined> {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(`https://esi.evetech.net/v4/characters/${characterId}/skills/`, {
|
||||
headers: {
|
||||
authorization: `Bearer ${accessToken}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (response.status !== 200) return undefined;
|
||||
|
||||
const data = await response.json();
|
||||
const skills: Record<string, number> = {};
|
||||
|
||||
for (const skill of data.skills) {
|
||||
skills[skill.skill_id] = skill.active_skill_level;
|
||||
}
|
||||
|
||||
return skills;
|
||||
}
|
||||
2
src/EsiProvider/index.ts
Normal file
2
src/EsiProvider/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { EsiContext, EsiProvider } from "./EsiProvider";
|
||||
export type { EsiCharacter, Esi } from "./EsiProvider";
|
||||
@@ -1,4 +1,6 @@
|
||||
export * from './DogmaEngineProvider';
|
||||
export * from './EsiCharacterSelection';
|
||||
export * from './EsiProvider';
|
||||
export * from './EveDataProvider';
|
||||
export * from './EveShipFitHash';
|
||||
export * from './EveShipFitLink';
|
||||
|
||||
Reference in New Issue
Block a user