diff --git a/tgui/.eslintrc.yml b/tgui/.eslintrc.yml index 24f6975e688..daf5f9936e9 100644 --- a/tgui/.eslintrc.yml +++ b/tgui/.eslintrc.yml @@ -376,6 +376,7 @@ rules: ignoreUrls: true, ignoreRegExpLiterals: true, ignoreStrings: true, + ignoreTemplateLiterals: true, }] ## Enforce a maximum number of lines per file # max-lines: error diff --git a/tgui/packages/tgui/backend.ts b/tgui/packages/tgui/backend.ts index eb58470ae0c..4a58d55c6a7 100644 --- a/tgui/packages/tgui/backend.ts +++ b/tgui/packages/tgui/backend.ts @@ -248,7 +248,7 @@ type BackendState = { shared: Record, suspending: boolean, suspended: boolean, -} +}; /** * Selects a backend-related slice of Redux state @@ -258,12 +258,9 @@ export const selectBackend = (state: any): BackendState => ( ); /** - * A React hook (sort of) for getting tgui state and related functions. + * Get data from tgui backend. * - * This is supposed to be replaced with a real React Hook, which can only - * be used in functional components. - * - * You can make + * Includes the `act` function for performing DM actions. */ export const useBackend = (context: any) => { const { store } = context; diff --git a/tgui/packages/tgui/interfaces/PersonalCrafting.js b/tgui/packages/tgui/interfaces/PersonalCrafting.js deleted file mode 100644 index 708eecc22a4..00000000000 --- a/tgui/packages/tgui/interfaces/PersonalCrafting.js +++ /dev/null @@ -1,185 +0,0 @@ -import { useBackend, useLocalState } from '../backend'; -import { Button, Dimmer, Icon, LabeledList, Section, Stack, Tabs } from '../components'; -import { Window } from '../layouts'; - -export const PersonalCrafting = (props, context) => { - const { act, data } = useBackend(context); - const { busy, display_craftable_only, display_compact } = data; - const crafting_recipes = data.crafting_recipes || {}; - // Sort everything into flat categories - const categories = []; - const recipes = []; - for (let category of Object.keys(crafting_recipes)) { - const subcategories = crafting_recipes[category]; - if ('has_subcats' in subcategories) { - for (let subcategory of Object.keys(subcategories)) { - if (subcategory === 'has_subcats') { - continue; - } - // Push category - categories.push({ - name: subcategory, - category, - subcategory, - }); - // Push recipes - const _recipes = subcategories[subcategory]; - for (let recipe of _recipes) { - recipes.push({ - ...recipe, - category: subcategory, - }); - } - } - continue; - } - // Push category - categories.push({ - name: category, - category, - }); - // Push recipes - const _recipes = crafting_recipes[category]; - for (let recipe of _recipes) { - recipes.push({ - ...recipe, - category, - }); - } - } - // Sort out the tab state - const [tab, setTab] = useLocalState(context, 'tab', categories[0]?.name); - const shownRecipes = recipes.filter((recipe) => recipe.category === tab); - return ( - - - - -
- - {categories.map((category) => ( - { - setTab(category.name); - act('set_category', { - category: category.category, - subcategory: category.subcategory, - }); - }}> - {category.name} - - ))} - -
-
- -
- act('toggle_compact')} - /> - act('toggle_recipes')} - /> - - }> -
- {busy ? ( - - - {' Crafting...'} - - ) : ( - - )} -
-
-
-
-
-
- ); -}; - -const CraftingList = (props, context) => { - const { craftables = [] } = props; - const { act, data } = useBackend(context); - const { craftability = {}, display_compact, display_craftable_only } = data; - return craftables.map((craftable) => { - if (display_craftable_only && !craftability[craftable.ref]) { - return null; - } - // Compact display - if (display_compact) { - return ( - - act('make', { - recipe: craftable.ref, - })} - /> - }> - {craftable.req_text} - - ); - } - // Full display - return ( -
- act('make', { - recipe: craftable.ref, - })} - /> - }> - - {!!craftable.req_text && ( - - {craftable.req_text} - - )} - {!!craftable.catalyst_text && ( - - {craftable.catalyst_text} - - )} - {!!craftable.tool_text && ( - - {craftable.tool_text} - - )} - -
- ); - }); -}; diff --git a/tgui/packages/tgui/interfaces/PersonalCrafting.tsx b/tgui/packages/tgui/interfaces/PersonalCrafting.tsx new file mode 100644 index 00000000000..05988555f68 --- /dev/null +++ b/tgui/packages/tgui/interfaces/PersonalCrafting.tsx @@ -0,0 +1,318 @@ +import { filter, sortBy } from 'common/collections'; +import { flow } from 'common/fp'; +import { BooleanLike, classes } from 'common/react'; +import { useBackend } from '../backend'; +import { Button, Dimmer, Icon, Section, Stack, Table } from '../components'; +import { Window } from '../layouts'; + +type RawRecipe = { + name: string; + ref: string; + req_text: string; + catalyst_text: string; + tool_text: string; +}; + +type RawData = { + // Dynamic data + busy: BooleanLike | null; + category: string; + subcategory: string; + display_craftable_only: BooleanLike; + display_compact: BooleanLike; + craftability: Record; + // Static data + crafting_recipes: Record< + string, + // That's the weirdest piece of type I had to write in my life. + // Yes, that's how subcategories are sent here. + RawRecipe[] | (Record & { has_subcats: 1 }) + >; +}; + +type Recipe = RawRecipe & { + craftable: boolean; + dm_category: string; + dm_subcategory?: string; +}; + +type Category = { + name: string; + dm_category: string; + dm_subcategory?: string; +}; + +type Group = { + name: string; + categories: Category[]; +}; + +type Data = RawData & { + dm_category: string; + dm_subcategory: string; + recipes: Recipe[]; + groups: Group[]; +}; + +const GENERAL_GROUP = 'General'; + +const remapData = (rawData: RawData): Data => { + const craftability_by_ref = rawData.craftability; + const dm_categories = rawData.crafting_recipes || {}; + + const recipes: Recipe[] = []; + const generalGroup: Group = { + name: GENERAL_GROUP, + categories: [], + }; + const groups: Group[] = [generalGroup]; + + for (const dm_category of Object.keys(dm_categories)) { + const dm_categories_item = dm_categories[dm_category]; + + // This is a nested part of the tree containing subcategories + if ('has_subcats' in dm_categories_item) { + // Create a new group for these categories + const group: Group = { + name: dm_category, + categories: [], + }; + groups.push(group); + for (const dm_subcategory of Object.keys(dm_categories_item)) { + // Skip even more nested subcats, they shouldn't exist + if (dm_subcategory === 'has_subcats') { + continue; + } + // Push category + group.categories.push({ + name: dm_subcategory, + dm_category, + dm_subcategory, + }); + // Push recipes + const dm_recipes = dm_categories_item[dm_subcategory]; + for (const dm_recipe of dm_recipes) { + recipes.push({ + ...dm_recipe, + dm_category, + dm_subcategory, + craftable: Boolean(craftability_by_ref[dm_recipe.ref]), + }); + } + } + continue; + } + + // This is a flat part of the tree where simple categories are located. + // We group these categories under the "General" group. + if (Array.isArray(dm_categories_item)) { + // Push category + generalGroup.categories.push({ + name: dm_category, + dm_category, + }); + // Push recipes + for (const dm_recipe of dm_categories_item) { + recipes.push({ + ...dm_recipe, + dm_category, + craftable: Boolean(craftability_by_ref[dm_recipe.ref]), + }); + } + continue; + } + + throw new Error(`Invalid entry in 'crafting_recipes', neither '{ has_subcats: 1 }' nor 'Recipe[]'`); + } + + return { + ...rawData, + dm_category: rawData.category, + dm_subcategory: rawData.subcategory, + recipes, + groups, + }; +}; + +const isCategorySelected = (data: Data, item: Category | Recipe) => ( + data.dm_category === item.dm_category + && (!data.dm_subcategory || data.dm_subcategory === item.dm_subcategory) +); + +export const PersonalCrafting = (props, context) => { + const { act, data: rawData } = useBackend(context); + const data = remapData(rawData); + + const { + busy, + display_craftable_only, + display_compact, + recipes, + groups, + } = data; + + const shownRecipes = flow([ + filter((recipe) => ( + // Show selected category only + isCategorySelected(data, recipe) + // If craftable only is selected, then filter by craftability + && (!display_craftable_only || recipe.craftable) + )), + sortBy((recipe) => [ + -recipe.craftable, + recipe.name, + ]), + ])(recipes); + + return ( + + + + +
+ {groups.map((group) => ( +
+ {group.categories.map((category) => ( + + ))} +
+ ))} +
+
+ +
+ act('toggle_compact')} + /> + act('toggle_recipes')} + /> + + }> +
+ {busy ? ( + + + {' Crafting...'} + + ) : ( + + )} +
+
+
+
+
+
+ ); +}; + +type CraftingListProps = { + recipes: Recipe[]; + // eslint-disable-next-line react/no-unused-prop-types + compact?: boolean; +}; + +const CraftingList = (props: CraftingListProps, context) => { + const { recipes = [], compact } = props; + const { act } = useBackend(context); + + if (compact) { + return ; + } + + return recipes.map((recipe) => ( +
{ + if (recipe.craftable) { + act('make', { + recipe: recipe.ref, + }); + } + }}> +
+
+ {recipe.name} +
+ {!!recipe.req_text && ( + recipe.req_text.split(', ').map((req, i) => ( +
+ {req} +
+ )) + )} + {!!recipe.catalyst_text && ( +
+ Catalyst: {recipe.catalyst_text} +
+ )} + {!!recipe.tool_text && ( +
+ Tools: {recipe.tool_text} +
+ )} +
+ {recipe.craftable ? 'Craft' : 'Uncraftable'} +
+
+
+ )) as any; +}; + +const CompactCraftingList = (props: CraftingListProps, context) => { + const { recipes = [] } = props; + const { act } = useBackend(context); + + return ( + + {recipes.map((recipe) => ( + + + {recipe.name} + + + {recipe.req_text} + + + + ))} +
+ ); +}; diff --git a/tgui/packages/tgui/styles/interfaces/PersonalCrafting.scss b/tgui/packages/tgui/styles/interfaces/PersonalCrafting.scss new file mode 100644 index 00000000000..00f61c8c960 --- /dev/null +++ b/tgui/packages/tgui/styles/interfaces/PersonalCrafting.scss @@ -0,0 +1,103 @@ +/** + * Copyright (c) 2022 Aleksej Komarov + * SPDX-License-Identifier: MIT + */ + +@use 'sass:color'; +@use '../base.scss'; +@use '../colors.scss'; +@use '../functions.scss'; + +.PersonalCraftingGridItem { + position: relative; + display: inline-block; + width: 33%; + vertical-align: top; + height: 10em; + overflow: hidden; + margin-bottom: 0.25em; + + @media only screen and (min-width: 50em) { + width: 25%; + } + + @media only screen and (min-width: 75em) { + width: 20%; + } + + &:hover { + overflow: visible; + } +} + +.PersonalCraftingGridItem--craftable { + cursor: pointer; +} + +.PersonalCraftingGridItem__content { + position: relative; + color: #888; + padding: 0.5em 0.5em 2.5em 0.5em; + min-height: 100%; + pointer-events: none; +} + +.PersonalCraftingGridItem:hover +.PersonalCraftingGridItem__content { + // This color is already lighter than Section which works well + // for the hover style. + background-color: base.$color-bg; + outline: 2px solid rgba(255, 255, 255, 0.2); + overflow: visible; + z-index: 1; +} + +.PersonalCraftingGridItem__craftability { + position: absolute; + bottom: 0; + right: 0; + left: 0; + padding: 0.5em; + color: #fff; + background-color: rgba(255, 0, 0, 0.1); + text-align: center; + visibility: hidden; +} + +.PersonalCraftingGridItem--craftable +.PersonalCraftingGridItem__craftability { + background-color: rgba(64, 255, 0, 0.1); +} + +.PersonalCraftingGridItem:hover +.PersonalCraftingGridItem__craftability { + visibility: visible; +} + +.PersonalCraftingGridItem__name { + position: relative; + overflow: hidden; + font-weight: bold; + margin-bottom: 0.5em; + color: #aaa; +} + +.PersonalCraftingGridItem--craftable +.PersonalCraftingGridItem__name { + color: #fff; +} + +.PersonalCraftingGridItem__prereq { + color: #777; + overflow: hidden; + white-space: nowrap; +} + +.PersonalCraftingGridItem--craftable +.PersonalCraftingGridItem__prereq { + color: #bbb; +} + +.PersonalCraftingGridItem__extra { + margin-top: 0.5em; +} diff --git a/tgui/packages/tgui/styles/main.scss b/tgui/packages/tgui/styles/main.scss index 3009a8cdce7..40000734786 100644 --- a/tgui/packages/tgui/styles/main.scss +++ b/tgui/packages/tgui/styles/main.scss @@ -60,6 +60,7 @@ @include meta.load-css('./interfaces/NuclearBomb.scss'); @include meta.load-css('./interfaces/Mecha.scss'); @include meta.load-css('./interfaces/Paper.scss'); +@include meta.load-css('./interfaces/PersonalCrafting.scss'); @include meta.load-css('./interfaces/PreferencesMenu.scss'); @include meta.load-css('./interfaces/Roulette.scss'); @include meta.load-css('./interfaces/Safe.scss');