diff --git a/locales/en/translation.json b/locales/en/translation.json
index aa1668640..e676d7f9b 100644
--- a/locales/en/translation.json
+++ b/locales/en/translation.json
@@ -31,6 +31,7 @@
"Alternative Critical Chance": "Alternative Critical Chance",
"Alternative Critical Damage": "Alternative Critical Damage",
"Applied Modifers": "Applied Modifers",
+ "Armor": "Armor",
"Auto-disable ritualist trinkets": "Auto-disable ritualist trinkets",
"Bonuses": "Bonuses",
"Buffs & Boons": "Buffs & Boons",
@@ -49,6 +50,8 @@
"Copied long link to clipboard! (Link shortener requires cloudflare environment.)": "Copied long link to clipboard! (Link shortener requires cloudflare environment.)",
"Copy Settings to clipboard": "Copy Settings to clipboard",
"Copy build": "Copy build",
+ "Copy build to temporary saved builds": "Copy build to temporary saved builds",
+ "Copy data to clipboard": "Copy data to clipboard",
"Copy from selected character": "Copy from selected character",
"Copy selected sigils to both slots": "Copy selected sigils to both slots",
"Create build templates that can be used for the gear optimizer.": "Create build templates that can be used for the gear optimizer.",
@@ -69,6 +72,7 @@
"Damage Change from ±5 Stat:": "Damage Change from ±5 Stat:",
"Damage Distribution": "Damage Distribution",
"Decline": "Decline",
+ "Delete": "Delete",
"Delete visible": "Delete visible",
"Desired Affixes": "Desired Affixes",
"Development": "Development",
@@ -98,12 +102,14 @@
"Formatting examples": "Formatting examples",
"Fractals": "Fractals",
"Game Mode": "Game Mode",
+ "Gear": "Gear",
"Gear Optimizer": "Gear Optimizer",
"Generate shareable links here!": "Generate shareable links here!",
"Global Settings": "Global Settings",
"Healing": "Healing",
"Help": "Help",
"Highlight differing gear": "Highlight differing gear",
+ "Import": "Import",
"Include ": "Include ",
"Includes the current options on this page and the currently selected character. Does not include every result in the table.": "Includes the current options on this page and the currently selected character. Does not include every result in the table.",
"Includes the current selected options on this page. Does not include every result in the table.": "Includes the current selected options on this page. Does not include every result in the table.",
@@ -134,7 +140,9 @@
"Optimize for:": "Optimize for:",
"Optimizer Templates": "Optimizer Templates",
"Outgoing Healing": "Outgoing Healing",
+ "Paste build data to import": "Paste build data to import",
"Per-Slot Exclusions": "Per-Slot Exclusions",
+ "Persistent build storage": "Persistent build storage",
"Phantasm Critical Chance": "Phantasm Critical Chance",
"Phantasm Critical Damage": "Phantasm Critical Damage",
"Phantasm Effective Power": "Phantasm Effective Power",
@@ -154,7 +162,9 @@
"Resume": "Resume",
"Runes": "Runes",
"Runes & Sigils & Food": "Runes & Sigils & Food",
+ "Save to persistent build storage": "Save to persistent build storage",
"Saved Results": "Saved Results",
+ "Saved Results Manager": "Saved Results Manager",
"Select 'Dual wield' if you're using weapons in both hands or 'Two-handed' when using a two-handed weapon.": "Select 'Dual wield' if you're using weapons in both hands or 'Two-handed' when using a two-handed weapon.",
"Select Affixes": "Select Affixes",
"Select a build template from the menu above!": "Select a build template from the menu above!",
@@ -181,7 +191,6 @@
"Show per-slot controls": "Show per-slot controls",
"Show prices": "Show prices",
"Show rarity controls": "Show rarity controls",
- "Show saved results table header": "Show saved results table header",
"Sigil 1": "Sigil 1",
"Sigil 2": "Sigil 2",
"Sigils": "Sigils",
@@ -197,8 +206,11 @@
"Survivability": "Survivability",
"Target AR": "Target AR",
"Target settings": "Target settings",
+ "Temporary saved builds": "Temporary saved builds",
"The gear optimizer is still being developed! Please report issues or suggest improvements in the Discretize <2>Discord2> or on <6><0>0> Github6>.": "The gear optimizer is still being developed! Please report issues or suggest improvements in the Discretize <2>Discord2> or on <6><0>0> Github6>.",
"There was an error exporting the state!": "There was an error exporting the state!",
+ "These builds will be deleted after you leave or refresh this page.": "These builds will be deleted after you leave or refresh this page.",
+ "These builds will remain saved in your browser's local storage. Clearing your cache or application data will remove your builds.": "These builds will remain saved in your browser's local storage. Clearing your cache or application data will remove your builds.",
"This class does not appear to have skills with extra buffs": "This class does not appear to have skills with extra buffs",
"This data represents your rotation. If we don't supply a template for a build, you can calculate the correct coefficients so that a tested build matches a golem log using the tool under \"development\" below, or calculate them manually.": "This data represents your rotation. If we don't supply a template for a build, you can calculate the correct coefficients so that a tested build matches a golem log using the tool under \"development\" below, or calculate them manually.",
"Threads": "Threads",
@@ -208,6 +220,7 @@
"Trait Template": "Trait Template",
"Traitline": "Traitline {{ lineNr }}",
"Traits": "Traits",
+ "Trinkets": "Trinkets",
"Two-handed": "Two-handed",
"Unselect all": "Unselect all",
"Use Owned <1>1>:": "Use Owned <1>1>:",
@@ -215,6 +228,7 @@
"Utility": "Utility",
"Warning: Shared character links do not currently support exotic gear.": "Warning: Shared character links do not currently support exotic gear.",
"Weapon type:": "Weapon type:",
+ "Weapons": "Weapons",
"Website Templates": "Website Templates",
"What to optimize the results for. 'Damage' includes power and condition damage according to the distribution below.": "What to optimize the results for. 'Damage' includes power and condition damage according to the distribution below.",
"Would you like to apply the version of your current template? This will overwrite your current form selections.": "Would you like to apply the version of your current template? This will overwrite your current form selections.",
diff --git a/src/components/sections/controls/ResultTableSettings.tsx b/src/components/sections/controls/ResultTableSettings.tsx
index a4419ebc9..34b3eee17 100644
--- a/src/components/sections/controls/ResultTableSettings.tsx
+++ b/src/components/sections/controls/ResultTableSettings.tsx
@@ -24,13 +24,11 @@ import {
changeDisplayAttributes,
changeFilterMode,
changeHighlightDiffering,
- changeSavedHeader,
changeTallTable,
getCompareByPercent,
getDisplayAttributes,
getFilterMode,
getHighlightDiffering,
- getSavedHeader,
getTallTable,
} from '../../../state/slices/controlsSlice';
import Settings from '../../baseComponents/Settings';
@@ -53,7 +51,6 @@ export default function ResultTableSettings() {
const highlightDiffering = useSelector(getHighlightDiffering);
const tallTable = useSelector(getTallTable);
- const savedHeader = useSelector(getSavedHeader);
const filterMode = useSelector(getFilterMode);
const displayAttributes = useSelector(getDisplayAttributes);
@@ -93,21 +90,6 @@ export default function ResultTableSettings() {
/>
-
- dispatch(changeSavedHeader(e.target.checked))}
- name="checked"
- color="primary"
- />
- }
- label={t('Show saved results table header')}
- classes={{ label: classes.comparisonLabel }}
- />
-
-
({
+ container: {
+ borderColor: theme.palette.background.paper,
+ border: '1px solid inherit',
+ backgroundColor: theme.palette.background.default,
+ minWidth: 989,
+ },
+ gw2icon: {
+ fontSize: '1.8rem',
+ },
+ table: { marginBottom: 0 },
+}));
+
+function Gear({ gear, infusions }) {
+ return (
+
+
+ Armor:
+
+ {gear.slice(0, 6).map((affix) => `${affix.slice(0, 4)} `)}
+
+ Trinkets:
+
+ {gear.slice(6, 12).map((affix) => `${affix.slice(0, 4)} `)}
+
+ Weapons:
+
+ {gear.slice(12).map((affix) => `${affix.slice(0, 4)} `)}
+ {infusions && (
+ <>
+
+ Infusions:
+
+ {Object.entries(infusions).map(([name, count]) => (
+
+ ))}
+ >
+ )}
+ >
+ }
+ >
+
+ Gear
+
+
+ );
+}
+
+function Extras({ classes, character }) {
+ return Object.entries(character.settings.extrasCombination).map(([key, value]) => {
+ if (!value) return null;
+ return (
+
+ );
+ });
+}
+
+export default function SavedResultManager({ isOpen, setOpen }) {
+ const { t } = useTranslation();
+ const { classes } = useStyles();
+ const dispatch = useDispatch();
+
+ const [stored, setStored] = React.useState(() =>
+ getAll().map(({ name, character }) => ({ name, character, checked: false })),
+ );
+ const selected = stored.filter(({ checked }) => checked);
+
+ const [importText, setImportText] = React.useState('');
+
+ const temporarySaved = useSelector(getSaved);
+ const selectedTemplate = useSelector(getSelectedTemplate);
+
+ React.useEffect(() => {
+ save(stored.map(({ name, character }) => ({ name, character })));
+ }, [stored]);
+
+ const handleClose = () => setOpen(false);
+ const handleSaveLocally = (character, name) => () => {
+ if (!stored.some(({ character: { id } }) => character.id === id)) {
+ setStored([
+ {
+ name: name || selectedTemplate || character.settings.specialization,
+ character,
+ checked: false,
+ },
+ ...stored,
+ ]);
+ }
+ };
+ const handleNameChange = (index) => (event) => {
+ setStored(
+ stored.map((entry, i) => (i === index ? { ...entry, name: event.target.value } : entry)),
+ );
+ };
+ const handleCopy = (character) => () => {
+ navigator.clipboard.writeText(JSON.stringify(character, null, 2));
+ };
+ const handleDelete = (character) => () => {
+ setStored(stored.filter((storedBuild) => storedBuild.character.id !== character.id));
+ };
+ const handleDeleteTemporary = (character) => () => {
+ dispatch(removeFromSaved(character));
+ };
+ const handleCopyToTemporary = (character) => () => {
+ if (!temporarySaved.some(({ id }) => character.id === id)) {
+ dispatch(addToSaved(character));
+ }
+ };
+ const handleSelectedChange = (index) => (event) => {
+ setStored(
+ stored.map((entry, i) => (i === index ? { ...entry, checked: event.target.checked } : entry)),
+ );
+ };
+ const handleImport = () => {
+ try {
+ const parsed = JSON.parse(importText);
+ const toImport = (Array.isArray(parsed) ? parsed : [parsed]).filter(
+ (importable) =>
+ typeof importable?.character === 'object' && typeof importable?.name === 'string',
+ );
+ if (toImport.length) {
+ toImport.forEach((importable) =>
+ dispatch(handleSaveLocally(importable.character, importable.name)),
+ );
+ setImportText('');
+ }
+ } catch {
+ console.warn('Error while importing build!');
+ // TODO add snackbar
+ }
+ };
+ const handleDownload = () => {
+ const data = JSON.stringify(selected.map(({ name, character }) => ({ name, character })));
+ const blobUrl = URL.createObjectURL(new Blob([data], { type: 'application/json' }));
+ const downloadAnchorNode = document.createElement('a');
+ downloadAnchorNode.href = blobUrl;
+ downloadAnchorNode.download = `GearOptimizerBuilds.json`;
+ document.body.appendChild(downloadAnchorNode); // required for firefox
+ downloadAnchorNode.click();
+ downloadAnchorNode.remove();
+ URL.revokeObjectURL(blobUrl);
+ };
+ const handleImportTextChange = (event) => {
+ setImportText(event.target.value);
+ };
+
+ return (
+
+ );
+}
diff --git a/src/components/sections/results/SavedResultManager/localStorage.js b/src/components/sections/results/SavedResultManager/localStorage.js
new file mode 100644
index 000000000..47904edfa
--- /dev/null
+++ b/src/components/sections/results/SavedResultManager/localStorage.js
@@ -0,0 +1,17 @@
+const KEY = 'savedResults';
+
+export function getAll() {
+ if (typeof window === 'undefined') return [];
+ let stored = [];
+ try {
+ stored = JSON.parse(localStorage.getItem(KEY) || '[]');
+ } catch {
+ console.warn('There was a problem loading the saved characters from your disk!');
+ }
+ return stored;
+}
+
+export function save(stored) {
+ if (typeof window === 'undefined') return;
+ localStorage.setItem(KEY, JSON.stringify(stored));
+}
diff --git a/src/components/sections/results/table/ResultTable.tsx b/src/components/sections/results/table/ResultTable.tsx
index 22089b9e6..6e1c9f24c 100644
--- a/src/components/sections/results/table/ResultTable.tsx
+++ b/src/components/sections/results/table/ResultTable.tsx
@@ -1,11 +1,12 @@
-import { TextDivider } from '@discretize/react-discretize-components';
-import { Box } from '@mui/material';
+import { HelperIcon } from '@discretize/react-discretize-components';
+import ManageAccountsIcon from '@mui/icons-material/ManageAccounts';
+import { Box, IconButton, Typography } from '@mui/material';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import React from 'react';
-import { useTranslation } from 'react-i18next';
+import { Trans, useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import type { Character } from '../../../../state/optimizer/optimizerCore';
@@ -17,13 +18,13 @@ import {
getHighlightDiffering,
getList,
getSaved,
- getSavedHeader,
getSelectedCharacter,
getTallTable,
} from '../../../../state/slices/controlsSlice';
import type { ExtrasType } from '../../../../state/slices/extras';
import type { AffixName } from '../../../../utils/gw2-data';
import { maxSlotsLength } from '../../../../utils/gw2-data';
+import SavedResultManager from '../SavedResultManager/SavedResultManager';
import ResultTableHeaderRow from './ResultTableHeaderRow';
import ResultTableRow from './ResultTableRow';
@@ -39,6 +40,9 @@ const useStyles = makeStyles()((theme) => ({
tallTable: {
maxHeight: '90vh',
},
+ tablehead: {
+ backgroundColor: theme.palette.background.paper,
+ },
tableCollapse: {
borderCollapse: 'collapse !important' as 'collapse',
marginBottom: '0px !important',
@@ -67,8 +71,10 @@ const emptyList: Character[] = [];
const StickyHeadTable = () => {
const { classes, cx } = useStyles();
-
const { t } = useTranslation();
+
+ const [managerOpen, setManagerOpen] = React.useState(false);
+
const selectedCharacter = useSelector(getSelectedCharacter);
const normalList = useSelector(getList);
const filteredLists = useSelector(getFilteredLists);
@@ -77,7 +83,6 @@ const StickyHeadTable = () => {
const highlightDiffering = useSelector(getHighlightDiffering);
const filterMode = useSelector(getFilterMode);
const tallTable = useSelector(getTallTable);
- const savedHeader = useSelector(getSavedHeader);
const list = {
None: normalList,
@@ -179,7 +184,7 @@ const StickyHeadTable = () => {
{list.map((character, i) => {
@@ -210,54 +215,60 @@ const StickyHeadTable = () => {
- {saved.length ? (
- <>
-
-
-
+
+ Saved Results{' '}
+
+
+
+ setManagerOpen(true)}>
+
+
+
+
+
+
+
+
+
+
+
-
-
- {
+ return (
+ character.id === id)}
+ unhighlightedAffixes={unhighlightedAffixes}
+ mostCommonRarity={mostCommonRarity}
+ selectedValue={selectedValue}
+ compareByPercent={compareByPercent}
displayExtras={displayExtras}
displayAttributes={displayAttributes}
/>
-
-
- {saved.map((character) => {
- return (
- character.id === id)}
- unhighlightedAffixes={unhighlightedAffixes}
- mostCommonRarity={mostCommonRarity}
- selectedValue={selectedValue}
- compareByPercent={compareByPercent}
- displayExtras={displayExtras}
- displayAttributes={displayAttributes}
- />
- );
- })}
-
-
-
-
- >
- ) : null}
+ );
+ })}
+
+
+
+
>
);
};
diff --git a/src/components/sections/results/table/ResultTableRow.tsx b/src/components/sections/results/table/ResultTableRow.tsx
index 23a5125b9..3a2bddfdf 100644
--- a/src/components/sections/results/table/ResultTableRow.tsx
+++ b/src/components/sections/results/table/ResultTableRow.tsx
@@ -1,4 +1,5 @@
-import { Item } from '@discretize/gw2-ui-new';
+import { Item, Profession } from '@discretize/gw2-ui-new';
+import CloseIcon from '@mui/icons-material/Close';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import { Typography } from '@mui/material';
import TableCell from '@mui/material/TableCell';
@@ -10,7 +11,11 @@ import { allExtrasModifiersById, placeholderItem } from '../../../../assets/modi
import { percents } from '../../../../assets/modifierdata/metadata';
import type { Character } from '../../../../state/optimizer/optimizerCore';
import type { DisplayAttributes } from '../../../../state/slices/controlsSlice';
-import { changeSelectedCharacter, toggleSaved } from '../../../../state/slices/controlsSlice';
+import {
+ changeSelectedCharacter,
+ removeFromSaved,
+ toggleSaved,
+} from '../../../../state/slices/controlsSlice';
import type { ExtrasType } from '../../../../state/slices/extras';
import { extrasTypes } from '../../../../state/slices/extras';
import type { AffixName } from '../../../../utils/gw2-data';
@@ -29,6 +34,7 @@ interface ResultTableRowProps {
compareByPercent: boolean;
displayExtras: Record;
displayAttributes: DisplayAttributes;
+ savedSection?: boolean;
}
const ResultTableRow = ({
@@ -42,6 +48,7 @@ const ResultTableRow = ({
compareByPercent,
displayExtras,
displayAttributes,
+ savedSection,
}: ResultTableRowProps) => {
const dispatch = useDispatch();
@@ -78,27 +85,50 @@ const ResultTableRow = ({
className={underlineClass}
>
- {
+ if (savedSection) dispatch(removeFromSaved(character));
+ e.stopPropagation();
+ }}
+ />
+ ) : (
+ {
- dispatch(toggleSaved(character));
- e.stopPropagation();
- }}
- />
+ }
+ : {
+ opacity: '0.2',
+ '&:hover': {
+ opacity: '1',
+ color: 'star',
+ },
+ }
+ }
+ onClick={(e) => {
+ dispatch(toggleSaved(character));
+ e.stopPropagation();
+ }}
+ />
+ )}
+ {savedSection && (
+
+ )}{' '}
{value?.toFixed(0)}
{comparisonText ? (
diff --git a/src/state/slices/controlsSlice.ts b/src/state/slices/controlsSlice.ts
index f0e22176b..c1ae9b2e8 100644
--- a/src/state/slices/controlsSlice.ts
+++ b/src/state/slices/controlsSlice.ts
@@ -103,7 +103,6 @@ const initialState: {
compareByPercent: boolean;
highlightDiffering: boolean;
tallTable: boolean;
- savedHeader: boolean;
filterMode: FilterMode;
displayAttributes: DisplayAttributes;
progress: number;
@@ -126,7 +125,6 @@ const initialState: {
compareByPercent: true,
highlightDiffering: false,
tallTable: false,
- savedHeader: false,
filterMode: 'None',
displayAttributes: [],
progress: 0,
@@ -209,6 +207,12 @@ export const controlSlice = createSlice({
state.saved.push(action.payload);
}
},
+ addToSaved: (state, action: PayloadAction) => {
+ state.saved.push(action.payload);
+ },
+ removeFromSaved: (state, action: PayloadAction) => {
+ state.saved = state.saved.filter((character) => character.id !== action.payload.id);
+ },
changeCompareByPercent: (state, action: PayloadAction) => {
state.compareByPercent = action.payload;
},
@@ -224,9 +228,6 @@ export const controlSlice = createSlice({
changeTallTable: (state, action: PayloadAction) => {
state.tallTable = action.payload;
},
- changeSavedHeader: (state, action: PayloadAction) => {
- state.savedHeader = action.payload;
- },
changeSelectedCharacter: (state, action: PayloadAction) => {
console.log('Selected Character Data:', action.payload);
@@ -275,7 +276,6 @@ export const getHighlightDiffering = (state: RootState) =>
export const getFilterMode = (state: RootState) => state.optimizer.control.filterMode;
export const getDisplayAttributes = (state: RootState) => state.optimizer.control.displayAttributes;
export const getTallTable = (state: RootState) => state.optimizer.control.tallTable;
-export const getSavedHeader = (state: RootState) => state.optimizer.control.savedHeader;
export const getSelectedCharacter = (state: RootState) => state.optimizer.control.selectedCharacter;
export const getError = (state: RootState) => state.optimizer.control.error;
export const getJsHeuristicsEnabled = (state: RootState) =>
@@ -326,8 +326,9 @@ export const {
changeFilterMode,
changeDisplayAttributes,
changeTallTable,
- changeSavedHeader,
toggleSaved,
+ addToSaved,
+ removeFromSaved,
changeCompareByPercent,
changeHighlightDiffering,
setBuildTemplate,