├── .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 |
4 |

Loading...

5 |
6 |
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 | 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 | 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 | 18 | 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 | 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 |
8 | 9 | paroxity 10 | 11 | 16 | github 17 | 18 | 23 | discord 24 | 25 |
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 | 32 | 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 |
21 | 22 | 28 | {signUp && ( 29 | 35 | )} 36 | 39 | 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 | 17 | 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 | 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 | 16 | {showLink && createPortal(
17 |
18 | Here's your sharable link: 19 |
20 | 26 |
27 | {document.queryCommandSupported("copy") && ( 28 | 38 | )} 39 | 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 | 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 | 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 | 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 | 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 | 62 | 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 | 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 | 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 | 44 | {open ? ( 45 |
46 |
49 |
Link your account
50 | { 55 | setAccountId(value); 56 | if (error) setError(""); 57 | }} 58 | /> 59 | 76 |