Implement the entities page

This commit is contained in:
KingRainbow44 2023-04-10 00:59:01 -04:00
parent 127d45f21f
commit 16875e85ac
No known key found for this signature in database
GPG Key ID: FC2CB64B00D257BE
13 changed files with 493 additions and 27 deletions

View File

@ -3,6 +3,7 @@ Use Grasscutter's dumpers to generate the data to put here.
## Files Required ## Files Required
- `commands.json` - `commands.json`
- `entities.csv`
- `avatars.csv` - `avatars.csv`
- `scenes.csv` - `scenes.csv`
- `items.csv` - `items.csv`

View File

@ -1,10 +1,11 @@
import commands from "@data/commands.json"; import commands from "@data/commands.json";
import entities from "@data/entities.csv";
import avatars from "@data/avatars.csv"; import avatars from "@data/avatars.csv";
import scenes from "@data/scenes.csv"; import scenes from "@data/scenes.csv";
import items from "@data/items.csv"; import items from "@data/items.csv";
import { Quality, ItemType, ItemCategory, SceneType } from "@backend/types"; import { Quality, ItemType, ItemCategory, SceneType } from "@backend/types";
import type { Command, Avatar, Item, Scene } from "@backend/types"; import type { Command, Avatar, Item, Scene, Entity } from "@backend/types";
import { inRange } from "@app/utils"; import { inRange } from "@app/utils";
@ -72,6 +73,21 @@ export function listCommands(): Command[] {
return Object.values(getCommands()); return Object.values(getCommands());
} }
/**
* Fetches and casts all entities in the file.
*/
export function getEntities(): Entity[] {
return entities.map((entry) => {
const values = Object.values(entry) as string[];
const id = parseInt(values[0]);
return {
id,
name: values[1],
internal: values[2]
};
});
}
/** /**
* Fetches and casts all avatars in the file. * Fetches and casts all avatars in the file.
*/ */

View File

