├── .firebaserc
├── firestore.indexes.json
├── public
├── favicon.ico
├── preview.png
├── robots.txt
├── favicon-16x16.png
├── favicon-32x32.png
├── manifest.json
└── index.html
├── src
├── icons
│ ├── cursor.png
│ ├── ducats.png
│ ├── github.png
│ ├── icon.png
│ ├── credits.png
│ ├── paroxity.png
│ ├── x-icon.svg
│ ├── checkmark.svg
│ ├── placeholder-icon.svg
│ ├── discord.svg
│ └── framehub.svg
├── utils
│ ├── checklist-types.js
│ ├── hash.js
│ ├── item-filter.js
│ ├── time.js
│ ├── items.js
│ ├── mastery-rank.js
│ ├── profile.js
│ └── nodes.js
├── components
│ ├── LoadingScreen.jsx
│ ├── FrameHubLogo.jsx
│ ├── sidebar
│ │ ├── ExitButton.jsx
│ │ ├── UnloadWarning.jsx
│ │ ├── SaveStatus.jsx
│ │ ├── LogoutButton.jsx
│ │ ├── ConfirmationPrompt.jsx
│ │ ├── Social.jsx
│ │ ├── SharePrompt.jsx
│ │ ├── MasteryRankInfo.jsx
│ │ ├── Sidebar.jsx
│ │ ├── DangerZone.jsx
│ │ ├── SidebarInputs.jsx
│ │ ├── MasteryBreakdownTooltip.jsx
│ │ └── LinkPrompt.jsx
│ ├── login
│ │ ├── FormInput.jsx
│ │ ├── AdditionalActions.jsx
│ │ ├── LoginForm.jsx
│ │ ├── AlternativeLogin.jsx
│ │ └── LoginFormPopup.jsx
│ ├── GluedComponents.jsx
│ ├── Button.jsx
│ ├── checklist
│ │ ├── BaseCategoryInfo.jsx
│ │ ├── Checklist.jsx
│ │ ├── planets
│ │ │ ├── PlanetInfoTooltip.jsx
│ │ │ ├── PlanetChecklist.jsx
│ │ │ ├── Planet.jsx
│ │ │ ├── PlanetJunction.jsx
│ │ │ ├── PlanetInfo.jsx
│ │ │ └── PlanetNode.jsx
│ │ ├── Category.jsx
│ │ ├── ItemComponent.jsx
│ │ ├── ItemRelicTooltip.jsx
│ │ ├── CategoryInfo.jsx
│ │ ├── ItemGeneralInfoTooltip.jsx
│ │ └── CategoryItem.jsx
│ ├── Toggle.jsx
│ ├── NumberInput.jsx
│ ├── Tooltip.jsx
│ ├── foundry
│ │ └── MissingIngredients.jsx
│ ├── Select.jsx
│ └── PaginatedTooltip.jsx
├── index.js
├── index.css
├── pages
│ ├── Login.jsx
│ └── MasteryChecklist.jsx
├── hooks
│ ├── useLoginFormStore.js
│ └── useStore.js
├── App.jsx
└── App.scss
├── .gitignore
├── .github
├── ISSUE_TEMPLATE
│ ├── question.md
│ ├── suggestion.md
│ └── bug_report.md
├── PULL_REQUEST_TEMPLATE
│ ├── typo.md
│ ├── bug_fix.md
│ └── new_feature.md
└── workflows
│ ├── firebase-deploy-preview.yml
│ ├── firebase-deploy.yml
│ └── update-items.yml
├── firestore.rules
├── .editorconfig
├── .prettierrc.json
├── firebase.json
├── .eslintrc.json
├── README.md
├── package.json
├── updater
├── warframe_exports.mjs
├── update_nodes.mjs
└── update_items.mjs
└── LICENSE
/.firebaserc:
--------------------------------------------------------------------------------
1 | {
2 | "projects": {
3 | "default": "framehub-f9cfb"
4 | }
5 | }
--------------------------------------------------------------------------------
/firestore.indexes.json:
--------------------------------------------------------------------------------
1 | {
2 | "indexes": [],
3 | "fieldOverrides": []
4 | }
5 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Paroxity/FrameHub/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/public/preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Paroxity/FrameHub/HEAD/public/preview.png
--------------------------------------------------------------------------------
/src/icons/cursor.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Paroxity/FrameHub/HEAD/src/icons/cursor.png
--------------------------------------------------------------------------------
/src/icons/ducats.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Paroxity/FrameHub/HEAD/src/icons/ducats.png
--------------------------------------------------------------------------------
/src/icons/github.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Paroxity/FrameHub/HEAD/src/icons/github.png
--------------------------------------------------------------------------------
/src/icons/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Paroxity/FrameHub/HEAD/src/icons/icon.png
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/src/icons/credits.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Paroxity/FrameHub/HEAD/src/icons/credits.png
--------------------------------------------------------------------------------
/src/icons/paroxity.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Paroxity/FrameHub/HEAD/src/icons/paroxity.png
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules
2 | /build
3 | items.json
4 | .env
5 | .idea
6 | .eslintcache
7 | .firebase
--------------------------------------------------------------------------------
/public/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Paroxity/FrameHub/HEAD/public/favicon-16x16.png
--------------------------------------------------------------------------------
/public/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Paroxity/FrameHub/HEAD/public/favicon-32x32.png
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/question.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: 'Question'
3 | about: 'Ask a question.'
4 | title: ''
5 | labels: 'Type: Question'
6 | assignees: ''
7 | ---
--------------------------------------------------------------------------------
/src/utils/checklist-types.js:
--------------------------------------------------------------------------------
1 | export const AUTHENTICATED = "authenticated";
2 | export const ANONYMOUS = "anonymous";
3 | export const SHARED = "shared";
4 |
--------------------------------------------------------------------------------
/firestore.rules:
--------------------------------------------------------------------------------
1 | rules_version = '2';
2 | service cloud.firestore {
3 | match /databases/{database}/documents {
4 | match /{document=**} {
5 | allow read, write: if false;
6 | }
7 | }
8 | }
--------------------------------------------------------------------------------
/src/components/LoadingScreen.jsx:
--------------------------------------------------------------------------------
1 | function LoadingScreen() {
2 | return (
3 |
7 | );
8 | }
9 |
10 | export default LoadingScreen;
11 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*.{js,jsx,json}]
4 | indent_style = tab
5 | indent_size = 4
6 |
7 | [{.firebaserc,firestore.rules}]
8 | indent_style = tab
9 | indent_size = 4
10 |
11 | [*.{html,scss,css}]
12 | indent_style = tab
13 | indent_size = 2
14 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE/typo.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Typo
3 | about: 'Fix a typo.'
4 | title: ''
5 | labels: 'Type: Contribution'
6 | assignees: ''
7 | ---
8 |
9 | #### **What does the PR change?**
10 |
11 |
12 | #### **Extra Information**
--------------------------------------------------------------------------------
/src/components/FrameHubLogo.jsx:
--------------------------------------------------------------------------------
1 | import logo from "../icons/framehub.svg";
2 |
3 | function FrameHubLogo() {
4 | return (
5 | e.preventDefault()}
10 | />
11 | );
12 | }
13 |
14 | export default FrameHubLogo;
15 |
--------------------------------------------------------------------------------
/src/utils/hash.js:
--------------------------------------------------------------------------------
1 | function hashCode(str) {
2 | let h = 0;
3 | for (let i = 0; i < str.length; i++) {
4 | h = (h * 31 + str.charCodeAt(i)) | 0;
5 | }
6 | return h >>> 0;
7 | }
8 |
9 | function assignGroup(uid, groups) {
10 | return hashCode(uid) % groups;
11 | }
12 |
13 | export { hashCode, assignGroup };
14 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 80,
3 | "useTabs": true,
4 | "semi": true,
5 | "singleQuote": false,
6 | "quoteProps": "as-needed",
7 | "jsxSingleQuote": false,
8 | "trailingComma": "none",
9 | "bracketSpacing": true,
10 | "jsxBracketSameLine": true,
11 | "arrowParens": "avoid",
12 | "endOfLine": "crlf"
13 | }
14 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {createRoot} from "react-dom/client";
3 | import App from "./App";
4 | import "./index.css";
5 | import { enableMapSet } from "immer";
6 |
7 | enableMapSet();
8 |
9 | createRoot(document.getElementById("root")).render(
10 |
11 |
12 | ,
13 | );
14 |
--------------------------------------------------------------------------------
/src/utils/item-filter.js:
--------------------------------------------------------------------------------
1 | import { foundersItems, itemIsPrime } from "./items";
2 |
3 | export function isItemFiltered(
4 | itemName,
5 | item,
6 | { itemsMastered, hideMastered, hideFounders, hidePrime }
7 | ) {
8 | if (itemsMastered.has(itemName)) {
9 | return hideMastered;
10 | }
11 |
12 | return (
13 | (hideFounders && foundersItems.includes(itemName)) ||
14 | (hidePrime && itemIsPrime(itemName))
15 | );
16 | }
17 |
18 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/suggestion.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: 'Feature Suggestion'
3 | about: 'Suggest a feature that you would like added.'
4 | title: ''
5 | labels: 'Type: Enhancement'
6 | assignees: ''
7 | ---
8 |
9 |
10 |
11 |
12 | ### Description
13 |
14 |
15 | ### Extra Information
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
4 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
5 | sans-serif;
6 | -webkit-font-smoothing: antialiased;
7 | -moz-osx-font-smoothing: grayscale;
8 | background-color: #080615;
9 | }
10 |
11 | code {
12 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
13 | monospace;
14 | }
15 |
--------------------------------------------------------------------------------
/src/components/sidebar/ExitButton.jsx:
--------------------------------------------------------------------------------
1 | import { useNavigate } from "react-router-dom";
2 | import { useStore } from "../../hooks/useStore";
3 | import { SHARED } from "../../utils/checklist-types";
4 | import Button from "../Button";
5 |
6 | function ExitButton() {
7 | const navigate = useNavigate();
8 | const type = useStore(state => state.type);
9 | return type === SHARED ? (
10 | navigate("/")}>
11 | Exit
12 |
13 | ) : null;
14 | }
15 |
16 | export default ExitButton;
17 |
--------------------------------------------------------------------------------
/firebase.json:
--------------------------------------------------------------------------------
1 | {
2 | "firestore": {
3 | "rules": "firestore.rules",
4 | "indexes": "firestore.indexes.json"
5 | },
6 | "hosting": {
7 | "public": "build",
8 | "ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
9 | "rewrites": [
10 | {
11 | "source": "**",
12 | "destination": "/index.html"
13 | }
14 | ]
15 | },
16 | "emulators": {
17 | "firestore": {
18 | "port": 8080
19 | },
20 | "hosting": {
21 | "port": 5000
22 | },
23 | "ui": {
24 | "enabled": true
25 | }
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "node": true
5 | },
6 | "extends": ["eslint:recommended", "react-app", "prettier"],
7 | "parserOptions": {
8 | "ecmaFeatures": {
9 | "jsx": true
10 | },
11 | "ecmaVersion": 12,
12 | "sourceType": "module"
13 | },
14 | "plugins": [],
15 | "rules": {
16 | "linebreak-style": "off",
17 | "quotes": ["error", "double"],
18 | "semi": ["error", "always"],
19 | "no-empty": [
20 | "error",
21 | {
22 | "allowEmptyCatch": true
23 | }
24 | ]
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/components/sidebar/UnloadWarning.jsx:
--------------------------------------------------------------------------------
1 | import { useStore } from "../../hooks/useStore";
2 | import { SHARED } from "../../utils/checklist-types";
3 |
4 | function UnloadWarning() {
5 | const { type, changed } = useStore(state => ({
6 | type: state.type,
7 | changed: state.unsavedChanges.length > 0
8 | }));
9 |
10 | window.onbeforeunload =
11 | type !== SHARED && changed
12 | ? e => {
13 | e.preventDefault();
14 | e.returnValue = "";
15 | }
16 | : undefined;
17 |
18 | return null;
19 | }
20 |
21 | export default UnloadWarning;
22 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "FrameHub",
3 | "name": "Mastery Checklist for Warframe",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "type": "image/x-icon",
8 | "sizes": "48x48"
9 | },
10 | {
11 | "src": "favicon-32x32.png",
12 | "type": "image/png",
13 | "sizes": "32x32"
14 | },
15 | {
16 | "src": "favicon-16x16.png",
17 | "type": "image/png",
18 | "sizes": "16x16"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#373E5D",
24 | "background_color": "#000000"
25 | }
26 |
--------------------------------------------------------------------------------
/src/components/sidebar/SaveStatus.jsx:
--------------------------------------------------------------------------------
1 | import { useStore } from "../../hooks/useStore";
2 | import { SHARED } from "../../utils/checklist-types";
3 |
4 | function SaveStatus() {
5 | const { type, changed } = useStore(state => ({
6 | type: state.type,
7 | changed: state.unsavedChanges.length > 0
8 | }));
9 | return type !== SHARED ? (
10 |
11 | {changed
12 | ? "Your changes are unsaved."
13 | : "Changes auto-saved." +
14 | (window.navigator.onLine ? "" : " (OFFLINE)")}
15 |
16 | ) : null;
17 | }
18 |
19 | export default SaveStatus;
20 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: 'Bug Report'
3 | about: 'Report an unexpected behavior/typo.'
4 | title: ''
5 | labels: 'Type: Bug'
6 | assignees: ''
7 | ---
8 |
9 |
10 |
11 | ### Issue Description
12 |
13 | - Expected result: What were you expecting to happen?
14 | - Actual result: What actually happened?
15 |
16 | #### Steps to Reproduce the Issue
17 | 1. ...
18 | 2. ...
19 | 3. ...
20 |
21 | ### Browser & OS
22 | - Browser: Google Chrome v1.2.3
23 | - Device:
24 |
25 |
26 | ### Extra Information
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE/bug_fix.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug Fix
3 | about: 'Fix a bug.'
4 | title: ''
5 | labels: 'Type: Contribution, Type: Bug Fix'
6 | assignees: ''
7 | ---
8 |
9 | Please make sure your pull request complies with these guidelines:
10 | - * [ ] Uses the same formatting.
11 | - * [ ] Changes must have been tested, including image proof.
12 | - * [ ] Does not include multiple bug fixes.
13 |
14 | #### **What does the PR change?**
15 |
16 | #### **Testing Environment**
17 | - Browser: Google Chrome v1.2.3
18 | - Device:
19 |
20 |
21 | #### **Extra Information**
22 |
--------------------------------------------------------------------------------
/src/components/login/FormInput.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from "prop-types";
2 |
3 | function FormInput(props) {
4 | return (
5 |
6 |
7 | props.valueSetter(e.target.value)}
12 | />
13 |
14 |
15 | );
16 | }
17 |
18 | FormInput.propTypes = {
19 | type: PropTypes.string,
20 | placeholder: PropTypes.string,
21 | autoComplete: PropTypes.bool,
22 | valueSetter: PropTypes.func.isRequired
23 | };
24 |
25 | export default FormInput;
26 |
--------------------------------------------------------------------------------
/src/components/GluedComponents.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from "prop-types";
2 | import React from "react";
3 |
4 | function GluedComponents(props) {
5 | const children = props.children.filter(child => child !== undefined);
6 | return children.length === 0 ? null : (
7 |
8 | {children.map((child, i) => {
9 | return (
10 |
11 | {i === 0 ? "" : props.separator}
12 | {child}
13 |
14 | );
15 | })}
16 |
17 | );
18 | }
19 |
20 | GluedComponents.propTypes = {
21 | separator: PropTypes.string.isRequired,
22 | children: PropTypes.node.isRequired
23 | };
24 |
25 | export default GluedComponents;
26 |
--------------------------------------------------------------------------------
/src/components/sidebar/LogoutButton.jsx:
--------------------------------------------------------------------------------
1 | import { signOut } from "firebase/auth";
2 | import { auth } from "../../App";
3 | import { useStore } from "../../hooks/useStore";
4 | import { AUTHENTICATED } from "../../utils/checklist-types";
5 | import Button from "../Button";
6 |
7 | function LogoutButton() {
8 | const { type, saveImmediately } = useStore(state => ({
9 | type: state.type,
10 | saveImmediately: state.saveImmediate
11 | }));
12 | return type === AUTHENTICATED ? (
13 | {
16 | saveImmediately();
17 | signOut(auth);
18 | }}>
19 | Logout
20 |
21 | ) : null;
22 | }
23 |
24 | export default LogoutButton;
25 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE/new_feature.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Implement Feature
3 | about: 'Add a new feature.'
4 | title: ''
5 | labels: 'Type: Contribution, Type: Enhancement'
6 | assignees: ''
7 | ---
8 |
9 | Please make sure your pull request complies with these guidelines:
10 | - * [ ] Uses the same formatting.
11 | - * [ ] Changes must have been tested, including image proof.
12 | - * [ ] Does not include multiple features additions.
13 |
14 | #### **What does the PR add?**
15 |
16 | #### **Why should this be implemented?**
17 |
18 | #### **Testing Environment**
19 | - Browser: Google Chrome v1.2.3
20 | - Device:
21 |
22 |
23 | #### **Extra Information**
--------------------------------------------------------------------------------
/src/utils/time.js:
--------------------------------------------------------------------------------
1 | export function secondsToDuration(seconds) {
2 | const days = Math.floor(seconds / 86400);
3 | const hours = Math.floor((seconds % 86400) / 3600);
4 | const minutes = Math.floor((seconds % 3600) / 60);
5 | seconds = seconds % 60;
6 | return { days: days, hours: hours, minutes: minutes, seconds: seconds };
7 | }
8 |
9 | export function detailedTime(seconds) {
10 | const duration = secondsToDuration(seconds);
11 | let formattedString = "";
12 | ["Days", "Hours", "Minutes", "Seconds"].forEach(interval => {
13 | const amount = duration[interval.toLowerCase()];
14 | if (amount > 0) {
15 | formattedString +=
16 | amount +
17 | " " +
18 | interval.slice(0, amount > 1 ? interval.length : -1);
19 | }
20 | });
21 | return formattedString;
22 | }
23 |
--------------------------------------------------------------------------------
/src/components/sidebar/ConfirmationPrompt.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from "prop-types";
2 | import Button from "../Button";
3 |
4 | function ConfirmationPrompt({ message, callback, close }) {
5 | return callback ? (
6 |
7 |
8 |
{message}
9 |
{
12 | close();
13 | callback();
14 | }}
15 | >
16 | Confirm
17 |
18 |
close()}>
19 | Cancel
20 |
21 |
22 |
23 | ) : null;
24 | }
25 |
26 | ConfirmationPrompt.propTypes = {
27 | message: PropTypes.string,
28 | callback: PropTypes.func,
29 | close: PropTypes.func
30 | };
31 |
32 | export default ConfirmationPrompt;
33 |
--------------------------------------------------------------------------------
/src/components/Button.jsx:
--------------------------------------------------------------------------------
1 | import classNames from "classnames";
2 | import PropTypes from "prop-types";
3 |
4 | function Button(props) {
5 | return (
6 |
12 |
18 | {props.children}
19 |
20 |
21 | );
22 | }
23 |
24 | Button.propTypes = {
25 | centered: PropTypes.bool,
26 | disabled: PropTypes.bool,
27 | submit: PropTypes.bool,
28 | onClick: PropTypes.func,
29 | className: PropTypes.string,
30 | children: PropTypes.node
31 | };
32 |
33 | export default Button;
34 |
--------------------------------------------------------------------------------
/src/pages/Login.jsx:
--------------------------------------------------------------------------------
1 | import "firebase/auth";
2 | import "firebase/storage";
3 | import FrameHubLogo from "../components/FrameHubLogo";
4 | import AdditionalActions from "../components/login/AdditionalActions";
5 | import AlternativeLogin from "../components/login/AlternativeLogin";
6 | import LoginForm from "../components/login/LoginForm";
7 | import LoginFormPopup from "../components/login/LoginFormPopup";
8 |
9 | function Login() {
10 | return (
11 |
12 |
13 |
14 |
15 |
16 |
17 | FrameHub is not affiliated with Digital Extremes or Warframe.
18 |
19 |
20 |
21 | );
22 | }
23 |
24 | export default Login;
25 |
26 |
--------------------------------------------------------------------------------
/src/components/checklist/BaseCategoryInfo.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from "prop-types";
2 |
3 | function BaseCategoryInfo(props) {
4 | return (
5 |
6 | {props.name}
7 |
8 |
9 | {props.mastered}/{props.total}
10 |
11 |
12 |
13 | {props.masteredXP.toLocaleString()}/
14 | {props.totalXP.toLocaleString()} XP
15 |
16 |
17 | );
18 | }
19 |
20 | BaseCategoryInfo.propTypes = {
21 | name: PropTypes.string.isRequired,
22 | mastered: PropTypes.number.isRequired,
23 | total: PropTypes.number.isRequired,
24 | masteredXP: PropTypes.number.isRequired,
25 | totalXP: PropTypes.number.isRequired
26 | };
27 |
28 | export default BaseCategoryInfo;
29 |
--------------------------------------------------------------------------------
/src/components/sidebar/Social.jsx:
--------------------------------------------------------------------------------
1 | import discordIcon from "../../icons/discord.svg";
2 | import githubIcon from "../../icons/github.png";
3 | import paroxityIcon from "../../icons/paroxity.png";
4 |
5 | export default function Social() {
6 | return (
7 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/.github/workflows/firebase-deploy-preview.yml:
--------------------------------------------------------------------------------
1 | name: Build & Deploy Preview to Firebase
2 | on: pull_request
3 | jobs:
4 | build_and_preview:
5 | name: Build & Deploy
6 | runs-on: ubuntu-latest
7 | steps:
8 | - name: Checkout Repo
9 | uses: actions/checkout@v4
10 | - name: Setup pnpm
11 | uses: pnpm/action-setup@v4
12 | with:
13 | version: 9.0.0
14 | run_install: true
15 | - name: Build
16 | run: pnpm run build
17 | - name: Deploy to Firebase
18 | uses: FirebaseExtended/action-hosting-deploy@v0
19 | with:
20 | repoToken: '${{ secrets.GITHUB_TOKEN }}'
21 | firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT_FRAMEHUB_F9CFB }}'
22 | projectId: framehub-f9cfb
23 | env:
24 | FIREBASE_CLI_PREVIEWS: hostingchannels
--------------------------------------------------------------------------------
/src/icons/x-icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
7 |
8 |
9 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/src/components/checklist/Checklist.jsx:
--------------------------------------------------------------------------------
1 | import Masonry from "react-masonry-css";
2 | import { useStore } from "../../hooks/useStore";
3 | import Category from "./Category";
4 | import { isItemFiltered } from "../../utils/item-filter";
5 |
6 | function Checklist() {
7 | const visibleColumns = useStore(state =>
8 | Object.keys(state.items).filter(category => {
9 | return (
10 | Object.entries(state.items[category]).some(
11 | ([itemName, item]) => !isItemFiltered(itemName, item, state)
12 | )
13 | );
14 | })
15 | );
16 |
17 | return (
18 |
28 | {visibleColumns.map(category => {
29 | return ;
30 | })}
31 |
32 | );
33 | }
34 |
35 | export default Checklist;
36 |
37 |
--------------------------------------------------------------------------------
/src/icons/checkmark.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
7 |
8 |
9 |
10 |
11 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/src/components/checklist/planets/PlanetInfoTooltip.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from "prop-types";
2 | import {
3 | factionIndexMap,
4 | missionIndexMap,
5 | nodeShape
6 | } from "../../../utils/nodes";
7 | import { PaginatedTooltipTitle } from "../../PaginatedTooltip";
8 |
9 | function PlanetInfoTooltip({ node }) {
10 | return (
11 |
12 |
13 |
14 | Level {node.lvl.join("-")}{" "}
15 |
22 | {factionIndexMap[node.faction]} {missionIndexMap[node.type]}
23 |
24 |
{node.xp ?? 0} XP
25 |
26 | );
27 | }
28 |
29 | PlanetInfoTooltip.propTypes = {
30 | node: PropTypes.shape(nodeShape).isRequired
31 | };
32 |
33 | export default PlanetInfoTooltip;
34 |
--------------------------------------------------------------------------------
/src/utils/items.js:
--------------------------------------------------------------------------------
1 | import PropTypes from "prop-types";
2 |
3 | export const SCHEMA_VERSION = 3;
4 |
5 | export const foundersItems = ["Excalibur Prime", "Skana Prime", "Lato Prime"];
6 |
7 | export function itemIsPrime(name) {
8 | return (
9 | (name.endsWith(" Prime") || name.startsWith("Prime ")) &&
10 | !foundersItems.includes(name)
11 | );
12 | }
13 |
14 | export function getComponentImageUrl(id) {
15 | return (
16 | "https://cdn.jsdelivr.net/gh/Aericio/warframe-exports-data/image/32x32/" +
17 | id.slice(1).replaceAll("/", ".") +
18 | ".png"
19 | );
20 | }
21 |
22 | export const relicTiers = ["Lith", "Meso", "Neo", "Axi", "Requiem", "Vanguard"];
23 | export const relicRarity = ["Common", "Uncommon", "Rare"];
24 |
25 | export const itemShape = {
26 | maxLvl: PropTypes.number,
27 | mr: PropTypes.number,
28 | wiki: PropTypes.string,
29 | vaulted: PropTypes.bool,
30 | relics: PropTypes.objectOf(
31 | PropTypes.objectOf(
32 | PropTypes.shape({
33 | vaulted: PropTypes.bool,
34 | rarity: PropTypes.number.isRequired
35 | })
36 | )
37 | ),
38 | baro: PropTypes.arrayOf(PropTypes.number),
39 | description: PropTypes.string
40 | };
41 |
42 |
--------------------------------------------------------------------------------
/src/components/login/AdditionalActions.jsx:
--------------------------------------------------------------------------------
1 | import { sendPasswordResetEmail } from "firebase/auth";
2 | import { shallow } from "zustand/shallow";
3 | import { auth } from "../../App";
4 | import { useLoginFormStore } from "../../hooks/useLoginFormStore";
5 | import Button from "../Button";
6 |
7 | function AdditionalActions() {
8 | const { email, setError, displayError, signUp, setSignUp } =
9 | useLoginFormStore(
10 | state => ({
11 | email: state.email,
12 | setError: state.setError,
13 | displayError: state.displayError,
14 | signUp: state.signUp,
15 | setSignUp: state.setSignUp
16 | }),
17 | shallow
18 | );
19 |
20 | return (
21 |
22 |
24 | sendPasswordResetEmail(auth, email)
25 | .then(() => setError("Email sent. Check your inbox."))
26 | .catch(e => setError(e.code))
27 | .finally(() => displayError(true))
28 | }
29 | >
30 | Forgot Password
31 |
32 | setSignUp(!signUp)}>
33 | {signUp ? "Login" : "Sign up"}
34 |
35 |
36 | );
37 | }
38 |
39 | export default AdditionalActions;
40 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # FrameHub
2 | [FrameHub](https://framehub.paroxity.net/) is a mastery checklist for [Warframe](https://warframe.com/).
3 |
4 | ## Features
5 | - Calculates Mastery Rank & XP
6 | - Includes Mission, Junction, & Intrinsics XP
7 | - Ease of use
8 | - Tooltip displays information on item, including crafting costs & time
9 | - Items locked behind MR are greyed out
10 | - `Ctrl + Left Click` for quick access to wiki pages
11 | - Responsive & mobile friendly design
12 | - Lists all crafting ingredients required
13 |
14 | ## License
15 | ```
16 | Copyright 2020 Paroxity
17 |
18 | Licensed under the Apache License, Version 2.0 (the "License");
19 | you may not use this file except in compliance with the License.
20 | You may obtain a copy of the License at
21 |
22 | http://www.apache.org/licenses/LICENSE-2.0
23 |
24 | Unless required by applicable law or agreed to in writing, software
25 | distributed under the License is distributed on an "AS IS" BASIS,
26 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
27 | See the License for the specific language governing permissions and
28 | limitations under the License.
29 | ```
--------------------------------------------------------------------------------
/.github/workflows/firebase-deploy.yml:
--------------------------------------------------------------------------------
1 | name: Build & Deploy to Firebase
2 | on:
3 | push:
4 | branches:
5 | - main
6 | jobs:
7 | build_and_deploy:
8 | name: Build & Deploy
9 | runs-on: ubuntu-latest
10 | if: "!contains(github.event.head_commit.message, 'ci skip')"
11 | steps:
12 | - name: Checkout Repo
13 | uses: actions/checkout@v4
14 | - name: Cache pnpm Modules
15 | uses: actions/cache@v4
16 | with:
17 | path: ~/.local/share/pnpm/store
18 | key: pnpm-cache-${{ hashFiles('pnpm-lock.yaml') }}
19 | restore-keys: |
20 | pnpm-cache-
21 | - name: Setup pnpm
22 | uses: pnpm/action-setup@v4
23 | with:
24 | version: 9.0.0
25 | run_install: true
26 | - name: Build
27 | run: pnpm run build
28 | - name: Deploy to Firebase
29 | uses: FirebaseExtended/action-hosting-deploy@v0
30 | with:
31 | repoToken: '${{ secrets.GITHUB_TOKEN }}'
32 | firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT_FRAMEHUB_F9CFB }}'
33 | channelId: live
34 | projectId: framehub-f9cfb
35 | env:
36 | FIREBASE_CLI_PREVIEWS: hostingchannels
--------------------------------------------------------------------------------
/src/icons/placeholder-icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/src/components/login/LoginForm.jsx:
--------------------------------------------------------------------------------
1 | import { shallow } from "zustand/shallow";
2 | import { useLoginFormStore } from "../../hooks/useLoginFormStore";
3 | import Button from "../Button";
4 | import FormInput from "./FormInput";
5 |
6 | function LoginForm() {
7 | const { handleSubmit, setEmail, setPassword, setConfirmPassword, signUp } =
8 | useLoginFormStore(
9 | state => ({
10 | handleSubmit: state.handleSubmit,
11 | setEmail: state.setEmail,
12 | setPassword: state.setPassword,
13 | setConfirmPassword: state.setConfirmPassword,
14 | signUp: state.signUp
15 | }),
16 | shallow
17 | );
18 |
19 | return (
20 |
40 | );
41 | }
42 |
43 | export default LoginForm;
44 |
--------------------------------------------------------------------------------
/src/components/checklist/planets/PlanetChecklist.jsx:
--------------------------------------------------------------------------------
1 | import Masonry from "react-masonry-css";
2 | import { useStore } from "../../../hooks/useStore";
3 | import nodes from "../../../resources/nodes.json";
4 | import { planetJunctionsMap } from "../../../utils/nodes";
5 | import Planet from "./Planet";
6 |
7 | function PlanetChecklist() {
8 | const visiblePlanets = useStore(state =>
9 | Object.keys(nodes).filter(planet => {
10 | return (
11 | !state.hideMastered ||
12 | !Object.keys(nodes[planet]).every(id =>
13 | state[
14 | state.displayingSteelPath ? "steelPath" : "starChart"
15 | ].has(id)
16 | ) ||
17 | (planetJunctionsMap[planet] &&
18 | !state[
19 | (state.displayingSteelPath
20 | ? "steelPath"
21 | : "starChart") + "Junctions"
22 | ].has(planet))
23 | );
24 | })
25 | );
26 |
27 | return (
28 |
38 | {visiblePlanets.map(planet => {
39 | return ;
40 | })}
41 |
42 | );
43 | }
44 |
45 | export default PlanetChecklist;
46 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "framehub",
3 | "version": "0.1.0",
4 | "dependencies": {
5 | "classnames": "^2.5.1",
6 | "firebase": "^11.2.0",
7 | "immer": "^10.1.1",
8 | "lua-json": "^1.0.1",
9 | "prop-types": "^15.8.1",
10 | "react": "^18.3.1",
11 | "react-dom": "^18.3.1",
12 | "react-masonry-css": "^1.0.16",
13 | "react-router-dom": "^6.27.0",
14 | "react-scripts": "^5.0.1",
15 | "sass": "^1.84.0",
16 | "use-sync-external-store": "^1.5.0",
17 | "zustand": "^5.0.3"
18 | },
19 | "devDependencies": {
20 | "@actions/core": "^1.11.1",
21 | "eslint": "^8.55.0",
22 | "eslint-config-prettier": "^9.1.0",
23 | "eslint-plugin-react": "7.34.4",
24 | "form-data": "^4.0.1",
25 | "jsdom": "^26.0.0",
26 | "json-diff": "^1.0.6",
27 | "lzma": "^2.3.2",
28 | "node-fetch": "^3.3.2",
29 | "prettier": "^3.4.2"
30 | },
31 | "scripts": {
32 | "start": "react-scripts start",
33 | "build": "react-scripts build",
34 | "test": "react-scripts test",
35 | "eject": "react-scripts eject"
36 | },
37 | "browserslist": {
38 | "production": [
39 | ">0.2%",
40 | "not dead",
41 | "not op_mini all"
42 | ],
43 | "development": [
44 | "last 1 chrome version",
45 | "last 1 firefox version",
46 | "last 1 safari version"
47 | ]
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/updater/warframe_exports.mjs:
--------------------------------------------------------------------------------
1 | import fetch from "node-fetch";
2 | import lzma from "lzma";
3 |
4 | const CONTENT_URL = "https://content.warframe.com";
5 | const ORIGIN_URL =
6 | process.env.WARFRAME_ORIGIN_PROXY ?? "https://origin.warframe.com";
7 |
8 | let endpoints;
9 |
10 | function parseDamagedJSON(json) {
11 | return JSON.parse(json.replace(/\\\"/g, "'").replace(/\n|\r|\\/g, ""));
12 | }
13 |
14 | async function fetchEndpoints() {
15 | const headers = {};
16 |
17 | if (process.env.X_PROXY_TOKEN) {
18 | headers["X-Proxy-Token"] = process.env.X_PROXY_TOKEN;
19 | }
20 |
21 | const response = await fetch(`${ORIGIN_URL}/PublicExport/index_en.txt.lzma`, {
22 | headers
23 | });
24 |
25 | if (!response.ok) {
26 | throw new Error(
27 | `Failed to fetch endpoints: ${response.status} ${response.statusText}`
28 | );
29 | }
30 |
31 | endpoints = lzma
32 | .decompress(Buffer.from(await response.arrayBuffer()))
33 | .split("\n");
34 | }
35 |
36 | export async function fetchEndpoint(endpoint) {
37 | if (!endpoints) {
38 | await fetchEndpoints();
39 | }
40 |
41 | return parseDamagedJSON(
42 | await (
43 | await fetch(
44 | `${CONTENT_URL}/PublicExport/Manifest/${endpoints.find(e =>
45 | e.startsWith(`Export${endpoint}`)
46 | )}`
47 | )
48 | ).text()
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/src/components/login/AlternativeLogin.jsx:
--------------------------------------------------------------------------------
1 | import { GoogleAuthProvider, signInWithPopup } from "firebase/auth";
2 | import { addDoc, collection } from "firebase/firestore";
3 | import { useNavigate } from "react-router-dom";
4 | import { auth, firestore } from "../../App";
5 | import Button from "../Button";
6 |
7 | function AlternativeLogin() {
8 | const navigate = useNavigate();
9 |
10 | return (
11 |
12 | signInWithPopup(auth, new GoogleAuthProvider())}
14 | >
15 | Sign in with Google
16 |
17 | {
19 | navigate(
20 | `/user/${
21 | (
22 | await addDoc(
23 | collection(
24 | firestore,
25 | "anonymousMasteryData"
26 | ),
27 | {
28 | hideFounders: true,
29 | hideMastered: false,
30 | mastered: [],
31 | partiallyMastered: {},
32 | intrinsics: 0,
33 | starChart: [],
34 | steelPath: [],
35 | starChartJunctions: [],
36 | steelPathJunctions: []
37 | }
38 | )
39 | ).id
40 | }`
41 | );
42 | }}
43 | >
44 | Sign in anonymously
45 |
46 |
47 | );
48 | }
49 |
50 | export default AlternativeLogin;
51 |
--------------------------------------------------------------------------------
/src/components/login/LoginFormPopup.jsx:
--------------------------------------------------------------------------------
1 | import classNames from "classnames";
2 | import { shallow } from "zustand/shallow";
3 | import { useLoginFormStore } from "../../hooks/useLoginFormStore";
4 | import Button from "../Button";
5 |
6 | const errorMessages = {
7 | "auth/invalid-email": "Login failed. Invalid email address format.",
8 | "auth/wrong-password": "Login failed. Check your info.",
9 | "auth/user-not-found": "No user matching that email address.",
10 | "auth/too-many-requests":
11 | "You've been sending too many requests! Try again in a few seconds.",
12 | "auth/email-already-in-use":
13 | "There is already a registered user with this email.",
14 | "auth/weak-password":
15 | "This password is too weak. Make sure it is at least 6 characters in length."
16 | };
17 |
18 | function LoginFormPopup() {
19 | const { displayError, error, errorDisplayed } = useLoginFormStore(
20 | state => ({
21 | displayError: state.displayError,
22 | error: state.error,
23 | errorDisplayed: state.errorDisplayed
24 | }),
25 | shallow
26 | );
27 | return (
28 |
29 |
30 | {errorMessages[error] || error}
31 | displayError(false)}>
32 | Ok
33 |
34 |
35 |
36 | );
37 | }
38 |
39 | export default LoginFormPopup;
40 |
--------------------------------------------------------------------------------
/src/components/checklist/Category.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from "prop-types";
2 | import { useState } from "react";
3 | import { useStore } from "../../hooks/useStore";
4 | import Toggle from "../Toggle";
5 | import CategoryInfo from "./CategoryInfo";
6 | import CategoryItem from "./CategoryItem";
7 |
8 | function Category({ name }) {
9 | const [visible, setVisible] = useState(
10 | localStorage.getItem("categoryShown")
11 | ? JSON.parse(localStorage.getItem("categoryShown"))[name] ?? true
12 | : true
13 | );
14 | const categoryItems = useStore(state => state.items[name]);
15 |
16 | return (
17 |
18 |
19 | {
22 | const categoryShown =
23 | JSON.parse(localStorage.getItem("categoryShown")) || {};
24 | categoryShown[name] = value;
25 | localStorage.setItem(
26 | "categoryShown",
27 | JSON.stringify(categoryShown)
28 | );
29 | setVisible(value);
30 | }}
31 | />
32 | {visible &&
33 | Object.entries(categoryItems).map(([itemName, item]) => {
34 | return (
35 |
40 | );
41 | })}
42 |
43 | );
44 | }
45 |
46 | Category.propTypes = {
47 | name: PropTypes.string.isRequired
48 | };
49 |
50 | export default Category;
51 |
--------------------------------------------------------------------------------
/src/components/sidebar/SharePrompt.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from "prop-types";
2 | import { useRef, useState } from "react";
3 | import { useStore } from "../../hooks/useStore";
4 | import Button from "../Button";
5 | import { createPortal } from "react-dom";
6 |
7 | function SharePrompt() {
8 | const id = useStore(state => state.id);
9 | const [showLink, setShowLink] = useState(false);
10 | const showLinkRef = useRef(null);
11 |
12 | return <>
13 | setShowLink(true)}>
14 | Share
15 |
16 | {showLink && createPortal(
17 |
18 | Here's your sharable link:
19 |
20 |
26 |
27 | {document.queryCommandSupported("copy") && (
28 |
{
31 | showLinkRef.current.select();
32 | document.execCommand("copy");
33 | setShowLink(false);
34 | }}
35 | >
36 | Copy
37 |
38 | )}
39 |
setShowLink(false)}>
40 | Close
41 |
42 |
43 |
, document.body)}
44 | >;
45 | }
46 |
47 | SharePrompt.propTypes = {
48 | showLink: PropTypes.bool,
49 | setShowLink: PropTypes.func
50 | };
51 |
52 | export default SharePrompt;
53 |
--------------------------------------------------------------------------------
/src/components/checklist/planets/Planet.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from "prop-types";
2 | import { useState } from "react";
3 | import nodes from "../../../resources/nodes.json";
4 | import { planetJunctionsMap } from "../../../utils/nodes";
5 | import Toggle from "../../Toggle";
6 | import PlanetInfo from "./PlanetInfo";
7 | import PlanetJunction from "./PlanetJunction";
8 | import PlanetNode from "./PlanetNode";
9 |
10 | function Planet({ name }) {
11 | const [visible, setVisible] = useState(
12 | localStorage.getItem("planetShown")
13 | ? JSON.parse(localStorage.getItem("planetShown"))[name] ?? true
14 | : true
15 | );
16 | const planetNodes = nodes[name];
17 |
18 | return (
19 |
20 |
21 |
{
24 | const planetShown =
25 | JSON.parse(localStorage.getItem("planetShown")) || {};
26 | planetShown[name] = value;
27 | localStorage.setItem(
28 | "planetShown",
29 | JSON.stringify(planetShown)
30 | );
31 | setVisible(value);
32 | }}
33 | />
34 | {visible && (
35 | <>
36 | {planetJunctionsMap[name] && (
37 |
38 | )}
39 | {Object.entries(planetNodes).map(([id, node]) => {
40 | return ;
41 | })}
42 | >
43 | )}
44 |
45 | );
46 | }
47 |
48 | Planet.propTypes = {
49 | name: PropTypes.string.isRequired
50 | };
51 |
52 | export default Planet;
53 |
--------------------------------------------------------------------------------
/src/components/Toggle.jsx:
--------------------------------------------------------------------------------
1 | import classNames from "classnames";
2 | import PropTypes from "prop-types";
3 | import checkmark from "../icons/checkmark.svg";
4 | import placeholderIcon from "../icons/placeholder-icon.svg";
5 | import xIcon from "../icons/x-icon.svg";
6 |
7 | function Toggle(props) {
8 | const onToggle = () => props.onToggle(!props.toggled);
9 |
10 | return (
11 |
12 |
13 |
e.preventDefault()}
15 | src={props.toggled ? checkmark : placeholderIcon}
16 | alt=""
17 | />
18 |
19 |
20 |
e.preventDefault()}
22 | src={props.toggled ? placeholderIcon : xIcon}
23 | alt=""
24 | />
25 |
26 |
27 | );
28 | }
29 |
30 | Toggle.propTypes = {
31 | toggled: PropTypes.bool,
32 | disabled: PropTypes.bool,
33 | onToggle: PropTypes.func
34 | };
35 |
36 | export default Toggle;
37 |
38 | export function LabeledToggle(props) {
39 | return (
40 |
41 | {props.label}
42 |
43 |
44 | );
45 | }
46 |
47 | LabeledToggle.propTypes = {
48 | className: PropTypes.string,
49 | label: PropTypes.string,
50 | toggled: PropTypes.bool,
51 | disabled: PropTypes.bool,
52 | onToggle: PropTypes.func
53 | };
54 |
--------------------------------------------------------------------------------
/src/components/sidebar/MasteryRankInfo.jsx:
--------------------------------------------------------------------------------
1 | import { useStore } from "../../hooks/useStore";
2 | import { masteryRankName, mrToXP } from "../../utils/mastery-rank";
3 | import MasteryBreakdownTooltip from "./MasteryBreakdownTooltip";
4 |
5 | function MasteryRankInfo() {
6 | const { itemsMasteredCount, totalItems, xp, totalXP, masteryRank } =
7 | useStore(state => ({
8 | itemsMasteredCount: state.itemsMasteredCount,
9 | totalItems: state.totalItems,
10 | xp: state.xp,
11 | totalXP: state.totalXP,
12 | masteryRank: state.masteryRank
13 | }));
14 |
15 | return (
16 |
17 | {`Mastery Rank ${masteryRank}`} {" "}
18 |
19 | {itemsMasteredCount.toLocaleString()}/
20 | {totalItems.toLocaleString()} Mastered
21 |
22 |
23 | {Math.floor(xp).toLocaleString()}/
24 | {Math.floor(totalXP).toLocaleString()} XP
25 |
26 |
31 |
32 | Next Rank:{" "}
33 |
34 | {masteryRankName(masteryRank + 1).toUpperCase()}
35 | {" "}
36 | in{" "}
37 |
38 | {Math.ceil(mrToXP(masteryRank + 1) - xp).toLocaleString()}
39 |
40 |
41 |
42 | );
43 | }
44 |
45 | export default MasteryRankInfo;
46 |
--------------------------------------------------------------------------------
/src/components/NumberInput.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from "prop-types";
2 | import React from "react";
3 | import Tooltip from "./Tooltip";
4 |
5 | function NumberInput(props) {
6 | return (
7 |
8 |
{props.name}
9 |
10 |
11 |
12 | {
19 | let newValue = Math.max(
20 | props.min,
21 | Math.min(
22 | parseInt(e.target.value || 0),
23 | props.max
24 | )
25 | );
26 | if (isNaN(newValue))
27 | newValue = parseInt(props.value);
28 | if (
29 | newValue !== parseInt(props.value) &&
30 | props.onChange &&
31 | !props.disabled
32 | )
33 | props.onChange(newValue);
34 | }}
35 | />
36 |
37 |
38 |
39 |
40 | );
41 | }
42 |
43 | NumberInput.propTypes = {
44 | name: PropTypes.string.isRequired,
45 | disabled: PropTypes.bool,
46 | min: PropTypes.number.isRequired,
47 | max: PropTypes.number.isRequired,
48 | value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
49 | onChange: PropTypes.func,
50 | tooltipTitle: PropTypes.string,
51 | tooltipContent: PropTypes.oneOfType([PropTypes.string, PropTypes.node])
52 | };
53 |
54 | export default NumberInput;
55 |
--------------------------------------------------------------------------------
/src/components/checklist/planets/PlanetJunction.jsx:
--------------------------------------------------------------------------------
1 | import classNames from "classnames";
2 | import PropTypes from "prop-types";
3 | import { useStore } from "../../../hooks/useStore";
4 | import checkmark from "../../../icons/checkmark.svg";
5 | import { SHARED } from "../../../utils/checklist-types";
6 | import Button from "../../Button";
7 |
8 | function PlanetJunction({ planet }) {
9 | const { readOnly, displayingSteelPath, masterJunction, mastered, hidden } =
10 | useStore(state => ({
11 | readOnly: (state.type === SHARED || state.gameSyncId !== undefined),
12 | displayingSteelPath: state.displayingSteelPath,
13 | masterJunction: state.masterJunction,
14 | mastered:
15 | state[
16 | (state.displayingSteelPath ? "steelPath" : "starChart") +
17 | "Junctions"
18 | ].has(planet),
19 | hidden:
20 | state.hideMastered &&
21 | state[
22 | (state.displayingSteelPath ? "steelPath" : "starChart") +
23 | "Junctions"
24 | ].has(planet)
25 | }));
26 | return hidden ? null : (
27 |
31 |
{
34 | if (!readOnly) {
35 | masterJunction(planet, displayingSteelPath, !mastered);
36 | }
37 | }}>
38 | Junction
39 | {mastered && (
40 |
41 | )}
42 |
43 |
44 | );
45 | }
46 |
47 | PlanetJunction.propTypes = {
48 | planet: PropTypes.string.isRequired
49 | };
50 |
51 | export default PlanetJunction;
52 |
--------------------------------------------------------------------------------
/src/components/checklist/planets/PlanetInfo.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from "prop-types";
2 | import { useStore } from "../../../hooks/useStore";
3 | import nodes from "../../../resources/nodes.json";
4 | import { junctionsToXP } from "../../../utils/mastery-rank";
5 | import { planetJunctionsMap } from "../../../utils/nodes";
6 | import BaseCategoryInfo from "../BaseCategoryInfo";
7 |
8 | function PlanetInfo({ name }) {
9 | const hasJunction = planetJunctionsMap[name] !== undefined;
10 |
11 | let masteredCount = 0;
12 | let masteredXP = 0;
13 | let totalCount = hasJunction ? 1 : 0;
14 | let totalXP = hasJunction ? junctionsToXP(1) : 0;
15 |
16 | const { nodesMastered, junctionMastered } = useStore(state => ({
17 | nodesMastered: state[
18 | state.displayingSteelPath ? "steelPath" : "starChart"
19 | ],
20 | junctionMastered:
21 | hasJunction &&
22 | state[
23 | (state.displayingSteelPath ? "steelPath" : "starChart") +
24 | "Junctions"
25 | ].has(name)
26 | }));
27 | Object.entries(nodes[name]).forEach(([id, node]) => {
28 | totalCount++;
29 | totalXP += node.xp ?? 0;
30 | if (nodesMastered.has(id)) {
31 | masteredCount++;
32 | masteredXP += node.xp ?? 0;
33 | }
34 | });
35 | if (junctionMastered) {
36 | masteredCount++;
37 | masteredXP += junctionsToXP(1);
38 | }
39 |
40 | return (
41 |
48 | );
49 | }
50 |
51 | PlanetInfo.propTypes = {
52 | name: PropTypes.string.isRequired
53 | };
54 |
55 | export default PlanetInfo;
56 |
--------------------------------------------------------------------------------
/src/components/checklist/ItemComponent.jsx:
--------------------------------------------------------------------------------
1 | import classNames from "classnames";
2 | import PropTypes from "prop-types";
3 | import { getComponentImageUrl } from "../../utils/items";
4 | import { useStore } from "../../hooks/useStore";
5 |
6 | export default function ItemComponent({
7 | componentName,
8 | componentCount,
9 | isSubcomponent
10 | }) {
11 | const componentRecipe = useStore(state => state.recipes[componentName]);
12 | const ingredientId = useStore(state => state.ingredientIds[componentName]);
13 |
14 | return (
15 |
16 |
17 |
23 |
24 | {componentCount.toLocaleString()}x {componentName}
25 |
26 |
27 | {componentRecipe?.components &&
28 | Object.entries(componentRecipe.components).map(
29 | ([subcomponentName, subcomponentCount]) => {
30 | return (
31 |
42 | );
43 | }
44 | )}
45 |
46 | );
47 | }
48 |
49 | ItemComponent.propTypes = {
50 | componentName: PropTypes.string.isRequired,
51 | componentCount: PropTypes.number.isRequired,
52 | isSubcomponent: PropTypes.bool
53 | };
54 |
55 |
--------------------------------------------------------------------------------
/src/hooks/useLoginFormStore.js:
--------------------------------------------------------------------------------
1 | import {
2 | createUserWithEmailAndPassword,
3 | signInWithEmailAndPassword
4 | } from "firebase/auth";
5 | import { auth } from "../App";
6 | import { createWithEqualityFn } from "zustand/traditional";
7 | import { shallow } from "zustand/shallow";
8 |
9 | export const useLoginFormStore = createWithEqualityFn(
10 | (set, get) => ({
11 | email: "",
12 | setEmail: email => set(() => ({ email })),
13 | password: "",
14 | setPassword: password => set(() => ({ password })),
15 | confirmPassword: "",
16 | setConfirmPassword: confirmPassword => set(() => ({ confirmPassword })),
17 | signUp: false,
18 | setSignUp: signUp => set(() => ({ signUp })),
19 | error: "",
20 | setError: error => set(() => ({ error })),
21 | errorDisplayed: false,
22 | displayError: displayError =>
23 | set(() => ({ errorDisplayed: displayError })),
24 | handleSubmit: event => {
25 | event.preventDefault();
26 |
27 | const { signUp, email, password, confirmPassword } = get();
28 | if (signUp) {
29 | if (password !== confirmPassword) {
30 | set({
31 | error: "Passwords do not match.",
32 | errorDisplayed: true
33 | });
34 | return;
35 | }
36 | createUserWithEmailAndPassword(auth, email, password)
37 | .then(() =>
38 | set({
39 | signUp: false,
40 | password: "",
41 | confirmPassword: "",
42 | email: ""
43 | })
44 | )
45 | .catch(e => set({ error: e.code, errorDisplayed: true }));
46 | return;
47 | }
48 | signInWithEmailAndPassword(auth, email, password)
49 | .then(() =>
50 | set({ password: "", confirmPassword: "", email: "" })
51 | )
52 | .catch(e => set({ error: e.code, errorDisplayed: true }));
53 | }
54 | }),
55 | shallow
56 | );
57 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Warframe Mastery Checklist | FrameHub
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
29 |
30 |
31 |
32 | You need to enable JavaScript to run this app.
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/updater/update_nodes.mjs:
--------------------------------------------------------------------------------
1 | import fs from "fs/promises";
2 | import { fetchEndpoint } from "./warframe_exports.mjs";
3 |
4 | const existingNodes = JSON.parse(
5 | await fs.readFile("src/resources/nodes.json", "utf-8")
6 | );
7 |
8 | const rawNodes = (await fetchEndpoint("Regions")).ExportRegions;
9 | const newNodes = {
10 | Earth: {},
11 | Venus: {},
12 | Mercury: {},
13 | Mars: {},
14 | Deimos: {},
15 | Phobos: {},
16 | Ceres: {},
17 | Jupiter: {},
18 | Europa: {},
19 | Saturn: {},
20 | Uranus: {},
21 | Neptune: {},
22 | Pluto: {},
23 | Eris: {},
24 | Sedna: {},
25 | Lua: {},
26 | "Kuva Fortress": {},
27 | Zariman: {},
28 | Duviri: {},
29 | Void: {},
30 | "Höllvania": {}
31 | };
32 |
33 | // ExportRegions has incorrect node names for Höllvania
34 | const hollvaniaNodeNames = {
35 | "SolNode850": "Köbinn West",
36 | "SolNode851": "Mischta Ramparts",
37 | "SolNode852": "Old Konderuk",
38 | "SolNode853": "Mausoleum East",
39 | "SolNode854": "Rhu Manor",
40 | "SolNode855": "Lower Vehrvod",
41 | "SolNode856": "Victory Plaza",
42 | "SolNode857": "Vehrvod District",
43 | }
44 |
45 | rawNodes.forEach(rawNode => {
46 | if (!newNodes[rawNode.systemName]) {
47 | throw new Error("Unknown Planet: " + rawNode.systemName);
48 | }
49 | if (!existingNodes[rawNode.systemName]?.[rawNode.uniqueName]) {
50 | console.log("New Node: " + JSON.stringify(rawNode));
51 | }
52 |
53 | newNodes[rawNode.systemName][rawNode.uniqueName] = {
54 | name: hollvaniaNodeNames[rawNode.uniqueName] ?? rawNode.name,
55 | type: rawNode.missionIndex,
56 | faction: rawNode.factionIndex,
57 | lvl: [rawNode.minEnemyLevel, rawNode.maxEnemyLevel],
58 | xp: existingNodes[rawNode.systemName]?.[rawNode.uniqueName]?.xp
59 | };
60 | });
61 |
62 | await fs.writeFile(
63 | "src/resources/nodes.json",
64 | JSON.stringify(newNodes, undefined, "\t")
65 | );
66 |
--------------------------------------------------------------------------------
/src/components/checklist/planets/PlanetNode.jsx:
--------------------------------------------------------------------------------
1 | import classNames from "classnames";
2 | import PropTypes from "prop-types";
3 | import { useStore } from "../../../hooks/useStore";
4 | import checkmark from "../../../icons/checkmark.svg";
5 | import { SHARED } from "../../../utils/checklist-types";
6 | import { nodeShape } from "../../../utils/nodes";
7 | import Button from "../../Button";
8 | import PaginatedTooltip from "../../PaginatedTooltip";
9 | import PlanetInfoTooltip from "./PlanetInfoTooltip";
10 |
11 | function PlanetNode({ id, node }) {
12 | const { readOnly, displayingSteelPath, masterNode, mastered, hidden } =
13 | useStore(state => ({
14 | readOnly: (state.type === SHARED || state.gameSyncId !== undefined),
15 | displayingSteelPath: state.displayingSteelPath,
16 | masterNode: state.masterNode,
17 | mastered:
18 | state[
19 | state.displayingSteelPath ? "steelPath" : "starChart"
20 | ].has(id),
21 | hidden:
22 | state.hideMastered &&
23 | state[
24 | state.displayingSteelPath ? "steelPath" : "starChart"
25 | ].has(id)
26 | }));
27 |
28 | return hidden ? null : (
29 |
32 |
33 | >
34 | }>
35 |
39 |
{
42 | if (!readOnly) {
43 | masterNode(id, displayingSteelPath, !mastered);
44 | }
45 | }}>
46 | {node.name}
47 | {mastered && (
48 |
49 | )}
50 |
51 |
52 |
53 | );
54 | }
55 |
56 | PlanetNode.propTypes = {
57 | id: PropTypes.string.isRequired,
58 | node: PropTypes.shape(nodeShape).isRequired
59 | };
60 |
61 | export default PlanetNode;
62 |
--------------------------------------------------------------------------------
/src/utils/mastery-rank.js:
--------------------------------------------------------------------------------
1 | export function xpFromItem(item, category, rank) {
2 | let xpPerRank;
3 | switch (category) {
4 | case "WF":
5 | case "AW":
6 | case "SENTINEL":
7 | case "DOG":
8 | case "INFESTED_DOG":
9 | case "CAT":
10 | case "INFESTED_CAT":
11 | case "MOA":
12 | case "MECH":
13 | case "KDRIVE":
14 | case "HOUND":
15 | case "PLEXUS":
16 | xpPerRank = 200;
17 | break;
18 | default:
19 | xpPerRank = 100;
20 | break;
21 | }
22 | return xpPerRank * (rank ?? item.maxLvl ?? 30);
23 | }
24 |
25 | export function itemLevelByXP(item, category, xp) {
26 | return Math.min(Math.floor(Math.sqrt(xp / xpFromItem(item, category, 5))), item.maxLvl ?? 30);
27 | }
28 |
29 | export function xpToMR(xp) {
30 | let mr = Math.floor(Math.sqrt(xp / 2500));
31 | if (mr >= 30) mr = 30 + Math.floor((xp - 2250000) / 147500);
32 | return mr;
33 | }
34 |
35 | export function mrToXP(mr) {
36 | if (mr > 30) return 2250000 + 147500 * (mr - 30);
37 | return 2500 * Math.pow(mr, 2);
38 | }
39 |
40 | export function junctionsToXP(junctions) {
41 | return junctions * 1000;
42 | }
43 |
44 | export function intrinsicsToXP(intrinsics) {
45 | return intrinsics * 1500;
46 | }
47 |
48 | export function masteryRankName(mr) {
49 | if (mr > 30) {
50 | return `Legendary ${mr - 30}`;
51 | } else if (mr >= 28) {
52 | return mr === 28 ? "Master" : `${mr === 29 ? "Middle" : "True"} Master`;
53 | } else if (mr === 0) {
54 | return "Unranked";
55 | } else {
56 | const ranks = [
57 | "Unranked",
58 | "Initiate",
59 | "Novice",
60 | "Disciple",
61 | "Seeker",
62 | "Hunter",
63 | "Eagle",
64 | "Tiger",
65 | "Dragon",
66 | "Sage"
67 | ];
68 | const rank = ranks[Math.ceil(mr / 3)];
69 | const tier = mr % 3;
70 | return tier === 1 ? rank : `${tier === 0 ? "Gold" : "Silver"} ${rank}`;
71 | }
72 | }
73 |
74 | export const totalRailjackIntrinsics = 50;
75 | export const totalDrifterIntrinsics = 40;
76 |
--------------------------------------------------------------------------------
/src/components/Tooltip.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from "prop-types";
2 | import { useLayoutEffect, useRef, useState } from "react";
3 |
4 | function Tooltip(props) {
5 | const [mouseX, setMouseX] = useState(0);
6 | const [mouseY, setMouseY] = useState(0);
7 | const [visible, setVisible] = useState(false);
8 |
9 | const element = useRef();
10 | const width = useRef(0);
11 | const height = useRef(0);
12 |
13 | useLayoutEffect(() => {
14 | if (visible) {
15 | width.current = parseFloat(element.current.clientWidth);
16 | height.current = parseFloat(element.current.clientHeight);
17 | }
18 | }, [visible, props.content]);
19 |
20 | const x =
21 | mouseX + width.current + 20 > document.documentElement.clientWidth
22 | ? mouseX - width.current
23 | : mouseX + 20;
24 | const y =
25 | mouseY + height.current + 30 > document.documentElement.clientHeight
26 | ? mouseY - height.current
27 | : mouseY + 30;
28 |
29 | return (
30 | {
33 | setMouseX(event.clientX);
34 | setMouseY(event.clientY);
35 | setVisible(true);
36 | if (props.onVisibilityChange) props.onVisibilityChange(true);
37 | }}
38 | onMouseLeave={() => {
39 | setVisible(false);
40 | if (props.onVisibilityChange) props.onVisibilityChange(false);
41 | }}
42 | onMouseMove={event => {
43 | setMouseX(event.clientX);
44 | setMouseY(event.clientY);
45 | }}>
46 | {props.children}
47 | {visible && (
48 |
52 | {props.title}
53 |
{props.content}
54 |
55 | )}
56 |
57 | );
58 | }
59 |
60 | Tooltip.propTypes = {
61 | className: PropTypes.string,
62 | children: PropTypes.node,
63 | title: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
64 | content: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
65 | onVisibilityChange: PropTypes.func
66 | };
67 |
68 | export default Tooltip;
69 |
--------------------------------------------------------------------------------
/src/components/foundry/MissingIngredients.jsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import Masonry from "react-masonry-css";
3 | import { useStore } from "../../hooks/useStore";
4 | import { LabeledToggle } from "../Toggle";
5 | import { getComponentImageUrl } from "../../utils/items";
6 |
7 | function MissingIngredients() {
8 | const [visible, setVisible] = useState(false);
9 | const ingredients = useStore(state => state.ingredients);
10 | const ingredientIds = useStore(state => state.ingredientIds);
11 | const formaCost = useStore(state => state.formaCost);
12 |
13 | return (
14 | <>
15 |
20 | {visible && (
21 |
31 | {Object.entries(ingredients)
32 | .sort(([nameA, countA], [nameB, countB]) =>
33 | countA > countB
34 | ? -1
35 | : countA < countB
36 | ? 1
37 | : nameA.localeCompare(nameB)
38 | )
39 | .map(([name, count]) => {
40 | return (
41 |
48 |
e.preventDefault()}
56 | />
57 |
58 | {count.toLocaleString()}x {name}
59 |
60 |
61 | );
62 | })}
63 |
64 | )}
65 | Forma Required for Max Rank: {formaCost}
66 | >
67 | );
68 | }
69 |
70 | export default MissingIngredients;
71 |
72 |
--------------------------------------------------------------------------------
/.github/workflows/update-items.yml:
--------------------------------------------------------------------------------
1 | name: Update & Upload items.json to Firebase
2 | on:
3 | push:
4 | branches:
5 | - main
6 | schedule:
7 | - cron: "*/20 * * * *"
8 | jobs:
9 | update_and_upload:
10 | if: (github.event_name != 'push' || !contains(github.event.head_commit.message, 'dependabot')) && !contains(github.event.head_commit.message, 'ci skip')
11 | runs-on: ubuntu-latest
12 | steps:
13 | - name: Checkout Repo
14 | uses: actions/checkout@v4
15 | - name: Setup Node.js
16 | uses: actions/setup-node@v4
17 | - name: Cache pnpm Modules
18 | uses: actions/cache@v4
19 | with:
20 | path: ~/.local/share/pnpm/store
21 | key: pnpm-cache-${{ hashFiles('pnpm-lock.yaml') }}
22 | restore-keys: |
23 | pnpm-cache-
24 | - name: Setup pnpm
25 | uses: pnpm/action-setup@v4
26 | with:
27 | version: 9.0.0
28 | run_install: true
29 | - name: Update items.json
30 | id: update_items
31 | run: node updater/update_items.mjs
32 | env:
33 | DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
34 | DISCORD_ADMIN_IDS: ${{ secrets.DISCORD_ADMIN_IDS }}
35 | FORCE_UPLOAD: ${{ contains(github.event.head_commit.message, 'force upload') && github.event_name == 'push' }}
36 | WARFRAME_ORIGIN_PROXY: ${{ secrets.WARFRAME_ORIGIN_PROXY }}
37 | X_PROXY_TOKEN: ${{ secrets.X_PROXY_TOKEN }}
38 | - name: Authenticate Firebase
39 | uses: 'google-github-actions/auth@v2'
40 | with:
41 | credentials_json: ${{ secrets.GCP_CREDENTIALS }}
42 | - name: Upload items.json to Firebase
43 | uses: google-github-actions/upload-cloud-storage@v2
44 | if: steps.update_items.outputs.updated == 'true'
45 | with:
46 | path: items.json
47 | destination: framehub-f9cfb.appspot.com
48 | - name: Upload Artifact
49 | uses: actions/upload-artifact@v4
50 | if: steps.update_items.outputs.updated == 'true'
51 | with:
52 | name: items.json
53 | path: items.json
54 |
--------------------------------------------------------------------------------
/src/components/checklist/ItemRelicTooltip.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from "prop-types";
2 | import React from "react";
3 | import { itemShape, relicRarity, relicTiers } from "../../utils/items";
4 | import { PaginatedTooltipTitle } from "../PaginatedTooltip";
5 |
6 | function ItemRelicTooltip({ item, name }) {
7 | return (
8 |
9 |
10 | {item.vaulted ? (
11 | `${name} is currently in the Prime Vault.`
12 | ) : (
13 |
14 | {Object.entries(item.relics).map(([i, relics]) => {
15 | return (
16 |
17 |
{i}
18 |
19 | {Object.entries(relics)
20 | .filter(
21 | ([, relicData]) =>
22 | !relicData.vaulted
23 | )
24 | .map(([relicName, relicData]) => {
25 | const rarity =
26 | relicRarity[relicData.rarity];
27 | return (
28 |
35 |
38 | relicName.startsWith(
39 | tier
40 | )
41 | )
42 | .toLowerCase()}-radiant.png`}
43 | alt=""
44 | width="24px"
45 | />
46 | {relicName} (
47 |
49 | {rarity}
50 |
51 | )
52 |
53 | );
54 | })}
55 |
56 |
57 | );
58 | })}
59 |
60 | Vaulted relics are not displayed.
61 |
62 |
63 | )}
64 |
65 | );
66 | }
67 |
68 | ItemRelicTooltip.propTypes = {
69 | item: PropTypes.shape(itemShape)
70 | };
71 |
72 | export default ItemRelicTooltip;
73 |
--------------------------------------------------------------------------------
/src/icons/discord.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/src/utils/profile.js:
--------------------------------------------------------------------------------
1 | const PROXY_URL = "https://proxy.framehub.app/?url=";
2 |
3 | export async function getGameProfile(accountId, platform) {
4 | if (accountId.length !== 24 || !accountId.match(/^[0-9a-z]+$/)) {
5 | throw new Error("Invalid Account ID");
6 | }
7 |
8 | let domainSuffix = "";
9 | switch (platform) {
10 | case "psn":
11 | domainSuffix = "-ps4";
12 | break;
13 | case "xbox":
14 | domainSuffix = "-xb1";
15 | break;
16 | case "switch":
17 | domainSuffix = "-swi";
18 | break;
19 | case "mobile":
20 | domainSuffix = "-mob";
21 | break;
22 | default:
23 | break;
24 | }
25 |
26 | const url = `${PROXY_URL}https://content${domainSuffix}.warframe.com/dynamic/getProfileViewingData.php?playerId=${encodeURIComponent(
27 | accountId
28 | )}`;
29 | const resp = await fetch(url, { cache: "no-cache" });
30 |
31 | if (!resp.ok) {
32 | const status = resp.status;
33 | let message;
34 | switch (status) {
35 | case 400:
36 | message =
37 | "Invalid request. Ensure the Account ID is valid and the correct platform is selected.";
38 | break;
39 | case 403:
40 | message =
41 | "We're experiencing issues getting profile data. Try again later.";
42 | break;
43 | case 409:
44 | message =
45 | "Account not found, check your account ID and platform";
46 | break;
47 | default:
48 | if (status >= 500) {
49 | message = "Internal server error. Please try again later.";
50 | } else {
51 | message = `Request failed (${status}).`;
52 | }
53 | break;
54 | }
55 | const error = new Error(message);
56 | error.status = status;
57 | throw error;
58 | }
59 |
60 | const json = await resp.json();
61 | if (!json?.Results || json.Results.length === 0) {
62 | const error = new Error(
63 | "Account not found, check your account ID and platform"
64 | );
65 | error.status = 409;
66 | throw error;
67 | }
68 | return json;
69 | }
70 |
71 | export function getUsernameFromProfile(profile) {
72 | if (profile.PlatformNames) {
73 | // If the profile is linked to an account from a different platform,
74 | // there is a platform icon character appended to the username.
75 | return profile.DisplayName.slice(0, profile.DisplayName.length - 1);
76 | }
77 |
78 | return profile.DisplayName;
79 | }
80 |
81 |
--------------------------------------------------------------------------------
/src/components/Select.jsx:
--------------------------------------------------------------------------------
1 | import classNames from "classnames";
2 | import PropTypes from "prop-types";
3 | import { useEffect, useMemo, useRef, useState } from "react";
4 |
5 | function Select({ centered, disabled, value, onChange, options = [], placeholder, className }) {
6 | const [open, setOpen] = useState(false);
7 | const rootRef = useRef(null);
8 |
9 | const selected = useMemo(() => options.find(o => o.value === value), [options, value]);
10 |
11 | useEffect(() => {
12 | function onDocumentClick(e) {
13 | if (rootRef.current && !rootRef.current.contains(e.target)) setOpen(false);
14 | }
15 | document.addEventListener("mousedown", onDocumentClick);
16 | return () => document.removeEventListener("mousedown", onDocumentClick);
17 | }, []);
18 |
19 | function toggleOpen() {
20 | if (disabled) return;
21 | setOpen(o => !o);
22 | }
23 |
24 | function selectOption(v) {
25 | onChange(v);
26 | setOpen(false);
27 | }
28 |
29 | return (
30 |
31 |
36 |
37 | {selected ? selected.label : placeholder}
38 |
39 |
▾
40 |
41 | {open && (
42 |
43 |
44 | {options.map(opt => (
45 |
{
49 | e.preventDefault();
50 | selectOption(opt.value);
51 | }}
52 | >
53 | {opt.label}
54 |
55 | ))}
56 |
57 |
58 | )}
59 |
60 |
61 | );
62 | }
63 |
64 | Select.propTypes = {
65 | centered: PropTypes.bool,
66 | disabled: PropTypes.bool,
67 | value: PropTypes.string,
68 | onChange: PropTypes.func.isRequired,
69 | options: PropTypes.arrayOf(
70 | PropTypes.shape({ value: PropTypes.string.isRequired, label: PropTypes.string.isRequired })
71 | ),
72 | placeholder: PropTypes.string,
73 | className: PropTypes.string
74 | };
75 |
76 | export default Select;
77 |
--------------------------------------------------------------------------------
/src/utils/nodes.js:
--------------------------------------------------------------------------------
1 | import { produce } from "immer";
2 | import PropTypes from "prop-types";
3 | import nodes from "../resources/nodes.json";
4 |
5 | export const flattenedNodes = Object.entries(nodes).reduce(
6 | (flattenedNodes, [planet, planetNodes]) => {
7 | Object.entries(planetNodes).reduce((flattenedNodes, [id, node]) => {
8 | flattenedNodes[id] = produce(node, draftNode => {
9 | draftNode.planet = planet;
10 | });
11 | return flattenedNodes;
12 | }, flattenedNodes);
13 | return flattenedNodes;
14 | },
15 | {}
16 | );
17 |
18 | export const planetJunctionsMap = {
19 | "Venus": "EarthToVenusJunction",
20 | "Mercury": "VenusToMercuryJunction",
21 | "Mars": "EarthToMarsJunction",
22 | "Phobos": "MarsToPhobosJunction",
23 | "Ceres": "MarsToCeresJunction",
24 | "Jupiter": "CeresToJupiterJunction",
25 | "Europa": "JupiterToEuropaJunction",
26 | "Saturn": "JupiterToSaturnJunction",
27 | "Uranus": "SaturnToUranusJunction",
28 | "Neptune": "UranusToNeptuneJunction",
29 | "Pluto": "NeptuneToPlutoJunction",
30 | "Eris": "PlutoToErisJunction",
31 | "Sedna": "ErisToSednaJunction"
32 | };
33 |
34 | export const missionIndexMap = [
35 | "Assasination",
36 | "Exterminate",
37 | "Survival",
38 | "Rescue",
39 | "Sabotage",
40 | "Capture",
41 | undefined,
42 | "Spy",
43 | "Defense",
44 | "Mobile Defense",
45 | undefined,
46 | undefined,
47 | undefined,
48 | "Interception",
49 | "Hijack",
50 | "Hive",
51 | undefined,
52 | "Excavation",
53 | undefined,
54 | undefined,
55 | undefined,
56 | "Infested Salvage",
57 | "Arena",
58 | undefined,
59 | "Pursuit (Archwing)",
60 | "Rush (Archwing)",
61 | "Assault",
62 | "Defection",
63 | "Free Roam",
64 | undefined,
65 | undefined,
66 | "The Circuit",
67 | undefined,
68 | "Disruption",
69 | "Void Flood",
70 | "Void Cascade",
71 | "Void Armageddon",
72 | undefined,
73 | "Alchemy",
74 | undefined,
75 | "Legacyte Harvest",
76 | "Shrine Defense",
77 | "Faceoff"
78 | ];
79 |
80 | export const factionIndexMap = [
81 | "Grineer",
82 | "Corpus",
83 | "Infested",
84 | "Corrupted",
85 | undefined,
86 | "Sentient",
87 | undefined,
88 | "Murmur",
89 | "Scaldra",
90 | "Techrot",
91 | "Duviri"
92 | ];
93 |
94 | export const nodeShape = {
95 | name: PropTypes.string.isRequired,
96 | type: PropTypes.number.isRequired,
97 | faction: PropTypes.number.isRequired,
98 | lvl: PropTypes.arrayOf(PropTypes.number).isRequired,
99 | xp: PropTypes.number
100 | };
101 |
--------------------------------------------------------------------------------
/src/components/checklist/CategoryInfo.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from "prop-types";
2 | import { useStore } from "../../hooks/useStore";
3 | import { xpFromItem } from "../../utils/mastery-rank";
4 | import BaseCategoryInfo from "./BaseCategoryInfo";
5 | import { isItemFiltered } from "../../utils/item-filter";
6 |
7 | const fancyCategoryNames = {
8 | WF: "Warframe",
9 | SENTINEL_WEAPON: "Sentinel Weapons",
10 | AW: "Archwing",
11 | AW_GUN: "Archwing Primary",
12 | AW_MELEE: "Archwing Melee",
13 | DOG: "Kubrow",
14 | INFESTED_DOG: "Predasite",
15 | CAT: "Kavat",
16 | INFESTED_CAT: "Vulpaphyla",
17 | MOA: "MOA",
18 | MECH: "Necramech",
19 | KDRIVE: "K-Drive"
20 | };
21 |
22 | function CategoryItem({ name }) {
23 | const { masteredCount, masteredXP, totalCount, totalXP } = useStore(
24 | state => {
25 | const { itemsMastered, partiallyMasteredItems } = state;
26 | const categoryItems = state.items[name];
27 |
28 | let masteredCount = 0;
29 | let masteredXP = 0;
30 | // There is an extra "Amp" item in the amp category in the
31 | // in-game profile.
32 | let totalCount = name === "AMP" ? 1 : 0;
33 | let totalXP = 0;
34 |
35 | Object.entries(categoryItems).forEach(([itemName, item]) => {
36 | if (
37 | isItemFiltered(itemName, item, {
38 | ...state,
39 | hideMastered: false
40 | })
41 | )
42 | return;
43 |
44 | totalCount++;
45 | totalXP += xpFromItem(item, name);
46 | if (itemsMastered.has(itemName)) {
47 | masteredCount++;
48 | masteredXP += xpFromItem(item, name);
49 | } else if (partiallyMasteredItems[itemName]) {
50 | masteredXP += xpFromItem(
51 | item,
52 | name,
53 | partiallyMasteredItems[itemName]
54 | );
55 | }
56 | });
57 |
58 | // Assume the ghost "Amp" item is mastered if there are any mastered amps.
59 | if (name === "AMP" && masteredCount > 0) masteredCount++;
60 |
61 | return { masteredCount, masteredXP, totalCount, totalXP };
62 | }
63 | );
64 |
65 | return (
66 |
73 | word.charAt(0).toUpperCase() +
74 | word.slice(1).toLowerCase()
75 | )
76 | .join(" ")
77 | }
78 | mastered={masteredCount}
79 | total={totalCount}
80 | masteredXP={masteredXP}
81 | totalXP={totalXP}
82 | />
83 | );
84 | }
85 |
86 | CategoryItem.propTypes = {
87 | name: PropTypes.string.isRequired
88 | };
89 |
90 | export default CategoryItem;
91 |
92 |
--------------------------------------------------------------------------------
/src/components/sidebar/Sidebar.jsx:
--------------------------------------------------------------------------------
1 | import classNames from "classnames";
2 | import { useState } from "react";
3 | import { useStore } from "../../hooks/useStore";
4 | import placeholderIcon from "../../icons/placeholder-icon.svg";
5 | import { ANONYMOUS, AUTHENTICATED } from "../../utils/checklist-types";
6 | import Button from "../Button";
7 | import ConfirmationPrompt from "./ConfirmationPrompt";
8 | import DangerZone from "./DangerZone";
9 | import ExitButton from "./ExitButton";
10 | import LogoutButton from "./LogoutButton";
11 | import MasteryRankInfo from "./MasteryRankInfo";
12 | import SaveStatus from "./SaveStatus";
13 | import SharePrompt from "./SharePrompt";
14 | import SidebarInputs from "./SidebarInputs";
15 | import Social from "./Social";
16 |
17 | function Sidebar() {
18 | const { type, gameSyncUsername, gameSyncPlatform, disableGameSync } = useStore(state => ({
19 | type: state.type,
20 | gameSyncUsername: state.gameSyncUsername,
21 | gameSyncPlatform: state.gameSyncPlatform,
22 | disableGameSync: state.disableGameSync
23 | }));
24 | const [toggled, setToggled] = useState(false);
25 | const [unlinkCb, setUnlinkCb] = useState();
26 | const [unlinkMsg, setUnlinkMsg] = useState("");
27 |
28 | function requestUnlink() {
29 | setUnlinkCb(() => disableGameSync);
30 | setUnlinkMsg(
31 | "Are you sure you want to unlink your account? This will stop syncing with your in-game profile."
32 | );
33 | }
34 |
35 | return (
36 | <>
37 |
38 |
39 |
40 |
41 | {gameSyncUsername && (
42 |
43 |
44 | Linked to {String(gameSyncPlatform).toUpperCase()} account:
45 | {gameSyncUsername}
46 |
47 |
Unlink Account
48 |
49 | )}
50 | {type === ANONYMOUS && (
51 |
Remember to bookmark this URL.
52 | )}
53 |
54 | {type === AUTHENTICATED &&
}
55 |
56 |
57 |
58 |
59 | {
63 | setToggled(!toggled);
64 | }}
65 | alt="menu"
66 | />
67 | setUnlinkCb(undefined)}
71 | />
72 | >
73 | );
74 | }
75 |
76 | export default Sidebar;
77 |
--------------------------------------------------------------------------------
/src/components/checklist/ItemGeneralInfoTooltip.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from "prop-types";
2 | import credits from "../../icons/credits.png";
3 | import ducats from "../../icons/ducats.png";
4 | import { itemShape } from "../../utils/items";
5 | import { detailedTime } from "../../utils/time";
6 | import GluedComponents from "../GluedComponents";
7 | import { PaginatedTooltipTitle } from "../PaginatedTooltip";
8 | import ItemComponent from "./ItemComponent";
9 | import { useStore } from "../../hooks/useStore";
10 |
11 | function ItemGeneralInfoTooltip({ item, itemName }) {
12 | const isMacOS =
13 | window.navigator.userAgentData?.platform === "macOS" ||
14 | window.navigator.platform === "MacIntel"; //TODO: Remove usage of deprecated Navigator.platform
15 | const recipe = useStore(state => state.recipes[itemName]);
16 |
17 | return (
18 |
19 |
20 | {recipe?.components ? (
21 |
22 | {Object.entries(recipe.components).map(
23 | ([componentName, componentCount]) => {
24 | return (
25 |
30 | );
31 | }
32 | )}
33 |
34 | ) : (
35 |
36 | {item.baro ? (
37 | <>
38 | Purchase from Baro Ki'Teer for{" "}
39 | {" "}
45 | {item.baro[1].toLocaleString()} /{" "}
46 | {" "}
52 | {item.baro[0].toLocaleString()}
53 | >
54 | ) : (
55 | (item.description ?? "Unknown Acquisition")
56 | )}
57 |
58 | )}
59 |
60 | {recipe?.components && (
61 |
62 | {detailedTime(recipe.time)}
63 |
64 | )}
65 | {recipe?.price && (
66 | <>
67 | {" "}
73 |
74 | {recipe.price.toLocaleString()}
75 |
76 | >
77 | )}
78 | {item.mr && {`Mastery Rank ${item.mr}`} }
79 |
80 |
{isMacOS ? "Cmd" : "Ctrl"} + Left Click for Wiki
81 |
82 | );
83 | }
84 |
85 | ItemGeneralInfoTooltip.propTypes = {
86 | item: PropTypes.shape(itemShape)
87 | };
88 |
89 | export default ItemGeneralInfoTooltip;
90 |
--------------------------------------------------------------------------------
/src/components/PaginatedTooltip.jsx:
--------------------------------------------------------------------------------
1 | import classNames from "classnames";
2 | import PropTypes from "prop-types";
3 | import { createContext, useContext, useEffect, useState } from "react";
4 | import Tooltip from "./Tooltip";
5 |
6 | const PaginatedTooltipContext = createContext({
7 | title: "",
8 | setTitle: () => {}
9 | });
10 |
11 | function PaginatedTooltip(props) {
12 | const [title, setTitle] = useState("");
13 | return (
14 |
15 |
16 | {props.children}
17 |
18 |
19 | );
20 | }
21 |
22 | function PaginatedTooltipTitle(props) {
23 | const { setTitle } = useContext(PaginatedTooltipContext);
24 | useEffect(() => setTitle(props.title), [setTitle, props.title]);
25 | return null;
26 | }
27 |
28 | function PaginatedTooltipWrapper(props) {
29 | let content = props.content.props.children;
30 | if (!Array.isArray(content)) content = [content];
31 | content = content.filter(c => c !== undefined);
32 |
33 | const [visible, setVisible] = useState(false);
34 | const [page, setPage] = useState(0);
35 | const maxPage = content.length - 1;
36 | const { title } = useContext(PaginatedTooltipContext);
37 |
38 | useEffect(() => {
39 | if (visible) {
40 | const listener = event => {
41 | if (event.key === "Shift") {
42 | event.preventDefault();
43 | setPage(page => {
44 | return page === maxPage ? 0 : ++page;
45 | });
46 | }
47 | };
48 | window.addEventListener("keydown", listener);
49 | return () => window.removeEventListener("keydown", listener);
50 | }
51 | }, [visible, maxPage]);
52 | useEffect(() => setPage(0), [visible]);
53 |
54 | return (
55 |
61 | {title}
62 |
63 | {Array.from(new Array(maxPage + 1)).map(
64 | (value, p) => {
65 | return (
66 |
76 | );
77 | }
78 | )}
79 |
Shift
80 |
81 | >
82 | )
83 | }
84 | content={content[page]}
85 | onVisibilityChange={setVisible}
86 | >
87 | {props.children}
88 |
89 | );
90 | }
91 |
92 | PaginatedTooltip.propTypes = PaginatedTooltipWrapper.propTypes = {
93 | content: PropTypes.node,
94 | children: PropTypes.node
95 | };
96 |
97 | PaginatedTooltipTitle.propTypes = {
98 | title: PropTypes.string.isRequired
99 | };
100 |
101 | export default PaginatedTooltip;
102 | export { PaginatedTooltipTitle };
103 |
--------------------------------------------------------------------------------
/src/App.jsx:
--------------------------------------------------------------------------------
1 | import { getAnalytics } from "firebase/analytics";
2 | import { initializeApp } from "firebase/app";
3 | import { getAuth, onAuthStateChanged } from "firebase/auth";
4 | import {
5 | getFirestore,
6 | initializeFirestore,
7 | persistentLocalCache,
8 | persistentMultipleTabManager
9 | } from "firebase/firestore";
10 | import { getPerformance } from "firebase/performance";
11 | import { getStorage } from "firebase/storage";
12 | import { useEffect, useState } from "react";
13 | import { BrowserRouter, Route, Routes } from "react-router-dom";
14 | import "./App.scss";
15 | import LoadingScreen from "./components/LoadingScreen";
16 | import Login from "./pages/Login";
17 | import MasteryChecklist from "./pages/MasteryChecklist";
18 | import { ANONYMOUS, AUTHENTICATED, SHARED } from "./utils/checklist-types";
19 |
20 | const framehubFirebase = initializeApp({
21 | apiKey: "AIzaSyBMGwuSb8vwSboz8DiPimsCu4KRfXkx-C4",
22 | authDomain: "framehub-f9cfb.firebaseapp.com",
23 | projectId: "framehub-f9cfb",
24 | storageBucket: "framehub-f9cfb.appspot.com",
25 | messagingSenderId: "333211073610",
26 | appId: "1:333211073610:web:9f5f3eed9a5e1c11dbbab3",
27 | measurementId: "G-32XJ3FSZB9"
28 | });
29 | const paroxityFirebase = initializeApp(
30 | {
31 | apiKey: "AIzaSyC30ZiFA2z0WXcIQzRxB0Q3FW9hYjSMD1k",
32 | authDomain: "paroxity-adfa8.firebaseapp.com",
33 | databaseURL: "https://paroxity-adfa8.firebaseio.com",
34 | projectId: "paroxity-adfa8",
35 | storageBucket: "paroxity-adfa8.appspot.com",
36 | messagingSenderId: "349640827147",
37 | appId: "1:349640827147:web:7cbb496709585bcd83820e",
38 | measurementId: "G-6WKVYVC3EB"
39 | },
40 | "paroxity"
41 | ); //TODO: Combine into one Firebase project
42 | export const auth = getAuth(paroxityFirebase);
43 | initializeFirestore(paroxityFirebase, {
44 | localCache: persistentLocalCache({
45 | tabManager: persistentMultipleTabManager()
46 | })
47 | });
48 | export const firestore = getFirestore(paroxityFirebase);
49 | export const storage = getStorage(framehubFirebase);
50 | getAnalytics(framehubFirebase);
51 | getPerformance(framehubFirebase);
52 |
53 | function App() {
54 | const [user, setUser] = useState(auth.currentUser);
55 | const [authLoading, setAuthLoading] = useState(auth.currentUser === null);
56 | useEffect(() => {
57 | return onAuthStateChanged(auth, user => {
58 | setAuthLoading(false);
59 | setUser(user);
60 | });
61 | }, []);
62 |
63 | return (
64 |
65 |
66 | }
69 | />
70 | }
73 | />
74 |
79 | ) : user ? (
80 |
84 | ) : (
85 |
86 | )
87 | }
88 | />
89 |
90 |
91 | );
92 | }
93 |
94 | export default App;
95 |
--------------------------------------------------------------------------------
/src/components/sidebar/DangerZone.jsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { useStore } from "../../hooks/useStore";
3 | import { SHARED } from "../../utils/checklist-types";
4 | import Button from "../Button";
5 | import ConfirmationPrompt from "./ConfirmationPrompt";
6 | import LinkPrompt from "./LinkPrompt";
7 |
8 | function DangerZone() {
9 | const {
10 | readOnly,
11 | displayingNodes,
12 | displayingSteelPath,
13 | masterAllItems,
14 | masterAllNodes,
15 | masterAllJunctions,
16 | gameSyncExperiment,
17 | } = useStore(state => ({
18 | readOnly: state.type === SHARED || state.gameSyncId !== undefined,
19 | displayingNodes: state.displayingNodes,
20 | displayingSteelPath: state.displayingSteelPath,
21 | masterAllItems: state.masterAllItems,
22 | masterAllNodes: state.masterAllNodes,
23 | masterAllJunctions: state.masterAllJunctions,
24 | gameSyncExperiment: state.gameSyncExperiment
25 | }));
26 |
27 | const [confirmationCallback, setConfirmationCallback] = useState();
28 | const [confirmationMessage, setConfirmationMessage] = useState("");
29 |
30 | return readOnly ? null : (
31 | <>
32 | Danger zone
33 |
34 | {gameSyncExperiment && }
35 | {
38 | if (displayingNodes) {
39 | setConfirmationCallback(() => () => {
40 | masterAllNodes(displayingSteelPath, true);
41 | masterAllJunctions(displayingSteelPath, true);
42 | });
43 | setConfirmationMessage(
44 | `Are you sure you would like to master all ${
45 | displayingSteelPath
46 | ? "Steel Path"
47 | : "Star Chart"
48 | } nodes?`
49 | );
50 | } else {
51 | setConfirmationCallback(
52 | () => () => masterAllItems(true)
53 | );
54 | setConfirmationMessage(
55 | "Are you sure you would like to master all items?"
56 | );
57 | }
58 | }}
59 | >
60 | Master All {displayingNodes ? "Nodes" : "Items"}
61 |
62 | {
65 | if (displayingNodes) {
66 | setConfirmationCallback(() => () => {
67 | masterAllNodes(displayingSteelPath);
68 | masterAllJunctions(displayingSteelPath);
69 | });
70 | setConfirmationMessage(
71 | `Are you sure you would like to reset all ${
72 | displayingSteelPath
73 | ? "Steel Path"
74 | : "Star Chart"
75 | } nodes?`
76 | );
77 | } else {
78 | setConfirmationCallback(() => masterAllItems);
79 | setConfirmationMessage(
80 | "Are you sure you would like to reset all items?"
81 | );
82 | }
83 | }}
84 | >
85 | Reset {displayingNodes ? "Nodes" : "Items"}
86 |
87 |
88 |
93 | >
94 | );
95 | }
96 |
97 | export default DangerZone;
98 |
--------------------------------------------------------------------------------
/src/components/sidebar/SidebarInputs.jsx:
--------------------------------------------------------------------------------
1 | import { useStore } from "../../hooks/useStore";
2 | import { SHARED } from "../../utils/checklist-types";
3 | import {
4 | totalDrifterIntrinsics,
5 | totalRailjackIntrinsics
6 | } from "../../utils/mastery-rank";
7 | import Button from "../Button";
8 | import NumberInput from "../NumberInput";
9 | import { LabeledToggle } from "../Toggle";
10 |
11 | function SidebarInputs() {
12 | const {
13 | readOnly,
14 | railjackIntrinsics,
15 | setRailjackIntrinsics,
16 | drifterIntrinsics,
17 | setDrifterIntrinsics,
18 | hideMastered,
19 | setHideMastered,
20 | hidePrime,
21 | setHidePrime,
22 | hideFounders,
23 | setHideFounders,
24 | displayingNodes,
25 | setDisplayingNodes,
26 | displayingSteelPath,
27 | setDisplayingSteelPath
28 | } = useStore(state => ({
29 | readOnly: (state.type === SHARED || state.gameSyncId !== undefined),
30 | railjackIntrinsics: state.railjackIntrinsics,
31 | setRailjackIntrinsics: state.setRailjackIntrinsics,
32 | drifterIntrinsics: state.drifterIntrinsics,
33 | setDrifterIntrinsics: state.setDrifterIntrinsics,
34 | hideMastered: state.hideMastered,
35 | setHideMastered: state.setHideMastered,
36 | hidePrime: state.hidePrime,
37 | setHidePrime: state.setHidePrime,
38 | hideFounders: state.hideFounders,
39 | setHideFounders: state.setHideFounders,
40 | displayingNodes: state.displayingNodes,
41 | setDisplayingNodes: state.setDisplayingNodes,
42 | displayingSteelPath: state.displayingSteelPath,
43 | setDisplayingSteelPath: state.setDisplayingSteelPath
44 | }));
45 | return (
46 | <>
47 | setDisplayingNodes(!displayingNodes)}>
48 | {displayingNodes ? "Exit Star Chart" : "Star Chart"}
49 |
50 | {displayingNodes && (
51 |
57 | )}
58 |
68 | Max of 10 per intrinsics class
69 | Maximum Value: {totalRailjackIntrinsics}
70 | >
71 | }
72 | />
73 |
83 | Max of 10 per intrinsics class
84 | Maximum Value: {totalDrifterIntrinsics}
85 | >
86 | }
87 | />
88 |
93 | {!displayingNodes && (
94 |
99 | )}
100 | {!displayingNodes && (
101 |
106 | )}
107 | >
108 | );
109 | }
110 |
111 | export default SidebarInputs;
112 |
--------------------------------------------------------------------------------
/src/components/sidebar/MasteryBreakdownTooltip.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from "prop-types";
2 | import { useStore } from "../../hooks/useStore";
3 | import Tooltip from "../Tooltip";
4 |
5 | function MasteryBreakdownTooltip({ children }) {
6 | return (
7 |
12 |
13 |
17 |
21 |
25 |
26 |
27 |
31 |
35 |
39 |
43 |
44 |
48 |
52 |
64 |
71 | Companions currently includes Plexus XP to reflect
72 | in-game.
73 |
74 |
75 |
76 |
77 |
81 |
82 |
83 |
84 | >
85 | }>
86 | {children}
87 |
88 | );
89 | }
90 |
91 | MasteryBreakdownTooltip.propTypes = {
92 | children: PropTypes.node
93 | };
94 |
95 | export default MasteryBreakdownTooltip;
96 |
97 | function BreakdownEntry({ name, categories }) {
98 | const xp = useStore(state =>
99 | Object.entries(state.masteryBreakdown)
100 | .filter(([category]) => categories.includes(category))
101 | .reduce((xp, [, categoryXP]) => xp + categoryXP, 0)
102 | );
103 | return (
104 |
105 | {xp.toLocaleString()} {" "}
106 | {name}
107 |
108 | );
109 | }
110 |
111 | BreakdownEntry.propTypes = {
112 | name: PropTypes.string.isRequired,
113 | categories: PropTypes.arrayOf(PropTypes.string).isRequired
114 | };
115 |
--------------------------------------------------------------------------------
/src/components/checklist/CategoryItem.jsx:
--------------------------------------------------------------------------------
1 | import classNames from "classnames";
2 | import PropTypes from "prop-types";
3 | import { useState } from "react";
4 | import { useStore } from "../../hooks/useStore";
5 | import checkmark from "../../icons/checkmark.svg";
6 | import { SHARED } from "../../utils/checklist-types";
7 | import { itemShape } from "../../utils/items";
8 | import Button from "../Button";
9 | import PaginatedTooltip from "../PaginatedTooltip";
10 | import ItemGeneralInfoTooltip from "./ItemGeneralInfoTooltip";
11 | import ItemRelicTooltip from "./ItemRelicTooltip";
12 | import { isItemFiltered } from "../../utils/item-filter";
13 |
14 | function CategoryItem({ name, item }) {
15 | const {
16 | readOnly,
17 | mastered,
18 | masteryRankLocked,
19 | partialRank,
20 | setPartiallyMasteredItem,
21 | hidden
22 | } = useStore(state => ({
23 | readOnly: state.type === SHARED || state.gameSyncId !== undefined,
24 | masterItem: state.masterItem,
25 | mastered: state.itemsMastered.has(name),
26 | masteryRankLocked: (item.mr || 0) > state.masteryRank,
27 | partialRank: state.partiallyMasteredItems[name],
28 | setPartiallyMasteredItem: state.setPartiallyMasteredItem,
29 | hidden: isItemFiltered(name, item, state)
30 | }));
31 | const [rankSelectToggled, setRankSelectToggled] = useState(false);
32 |
33 | return hidden ? null : (
34 | <>
35 | {!readOnly && rankSelectToggled && item.maxLvl && (
36 |
37 | {Array.from(Array((item.maxLvl - 30) / 2 + 2)).map(
38 | (i, j) => {
39 | const rank = j === 0 ? 0 : j * 2 + 28;
40 | return (
41 |
45 | setPartiallyMasteredItem(
46 | name,
47 | rank,
48 | item.maxLvl
49 | )
50 | }>
51 | {rank}
52 |
53 | );
54 | }
55 | )}
56 |
57 | )}
58 |
61 |
62 | {item.relics && (
63 |
64 | )}
65 | >
66 | }>
67 |
72 |
{
75 | // The meta key (MacOS command/⌘ key) is used as an alternative to the Ctrl key as
76 | // default macOS settings convert Ctrl + Left Click to a Right Click.
77 | if (e.ctrlKey || e.metaKey) {
78 | window.open(
79 | item.wiki ||
80 | `https://wiki.warframe.com/w/${name}`
81 | );
82 | } else if (!readOnly) {
83 | if (item.maxLvl) {
84 | setRankSelectToggled(!rankSelectToggled);
85 | } else {
86 | setPartiallyMasteredItem(
87 | name,
88 | mastered ? 0 : 30,
89 | 30
90 | );
91 | }
92 | }
93 | }}>
94 | {name +
95 | (partialRank
96 | ? ` [${partialRank}/${item.maxLvl ?? 30}]`
97 | : item.maxLvl
98 | ? ` [${item.maxLvl}]`
99 | : "")}
100 | {mastered && (
101 |
102 | )}
103 |
104 |
105 |
106 | >
107 | );
108 | }
109 |
110 | CategoryItem.propTypes = {
111 | name: PropTypes.string.isRequired,
112 | item: PropTypes.shape(itemShape).isRequired
113 | };
114 |
115 | export default CategoryItem;
116 |
117 |
--------------------------------------------------------------------------------
/src/components/sidebar/LinkPrompt.jsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { useStore } from "../../hooks/useStore";
3 | import Button from "../Button";
4 | import FormInput from "../login/FormInput";
5 | import Select from "../Select";
6 |
7 | function LinkPrompt() {
8 | const enableGameSync = useStore(state => state.enableGameSync);
9 | const backupMasteryData = useStore(state => state.backupMasteryData);
10 |
11 | const [open, setOpen] = useState(false);
12 | const [accountId, setAccountId] = useState("");
13 | const [platform, setPlatform] = useState("pc");
14 | const [error, setError] = useState("");
15 | const [loading, setLoading] = useState(false);
16 | const confirmDisabled =
17 | !accountId || accountId.trim().length === 0 || loading;
18 |
19 | async function onConfirm() {
20 | setError("");
21 | setLoading(true);
22 | try {
23 | await backupMasteryData(accountId.trim());
24 | await enableGameSync(accountId.trim(), platform);
25 | setOpen(false);
26 | } catch (e) {
27 | // Show any error message inline without closing the popup
28 | setError(e?.message || String(e));
29 | } finally {
30 | setLoading(false);
31 | }
32 | }
33 |
34 | function openPopup() {
35 | setError("");
36 | setOpen(true);
37 | }
38 |
39 | return (
40 | <>
41 |
42 | Link Account
43 |
44 | {open ? (
45 |
46 |
49 |
Link your account
50 |
{
55 | setAccountId(value);
56 | if (error) setError("");
57 | }}
58 | />
59 |
76 | {
80 | setPlatform(value);
81 | if (error) setError("");
82 | }}
83 | placeholder="Platform"
84 | options={[
85 | { value: "pc", label: "PC" },
86 | { value: "psn", label: "PlayStation" },
87 | { value: "xbox", label: "Xbox" },
88 | { value: "switch", label: "Switch" },
89 | { value: "mobile", label: "Mobile" }
90 | ]}
91 | />
92 |
99 | A backup of your current account will be created and
100 | stored for 28 days minimum.
101 |
102 | {error ? (
103 |
119 | ) : null}
120 |
121 |
125 | {loading ? (
126 |
127 | ) : (
128 | "Confirm"
129 | )}
130 |
131 |
setOpen(false)}>
132 | Cancel
133 |
134 |
135 |
136 |
137 | ) : null}
138 | >
139 | );
140 | }
141 |
142 | export default LinkPrompt;
143 |
--------------------------------------------------------------------------------
/src/pages/MasteryChecklist.jsx:
--------------------------------------------------------------------------------
1 | import { collection, doc, onSnapshot } from "firebase/firestore";
2 | import PropTypes from "prop-types";
3 | import { useEffect, useState } from "react";
4 | import { firestore } from "../App";
5 | import Checklist from "../components/checklist/Checklist";
6 | import PlanetChecklist from "../components/checklist/planets/PlanetChecklist";
7 | import MissingIngredients from "../components/foundry/MissingIngredients";
8 | import FrameHubLogo from "../components/FrameHubLogo";
9 | import LoadingScreen from "../components/LoadingScreen";
10 | import Sidebar from "../components/sidebar/Sidebar";
11 | import UnloadWarning from "../components/sidebar/UnloadWarning";
12 | import Button from "../components/Button";
13 | import { useStore } from "../hooks/useStore";
14 | import { ANONYMOUS, AUTHENTICATED, SHARED } from "../utils/checklist-types";
15 | import { useParams } from "react-router-dom";
16 |
17 | function MasteryChecklist(props) {
18 | let { id } = useParams();
19 | id = props.id ?? id;
20 |
21 | const {
22 | setId,
23 | setType,
24 | reset,
25 | setItemsMastered,
26 | setPartiallyMasteredItems,
27 | setNodesMastered,
28 | setJunctionsMastered,
29 | setRailjackIntrinsics,
30 | setDrifterIntrinsics,
31 | setHideMastered,
32 | setHidePrime,
33 | setHideFounders,
34 | displayingNodes,
35 | setGameSyncInfo,
36 | gameSyncExperiment,
37 | initGameSyncExperiment,
38 | popupsDismissed,
39 | setPopupsDismissed,
40 | updateFirestore,
41 | setAccountLinkErrors,
42 | incrementAccountLinkErrors
43 | } = useStore(state => ({
44 | setId: state.setId,
45 | setType: state.setType,
46 | reset: state.reset,
47 | setItemsMastered: state.setItemsMastered,
48 | setPartiallyMasteredItems: state.setPartiallyMasteredItems,
49 | setNodesMastered: state.setNodesMastered,
50 | setJunctionsMastered: state.setJunctionsMastered,
51 | setRailjackIntrinsics: state.setRailjackIntrinsics,
52 | setDrifterIntrinsics: state.setDrifterIntrinsics,
53 | setHideMastered: state.setHideMastered,
54 | setHidePrime: state.setHidePrime,
55 | setHideFounders: state.setHideFounders,
56 | displayingNodes: state.displayingNodes,
57 | setGameSyncInfo: state.setGameSyncInfo,
58 | gameSyncExperiment: state.gameSyncExperiment,
59 | initGameSyncExperiment: state.initGameSyncExperiment,
60 | popupsDismissed: state.popupsDismissed,
61 | setPopupsDismissed: state.setPopupsDismissed,
62 | updateFirestore: state.updateFirestore,
63 | setAccountLinkErrors: state.setAccountLinkErrors,
64 | incrementAccountLinkErrors: state.incrementAccountLinkErrors
65 | }));
66 |
67 | const [dataLoading, setDataLoading] = useState(true);
68 | const [syncError, setSyncError] = useState(false);
69 | useEffect(() => {
70 | setId(id);
71 | setType(props.type);
72 | setDataLoading(true);
73 | reset();
74 |
75 | return onSnapshot(
76 | doc(
77 | collection(
78 | firestore,
79 | props.type === ANONYMOUS
80 | ? "anonymousMasteryData"
81 | : "masteryData"
82 | ),
83 | id
84 | ),
85 | snapshot => {
86 | const data = snapshot.data();
87 |
88 | setItemsMastered(data?.mastered ?? []);
89 | setPartiallyMasteredItems(data?.partiallyMastered ?? {});
90 | setRailjackIntrinsics(data?.intrinsics ?? 0, true);
91 | setDrifterIntrinsics(data?.drifterIntrinsics ?? 0, true);
92 | setHideMastered(data?.hideMastered ?? false, true);
93 | setHidePrime(data?.hidePrime ?? false, true);
94 | setHideFounders(data?.hideFounders ?? true, true);
95 | setNodesMastered(data?.starChart ?? [], false);
96 | setNodesMastered(data?.steelPath ?? [], true);
97 | setJunctionsMastered(data?.starChartJunctions ?? [], false);
98 | setJunctionsMastered(data?.steelPathJunctions ?? [], true);
99 | setGameSyncInfo(data?.gameSyncUsername, data?.gameSyncId, data?.gameSyncPlatform);
100 | setPopupsDismissed(data?.popupsDismissed ?? []);
101 | setAccountLinkErrors(data?.accountLinkErrors ?? 0);
102 |
103 | setDataLoading(false);
104 |
105 | initGameSyncExperiment();
106 | }
107 | );
108 | }, [id, props.type]); //eslint-disable-line
109 |
110 | const { items, fetchData, gameSync } = useStore(state => ({
111 | items: state.items,
112 | fetchData: state.fetchData,
113 | gameSync: state.gameSync
114 | }));
115 | const itemsLoading = Object.keys(items).length === 0;
116 | useEffect(() => {
117 | fetchData();
118 | }, [fetchData]);
119 | useEffect(() => {
120 | if (props.type !== SHARED && !dataLoading && !itemsLoading) {
121 | const handleGameSync = async () => {
122 | try {
123 | await gameSync();
124 | } catch (error) {
125 | setSyncError(true);
126 |
127 | // Increment account link errors for experimental users
128 | if (gameSyncExperiment) {
129 | incrementAccountLinkErrors();
130 | }
131 | }
132 | };
133 |
134 | handleGameSync();
135 |
136 | const interval = setInterval(handleGameSync, 5 * 60 * 1000);
137 | return () => clearInterval(interval);
138 | }
139 | }, [
140 | props.type,
141 | dataLoading,
142 | itemsLoading,
143 | gameSync,
144 | gameSyncExperiment,
145 | incrementAccountLinkErrors,
146 | updateFirestore
147 | ]);
148 |
149 | const showGameSyncExperimentPopup =
150 | gameSyncExperiment &&
151 | !popupsDismissed.includes("experimental-account-link");
152 |
153 | return dataLoading || itemsLoading ? (
154 |
155 | ) : (
156 |
157 |
158 |
159 |
160 |
161 | {displayingNodes ? (
162 |
163 | ) : (
164 | <>
165 |
166 |
167 | >
168 | )}
169 |
170 | {syncError && (
171 |
172 |
173 | Recent stats failed to sync. Your last saved progress is
174 | still available.
175 | setSyncError(false)}>
176 | Ok
177 |
178 |
179 |
180 | )}
181 | {showGameSyncExperimentPopup && (
182 |
183 |
186 |
187 | Warframe Account Linking Now Available
188 |
189 |
194 | There is now a new "Link Account" button in the
195 | sidebar that allows you to link your Warframe
196 | account. This is an experimental feature available
197 | to a subset of our users. Accounts can be unlinked
198 | at any time.
199 |
200 |
206 |
207 | {
209 | const updatedPopups = [
210 | ...popupsDismissed,
211 | "experimental-account-link"
212 | ];
213 | setPopupsDismissed(updatedPopups);
214 | updateFirestore({
215 | popupsDismissed: updatedPopups
216 | });
217 | }}>
218 | Got it
219 |
220 |
221 |
222 |
223 |
224 | )}
225 |
226 | );
227 | }
228 |
229 | MasteryChecklist.propTypes = {
230 | type: PropTypes.oneOf([AUTHENTICATED, ANONYMOUS, SHARED]).isRequired,
231 | id: PropTypes.string
232 | };
233 |
234 | export default MasteryChecklist;
235 |
236 |
--------------------------------------------------------------------------------
/src/icons/framehub.svg:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 |
6 |
8 |
9 |
10 |
12 |
13 |
14 |
16 |
17 |
18 |
20 |
21 |
22 |
24 |
25 |
26 |
28 |
29 |
30 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
42 |
43 |
44 |
45 |
46 |
48 |
49 |
50 |
51 |
53 |
54 |
55 |
56 |
58 |
59 |
60 |
61 |
63 |
64 |
65 |
66 |
68 |
69 |
70 |
71 |
73 |
74 |
75 |
76 |
78 |
79 |
80 |
81 |
83 |
84 |
85 |
86 |
88 |
89 |
90 |
91 |
93 |
94 |
95 |
96 |
97 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright 2020 Paroxity
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
--------------------------------------------------------------------------------
/updater/update_items.mjs:
--------------------------------------------------------------------------------
1 | import FormData from "form-data";
2 | import fs from "fs/promises";
3 | import { colorize, diff } from "json-diff";
4 | import lua from "lua-json";
5 | import fetch from "node-fetch";
6 | import { JSDOM } from "jsdom";
7 | import { setOutput } from "@actions/core";
8 | import { fetchEndpoint } from "./warframe_exports.mjs";
9 |
10 | const SCHEMA_VERSION = 3;
11 |
12 | const ITEM_ENDPOINTS = ["Warframes", "Weapons", "Sentinels"];
13 | const WIKI_URL = "https://wiki.warframe.com/w";
14 | const DROP_TABLE_URL = "https://www.warframe.com/droptables";
15 |
16 | const ITEM_OVERWRITES = {
17 | AMP: {
18 | "Mote Prism": {
19 | id: "/Lotus/Weapons/Sentients/OperatorAmplifiers/SentTrainingAmplifier/SentAmpTrainingBarrel"
20 | }
21 | },
22 | PRIMARY: {
23 | "Ax-52": {
24 | wiki: `${WIKI_URL}/AX-52`
25 | },
26 | "Efv-5 Jupiter": {
27 | wiki: `${WIKI_URL}/EFV-5 Jupiter`
28 | }
29 | },
30 | SECONDARY: {
31 | "Efv-8 Mars": {
32 | wiki: `${WIKI_URL}/EFV-8 Mars`
33 | }
34 | },
35 | CAT: {
36 | Venari: {
37 | id: "/Lotus/Powersuits/Khora/Kavat/KhoraKavatPowerSuit"
38 | },
39 | "Venari Prime": {
40 | id: "/Lotus/Powersuits/Khora/Kavat/KhoraPrimeKavatPowerSuit"
41 | }
42 | },
43 | PLEXUS: {
44 | Plexus: {
45 | id: "/Lotus/Types/Game/CrewShip/RailJack/DefaultHarness"
46 | }
47 | }
48 | };
49 | const RECIPE_OVERWRITES = {
50 | "Mote Prism": {
51 | components: {
52 | "Cetus Wisp": 1,
53 | "Tear Azurite": 20,
54 | "Pyrotic Alloy": 10,
55 | "Fish Oil": 30
56 | },
57 | count: 1,
58 | time: 600,
59 | price: 1000
60 | },
61 | "Tenet Agendus": undefined
62 | };
63 | const BLACKLIST = [];
64 |
65 | const SISTER_WEAPONS = [
66 | "Tenet Arca Plasmor",
67 | "Tenet Flux Rifle",
68 | "Tenet Glaxion",
69 | "Tenet Tetra",
70 | "Tenet Cycron",
71 | "Tenet Detron",
72 | "Tenet Plinx",
73 | "Tenet Envoy",
74 | "Tenet Diplos",
75 | "Tenet Spirex"
76 | ];
77 | const HOLOKEY_WEAPONS = [
78 | "Tenet Agendus",
79 | "Tenet Exec",
80 | "Tenet Livia",
81 | "Tenet Grigori",
82 | "Tenet Ferrox"
83 | ];
84 | const MARKET_WEAPONS = {};
85 |
86 | class ItemUpdater {
87 | constructor(itemOverwrites, recipeOverwrites, blacklist) {
88 | this.itemOverwrites = itemOverwrites;
89 | this.recipeOverwrites = recipeOverwrites;
90 | this.blacklist = blacklist;
91 | }
92 |
93 | async run() {
94 | this.processedItems = {
95 | WF: {},
96 | PRIMARY: {},
97 | SECONDARY: {},
98 | KITGUN: {},
99 | MELEE: {},
100 | ZAW: {},
101 | SENTINEL: {},
102 | SENTINEL_WEAPON: {},
103 | AMP: {},
104 | AW: {},
105 | AW_GUN: {},
106 | AW_MELEE: {},
107 | DOG: {},
108 | INFESTED_DOG: {},
109 | CAT: {},
110 | INFESTED_CAT: {},
111 | MOA: {},
112 | HOUND: {},
113 | KDRIVE: {},
114 | MECH: {},
115 | PLEXUS: {}
116 | };
117 | this.processedRecipes = {};
118 | this.ingredientIds = {};
119 |
120 | await Promise.all([
121 | this.fetchBaroData(),
122 | this.fetchVaultStatus().then(() => this.fetchRelics()),
123 | this.fetchItems(),
124 | this.fetchResources(),
125 | this.fetchRecipes()
126 | ]);
127 |
128 | this.mapItemNames(this.items, this.resources);
129 | this.processItems();
130 | this.processedItems = this.mergeObjects(
131 | this.processedItems,
132 | this.itemOverwrites
133 | );
134 | this.processedRecipes = this.mergeObjects(
135 | this.processedRecipes,
136 | this.recipeOverwrites
137 | );
138 | this.orderItems();
139 | }
140 |
141 | orderItems() {
142 | this.processedItems = Object.entries(this.processedItems).reduce(
143 | (sortedCategories, [category, items]) => {
144 | sortedCategories[category] = Object.keys(items)
145 | .sort()
146 | .reduce((sortedItems, name) => {
147 | sortedItems[name] = items[name];
148 | return sortedItems;
149 | }, {});
150 | return sortedCategories;
151 | },
152 | {}
153 | );
154 | }
155 |
156 | mergeObjects(target, source) {
157 | const output = { ...target };
158 | Object.entries(source).forEach(([key, value]) => {
159 | if (
160 | value &&
161 | typeof value === "object" &&
162 | !Array.isArray(value) &&
163 | key in target
164 | )
165 | output[key] = this.mergeObjects(target[key], source[key]);
166 | else if (value === undefined) delete output[key];
167 | else Object.assign(output, { [key]: value });
168 | });
169 | return output;
170 | }
171 |
172 | processItems() {
173 | Object.values(this.items).forEach(item => {
174 | const type = this.categorizeItem(item);
175 | const name = this.processItemName(item.name);
176 | if (type && !this.blacklist.includes(name)) {
177 | const recipe = this.recipes[item.uniqueName];
178 | const processedItem = {
179 | id: item.uniqueName,
180 | maxLvl: type === "MECH" ? 40 : item.maxLevelCap,
181 | mr: item.masteryReq
182 | };
183 | if (recipe) {
184 | if (this.relics[recipe.uniqueName]) {
185 | processedItem.vaulted = Object.values(
186 | this.relics[recipe.uniqueName]
187 | ).every(relic => relic.vaulted);
188 | processedItem.relics = {
189 | [`${name} Blueprint`]: this.relics[recipe.uniqueName]
190 | };
191 | }
192 | this.processRecipe(processedItem, name, recipe);
193 | }
194 |
195 | const description = this.describeItem(name);
196 | if (description) processedItem.description = description;
197 |
198 | if (name.startsWith("Mk1-"))
199 | processedItem.wiki = `${WIKI_URL}/${name.replace("Mk1-", "MK1-")}`;
200 | else if (type === "MOA" || type === "HOUND")
201 | processedItem.wiki = `${WIKI_URL}/Model#${name.substr(
202 | 0,
203 | name.length - type.length - 1
204 | )}`;
205 |
206 | const baroData = this.baroData[name];
207 | if (baroData)
208 | processedItem.baro = [baroData.CreditCost, baroData.DucatCost];
209 |
210 | Object.entries(processedItem).forEach(([key, value]) => {
211 | if (!value) delete processedItem[key];
212 | });
213 | this.processedItems[type][name] = processedItem;
214 | }
215 | });
216 | }
217 |
218 | processRecipe(parentItem, itemName, recipe) {
219 | if (this.processedRecipes[itemName]) return;
220 |
221 | const processedRecipe = {
222 | count: recipe.num,
223 | time: recipe.buildTime,
224 | price: recipe.buildPrice
225 | };
226 | if (recipe.ingredients?.length > 0) {
227 | processedRecipe.components = {};
228 |
229 | recipe.ingredients.forEach(ingredient => {
230 | const ingredientRawName = ingredient.ItemType;
231 | const ingredientName = this.itemNames[ingredientRawName];
232 |
233 | if (
234 | this.recipes[ingredientRawName] &&
235 | (!ingredientRawName.includes("MiscItems") ||
236 | ingredientRawName === "/Lotus/Types/Items/MiscItems/Forma")
237 | ) {
238 | this.processRecipe(
239 | parentItem,
240 | ingredientName,
241 | this.recipes[ingredientRawName]
242 | );
243 | }
244 |
245 | processedRecipe.components[ingredientName] =
246 | (processedRecipe.components[ingredientName] ?? 0) +
247 | ingredient.ItemCount;
248 | if (!this.ingredientIds[ingredientName])
249 | this.ingredientIds[ingredientName] = ingredientRawName;
250 |
251 | const relics =
252 | this.relics[ingredientRawName] ||
253 | this.relics[ingredientRawName.replace("Component", "Blueprint")];
254 | if (relics && parentItem.relics)
255 | parentItem.relics[ingredientName] = relics;
256 | });
257 | }
258 | this.processedRecipes[itemName] = processedRecipe;
259 | }
260 |
261 | categorizeItem(item) {
262 | const uniqueName = item.uniqueName;
263 | let type;
264 | switch (item.productCategory) {
265 | case "Pistols":
266 | if (uniqueName.includes("ModularMelee")) {
267 | if (uniqueName.includes("Tip") && !uniqueName.includes("PvPVariant"))
268 | type = "ZAW";
269 | break;
270 | }
271 | if (
272 | uniqueName.includes("ModularPrimary") ||
273 | uniqueName.includes("ModularSecondary") ||
274 | uniqueName.includes("InfKitGun")
275 | ) {
276 | if (uniqueName.includes("Barrel")) type = "KITGUN";
277 | break;
278 | }
279 | if (uniqueName.includes("OperatorAmplifiers")) {
280 | if (uniqueName.includes("Barrel")) type = "AMP";
281 | break;
282 | }
283 | if (uniqueName.includes("Hoverboard")) {
284 | if (uniqueName.includes("Deck")) type = "KDRIVE";
285 | break;
286 | }
287 | if (uniqueName.includes("MoaPets")) {
288 | if (uniqueName.includes("MoaPetHead")) type = "MOA";
289 | break;
290 | }
291 | if (uniqueName.includes("ZanukaPets")) {
292 | if (uniqueName.includes("ZanukaPetPartHead")) type = "HOUND";
293 | break;
294 | }
295 | if (item.slot === 0) type = "SECONDARY";
296 | break;
297 | case "KubrowPets":
298 | type = uniqueName.includes("InfestedCatbrow")
299 | ? "INFESTED_CAT"
300 | : uniqueName.includes("Catbrow")
301 | ? "CAT"
302 | : uniqueName.includes("PredatorKubrow")
303 | ? "INFESTED_DOG"
304 | : "DOG";
305 | break;
306 | default:
307 | type = {
308 | SpaceMelee: "AW_MELEE",
309 | SpaceGuns: "AW_GUN",
310 | SpaceSuits: "AW",
311 | Suits: "WF",
312 | MechSuits: "MECH",
313 | LongGuns: "PRIMARY",
314 | Melee: "MELEE",
315 | Sentinels: "SENTINEL",
316 | SentinelWeapons: "SENTINEL_WEAPON",
317 | OperatorAmps: "AMP"
318 | }[item.productCategory];
319 | }
320 | return type;
321 | }
322 |
323 | describeItem(itemName) {
324 | const prefix = itemName.split(" ")[0];
325 | switch (prefix) {
326 | case "Dex":
327 | return "Acquire from yearly anniversary alerts";
328 | case "Vaykor":
329 | return "Purchase from Steel Meridian for 125,000 standing";
330 | case "Rakta":
331 | return "Purchase from Red Veil for 125,000 standing";
332 | case "Secura":
333 | return "Purchase from The Perrin Sequence for 125,000 standing";
334 | case "Sancti":
335 | return "Purchase from New Loka for 125,000 standing";
336 | case "Telos":
337 | return "Purchase from Arbiters of Hexis for 125,000 standing";
338 | case "Synoid":
339 | return "Purchase from Cephalon Suda for 125,000 standing";
340 | case "Kuva":
341 | return "Acquire by vanquishing a Kuva Lich";
342 | case "Tenet":
343 | if (SISTER_WEAPONS.includes(itemName))
344 | return "Acquire by vanquishing a Sister of Parvos";
345 | if (HOLOKEY_WEAPONS.includes(itemName))
346 | return "Purchase from Ergo Glast for 40 Corrupted Holokeys";
347 | break;
348 | case "Coda":
349 | return "Purchase from Eleanor in the Höllvania Central Mall for 10 Live Heartcells";
350 | }
351 | }
352 |
353 | mapItemNames() {
354 | this.itemNames = {};
355 |
356 | Object.values(Array.from(arguments)).forEach(items => {
357 | items.forEach(item => {
358 | if (item.uniqueName && item.name)
359 | this.itemNames[item.uniqueName] = this.processItemName(item.name);
360 | });
361 | });
362 | }
363 |
364 | processItemName(name) {
365 | return name
366 | .replace(" ", "")
367 | .toLowerCase()
368 | .split(" ")
369 | .map(word => word.charAt(0).toUpperCase() + word.slice(1))
370 | .join(" ")
371 | .split("-")
372 | .map(word => word.charAt(0).toUpperCase() + word.slice(1))
373 | .join("-");
374 | }
375 |
376 | async fetchRelics() {
377 | this.relics = {};
378 | (await fetchEndpoint("RelicArcane")).ExportRelicArcane.forEach(relic => {
379 | if (relic.relicRewards)
380 | relic.relicRewards.forEach(reward => {
381 | const processedRelic = {
382 | rarity:
383 | reward.rarity === "COMMON"
384 | ? 0
385 | : reward.rarity === "UNCOMMON"
386 | ? 1
387 | : 2
388 | };
389 | if (this.vaultedRelics.includes(relic.name))
390 | processedRelic.vaulted = true;
391 |
392 | const rewardName = reward.rewardName.replace("/StoreItems", "");
393 | if (!this.relics[rewardName]) this.relics[rewardName] = {};
394 | this.relics[rewardName][this.processItemName(relic.name)] =
395 | processedRelic;
396 | });
397 | });
398 | }
399 |
400 | async fetchRecipes() {
401 | this.recipes = (await fetchEndpoint("Recipes")).ExportRecipes.reduce(
402 | (recipes, recipe) => {
403 | const invalidBPs = [
404 | "/Lotus/Types/Recipes/Weapons/CorpusHandcannonBlueprint",
405 | "/Lotus/Types/Recipes/Weapons/GrineerCombatKnifeBlueprint"
406 | ];
407 | if (
408 | !invalidBPs.includes(recipe.uniqueName) &&
409 | !recipes[recipe.resultType]
410 | )
411 | recipes[recipe.resultType] = recipe;
412 | return recipes;
413 | },
414 | {}
415 | );
416 | }
417 |
418 | async fetchItems() {
419 | const data = await Promise.all(
420 | ITEM_ENDPOINTS.map(async e => {
421 | return (await fetchEndpoint(e))[`Export${e}`];
422 | })
423 | );
424 | this.items = data.reduce((merged, d) => {
425 | return [...merged, ...d];
426 | }, []);
427 | }
428 |
429 | async fetchResources() {
430 | this.resources = (await fetchEndpoint("Resources")).ExportResources;
431 | }
432 |
433 | async fetchBaroData() {
434 | const luaTable = await (
435 | await fetch(WIKI_URL + "/Module:Baro/data?action=raw")
436 | ).text();
437 | this.baroData = lua.parse(luaTable).Items;
438 | }
439 |
440 | async fetchVaultStatus() {
441 | const document = (await JSDOM.fromURL(DROP_TABLE_URL)).window.document;
442 | const relics = Array.from(document.querySelectorAll("th"))
443 | .filter(e => e.innerHTML.includes(" Relic (Intact)"))
444 | .map(e => e.innerHTML.replace(" (Intact)", ""));
445 | const unvaultedRelics = Array.from(document.querySelectorAll("td"))
446 | .map(e => e.innerHTML)
447 | .filter(i => i.includes(" Relic"));
448 | this.vaultedRelics = relics.filter(
449 | relic => !unvaultedRelics.includes(relic)
450 | );
451 | }
452 | }
453 |
454 | (async () => {
455 | const startTime = Date.now();
456 |
457 | let existingData;
458 | try {
459 | existingData = JSON.parse(await fs.readFile("items.json", "utf8"));
460 | } catch (e) {
461 | existingData = await (
462 | await fetch(
463 | "https://firebasestorage.googleapis.com/v0/b/framehub-f9cfb.appspot.com/o/items.json?alt=media"
464 | )
465 | ).json();
466 | }
467 |
468 | const updater = new ItemUpdater(
469 | ITEM_OVERWRITES,
470 | RECIPE_OVERWRITES,
471 | BLACKLIST
472 | );
473 | await updater.run();
474 |
475 | const data = {
476 | schema_version: SCHEMA_VERSION,
477 | items: updater.processedItems,
478 | recipes: updater.processedRecipes,
479 | ingredient_ids: updater.ingredientIds
480 | };
481 | const difference = diff(existingData, data);
482 | if (difference || process.env.FORCE_UPLOAD === "true") {
483 | await fs.writeFile("items.json", JSON.stringify(data));
484 | console.log(colorize(difference));
485 | console.log(
486 | `File size: ${((await fs.stat("items.json")).size / 1024).toFixed(3)}KB`
487 | );
488 |
489 | if (process.env.DISCORD_WEBHOOK && process.env.DISCORD_ADMIN_IDS) {
490 | const form = new FormData();
491 | form.append(
492 | "content",
493 | process.env.DISCORD_ADMIN_IDS.split(",")
494 | .map(id => `<@${id}>`)
495 | .join(" ")
496 | );
497 | form.append("file", colorize(difference, { color: false }), "items.diff");
498 | form.submit(process.env.DISCORD_WEBHOOK, (err, res) => {
499 | if (err) {
500 | console.log("Discord webhook error: " + err);
501 | return;
502 | }
503 | console.log("Discord webhook message sent");
504 | res.resume();
505 | });
506 | }
507 | }
508 | console.log(`Completed in ${(Date.now() - startTime) / 1000} seconds.`);
509 |
510 | if (process.env.GITHUB_ACTION)
511 | setOutput(
512 | "updated",
513 | difference !== undefined || process.env.FORCE_UPLOAD === "true"
514 | );
515 | })();
516 |
--------------------------------------------------------------------------------
/src/App.scss:
--------------------------------------------------------------------------------
1 | html,
2 | body,
3 | input,
4 | button {
5 | cursor: url("icons/cursor.png"), auto !important;
6 | background-color: #05040e;
7 | }
8 |
9 | @media (max-height: 720px) {
10 |
11 | html,
12 | input,
13 | button {
14 | font-size: 0.75em;
15 | }
16 | }
17 |
18 | .app {
19 | background-color: #05040e;
20 | color: #bea966;
21 | margin-bottom: 20em;
22 | }
23 |
24 | h1,
25 | h2,
26 | h3,
27 | h4,
28 | h5,
29 | h6 {
30 | color: white;
31 | }
32 |
33 | /* Categories */
34 | .category-name,
35 | .category-xp,
36 | .items-mastered,
37 | .xp,
38 | .mastery-progress,
39 | .category,
40 | .planet {
41 | font-size: 1.1em;
42 | }
43 |
44 | .category-name {
45 | text-transform: uppercase;
46 | font-weight: 700;
47 | }
48 |
49 | .category,
50 | .planet {
51 | flex: 1 0 auto;
52 | margin-top: 1em;
53 | overflow: hidden;
54 | }
55 |
56 | .mastery-rank {
57 | font-size: 1.5em;
58 | text-transform: uppercase;
59 | font-weight: 700;
60 | }
61 |
62 | .category-info {
63 | display: block;
64 | }
65 |
66 | .masonry-grid {
67 | display: -webkit-box;
68 | /* Not needed if autoprefixing */
69 | display: -ms-flexbox;
70 | /* Not needed if autoprefixing */
71 | display: flex;
72 | width: auto;
73 | }
74 |
75 | .masonry-grid_column {
76 | padding: 0 15px;
77 | /* gutter size */
78 | background-clip: padding-box;
79 | }
80 |
81 | @media (max-width: 550px) {
82 | .masonry-grid_column {
83 | padding-left: 0;
84 | padding-right: 0;
85 | background-clip: padding-box;
86 | }
87 | }
88 |
89 | /* Standard button */
90 | button {
91 | font-family: "Roboto", sans-serif;
92 | text-transform: uppercase;
93 | color: #bea966;
94 | font-weight: 700;
95 | font-size: 1.25em;
96 | display: inline-block;
97 | position: relative;
98 | background-color: rgba(245, 227, 173, 0.05);
99 | border: 1px solid rgba(245, 227, 173, 0);
100 | padding: 3px 10px;
101 | transition: all 0.2s ease-out;
102 | }
103 |
104 | button:focus {
105 | outline: none;
106 | }
107 |
108 | button:hover {
109 | border: 1px solid rgba(245, 227, 173, 0.25);
110 | }
111 |
112 | button::before {
113 | content: "";
114 | display: block;
115 | position: absolute;
116 | height: 0;
117 | bottom: 0;
118 | left: 0;
119 | width: 100%;
120 | background: linear-gradient(180deg,
121 | rgba(0, 0, 0, 0) 0%,
122 | rgba(245, 227, 173, 0.25) 100%);
123 | transition: all 0.2s ease-out;
124 | }
125 |
126 | button:hover::before {
127 | height: 100%;
128 | }
129 |
130 | /* Styled select to match button */
131 | select {
132 | font-family: "Roboto", sans-serif;
133 | text-transform: uppercase;
134 | color: #bea966;
135 | font-weight: 700;
136 | font-size: 1.25em;
137 | display: inline-block;
138 | position: relative;
139 | background-color: rgba(245, 227, 173, 0.05);
140 | border: 1px solid rgba(245, 227, 173, 0);
141 | padding: 3px 10px;
142 | transition: all 0.2s ease-out;
143 | appearance: none;
144 | -webkit-appearance: none;
145 | -moz-appearance: none;
146 | background-image: none;
147 | }
148 |
149 | select:focus {
150 | outline: none;
151 | }
152 |
153 | select:hover {
154 | border: 1px solid rgba(245, 227, 173, 0.25);
155 | }
156 |
157 | select:disabled {
158 | pointer-events: none;
159 | }
160 |
161 | .button {
162 | position: relative;
163 | width: fit-content;
164 | margin: 10px auto;
165 | display: block;
166 | transition: all 0.2s;
167 | opacity: 1;
168 | z-index: 0;
169 | }
170 |
171 | .button::before {
172 | content: "";
173 | display: block;
174 | position: absolute;
175 | height: 2px;
176 | bottom: 0;
177 | left: 0;
178 | width: 100%;
179 | background-color: rgba(245, 227, 173, 0.25);
180 | }
181 |
182 | .button::after,
183 | .input::after {
184 | content: "";
185 | display: block;
186 | position: absolute;
187 | height: 2px;
188 | bottom: 0;
189 | left: 0;
190 | width: 100%;
191 | background-color: #f5e3ad;
192 | visibility: hidden;
193 | transform: scaleX(0);
194 | transition: all 0.2s ease-out;
195 | pointer-events: none;
196 | }
197 |
198 | .button:hover::after {
199 | visibility: visible;
200 | transform: scaleX(1);
201 | }
202 |
203 | .button.center {
204 | margin: 5px auto;
205 | display: block;
206 | }
207 |
208 | .item-mastered {
209 | .button::after {
210 | visibility: visible;
211 | transform: scaleX(1);
212 | }
213 |
214 | .button:hover::after {
215 | visibility: hidden;
216 | transform: scaleX(0);
217 | }
218 |
219 | button::before {
220 | height: 100%;
221 | }
222 |
223 | button:hover::before {
224 | height: 0;
225 | }
226 |
227 | button {
228 | border: 1px solid rgba(245, 227, 173, 0.25);
229 | }
230 |
231 | button:hover {
232 | border: 1px solid rgba(245, 227, 173, 0);
233 | }
234 | }
235 |
236 | .popup-box.button {
237 | margin-top: 1em;
238 | width: auto;
239 |
240 | button,
241 | select {
242 | width: 100%;
243 | height: 100%;
244 | }
245 | }
246 |
247 | .item {
248 | .button {
249 | width: 100%;
250 |
251 | button {
252 | width: 100%;
253 | padding-left: 0;
254 | padding-right: 0;
255 | }
256 | }
257 | }
258 |
259 | .item-locked {
260 | .button {
261 | opacity: 0.5;
262 | }
263 |
264 | .button:hover {
265 | opacity: 1;
266 | }
267 | }
268 |
269 | .item-locked.item-mastered {
270 | .button {
271 | opacity: 1;
272 | }
273 | }
274 |
275 | .button.disabled {
276 | opacity: 0.5;
277 | pointer-events: none;
278 | }
279 |
280 | /* Text/Password input */
281 | input[type="text"],
282 | input[type="email"],
283 | input[type="password"],
284 | input[type="number"] {
285 | background-color: rgba(245, 227, 173, 0.05);
286 | color: #f5e3ad;
287 | caret-color: #f5e3ad;
288 | padding: 5px 0.75em 0.1em;
289 | font-size: 1.25em;
290 | border: 1px solid rgba(245, 227, 173, 0);
291 | transition: all 0.2s ease-out;
292 | }
293 |
294 | input[type="text"]:hover,
295 | input[type="email"]:hover,
296 | input[type="password"]:hover,
297 | input[type="number"]:hover,
298 | input[type="text"]:focus,
299 | input[type="email"]:focus,
300 | input[type="password"]:focus,
301 | input[type="number"]:focus {
302 | border: 1px solid rgba(245, 227, 173, 0.25);
303 | }
304 |
305 | input::placeholder {
306 | color: #bea966;
307 | font-family: "Roboto", sans-serif;
308 | }
309 |
310 | .form-bg {
311 | position: relative;
312 | width: fit-content;
313 | margin: 10px;
314 | }
315 |
316 | .form-bg::before {
317 | content: "";
318 | display: block;
319 | position: absolute;
320 | height: 0;
321 | bottom: 0;
322 | left: 0;
323 | width: 100%;
324 | background: linear-gradient(180deg,
325 | rgba(0, 0, 0, 0) 0%,
326 | rgba(245, 227, 173, 0.25) 90%);
327 | transition: all 0.2s ease-out;
328 | }
329 |
330 | .form-bg:hover::before {
331 | height: 100%;
332 | }
333 |
334 | .form-bg:focus-within::before {
335 | height: 100%;
336 | }
337 |
338 | .form-bg.center {
339 | margin: 5px auto;
340 | }
341 |
342 | .input {
343 | position: relative;
344 | }
345 |
346 | input:focus {
347 | outline: none;
348 | }
349 |
350 | .input::before {
351 | content: "";
352 | display: block;
353 | position: absolute;
354 | height: 2px;
355 | bottom: 0;
356 | left: 0;
357 | width: 100%;
358 | background-color: #161827;
359 | }
360 |
361 | .input:hover::after {
362 | visibility: visible;
363 | transform: scaleX(1);
364 | }
365 |
366 | .input:focus-within::after {
367 | visibility: visible;
368 | transform: scaleX(1);
369 | }
370 |
371 | input:disabled {
372 | pointer-events: none;
373 | }
374 |
375 | .custom-select {
376 | background-color: rgba(245, 227, 173, 0.05);
377 | border: 1px solid rgba(245, 227, 173, 0);
378 | padding: 5px 0.75em 0.1em;
379 | font-size: 1.25em;
380 | cursor: url("icons/cursor.png"), auto !important;
381 | color: #bea966;
382 | font-family: "Roboto", sans-serif;
383 | text-align: left;
384 | }
385 |
386 | .select-container:focus-within::before {
387 | height: 0;
388 | }
389 |
390 | .custom-select:focus-within::after {
391 | visibility: hidden;
392 | transform: scaleX(0);
393 | }
394 |
395 | .custom-select .select-label {
396 | color: #bea966;
397 | display: block;
398 | text-align: left;
399 | }
400 |
401 | .custom-select .select-arrow {
402 | position: absolute;
403 | right: 0.75em;
404 | top: 50%;
405 | transform: translateY(-50%);
406 | color: #bea966;
407 | pointer-events: none;
408 | }
409 |
410 | .custom-select.disabled {
411 | opacity: 0.5;
412 | pointer-events: none;
413 | }
414 |
415 | .select-dropdown {
416 | position: absolute;
417 | left: 0;
418 | right: 0;
419 | top: calc(100% + 6px);
420 | z-index: 10;
421 | }
422 |
423 | .select-dropdown-box {
424 | background-color: rgba(8, 6, 21, 1);
425 | position: relative;
426 | padding: 0;
427 | color: white;
428 | }
429 |
430 | .select-dropdown-box::before {
431 | content: "";
432 | position: absolute;
433 | width: calc(100% + 2px);
434 | height: calc(100% + 2px);
435 | right: -1px;
436 | top: -1px;
437 | background: linear-gradient(60deg, #f5e3ad, #bea966, #f5e3ad, #f5e3ad, #bea966);
438 | z-index: -1;
439 | animation: animatedgradient 3s ease alternate infinite;
440 | background-size: 300% 300%;
441 | }
442 |
443 | .select-option {
444 | padding: 0.375em 0.5em;
445 | text-align: left;
446 | white-space: nowrap;
447 | cursor: url("icons/cursor.png"), auto !important;
448 | font-size: 1em;
449 | }
450 |
451 | .select-option:hover {
452 | background-color: rgba(64, 57, 45, 0.4);
453 | }
454 |
455 | .select-option.selected,
456 | .select-option.selected:hover {
457 | background-color: #40392d;
458 | }
459 |
460 | /* Loading Screen */
461 | .loading {
462 | font-size: 35px;
463 | }
464 |
465 | .spinner {
466 | border: 20px solid transparent;
467 | border-top: 20px solid #c9ba8f;
468 | border-radius: 50%;
469 | width: 192px;
470 | height: 192px;
471 | animation: spin 0.4s ease-out infinite;
472 | }
473 |
474 | .spinner-small {
475 | border: 2px solid transparent;
476 | border-top: 2px solid #c9ba8f;
477 | border-radius: 50%;
478 | width: 16px;
479 | height: 16px;
480 | animation: spin 0.4s ease-out infinite;
481 | display: inline-block;
482 | margin: 0;
483 | }
484 |
485 | @keyframes spin {
486 | 100% {
487 | transform: rotate(360deg);
488 | }
489 | }
490 |
491 | /* Login Screen */
492 | .login,
493 | .loading,
494 | .sidebar {
495 | display: flex;
496 | flex-direction: column;
497 | justify-content: center;
498 | align-items: center;
499 | text-align: center;
500 | min-height: 100vh;
501 | user-select: none;
502 | -moz-user-select: none;
503 | -webkit-user-select: none;
504 | -ms-user-select: none;
505 | }
506 |
507 | .login .framehub-logo {
508 | margin-bottom: 21px;
509 | }
510 |
511 | @media (min-width: 620px) {
512 | .actions {
513 | bottom: 20px;
514 | right: 20px;
515 | position: absolute;
516 |
517 | .button {
518 | display: inline-block;
519 | margin: 10px 5px;
520 | }
521 | }
522 |
523 | .alternative-login {
524 | bottom: 20px;
525 | left: 20px;
526 | position: absolute;
527 |
528 | .button {
529 | display: inline-block;
530 | margin: 10px 5px;
531 | }
532 | }
533 | }
534 |
535 | .framehub-logo {
536 | width: 40%;
537 | user-select: none;
538 | -moz-user-select: none;
539 | -webkit-user-select: none;
540 | -ms-user-select: none;
541 | }
542 |
543 | @media (max-width: 620px) {
544 | .button {
545 | margin: 5px auto;
546 | display: block;
547 | }
548 |
549 | .actions {
550 | margin: -5px;
551 | }
552 |
553 | .framehub-logo {
554 | width: 60%;
555 | }
556 | }
557 |
558 | .popup {
559 | height: 100%;
560 | width: 100%;
561 | z-index: 100;
562 | left: 0;
563 | top: 0;
564 | position: fixed;
565 | overflow: hidden;
566 | background-color: rgba(8, 6, 21, 0.75);
567 | display: flex;
568 | flex-direction: column;
569 | justify-content: center;
570 | align-items: center;
571 | text-align: center;
572 | min-height: 100vh;
573 | opacity: 0;
574 | transition: all 0.5s;
575 | pointer-events: none;
576 | }
577 |
578 | .show {
579 | opacity: 1;
580 | pointer-events: all;
581 | }
582 |
583 | .popup-box {
584 | color: white;
585 | font-size: 1.25em;
586 | padding: 3em 6em;
587 | background-color: rgba(8, 6, 21, 1);
588 | position: relative;
589 |
590 | .input {
591 | margin-top: 1em;
592 | }
593 |
594 | /* Make form inputs and custom select full-width in popups */
595 | .form-bg {
596 | width: 100%;
597 | }
598 |
599 | .input {
600 | width: 100%;
601 | }
602 |
603 | .input input {
604 | width: 100%;
605 | box-sizing: border-box;
606 | }
607 |
608 | .custom-select {
609 | width: 100%;
610 | box-sizing: border-box;
611 | }
612 | }
613 |
614 | /* LinkPrompt popup adjustments */
615 | .link-popup {
616 | text-align: left;
617 | }
618 |
619 | /* Reduce left-right padding for LinkPrompt */
620 | .popup-box.link-popup {
621 | padding: 3em 3em;
622 | /* was 3em 6em */
623 | }
624 |
625 | /* Make LinkPrompt header gold and slightly smaller */
626 | .link-popup .mastery-rank {
627 | font-size: 1.25em;
628 | /* smaller than default */
629 | color: #bea966;
630 | /* gold */
631 | padding-bottom: 16px;
632 | /* adjust per request */
633 | }
634 |
635 | /* Reduce space between header and first form control */
636 | .popup-box.link-popup .input:first-of-type {
637 | margin-top: 0.33em;
638 | /* was 1em */
639 | }
640 |
641 | /* Inline error box for LinkPrompt */
642 | .link-popup .error-box {
643 | border: 1px solid #ff4d4f;
644 | background-color: rgba(255, 77, 79, 0.12);
645 | color: #ff8082;
646 | padding: 0.5em 0.75em;
647 | margin-top: 0.5em;
648 | margin-bottom: 0.5em;
649 | text-transform: none;
650 | }
651 |
652 | .link-popup .button-row {
653 | display: flex;
654 | justify-content: space-between;
655 | gap: 1em;
656 | flex-wrap: nowrap;
657 | margin-top: 1em;
658 | }
659 |
660 | .link-popup .button-row .button {
661 | flex: 1 1 0;
662 | /* equal width buttons in a row */
663 | margin: 0;
664 | /* override default center margins */
665 | width: 100%;
666 | }
667 |
668 | .link-popup .button-row .button.center {
669 | margin: 0;
670 | /* ensure not centered in flex row */
671 | }
672 |
673 | .link-popup .button-row .button button {
674 | width: 100%;
675 | }
676 |
677 | .popup-box::before {
678 | content: "";
679 | position: absolute;
680 | width: calc(100% + 2px);
681 | height: calc(100% + 2px);
682 | right: -1px;
683 | top: -1px;
684 | background: linear-gradient(60deg,
685 | #f5e3ad,
686 | #bea966,
687 | #f5e3ad,
688 | #f5e3ad,
689 | #bea966);
690 | z-index: -1;
691 | animation: animatedgradient 3s ease alternate infinite;
692 | background-size: 300% 300%;
693 | }
694 |
695 | @keyframes animatedGradient {
696 | 0% {
697 | background-position: 0 50%;
698 | }
699 |
700 | 50% {
701 | background-position: 100% 50%;
702 | }
703 |
704 | 100% {
705 | background-position: 0 50%;
706 | }
707 | }
708 |
709 | /* Content */
710 | .content {
711 | margin-left: 25em;
712 | margin-right: 2em;
713 | text-align: center;
714 |
715 | .framehub-logo {
716 | padding: 3em 0;
717 | }
718 | }
719 |
720 | .component-image,
721 | .credits,
722 | .ducats {
723 | vertical-align: middle;
724 | user-select: none;
725 | -moz-user-select: none;
726 | -webkit-user-select: none;
727 | -ms-user-select: none;
728 | }
729 |
730 | /* Sidebar */
731 | .sidebar {
732 | & {
733 | text-align: center;
734 | float: left;
735 | position: fixed;
736 | top: 0;
737 | height: 100%;
738 | width: 19em;
739 | padding: 0 2em 2em;
740 | border-right: 1px solid #bea966;
741 | color: #bea966;
742 | z-index: 2;
743 | transition: opacity 0.2s;
744 | opacity: 1;
745 | }
746 |
747 | input {
748 | width: 4em;
749 | }
750 | }
751 |
752 | .labeled-input {
753 | span {
754 | margin-right: 10px;
755 | font-size: 1.25em;
756 | color: #bea966;
757 | }
758 |
759 | span::after {
760 | content: ":";
761 | }
762 |
763 | div {
764 | display: inline-block;
765 | }
766 |
767 | & {
768 | position: relative;
769 | }
770 | }
771 |
772 | .hamburger {
773 | display: none;
774 | top: 1em;
775 | left: 1em;
776 | position: fixed;
777 | width: 30px;
778 | transform: rotate(90deg);
779 | z-index: 2;
780 | }
781 |
782 | @media (max-width: 950px) {
783 | .sidebar {
784 | opacity: 0;
785 | pointer-events: none;
786 | width: 100%;
787 | background-color: #05040e;
788 | padding: 0;
789 | bottom: 0;
790 | position: fixed;
791 | z-index: 1;
792 | height: auto;
793 | overflow-y: scroll;
794 | overflow-x: hidden;
795 |
796 | .labeled-input {
797 | span {
798 | padding-right: 10px;
799 | }
800 | }
801 | }
802 |
803 | .sidebar.toggled {
804 | opacity: 1;
805 | pointer-events: all;
806 | }
807 |
808 | .content {
809 | margin: 0 2em;
810 |
811 | .labeled-input {
812 | span {
813 | margin-right: 10px;
814 | }
815 | }
816 | }
817 |
818 | .radio-checkbox {
819 | display: block;
820 | margin: 10px auto !important;
821 | width: 100%;
822 | }
823 |
824 | .hamburger {
825 | display: block;
826 | }
827 | }
828 |
829 | .xp,
830 | .items-mastered {
831 | user-select: text !important;
832 | -moz-user-select: text !important;
833 | -webkit-user-select: text !important;
834 | -ms-user-select: text !important;
835 | }
836 |
837 | /* Radio checkbox */
838 | .radio-checkbox {
839 | & {
840 | user-select: none;
841 | -moz-user-select: none;
842 | -webkit-user-select: none;
843 | -ms-user-select: none;
844 | border: 2px solid #6c6046;
845 | display: block;
846 | margin: 10px auto !important;
847 | width: fit-content;
848 | }
849 |
850 | input[type="radio"] {
851 | opacity: 0;
852 | position: fixed;
853 | width: 0;
854 | pointer-events: none;
855 | }
856 | }
857 |
858 | .radio-checkbox:hover {
859 | border-color: #96855e;
860 | }
861 |
862 | .radio-checkbox div {
863 | display: inline-block;
864 | background-color: #18161b;
865 | padding: 5px 10px;
866 | cursor: url("icons/cursor.png"), auto !important;
867 |
868 | img {
869 | margin-bottom: -1px;
870 | width: 15px;
871 | }
872 | }
873 |
874 | .radio-checkbox .selected {
875 | background-color: #40392d;
876 | }
877 |
878 | /* Tooltip */
879 | .tooltip {
880 | text-align: left;
881 | font-family: "Roboto", sans-serif;
882 | font-weight: 700;
883 | background-color: #14131d;
884 | border: 1px solid #39332e;
885 | width: fit-content;
886 | color: #f5e3ad;
887 | text-transform: uppercase;
888 | padding: 5px 10px;
889 | position: fixed;
890 | z-index: 10;
891 | pointer-events: none;
892 | white-space: nowrap;
893 |
894 | .info {
895 | display: block;
896 | text-transform: unset;
897 | padding: 10px 0;
898 | color: white;
899 | font-weight: 400;
900 | margin: 10px 10px 0;
901 | background: linear-gradient(90deg,
902 | rgba(0, 0, 0, 0) 0%,
903 | rgba(129, 107, 62, 1) 50%,
904 | rgba(0, 0, 0, 0) 100%);
905 | }
906 | }
907 |
908 | .item-uncraftable {
909 | max-width: 24em;
910 | text-wrap: wrap;
911 | }
912 |
913 | .item-subcomponent {
914 | margin-left: 30px;
915 | }
916 |
917 | .mastery-progress-bar {
918 | background: rgb(75, 85, 90);
919 | appearance: none;
920 | border: none;
921 | width: 100%;
922 | }
923 |
924 | .mastery-progress-bar::-webkit-progress-bar {
925 | background: rgb(75, 85, 90);
926 | }
927 |
928 | .mastery-progress-bar::-webkit-progress-value {
929 | background: #bea966;
930 | transition: width 0.5s ease;
931 | }
932 |
933 | .mastery-progress-bar::-moz-progress-bar {
934 | background: #bea966;
935 | }
936 |
937 | .mastery-rank,
938 | .xp,
939 | .mastery-progress {
940 | padding-bottom: 21px;
941 | }
942 |
943 | .mastery-progress {
944 | padding-top: 5px;
945 | }
946 |
947 | .mastery-info>span {
948 | display: block;
949 | }
950 |
951 | .mastery-breakdown-entry {
952 | display: block;
953 | }
954 |
955 | .mastery-breakdown-xp {
956 | font-weight: bold;
957 | margin-right: 0.25em;
958 | }
959 |
960 | .mastery-breakdown-name {
961 | color: #bea966;
962 | text-transform: none;
963 | }
964 |
965 | .bold {
966 | font-weight: bold;
967 | }
968 |
969 | .danger {
970 | border: 1px solid #bea966;
971 | padding: 0.5em 1em;
972 | margin-bottom: 21px;
973 | }
974 |
975 | .danger-text {
976 | margin-top: 1em;
977 | color: #bea966;
978 | text-transform: uppercase;
979 | font-family: "Roboto", sans-serif;
980 | font-weight: 700;
981 | }
982 |
983 | .disclaimer {
984 | position: absolute;
985 | bottom: 2em;
986 | font-size: 0.75em;
987 | width: 100%;
988 | text-align: center;
989 | color: #bea966;
990 | }
991 |
992 | .checkmark {
993 | width: 15px;
994 | padding-left: 10px;
995 | }
996 |
997 | .autosave-text {
998 | padding: 10px 0;
999 | }
1000 |
1001 | .foundry {
1002 | text-align: left;
1003 | }
1004 |
1005 | .social {
1006 | display: flex;
1007 | justify-content: space-between;
1008 |
1009 | a {
1010 | margin: 0.5em;
1011 | }
1012 | }
1013 |
1014 | .item-tooltip {
1015 | &> :not(:last-child) {
1016 | margin-bottom: 1em;
1017 | }
1018 |
1019 | &>span {
1020 | display: block;
1021 | }
1022 | }
1023 |
1024 | .pagination-ui {
1025 | float: right;
1026 |
1027 | .pagination-page {
1028 | display: inline-block;
1029 | border: 1px solid #bea966;
1030 | width: 0.375em;
1031 | height: 0.375em;
1032 | margin: 0 0.2em;
1033 | transform: rotate(45deg);
1034 | }
1035 |
1036 | .pagination-page-current {
1037 | background: #bea966;
1038 | }
1039 |
1040 | .pagination-key {
1041 | font-size: 0.825em;
1042 | text-transform: none;
1043 | margin-left: 0.5em;
1044 | border: 1px solid #bea966;
1045 | border-radius: 0.25em;
1046 | padding: 0.25em 0.125em;
1047 | }
1048 | }
1049 |
1050 | .relics {
1051 | &>span {
1052 | display: block;
1053 | padding-left: 1em;
1054 | }
1055 |
1056 | .relic-rarity-common {
1057 | color: #9c7344;
1058 | }
1059 |
1060 | .relic-rarity-uncommon {
1061 | color: #d3d3d3;
1062 | }
1063 |
1064 | .relic-rarity-rare {
1065 | color: #d1b962;
1066 | }
1067 | }
1068 |
1069 | .vaulted-relic-disclaimer {
1070 | display: block;
1071 | margin-top: 1em;
1072 | }
1073 |
1074 | .rank-options {
1075 | display: flex;
1076 | margin: 0 0.5em;
1077 | }
1078 |
1079 | .rank-option {
1080 | flex-basis: 100%;
1081 | margin: 0 0.125em;
1082 | border-radius: 0.25em;
1083 | background: #bea966;
1084 | color: white;
1085 | }
1086 |
1087 | .steel-path-toggle {
1088 | margin-bottom: 1em;
1089 | }
1090 |
1091 | .mission-info {
1092 | margin-bottom: 2em;
1093 | }
1094 |
1095 | .faction-icon {
1096 | width: 1em;
1097 | display: inline-block;
1098 | }
1099 |
--------------------------------------------------------------------------------
/src/hooks/useStore.js:
--------------------------------------------------------------------------------
1 | import {
2 | addDoc,
3 | arrayRemove,
4 | arrayUnion,
5 | collection,
6 | deleteField,
7 | doc,
8 | getDoc,
9 | updateDoc,
10 | writeBatch
11 | } from "firebase/firestore";
12 | import { getMetadata, ref } from "firebase/storage";
13 | import { produce } from "immer";
14 | import { shallow } from "zustand/shallow";
15 | import { createWithEqualityFn } from "zustand/traditional";
16 | import { firestore, storage } from "../App";
17 | import { ANONYMOUS, SHARED } from "../utils/checklist-types";
18 | import { foundersItems, SCHEMA_VERSION } from "../utils/items";
19 | import {
20 | intrinsicsToXP,
21 | itemLevelByXP,
22 | junctionsToXP,
23 | totalDrifterIntrinsics,
24 | totalRailjackIntrinsics,
25 | xpFromItem,
26 | xpToMR
27 | } from "../utils/mastery-rank";
28 | import { flattenedNodes, planetJunctionsMap } from "../utils/nodes";
29 | import { getGameProfile, getUsernameFromProfile } from "../utils/profile";
30 |
31 | export const useStore = createWithEqualityFn(
32 | (set, get) => ({
33 | type: undefined,
34 | setType: type => set({ type }),
35 | id: undefined,
36 | setId: id => set({ id }),
37 | reset: () => set({ unsavedChanges: [] }),
38 |
39 | unsavedChanges: [],
40 | saveImmediate: () => {
41 | const { getDocRef, type, unsavedChanges } = get();
42 | if (type !== SHARED && unsavedChanges.length > 0) {
43 | const docRef = getDocRef();
44 | const batch = writeBatch(firestore);
45 |
46 | batch.set(
47 | docRef,
48 | unsavedChanges
49 | .filter(change => change.type === "field")
50 | .reduce((changes, change) => {
51 | changes[change.id] = change.new;
52 | return changes;
53 | }, {}),
54 | { merge: true }
55 | );
56 | Object.entries({
57 | itemsMastered: "mastered",
58 | starChart: "starChart",
59 | starChartJunctions: "starChartJunctions",
60 | steelPath: "steelPath",
61 | steelPathJunctions: "steelPathJunctions"
62 | }).forEach(([changeType, field]) => {
63 | batch.set(
64 | docRef,
65 | {
66 | [field]: arrayUnion(
67 | ...unsavedChanges
68 | .filter(
69 | change =>
70 | change.type === changeType &&
71 | change.mastered
72 | )
73 | .map(change => change.id)
74 | )
75 | },
76 | { merge: true }
77 | );
78 | batch.set(
79 | docRef,
80 | {
81 | [field]: arrayRemove(
82 | ...unsavedChanges
83 | .filter(
84 | change =>
85 | change.type === changeType &&
86 | !change.mastered
87 | )
88 | .map(change => change.id)
89 | )
90 | },
91 | { merge: true }
92 | );
93 | });
94 | batch.update(
95 | docRef,
96 | unsavedChanges
97 | .filter(
98 | change => change.type === "partiallyMasteredItems"
99 | )
100 | .reduce((data, change) => {
101 | data["partiallyMastered." + change.id] =
102 | change.new ?? deleteField();
103 | return data;
104 | }, {}),
105 | { merge: true }
106 | );
107 |
108 | batch.commit();
109 |
110 | set({ unsavedChanges: [] });
111 | }
112 | },
113 | saveTimeoutId: undefined,
114 | save: () => {
115 | set(state => {
116 | if (state.saveTimeout) clearTimeout(state.saveTimeout);
117 | return {
118 | saveTimeout: setTimeout(() => state.saveImmediate(), 2500)
119 | };
120 | });
121 | },
122 |
123 | items: {},
124 | recipes: {},
125 | ingredientIds: {},
126 | flattenedItems: {},
127 | setData: ({ items, recipes, ingredient_ids: ingredientIds }) => {
128 | set({
129 | items,
130 | recipes,
131 | ingredientIds,
132 | flattenedItems: Object.entries(items).reduce(
133 | (flattenedItems, [category, categoryItems]) => {
134 | Object.entries(categoryItems).reduce(
135 | (flattenedItems, [name, item]) => {
136 | flattenedItems[name] = produce(
137 | item,
138 | draftItem => {
139 | draftItem.type = category;
140 | }
141 | );
142 | return flattenedItems;
143 | },
144 | flattenedItems
145 | );
146 | return flattenedItems;
147 | },
148 | {}
149 | )
150 | });
151 | get().recalculateMasteryRank();
152 | get().recalculateIngredients();
153 | },
154 | fetchData: async () => {
155 | let cached = false;
156 | if (localStorage.getItem("items")) {
157 | const cachedData = JSON.parse(localStorage.getItem("items"));
158 | if (cachedData.schema_version === SCHEMA_VERSION) {
159 | get().setData(cachedData);
160 | cached = true;
161 | }
162 | }
163 |
164 | const { updated } = await getMetadata(ref(storage, "items.json"));
165 | if (
166 | !cached ||
167 | localStorage.getItem("items-updated-at") !== updated
168 | ) {
169 | const data = await (
170 | await fetch(
171 | "https://firebasestorage.googleapis.com/v0/b/framehub-f9cfb.appspot.com/o/items.json?alt=media"
172 | )
173 | ).json();
174 | localStorage.setItem("items", JSON.stringify(data));
175 | localStorage.setItem("items-updated-at", updated);
176 | get().setData(data);
177 | }
178 | },
179 |
180 | masteryRank: 0,
181 | masteryBreakdown: {},
182 | xp: 0,
183 | itemsMasteredCount: 0,
184 | totalXP: 0,
185 | totalItems: 0,
186 | recalculateMasteryRank: () => {
187 | const {
188 | flattenedItems,
189 | itemsMastered,
190 | partiallyMasteredItems,
191 | railjackIntrinsics,
192 | drifterIntrinsics,
193 | hideFounders,
194 | starChart,
195 | starChartJunctions,
196 | steelPath,
197 | steelPathJunctions
198 | } = get();
199 |
200 | const masteryBreakdown = {
201 | STAR_CHART: junctionsToXP(starChartJunctions.size),
202 | STEEL_PATH: junctionsToXP(steelPathJunctions.size),
203 | RAILJACK_INTRINSICS: intrinsicsToXP(railjackIntrinsics),
204 | DRIFTER_INTRINSICS: intrinsicsToXP(drifterIntrinsics)
205 | };
206 |
207 | let xp = Object.values(masteryBreakdown).reduce(
208 | (xp, categoryXP) => xp + categoryXP,
209 | 0
210 | );
211 |
212 | function addItemXP(item, rank) {
213 | const additionalXP = xpFromItem(item, item.type, rank);
214 | if (!masteryBreakdown[item.type])
215 | masteryBreakdown[item.type] = 0;
216 | masteryBreakdown[item.type] += additionalXP;
217 | xp += additionalXP;
218 | }
219 |
220 | let itemsMasteredCount = 0;
221 |
222 | let totalXP =
223 | junctionsToXP(Object.keys(planetJunctionsMap).length * 2) +
224 | intrinsicsToXP(
225 | totalRailjackIntrinsics + totalDrifterIntrinsics
226 | );
227 | let totalItems = 1; // There is an extra "Amp" item that is shown in the in-game profile.
228 |
229 | // Keep track of whether an amp has been mastered, because we assume the ghost "Amp" item is "mastered" by the
230 | // time a user has obtained and mastered an actual amp.
231 | let ampFound = false;
232 |
233 | Object.entries(flattenedItems).forEach(([itemName, item]) => {
234 | // Venari gains mastery XP through leveling, but does not show under the Profile. Kitguns show under both Primary
235 | // and Secondary tabs in the Profile, contributing 2 to the total count per barrel while only providing the
236 | // mastery XP once.
237 | const additionalItemCount = itemName.includes("Venari")
238 | ? 0
239 | : item.type === "KITGUN"
240 | ? 2
241 | : 1;
242 |
243 | if (itemsMastered.has(itemName)) {
244 | addItemXP(item);
245 | itemsMasteredCount += additionalItemCount;
246 |
247 | // See comment above regarding `ampFound`.
248 | if (!ampFound && item.type === "AMP") {
249 | ampFound = true;
250 | itemsMasteredCount++;
251 | }
252 | } else if (partiallyMasteredItems[itemName]) {
253 | addItemXP(item, partiallyMasteredItems[itemName]);
254 | }
255 | if (
256 | hideFounders &&
257 | foundersItems.includes(itemName) &&
258 | !itemsMastered.has(itemName)
259 | )
260 | return;
261 | totalXP += xpFromItem(item, item.type);
262 | totalItems += additionalItemCount;
263 | });
264 |
265 | Object.entries(flattenedNodes).forEach(([id, node]) => {
266 | if (node.xp) {
267 | totalXP += node.xp * 2;
268 | if (starChart.has(id)) {
269 | xp += node.xp;
270 | masteryBreakdown.STAR_CHART += node.xp;
271 | }
272 | if (steelPath.has(id)) {
273 | xp += node.xp;
274 | masteryBreakdown.STEEL_PATH += node.xp;
275 | }
276 | }
277 | });
278 |
279 | set({
280 | masteryRank: Math.floor(xpToMR(xp)),
281 | masteryBreakdown,
282 | xp,
283 | itemsMasteredCount,
284 | totalXP,
285 | totalItems
286 | });
287 | },
288 |
289 | itemsMastered: new Set(),
290 | setItemsMastered: itemsMastered => {
291 | setMastered("itemsMastered", itemsMastered);
292 | get().recalculateIngredients();
293 | },
294 | masterItem: (name, mastered) => {
295 | master("itemsMastered", name, mastered);
296 | get().recalculateIngredients();
297 | },
298 | masterAllItems: mastered => {
299 | Object.keys(get().partiallyMasteredItems).forEach(item =>
300 | get().setPartiallyMasteredItem(item, 0)
301 | );
302 | masterAll(
303 | "itemsMastered",
304 | Object.keys(get().flattenedItems).filter(
305 | i =>
306 | !get().hideFounders ||
307 | !foundersItems.includes(i) ||
308 | get().itemsMastered.has(i)
309 | ),
310 | mastered
311 | );
312 | get().recalculateIngredients();
313 | },
314 |
315 | partiallyMasteredItems: {},
316 | setPartiallyMasteredItems: partiallyMasteredItems => {
317 | set(state =>
318 | produce(state, draftState => {
319 | state.unsavedChanges
320 | .filter(change => change.type === "partialItem")
321 | .forEach(change => {
322 | partiallyMasteredItems[change.item] = change.rank;
323 | });
324 | draftState.partiallyMasteredItems = partiallyMasteredItems;
325 | })
326 | );
327 | get().recalculateMasteryRank();
328 | get().recalculateIngredients();
329 | },
330 | setPartiallyMasteredItem: (name, rank, maxRank) => {
331 | const oldRank =
332 | get().partiallyMasteredItems[name] ??
333 | (get().itemsMastered.has(name) ? maxRank : 0);
334 | if (rank === oldRank) return;
335 |
336 | if (rank === maxRank) get().masterItem(name, true);
337 | else if (get().itemsMastered.has(name))
338 | get().masterItem(name, false);
339 | rank = rank === maxRank || rank === 0 ? undefined : rank;
340 |
341 | set(state =>
342 | produce(state, draftState => {
343 | markOldNewChange(
344 | draftState,
345 | "partiallyMasteredItems",
346 | name,
347 | draftState.partiallyMasteredItems[name],
348 | rank
349 | );
350 | if (rank === 0 || rank === maxRank) {
351 | delete draftState.partiallyMasteredItems[name];
352 | } else {
353 | draftState.partiallyMasteredItems[name] = rank;
354 | }
355 | })
356 | );
357 | get().recalculateMasteryRank();
358 | get().recalculateIngredients();
359 | get().save();
360 | },
361 |
362 | displayingNodes: false,
363 | setDisplayingNodes: displayingNodes => set({ displayingNodes }),
364 | displayingSteelPath: false,
365 | setDisplayingSteelPath: displayingSteelPath =>
366 | set({ displayingSteelPath }),
367 | starChart: new Set(),
368 | starChartJunctions: new Set(),
369 | steelPath: new Set(),
370 | steelPathJunctions: new Set(),
371 | setNodesMastered: (nodesMastered, steelPath) =>
372 | setMastered(steelPath ? "steelPath" : "starChart", nodesMastered),
373 | masterNode: (id, steelPath, mastered) =>
374 | master(steelPath ? "steelPath" : "starChart", id, mastered),
375 | masterAllNodes: (steelPath, mastered) =>
376 | masterAll(
377 | steelPath ? "steelPath" : "starChart",
378 | Object.keys(flattenedNodes),
379 | mastered
380 | ),
381 | setJunctionsMastered: (junctionsMastered, steelPath) =>
382 | setMastered(
383 | (steelPath ? "steelPath" : "starChart") + "Junctions",
384 | junctionsMastered
385 | ),
386 | masterJunction: (id, steelPath, mastered) =>
387 | master(
388 | (steelPath ? "steelPath" : "starChart") + "Junctions",
389 | id,
390 | mastered
391 | ),
392 | masterAllJunctions: (steelPath, mastered) =>
393 | masterAll(
394 | (steelPath ? "steelPath" : "starChart") + "Junctions",
395 | Object.keys(planetJunctionsMap),
396 | mastered
397 | ),
398 |
399 | ingredients: {},
400 | formaCost: 0,
401 | recalculateIngredients: () => {
402 | const {
403 | flattenedItems,
404 | recipes,
405 | ingredientIds,
406 | itemsMastered,
407 | partiallyMasteredItems
408 | } = get();
409 |
410 | const necessaryComponents = {};
411 | let formaCost = 0;
412 |
413 | const needsRounding = new Map();
414 |
415 | function calculate(recipe, count = 1) {
416 | if (!recipe.components) return;
417 |
418 | Object.entries(recipe.components).forEach(
419 | ([componentName, componentCount]) => {
420 | componentCount *= count / recipe.count;
421 |
422 | const componentRecipe = recipes[componentName];
423 | if (componentRecipe) {
424 | if (
425 | !Number.isInteger(
426 | componentCount / componentRecipe.count
427 | )
428 | ) {
429 | needsRounding.set(
430 | componentName,
431 | (needsRounding.get(componentName) ?? 0) +
432 | componentCount
433 | );
434 | return;
435 | }
436 | calculate(componentRecipe, componentCount);
437 | }
438 |
439 | const ingredientId = ingredientIds[componentName];
440 | // Do not show generic components such as Barrels, Receivers, etc.
441 | if (
442 | ingredientId.includes("WeaponParts") ||
443 | ingredientId.includes("WarframeRecipes") ||
444 | ingredientId.includes("ArchwingRecipes") ||
445 | ingredientId.includes("mechPart") ||
446 | componentName.startsWith("Cortege") ||
447 | componentName.startsWith("Morgha")
448 | ) {
449 | return;
450 | }
451 |
452 | necessaryComponents[componentName] =
453 | (necessaryComponents[componentName] ?? 0) +
454 | componentCount;
455 | }
456 | );
457 | }
458 |
459 | Object.entries(flattenedItems).forEach(([itemName, item]) => {
460 | if (!itemsMastered.has(itemName)) {
461 | if (
462 | !partiallyMasteredItems[itemName] &&
463 | recipes[itemName]
464 | ) {
465 | calculate(recipes[itemName]);
466 | }
467 | if (item.maxLvl) {
468 | formaCost += Math.floor(
469 | (item.maxLvl -
470 | (partiallyMasteredItems[itemName] ?? 30)) /
471 | 2
472 | );
473 | }
474 | }
475 | });
476 |
477 | while (needsRounding.size !== 0) {
478 | for (const [componentName, componentCount] of needsRounding) {
479 | const componentRecipe = recipes[componentName];
480 | const newCount =
481 | Math.ceil(componentCount / componentRecipe.count) *
482 | componentRecipe.count;
483 |
484 | needsRounding.delete(componentName);
485 | calculate(componentRecipe, newCount);
486 | necessaryComponents[componentName] =
487 | (necessaryComponents[componentName] ?? 0) + newCount;
488 | }
489 | }
490 |
491 | set({ ingredients: necessaryComponents, formaCost });
492 | },
493 |
494 | hideMastered: true,
495 | setHideMastered: firestoreFieldSetter("hideMastered"),
496 | hidePrime: false,
497 | setHidePrime: firestoreFieldSetter("hidePrime"),
498 | hideFounders: true,
499 | setHideFounders: firestoreFieldSetter("hideFounders"),
500 |
501 | railjackIntrinsics: 0,
502 | setRailjackIntrinsics: firestoreFieldSetter(
503 | "intrinsics",
504 | "railjackIntrinsics"
505 | ),
506 | drifterIntrinsics: 0,
507 | setDrifterIntrinsics: firestoreFieldSetter("drifterIntrinsics"),
508 |
509 | popupsDismissed: [],
510 | setPopupsDismissed: popupsDismissed => set({ popupsDismissed }),
511 |
512 | accountLinkErrors: 0,
513 | setAccountLinkErrors: accountLinkErrors => set({ accountLinkErrors }),
514 | incrementAccountLinkErrors: async () => {
515 | const updatedAccountLinkErrors = get().accountLinkErrors + 1;
516 |
517 | set({ accountLinkErrors: updatedAccountLinkErrors });
518 |
519 | const docRef = get().getDocRef();
520 | await updateDoc(docRef, {
521 | accountLinkErrors: updatedAccountLinkErrors
522 | });
523 | },
524 |
525 | gameSyncId: undefined,
526 | gameSyncPlatform: undefined,
527 | gameSyncUsername: undefined,
528 | gameSync: async prefetchedProfile => {
529 | const {
530 | gameSyncUsername,
531 | gameSyncId: accountId,
532 | gameSyncPlatform: platform,
533 | flattenedItems,
534 | partiallyMasteredItems,
535 | setPartiallyMasteredItem,
536 | itemsMastered,
537 | setRailjackIntrinsics,
538 | setDrifterIntrinsics,
539 | starChart,
540 | starChartJunctions,
541 | steelPath,
542 | steelPathJunctions,
543 | masterNode,
544 | masterJunction,
545 | updateFirestore
546 | } = get();
547 | if (!accountId) return;
548 |
549 | const gameProfile =
550 | prefetchedProfile ??
551 | (await getGameProfile(accountId, platform))?.Results?.[0];
552 | if (
553 | !gameProfile?.LoadOutInventory?.XPInfo?.[0].ItemType ||
554 | !gameProfile?.LoadOutInventory?.XPInfo?.[0].XP ||
555 | !gameProfile?.Missions?.[0]?.Tag
556 | )
557 | return;
558 |
559 | const accountUsername = getUsernameFromProfile(gameProfile);
560 | if (accountUsername !== gameSyncUsername) {
561 | set({ gameSyncUsername: accountUsername });
562 | updateFirestore({
563 | gameSyncUsername: accountUsername
564 | });
565 | }
566 |
567 | const gameProfileItemsXP = new Map();
568 | const gameProfileMissions = new Map();
569 | gameProfile.LoadOutInventory.XPInfo.forEach(({ ItemType, XP }) => {
570 | gameProfileItemsXP.set(ItemType, XP);
571 | });
572 | gameProfile.Missions.forEach(m => {
573 | gameProfileMissions.set(m.Tag, m.Tier ?? 0);
574 | });
575 |
576 | Object.entries(flattenedItems).forEach(([itemName, item]) => {
577 | const currentPartialMastery = itemsMastered.has(itemName)
578 | ? (item.maxLvl ?? 30)
579 | : (partiallyMasteredItems[itemName] ?? 0);
580 | const gameLevel = itemLevelByXP(
581 | item,
582 | item.type,
583 | gameProfileItemsXP.get(item.id) ?? 0
584 | );
585 |
586 | if (currentPartialMastery !== gameLevel)
587 | setPartiallyMasteredItem(
588 | itemName,
589 | gameLevel,
590 | item.maxLvl ?? 30
591 | );
592 | });
593 |
594 | Object.keys(flattenedNodes).forEach(node => {
595 | const hasStarChart = starChart.has(node);
596 | const hasStarChartInGame = gameProfileMissions.has(node);
597 | if (hasStarChart !== hasStarChartInGame)
598 | masterNode(node, false, hasStarChartInGame);
599 |
600 | const hasSteelPath = steelPath.has(node);
601 | const hasSteelPathInGame = gameProfileMissions.get(node) === 1;
602 | if (hasSteelPath !== hasSteelPathInGame)
603 | masterNode(node, true, hasSteelPathInGame);
604 | });
605 |
606 | Object.entries(planetJunctionsMap).forEach(
607 | ([planet, junctionNode]) => {
608 | const hasStarChart = starChartJunctions.has(planet);
609 | const hasStarChartInGame =
610 | gameProfileMissions.has(junctionNode);
611 | if (hasStarChart !== hasStarChartInGame)
612 | masterJunction(planet, false, hasStarChartInGame);
613 |
614 | const hasSteelPath = steelPathJunctions.has(planet);
615 | const hasSteelPathInGame =
616 | gameProfileMissions.get(junctionNode) === 1;
617 | if (hasSteelPath !== hasSteelPathInGame)
618 | masterJunction(planet, true, hasSteelPathInGame);
619 | }
620 | );
621 |
622 | const intrinsics = gameProfile.PlayerSkills;
623 | setRailjackIntrinsics(
624 | [
625 | "LPS_COMMAND",
626 | "LPS_ENGINEERING",
627 | "LPS_GUNNERY",
628 | "LPS_PILOTING",
629 | "LPS_TACTICAL"
630 | ].reduce((railjackIntrinsics, key) => {
631 | return railjackIntrinsics + (intrinsics?.[key] ?? 0);
632 | }, 0)
633 | );
634 | setDrifterIntrinsics(
635 | [
636 | "LPS_DRIFT_COMBAT",
637 | "LPS_DRIFT_ENDURANCE",
638 | "LPS_DRIFT_OPPORTUNITY",
639 | "LPS_DRIFT_RIDING"
640 | ].reduce((railjackIntrinsics, key) => {
641 | return railjackIntrinsics + (intrinsics?.[key] ?? 0);
642 | }, 0)
643 | );
644 | },
645 | setGameSyncInfo: (accountUsername, accountId, platform) => {
646 | // Disabled Game Sync feature due to API rate limiting
647 | // set({
648 | // gameSyncUsername: accountUsername,
649 | // gameSyncId: accountId,
650 | // gameSyncPlatform: platform
651 | // });
652 | },
653 | enableGameSync: async (accountId, platform) => {
654 | const response = await getGameProfile(accountId, platform);
655 | const gameProfile = response?.Results?.[0];
656 | const accountUsername = getUsernameFromProfile(gameProfile);
657 |
658 | get().updateFirestore({
659 | gameSyncUsername: accountUsername,
660 | gameSyncId: accountId,
661 | gameSyncPlatform: platform
662 | });
663 | get().setGameSyncInfo(accountUsername, accountId, platform);
664 | get().gameSync(gameProfile);
665 | },
666 | disableGameSync: () => {
667 | if (!get().gameSyncId) return;
668 |
669 | const docRef = get().getDocRef();
670 | updateDoc(docRef, {
671 | gameSyncUsername: deleteField(),
672 | gameSyncId: deleteField(),
673 | gameSyncPlatform: deleteField()
674 | });
675 | get().setGameSyncInfo();
676 | },
677 | gameSyncExperiment: false,
678 | initGameSyncExperiment: () => {
679 | // Disabled Game Sync feature due to API rate limiting
680 | // set({
681 | // gameSyncExperiment:
682 | // get().type !== SHARED && assignGroup(get().id, 100) < 2
683 | // });
684 | },
685 |
686 | updateFirestore: async data => {
687 | const docRef = get().getDocRef();
688 | await updateDoc(docRef, data);
689 | },
690 |
691 | getDocRef: () => {
692 | const { type, id } = get();
693 | return doc(
694 | collection(
695 | firestore,
696 | type === ANONYMOUS ? "anonymousMasteryData" : "masteryData"
697 | ),
698 | id
699 | );
700 | },
701 |
702 | backupMasteryData: async attemptedGameSyncId => {
703 | try {
704 | const { type, id } = get();
705 | const docRef = get().getDocRef();
706 |
707 | const docSnapshot = await getDoc(docRef);
708 | if (!docSnapshot.exists()) {
709 | return;
710 | }
711 |
712 | const userData = docSnapshot.data();
713 | const backupData = {
714 | ...userData,
715 | userId: id,
716 | backupTimestamp: new Date().toISOString(),
717 | attemptedGameSyncId
718 | };
719 |
720 | const backupCollectionName =
721 | type === ANONYMOUS ? "backupMasteryAnon" : "backupMastery";
722 | const backupCollection = collection(
723 | firestore,
724 | backupCollectionName
725 | );
726 |
727 | await addDoc(backupCollection, backupData);
728 | } catch (error) {
729 | console.error("Backup failed:", error);
730 | throw new Error("Failed to backup account data");
731 | }
732 | }
733 | }),
734 | shallow
735 | );
736 |
737 | const get = () => useStore.getState();
738 | const set = value => useStore.setState(value);
739 |
740 | global.framehub = {
741 | getItems: () => get().items,
742 | getFlattenedItems: () => get().flattenedItems,
743 | masterItem: (name, mastered) => get().masterItem(name, mastered),
744 | getItemsMastered: () => get().itemsMastered,
745 |
746 | getPartiallyMasteredItems: () => get().partiallyMasteredItems,
747 | setPartiallyMasteredItem: (name, rank, maxRank) =>
748 | get().setPartiallyMasteredItem(name, rank, maxRank),
749 |
750 | getStarChart: () => get().starChart,
751 | getStarChartJunctions: () => get().starChartJunctions,
752 | getSteelPath: () => get().steelPath,
753 | getSteelPathJunctions: () => get().steelPathJunctions,
754 | masterNode: (id, steelPath, mastered) =>
755 | get().masterNode(id, steelPath, mastered),
756 | masterJunction: (id, steelPath, mastered) =>
757 | get().masterJunction(id, steelPath, mastered)
758 | };
759 |
760 | function firestoreFieldSetter(key, stateKey = key) {
761 | return (value, load) => {
762 | set(state =>
763 | produce(state, draftState => {
764 | if (state[stateKey] !== value) {
765 | if (!load)
766 | markOldNewChange(
767 | draftState,
768 | "field",
769 | key,
770 | state[stateKey],
771 | value
772 | );
773 | draftState[stateKey] = value;
774 | }
775 | })
776 | );
777 | get().recalculateMasteryRank();
778 | if (!load) get().save();
779 | };
780 | }
781 |
782 | function markOldNewChange(draftState, type, id, old, _new) {
783 | const unsavedChanges = draftState.unsavedChanges;
784 |
785 | const existingChangeIndex = unsavedChanges.findIndex(
786 | change => change.type === type && change.id === id
787 | );
788 | if (existingChangeIndex !== -1) {
789 | const existingChange = unsavedChanges[existingChangeIndex];
790 | if (existingChange.old === _new) {
791 | unsavedChanges.splice(existingChangeIndex, 1);
792 | } else {
793 | existingChange.new = _new;
794 | }
795 | } else {
796 | unsavedChanges.push({
797 | type,
798 | id,
799 | old,
800 | new: _new
801 | });
802 | }
803 | }
804 |
805 | function setMastered(key, mastered) {
806 | const unsavedChanges = get().unsavedChanges.filter(
807 | change => change.type === key
808 | );
809 | const added = unsavedChanges
810 | .filter(change => change.mastered)
811 | .map(change => change.id);
812 | const removed = unsavedChanges
813 | .filter(change => !change.mastered)
814 | .map(change => change.id);
815 | mastered = mastered.filter(item => !removed.includes(item));
816 | added.forEach(item => {
817 | if (!mastered.includes(item)) mastered.push(item);
818 | });
819 |
820 | set(() => ({ [key]: new Set(mastered) }));
821 | get().recalculateMasteryRank();
822 | }
823 |
824 | function master(key, id, mastered) {
825 | set(state =>
826 | produce(state, draftState => {
827 | const previouslyMastered = draftState[key].has(id);
828 | if (previouslyMastered === mastered) return;
829 |
830 | if (mastered) {
831 | draftState[key].add(id);
832 | } else {
833 | draftState[key].delete(id);
834 | }
835 | markMasteryChange(draftState, key, id, mastered);
836 | })
837 | );
838 | get().recalculateMasteryRank();
839 | get().save();
840 | }
841 |
842 | function masterAll(key, all, mastered) {
843 | set(state =>
844 | produce(state, draftState => {
845 | all.forEach(id => {
846 | if (mastered && !draftState[key].has(id)) {
847 | draftState[key].add(id);
848 | markMasteryChange(draftState, key, id, mastered);
849 | } else if (!mastered && draftState[key].has(id)) {
850 | markMasteryChange(draftState, key, id, mastered);
851 | }
852 | });
853 | if (!mastered) draftState[key] = new Set();
854 | })
855 | );
856 | get().recalculateMasteryRank();
857 | get().save();
858 | }
859 |
860 | function markMasteryChange(draftState, key, id, mastered) {
861 | const existingChangeIndex = draftState.unsavedChanges.findIndex(
862 | change => change.type === key && change.id === id
863 | );
864 | if (existingChangeIndex !== -1) {
865 | draftState.unsavedChanges.splice(existingChangeIndex, 1);
866 | } else {
867 | draftState.unsavedChanges.push({
868 | type: key,
869 | id,
870 | mastered
871 | });
872 | }
873 | }
874 |
875 |
--------------------------------------------------------------------------------