mirror of
https://github.com/Bubberstation/Bubberstation.git
synced 2026-08-24 13:38:41 +01:00
tgui: Improved Personal Crafting UI (#66933)
This commit is contained in:
@@ -376,6 +376,7 @@ rules:
|
||||
ignoreUrls: true,
|
||||
ignoreRegExpLiterals: true,
|
||||
ignoreStrings: true,
|
||||
ignoreTemplateLiterals: true,
|
||||
}]
|
||||
## Enforce a maximum number of lines per file
|
||||
# max-lines: error
|
||||
|
||||
@@ -248,7 +248,7 @@ type BackendState<TData> = {
|
||||
shared: Record<string, any>,
|
||||
suspending: boolean,
|
||||
suspended: boolean,
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Selects a backend-related slice of Redux state
|
||||
@@ -258,12 +258,9 @@ export const selectBackend = <TData>(state: any): BackendState<TData> => (
|
||||
);
|
||||
|
||||
/**
|
||||
* 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 = <TData>(context: any) => {
|
||||
const { store } = context;
|
||||
|
||||
@@ -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 (
|
||||
<Window title="Crafting Menu" width={700} height={700}>
|
||||
<Window.Content>
|
||||
<Stack fill>
|
||||
<Stack.Item grow={1}>
|
||||
<Section fill scrollable title="Category">
|
||||
<Tabs vertical>
|
||||
{categories.map((category) => (
|
||||
<Tabs.Tab
|
||||
height={2}
|
||||
key={category.name}
|
||||
selected={category.name === tab}
|
||||
onClick={() => {
|
||||
setTab(category.name);
|
||||
act('set_category', {
|
||||
category: category.category,
|
||||
subcategory: category.subcategory,
|
||||
});
|
||||
}}>
|
||||
{category.name}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs>
|
||||
</Section>
|
||||
</Stack.Item>
|
||||
<Stack.Item grow={3}>
|
||||
<Section
|
||||
fill
|
||||
title="Recipes"
|
||||
buttons={
|
||||
<>
|
||||
<Button.Checkbox
|
||||
content="Compact"
|
||||
checked={display_compact}
|
||||
onClick={() => act('toggle_compact')}
|
||||
/>
|
||||
<Button.Checkbox
|
||||
content="Craftable Only"
|
||||
checked={display_craftable_only}
|
||||
onClick={() => act('toggle_recipes')}
|
||||
/>
|
||||
</>
|
||||
}>
|
||||
<Section fill scrollable>
|
||||
{busy ? (
|
||||
<Dimmer fontSize="32px">
|
||||
<Icon name="cog" spin={1} />
|
||||
{' Crafting...'}
|
||||
</Dimmer>
|
||||
) : (
|
||||
<CraftingList craftables={shownRecipes} />
|
||||
)}
|
||||
</Section>
|
||||
</Section>
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<LabeledList.Item
|
||||
key={craftable.name}
|
||||
label={craftable.name}
|
||||
className="candystripe"
|
||||
buttons={
|
||||
<Button
|
||||
icon="cog"
|
||||
content="Craft"
|
||||
disabled={!craftability[craftable.ref]}
|
||||
tooltip={
|
||||
craftable.tool_text && 'Tools needed: ' + craftable.tool_text
|
||||
}
|
||||
tooltipPosition="left"
|
||||
onClick={() =>
|
||||
act('make', {
|
||||
recipe: craftable.ref,
|
||||
})}
|
||||
/>
|
||||
}>
|
||||
{craftable.req_text}
|
||||
</LabeledList.Item>
|
||||
);
|
||||
}
|
||||
// Full display
|
||||
return (
|
||||
<Section
|
||||
key={craftable.name}
|
||||
title={craftable.name}
|
||||
level={2}
|
||||
buttons={
|
||||
<Button
|
||||
icon="cog"
|
||||
content="Craft"
|
||||
disabled={!craftability[craftable.ref]}
|
||||
onClick={() =>
|
||||
act('make', {
|
||||
recipe: craftable.ref,
|
||||
})}
|
||||
/>
|
||||
}>
|
||||
<LabeledList>
|
||||
{!!craftable.req_text && (
|
||||
<LabeledList.Item label="Required">
|
||||
{craftable.req_text}
|
||||
</LabeledList.Item>
|
||||
)}
|
||||
{!!craftable.catalyst_text && (
|
||||
<LabeledList.Item label="Catalyst">
|
||||
{craftable.catalyst_text}
|
||||
</LabeledList.Item>
|
||||
)}
|
||||
{!!craftable.tool_text && (
|
||||
<LabeledList.Item label="Tools">
|
||||
{craftable.tool_text}
|
||||
</LabeledList.Item>
|
||||
)}
|
||||
</LabeledList>
|
||||
</Section>
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -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<string, BooleanLike>;
|
||||
// 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<string, RawRecipe[]> & { 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<RawData>(context);
|
||||
const data = remapData(rawData);
|
||||
|
||||
const {
|
||||
busy,
|
||||
display_craftable_only,
|
||||
display_compact,
|
||||
recipes,
|
||||
groups,
|
||||
} = data;
|
||||
|
||||
const shownRecipes = flow([
|
||||
filter<Recipe>((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) => [
|
||||
-recipe.craftable,
|
||||
recipe.name,
|
||||
]),
|
||||
])(recipes);
|
||||
|
||||
return (
|
||||
<Window title="Crafting Menu" width={700} height={700}>
|
||||
<Window.Content>
|
||||
<Stack fill>
|
||||
<Stack.Item width="150px">
|
||||
<Section fill scrollable title="Category">
|
||||
{groups.map((group) => (
|
||||
<Section key={group.name} title={group.name}>
|
||||
{group.categories.map((category) => (
|
||||
<Button
|
||||
key={category.name}
|
||||
fluid
|
||||
color="transparent"
|
||||
selected={isCategorySelected(data, category)}
|
||||
onClick={() => act('set_category', {
|
||||
category: category.dm_category,
|
||||
subcategory: category.dm_subcategory,
|
||||
})}>
|
||||
{category.name}
|
||||
</Button>
|
||||
))}
|
||||
</Section>
|
||||
))}
|
||||
</Section>
|
||||
</Stack.Item>
|
||||
<Stack.Item grow={3}>
|
||||
<Section
|
||||
fill
|
||||
title="Recipes"
|
||||
buttons={
|
||||
<>
|
||||
<Button.Checkbox
|
||||
content="Compact"
|
||||
checked={display_compact}
|
||||
onClick={() => act('toggle_compact')}
|
||||
/>
|
||||
<Button.Checkbox
|
||||
content="Craftable Only"
|
||||
checked={display_craftable_only}
|
||||
onClick={() => act('toggle_recipes')}
|
||||
/>
|
||||
</>
|
||||
}>
|
||||
<Section fill scrollable>
|
||||
{busy ? (
|
||||
<Dimmer fontSize="32px">
|
||||
<Icon name="cog" spin={1} />
|
||||
{' Crafting...'}
|
||||
</Dimmer>
|
||||
) : (
|
||||
<CraftingList
|
||||
recipes={shownRecipes}
|
||||
compact={Boolean(display_compact)} />
|
||||
)}
|
||||
</Section>
|
||||
</Section>
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
|
||||
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<RawData>(context);
|
||||
|
||||
if (compact) {
|
||||
return <CompactCraftingList recipes={recipes} />;
|
||||
}
|
||||
|
||||
return recipes.map((recipe) => (
|
||||
<div
|
||||
key={recipe.ref}
|
||||
className={classes([
|
||||
'PersonalCraftingGridItem',
|
||||
recipe.craftable && 'PersonalCraftingGridItem--craftable',
|
||||
])}
|
||||
onClick={() => {
|
||||
if (recipe.craftable) {
|
||||
act('make', {
|
||||
recipe: recipe.ref,
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<div className="PersonalCraftingGridItem__content">
|
||||
<div className="PersonalCraftingGridItem__name">
|
||||
{recipe.name}
|
||||
</div>
|
||||
{!!recipe.req_text && (
|
||||
recipe.req_text.split(', ').map((req, i) => (
|
||||
<div key={req + i} className="PersonalCraftingGridItem__prereq">
|
||||
{req}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{!!recipe.catalyst_text && (
|
||||
<div className="PersonalCraftingGridItem__extra">
|
||||
<b>Catalyst:</b> {recipe.catalyst_text}
|
||||
</div>
|
||||
)}
|
||||
{!!recipe.tool_text && (
|
||||
<div className="PersonalCraftingGridItem__extra">
|
||||
<b>Tools:</b> {recipe.tool_text}
|
||||
</div>
|
||||
)}
|
||||
<div className="PersonalCraftingGridItem__craftability">
|
||||
{recipe.craftable ? 'Craft' : 'Uncraftable'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)) as any;
|
||||
};
|
||||
|
||||
const CompactCraftingList = (props: CraftingListProps, context) => {
|
||||
const { recipes = [] } = props;
|
||||
const { act } = useBackend<RawData>(context);
|
||||
|
||||
return (
|
||||
<table>
|
||||
{recipes.map((recipe) => (
|
||||
<tr key={recipe.ref} className="candystripe">
|
||||
<Table.Cell bold maxWidth="250px">
|
||||
{recipe.name}
|
||||
</Table.Cell>
|
||||
<Table.Cell opacity={0.6}>
|
||||
{recipe.req_text}
|
||||
</Table.Cell>
|
||||
<Table.Cell collapsing>
|
||||
<Button
|
||||
icon="cog"
|
||||
content="Craft"
|
||||
disabled={!recipe.craftable}
|
||||
tooltip={
|
||||
recipe.tool_text && 'Tools needed: ' + recipe.tool_text
|
||||
}
|
||||
tooltipPosition="left"
|
||||
onClick={() => act('make', {
|
||||
recipe: recipe.ref,
|
||||
})} />
|
||||
</Table.Cell>
|
||||
</tr>
|
||||
))}
|
||||
</table>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user