@ -1,5 +1,5 @@
export type Page = "Home" | "Commands" | "Avatars" | "Items" export type Page = "Home" | "Commands" | "Avatars" | "Items"
| "Scenes"; | "Entities" | "Scenes";
export type Days = "Sunday" | "Monday" | "Tuesday" export type Days = "Sunday" | "Monday" | "Tuesday"
| "Wednesday" | "Thursday" | "Friday" | "Saturday"; | "Wednesday" | "Thursday" | "Friday" | "Saturday";
@ -31,6 +31,12 @@ export type Item = {
icon: string; icon: string;
}; };
export type Entity = {
id: number;
name: string;
internal: string;
};
// Exported from Project Amber. // Exported from Project Amber.
export type ItemInfo = { export type ItemInfo = {
response: number | 200 | 404; response: number | 200 | 404;
@ -51,6 +57,23 @@ export type ItemInfo = {
}; };
}; };
// Exported from Project Amber.
export type EntityInfo = {
response: number | 200 | 404;
data: {
id: number;
name: string;
type: string;
icon: string;
route: string;
title: string;
specialName: string;
description: string;
entries: any[];
tips: null;
};
};
export enum Target { export enum Target {
None = "NONE", None = "NONE",
Offline = "OFFLINE", Offline = "OFFLINE",

View File

@ -0,0 +1,93 @@
.EntitiesPage {
display: flex;
height: 100%;
width: 100%;
flex-direction: row;
justify-content: space-between;
background-color: var(--background-color);
padding: 24px;
}
.EntitiesPage_Content {
display: flex;
flex-direction: column;
width: 80%;
}
.EntitiesPage_Header {
display: flex;
flex-direction: row;
gap: 30px;
align-content: center;
margin-bottom: 30px;
}
.EntitiesPage_Title {
max-width: 230px;
max-height: 60px;
font-size: 48px;
font-weight: bold;
text-align: center;
justify-content: center;
}
.EntitiesPage_Search {
display: flex;
width: 100%;
height: 100%;
max-width: 465px;
max-height: 60px;
box-sizing: border-box;
align-items: center;
border-radius: 10px;
background-color: var(--secondary-color);
}
.EntitiesPage_Input {
background-color: transparent;
border: none;
color: var(--text-primary-color);
font-size: 20px;
width: 100%;
padding: 11px;
&:focus, &:active {
outline: none;
}
}
.EntitiesPage_Input::placeholder {
color: var(--text-secondary-color);
opacity: 1;
}
.EntitiesPage_List {
display: grid;
gap: 15px 15px;
grid-template-columns: repeat(15, 100px);
margin-bottom: 28px;
overflow-y: scroll;
}
.EntitiesPage_Card {
display: flex;
width: 100%;
max-width: 300px;
min-height: 300px;
max-height: 700px;
align-self: center;
}

View File

@ -1,4 +1,4 @@
.Item { .MiniCard {
display: flex; display: flex;
width: 64px; width: 64px;
@ -8,7 +8,7 @@
justify-content: center; justify-content: center;
} }
.Item_Background { .MiniCard_Background {
display: flex; display: flex;
align-items: center; align-items: center;
@ -19,13 +19,14 @@
background-color: var(--secondary-color); background-color: var(--secondary-color);
} }
.Item_Icon { .MiniCard_Icon {
max-width: 64px; max-width: 64px;
max-height: 64px; max-height: 64px;
object-fit: scale-down; object-fit: scale-down;
border-radius: 10px;
} }
.Item_Label { .MiniCard_Label {
width: 64px; width: 64px;
max-height: 64px; max-height: 64px;
text-align: center; text-align: center;
@ -33,7 +34,7 @@
color: var(--text-primary-color); color: var(--text-primary-color);
} }
.Item_Info { .MiniCard_Info {
position: absolute; position: absolute;
display: flex; display: flex;
} }

View File

@ -0,0 +1,151 @@
import React, { ChangeEvent } from "react";
import MiniCard from "@widgets/MiniCard";
import VirtualizedGrid from "@components/VirtualizedGrid";
import { Entity, ItemCategory } from "@backend/types";
import type { Entity as EntityType, EntityInfo } from "@backend/types";
import { getEntities } from "@backend/data";
import { entityIcon, fetchEntityData } from "@app/utils";
import "@css/pages/EntitiesPage.scss";
import EntityCard from "@widgets/EntityCard";
interface IState {
filters: ItemCategory[];
search: string;
selected: EntityType | null;
selectedInfo: EntityInfo | null;
}
class EntitiesPage extends React.Component<{}, IState> {
constructor(props: {}) {
super(props);
this.state = {
filters: [],
search: "",
selected: null,
selectedInfo: null
};
}
/**
* Should the entity be shown?
*
* @param entity The entity.
* @private
*/
private showEntity(entity: Entity): boolean {
// Check if the entity's name starts with N/A.
if (entity.name.includes("[N/A]")) return false;
return entity.id > 0;
}
/**
* Gets the items to render.
* @private
*/
private getEntities(): EntityType[] {
let entities: EntityType[] = [];
// Add items based on filters.
const filters = this.state.filters;
if (filters.length == 0) {
entities = getEntities();
} else {
for (const filter of filters) {
// Remove duplicate items.
entities = entities.filter((item, index) => {
return entities.indexOf(item) == index;
});
}
}
// Filter out items that don't match the search.
const search = this.state.search.toLowerCase();
if (search != "") {
entities = entities.filter((item) => {
return item.name.toLowerCase().includes(search);
});
}
return entities;
}
/**
* Invoked when the search input changes.
*
* @param event The event.
* @private
*/
private onChange(event: ChangeEvent<HTMLInputElement>): void {
this.setState({ search: event.target.value });
}
/**
* Sets the selected entity.
*
* @param entity The entity.
* @private
*/
private async setSelectedItem(entity: EntityType): Promise<void> {
let data: EntityInfo | null = null; try {
data = await fetchEntityData(entity);
} catch { }
this.setState({
selected: entity,
selectedInfo: data
});
}
render() {
const entities = this.getEntities();
return (
<div className={"EntitiesPage"}>
<div className={"EntitiesPage_Content"}>
<div className={"EntitiesPage_Header"}>
<h1 className={"EntitiesPage_Title"}>Monsters</h1>
<div className={"EntitiesPage_Search"}>
<input
type={"text"}
className={"EntitiesPage_Input"}
placeholder={"Search..."}
onChange={this.onChange.bind(this)}
/>
</div>
</div>
{entities.length > 0 ? (
<VirtualizedGrid
list={entities.filter(entity => this.showEntity(entity))}
itemHeight={64}
itemsPerRow={18}
gap={5}
itemGap={5}
render={(entity) => <MiniCard
key={entity.id} data={entity} icon={entityIcon(entity)}
onClick={() => this.setSelectedItem(entity)}
/>}
/>
) : undefined}
</div>
<div className={"EntitiesPage_Card"}>
<EntityCard
entity={this.state.selected}
info={this.state.selectedInfo}
/>
</div>
</div>
);
}
}
export default EntitiesPage;

View File

@ -21,7 +21,7 @@ class HomePage extends React.Component<any, any> {
<HomeButton name={"Commands"} anchor={"Commands"} /> <HomeButton name={"Commands"} anchor={"Commands"} />
<HomeButton name={"Characters"} anchor={"Avatars"} /> <HomeButton name={"Characters"} anchor={"Avatars"} />
<HomeButton name={"Items"} anchor={"Items"} /> <HomeButton name={"Items"} anchor={"Items"} />
<HomeButton name={"Entities"} anchor={"Home"} /> <HomeButton name={"Entities"} anchor={"Entities"} />
<HomeButton name={"Scenes"} anchor={"Scenes"} /> <HomeButton name={"Scenes"} anchor={"Scenes"} />
</div> </div>

View File

@ -1,13 +1,13 @@
import React, { ChangeEvent } from "react"; import React, { ChangeEvent } from "react";
import Item from "@widgets/Item"; import MiniCard from "@widgets/MiniCard";
import ItemCard from "@widgets/ItemCard"; import ItemCard from "@widgets/ItemCard";
import VirtualizedGrid from "@components/VirtualizedGrid"; import VirtualizedGrid from "@components/VirtualizedGrid";
import { ItemCategory } from "@backend/types"; import { ItemCategory } from "@backend/types";
import type { Item as ItemType, ItemInfo } from "@backend/types"; import type { Item as ItemType, ItemInfo } from "@backend/types";
import { getItems, sortedItems } from "@backend/data"; import { getItems, sortedItems } from "@backend/data";
import { fetchItemData } from "@app/utils"; import { fetchItemData, itemIcon } from "@app/utils";
import "@css/pages/ItemsPage.scss"; import "@css/pages/ItemsPage.scss";
@ -133,8 +133,8 @@ class ItemsPage extends React.Component<{}, IState> {
itemsPerRow={18} itemsPerRow={18}
gap={5} gap={5}
itemGap={5} itemGap={5}
render={(item) => <Item render={(item) => <MiniCard
key={item.id} data={item} key={item.id} data={item} icon={itemIcon(item)}
onClick={() => this.setSelectedItem(item)} onClick={() => this.setSelectedItem(item)}
/>} />}
/> />

View File

@ -4,6 +4,7 @@ import HomePage from "@pages/HomePage";
import CommandsPage from "@pages/CommandsPage"; import CommandsPage from "@pages/CommandsPage";
import AvatarsPage from "@pages/AvatarsPage"; import AvatarsPage from "@pages/AvatarsPage";
import ItemsPage from "@pages/ItemsPage"; import ItemsPage from "@pages/ItemsPage";
import EntitiesPage from "@pages/EntitiesPage";
import ScenesPage from "@pages/ScenesPage"; import ScenesPage from "@pages/ScenesPage";
import type { Page } from "@backend/types"; import type { Page } from "@backend/types";
@ -58,6 +59,8 @@ class Content extends React.Component<IProps, IState> {
return <AvatarsPage />; return <AvatarsPage />;
case "Items": case "Items":
return <ItemsPage />; return <ItemsPage />;
case "Entities":
return <EntitiesPage />;
case "Scenes": case "Scenes":
return <ScenesPage />; return <ScenesPage />;
} }

View File

@ -50,7 +50,7 @@ class SideBar extends React.Component<{}, IState> {
<SideBarButton name={"Commands"} anchor={"Commands"} /> <SideBarButton name={"Commands"} anchor={"Commands"} />
<SideBarButton name={"Characters"} anchor={"Avatars"} /> <SideBarButton name={"Characters"} anchor={"Avatars"} />
<SideBarButton name={"Items"} anchor={"Items"} /> <SideBarButton name={"Items"} anchor={"Items"} />
<SideBarButton name={"Entities"} anchor={"Home"} /> <SideBarButton name={"Entities"} anchor={"Entities"} />
<SideBarButton name={"Scenes"} anchor={"Scenes"} /> <SideBarButton name={"Scenes"} anchor={"Scenes"} />
<SideBarButton name={"Quests"} anchor={"Home"} /> <SideBarButton name={"Quests"} anchor={"Home"} />
<SideBarButton name={"Achievements"} anchor={"Home"} /> <SideBarButton name={"Achievements"} anchor={"Home"} />

View File

@ -0,0 +1,156 @@
import React from "react";
import type { Entity as EntityType, EntityInfo } from "@backend/types";
import { entityIcon } from "@app/utils";
import "@css/widgets/ItemCard.scss";
/**
* Converts a description string into a list of paragraphs.
*
* @param description The description to convert.
*/
function toDescription(description: string | undefined): JSX.Element[] {
if (!description) return [];
return description.split("\\n")
.map((line, index) => {
return <p key={index}>{line}</p>;
});
}
interface IProps {
entity: EntityType | null;
info: EntityInfo | null;
}
interface IState {
icon: boolean;
count: number | string;
}
const defaultState = {
icon: true,
count: 1
};
class EntityCard extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);
this.state = defaultState;
}
/**
* Updates the count of the item.
*
* @param event The change event.
* @private
*/
private updateCount(event: React.ChangeEvent<HTMLInputElement>) {
const value = event.target.value;
if (isNaN(parseInt(value)) && value.length > 1) return;
this.setState({ count: value });
}
/**
* Adds to the count of the entity.
*
* @param positive Is the count being added or subtracted?
* @param multiple Is the count being multiplied by 10?
* @private
*/
private addCount(positive: boolean, multiple: boolean) {
let { count } = this.state;
if (count === "") count = 1;
if (typeof count == "string")
count = parseInt(count);
if (count < 1) count = 1;
let increment = 1;
if (!positive) increment = -1;
if (multiple) increment *= 10;
count = Math.max(1, count + increment);
this.setState({ count });
}
/**
* Summons the entity at the connected player's position.
* @private
*/
private async summonAtPlayer(): Promise<void> {
// TODO: Implement server access.
}
componentDidUpdate(prevProps: Readonly<IProps>, prevState: Readonly<IState>, snapshot?: any) {
if (this.props.entity != prevProps.entity) {
this.setState(defaultState);
}
}
render() {
const { entity, info } = this.props;
const data = info?.data;
return entity ? (
<div className={"ItemCard"}>
<div className={"ItemCard_Content"}>
<div className={"ItemCard_Header"}>
<div className={"ItemCard_Info"}>
<p>{data?.name ?? entity.name}</p>
<p>{data?.type ?? ""}</p>
</div>
{ this.state.icon && <img
className={"ItemCard_Icon"}
alt={entity.name}
src={entityIcon(entity)}
onError={() => this.setState({ icon: false })}
/> }
</div>
<div className={"ItemCard_Description"}>
{toDescription(data?.description)}
</div>
</div>
<div className={"ItemCard_Actions"}>
<div className={"ItemCard_Counter"}>
<div onClick={() => this.addCount(false, false)}
onContextMenu={(e) => {
e.preventDefault();
this.addCount(false, true);
}}
className={"ItemCard_Operation"}>-</div>
<input type={"text"}
value={this.state.count}
className={"ItemCard_Count"}
onChange={this.updateCount.bind(this)}
onBlur={() => {
if (this.state.count == "") {
this.setState({ count: 1 });
}
}}
/>
<div onClick={() => this.addCount(true, false)}
onContextMenu={(e) => {
e.preventDefault();
this.addCount(true, true);
}}
className={"ItemCard_Operation"}>+</div>
</div>
<button
className={"ItemCard_Submit"}
onClick={this.summonAtPlayer.bind(this)}
>Summon</button>
</div>
</div>
) : undefined;
}
}
export default EntityCard;

View File

@ -1,12 +1,13 @@
import React from "react"; import React from "react";
import type { Item as ItemData } from "@backend/types";
import { itemIcon } from "@app/utils"; import { itemIcon } from "@app/utils";
import "@css/widgets/Item.scss"; import "@css/widgets/MiniCard.scss";
interface IProps { interface IProps {
data: ItemData; data: { name: string; };
icon: string;
onClick?: () => void; onClick?: () => void;
} }
@ -16,7 +17,7 @@ interface IState {
loaded: boolean; loaded: boolean;
} }
class Item extends React.Component<IProps, IState> { class MiniCard extends React.Component<IProps, IState> {
loading: number | any; loading: number | any;
constructor(props: IProps) { constructor(props: IProps) {
@ -52,27 +53,25 @@ class Item extends React.Component<IProps, IState> {
render() { render() {
return ( return (
<div className={"Item"} <div className={"MiniCard"}
onClick={this.props.onClick} onClick={this.props.onClick}
> >
<div className={"Item_Background"}> <div className={"MiniCard_Background"}>
{this.state.icon && ( {this.state.icon && (
<img <img
className={"Item_Icon"} className={"MiniCard_Icon"}
alt={this.props.data.name} alt={this.props.data.name}
src={itemIcon(this.props.data)} src={this.props.icon}
onError={this.replaceIcon.bind(this)} onError={this.replaceIcon.bind(this)}
onLoad={() => this.setState({ loaded: true })} onLoad={() => this.setState({ loaded: true })}
/> />
)} )}
{(!this.state.loaded || !this.state.icon) && <p className={"Item_Label"}>{this.props.data.name}</p>} {(!this.state.loaded || !this.state.icon) && <p className={"MiniCard_Label"}>{this.props.data.name}</p>}
</div> </div>
<div className={"Item_Info"}></div>
</div> </div>
); );
} }
} }
export default Item; export default MiniCard;

View File

@ -1,5 +1,5 @@
import type { Item } from "@backend/types"; import type { Entity, Item, EntityInfo, ItemInfo } from "@backend/types";
import { ItemInfo, ItemType, Quality } from "@backend/types"; import { ItemType, Quality } from "@backend/types";
/** /**
* Fetches the name of the CSS variable for the quality. * Fetches the name of the CSS variable for the quality.
@ -56,6 +56,16 @@ export function itemIcon(item: Item): string {
} }
} }
/**
* Gets the path to the icon for an entity.
* Uses the Project Amber API to get the icon.
*
* @param entity The entity to get the icon for. Project Amber data required.
*/
export function entityIcon(entity: Entity): string {
return `https://api.ambr.top/assets/UI/monster/UI_MonsterIcon_${entity.internal}.png`;
}
/** /**
* Formats a character's name to fit with the reference name. * Formats a character's name to fit with the reference name.
* Example: Hu Tao -> hu_tao * Example: Hu Tao -> hu_tao
@ -111,3 +121,16 @@ export async function fetchItemData(item: Item): Promise<ItemInfo> {
.then((res) => res.json()) .then((res) => res.json())
.catch(() => {}); .catch(() => {});
} }
/**
* Fetches the data for an entity.
* Uses the Project Amber API to get the data.
*
* @route GET https://api.ambr.top/v2/en/monster/{id}
* @param entity The entity to fetch the data for.
*/
export async function fetchEntityData(entity: Entity): Promise<EntityInfo> {
return fetch(`https://api.ambr.top/v2/en/monster/${entity.id}`)
.then((res) => res.json())
.catch(() => {});
}