Can edit weapons in SendouQ settings

This commit is contained in:
Kalle 2022-03-06 13:38:55 +02:00
parent 765b3d8004
commit 1d7f736dee
10 changed files with 232 additions and 12 deletions

View File

@ -39,10 +39,11 @@ export function Button(props: ButtonProps) {
minimal: variant === "minimal",
"minimal-success": variant === "minimal-success",
"minimal-destructive": variant === "minimal-destructive",
loading: loading,
"disabled-opaque": props.disabled,
loading,
tiny,
})}
disabled={loading}
disabled={props.disabled || loading}
{...rest}
>
{icon && React.cloneElement(icon, { className: "button-icon" })}

View File

@ -0,0 +1,58 @@
import { Combobox as HeadlessCombobox } from "@headlessui/react";
import * as React from "react";
import Fuse from "fuse.js";
import clsx from "clsx";
const MAX_RESULTS_SHOWN = 6;
export function Combobox({
options,
onChange,
inputName,
placeholder,
}: {
options: string[] | readonly string[];
onChange: (value: string) => void;
inputName: string;
placeholder: string;
}) {
const [query, setQuery] = React.useState("");
const filteredOptions = (() => {
if (!query) return [];
const fuse = new Fuse(options);
return fuse
.search(query)
.slice(0, MAX_RESULTS_SHOWN)
.map((res) => res.item);
})();
return (
<HeadlessCombobox value="" onChange={onChange}>
<HeadlessCombobox.Input
onChange={(event) => setQuery(event.target.value)}
placeholder={placeholder}
className="combobox-input"
name={inputName}
/>
<HeadlessCombobox.Options
className={clsx("combobox-options", {
invisible: filteredOptions.length === 0,
})}
>
{filteredOptions.map((option) => (
<HeadlessCombobox.Option
key={option}
value={option}
as={React.Fragment}
>
{({ active }) => (
<li className={clsx("combobox-item", { active })}>{option}</li>
)}
</HeadlessCombobox.Option>
))}
</HeadlessCombobox.Options>
</HeadlessCombobox>
);
}

View File

@ -23,6 +23,7 @@ export const MMR_TOPX_VISIBILITY_CUTOFF = 50;
export const LFG_AMOUNT_OF_STAGES_TO_GENERATE = 7;
export const MINI_BIO_MAX_LENGTH = 280;
export const LFG_WEAPON_POOL_MAX_LENGTH = 3;
export const CLOSE_MMR_LIMIT = 250;
export const BIT_HIGHER_MMR_LIMIT = 750;

View File

@ -24,9 +24,11 @@ export function findById(userId?: string) {
export function update({
userId,
miniBio,
weapons,
}: {
userId: string;
miniBio: Nullable<string>;
weapons: string[];
}) {
return db.user.update({ where: { id: userId }, data: { miniBio } });
return db.user.update({ where: { id: userId }, data: { miniBio, weapons } });
}

View File

@ -10,16 +10,23 @@ import {
useTransition,
} from "remix";
import { Button } from "~/components/Button";
import { MINI_BIO_MAX_LENGTH } from "~/constants";
import {
LFG_WEAPON_POOL_MAX_LENGTH,
MINI_BIO_MAX_LENGTH,
weapons,
} from "~/constants";
import styles from "~/styles/play-settings.css";
import {
falsyToNull,
makeTitle,
parseRequestFormData,
requireUser,
safeJSONParse,
} from "~/utils";
import * as User from "~/models/User.server";
import { z } from "zod";
import { Combobox } from "~/components/Combobox";
import { WeaponImage } from "~/components/WeaponImage";
export const meta: MetaFunction = () => {
return {
@ -36,6 +43,10 @@ const settingsActionSchema = z.object({
falsyToNull,
z.string().max(MINI_BIO_MAX_LENGTH).nullable()
),
weapons: z.preprocess(
safeJSONParse,
z.array(z.enum(weapons)).max(LFG_WEAPON_POOL_MAX_LENGTH)
),
});
export const action: ActionFunction = async ({ request, context }) => {
@ -45,32 +56,46 @@ export const action: ActionFunction = async ({ request, context }) => {
schema: settingsActionSchema,
});
await User.update({ userId: user.id, miniBio: data.miniBio });
await User.update({
userId: user.id,
miniBio: data.miniBio,
weapons: data.weapons,
});
return null;
};
export type SettingsLoaderData = {
miniBio?: string;
weapons: string[];
};
export const loader: ActionFunction = async ({ context }) => {
const user = requireUser(context);
const { miniBio } = (await User.findById(user.id)) ?? {};
const { miniBio, weapons } = (await User.findById(user.id)) ?? {};
return json<SettingsLoaderData>({ miniBio: miniBio ?? undefined });
return json<SettingsLoaderData>({
miniBio: miniBio ?? undefined,
weapons: weapons ?? [],
});
};
export default function PlaySettingsPage() {
const data = useLoaderData<SettingsLoaderData>();
const transition = useTransition();
const [miniBio, setMiniBio] = React.useState(data.miniBio ?? "");
const [weaponPool, setWeaponPool] = React.useState(data.weapons);
return (
<div>
<Form method="post">
<label className="play-settings__mini-bio-label" htmlFor="mini-bio">
<input
type="hidden"
name="weapons"
value={JSON.stringify(weaponPool)}
/>
<label className="play-settings__label" htmlFor="mini-bio">
SendouQ Bio
</label>
<div className="play-settings__explanation">
@ -92,10 +117,50 @@ export default function PlaySettingsPage() {
>
{miniBio.length}/{MINI_BIO_MAX_LENGTH}
</div>
<div className="mt-4">
<label className="play-settings__label mt-4" htmlFor="weapon-pool">
Weapon pool
</label>
<div className="play-settings__explanation">
What are your preferred weapons to play? Select up to{" "}
{LFG_WEAPON_POOL_MAX_LENGTH}.
</div>
<Combobox
options={weapons.filter((wpn) => !weaponPool.includes(wpn))}
onChange={(val) => setWeaponPool((pool) => [...pool, val])}
inputName="weapon-pool"
placeholder="Luna Blaster"
/>
<ol className="play-settings__weapons">
{weaponPool.map((wpn, i) => (
<li key={wpn} className="play-settings__weapon-row">
<WeaponImage className="play-settings__weapon" weapon={wpn} />{" "}
{i + 1}) {wpn}{" "}
<Button
className="ml-auto"
tiny
type="button"
onClick={() =>
setWeaponPool((pool) =>
pool.filter((weaponInPool) => weaponInPool !== wpn)
)
}
>
</Button>
</li>
))}
</ol>
{weaponPool.length > LFG_WEAPON_POOL_MAX_LENGTH && (
<div className="play-settings__error-text">
You can have at most {LFG_WEAPON_POOL_MAX_LENGTH} weapons in your
weapon pool
</div>
)}
<div className="mt-6">
<Button
loading={transition.state === "submitting"}
loadingText="Saving..."
disabled={weaponPool.length > LFG_WEAPON_POOL_MAX_LENGTH}
>
Save
</Button>

View File

@ -188,7 +188,7 @@ button > .button-icon {
margin-inline-end: var(--s-1-5);
}
textarea {
textarea:not(.plain) {
padding: var(--s-2) var(--s-3);
border: 1px solid var(--border);
accent-color: var(--theme-secondary);
@ -202,6 +202,13 @@ textarea {
white-space: pre;
}
textarea:not(.plain):focus-within {
border-color: transparent;
/* TODO: rectangle on Safari */
outline: 2px solid var(--theme);
}
input:not(.plain) {
height: 1rem;
padding: var(--s-4) var(--s-3);
@ -228,6 +235,7 @@ input:not(.plain):focus-within {
input:not(.plain)::placeholder {
color: var(--text-lighter);
font-size: var(--fonts-xxs);
font-weight: var(--semi-bold);
letter-spacing: 0.5px;
}
@ -443,6 +451,31 @@ hr {
white-space: pre-wrap;
}
.combobox-input {
width: 12rem;
}
.combobox-options {
position: absolute;
width: 12rem;
margin-top: var(--s-2);
background-color: var(--bg-darker);
border-radius: var(--rounded);
color: var(--text);
font-size: var(--fonts-sm);
padding-block: var(--s-3);
padding-inline: 0;
}
.combobox-item {
padding: var(--s-1) var(--s-3);
list-style: none;
}
.combobox-item.active {
background-color: var(--theme-transparent);
}
.four-zero-one__container {
text-align: center;
}
@ -537,10 +570,22 @@ hr {
margin-block-start: var(--s-4);
}
.mt-6 {
margin-block-start: var(--s-6);
}
.mb-0 {
margin-block-end: 0;
}
.mb-2 {
margin-block-end: var(--s-2);
}
.ml-auto {
margin-inline-start: auto;
}
.ml-2 {
margin-inline-start: var(--s-2);
}

View File

@ -113,8 +113,8 @@
.layout__main {
max-width: 48rem;
padding-top: var(--s-8);
margin: 0 auto;
padding-block: var(--s-8);
padding-inline: var(--s-2);
}

View File

@ -1,15 +1,48 @@
.play-settings__mini-bio-label {
.play-settings__label {
display: block;
font-size: var(--fonts-sm);
font-weight: var(--bold);
}
.play-settings__weapons {
display: flex;
width: 17rem;
flex-direction: column;
padding: 0;
gap: var(--s-2);
margin-block-start: var(--s-3);
}
.play-settings__weapon-row {
display: flex;
align-items: center;
padding: var(--s-1) var(--s-2);
background-color: var(--theme);
border-radius: var(--rounded);
color: var(--button-text);
font-size: var(--fonts-xxs);
font-weight: var(--bold);
letter-spacing: var(--s-0-5);
text-transform: uppercase;
}
.play-settings__weapon {
width: 2rem;
margin-inline-end: var(--s-2);
}
.play-settings__explanation {
color: var(--text-lighter);
font-size: var(--fonts-sm);
margin-block-end: var(--s-2);
}
.play-settings__error-text {
color: var(--theme-error);
font-size: var(--fonts-sm);
margin-block-start: var(--s-2);
}
.play-settings__mini-bio {
width: min(18rem, 100%);
height: 6rem;

14
package-lock.json generated
View File

@ -23,6 +23,7 @@
"cookie-session": "^2.0.0",
"cross-env": "^7.0.3",
"express": "^4.17.3",
"fuse.js": "^6.5.3",
"just-clone": "^5.0.1",
"just-shuffle": "^4.0.1",
"morgan": "^1.10.0",
@ -4454,6 +4455,14 @@
"integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=",
"dev": true
},
"node_modules/fuse.js": {
"version": "6.5.3",
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-6.5.3.tgz",
"integrity": "sha512-sA5etGE7yD/pOqivZRBvUBd/NaL2sjAu6QuSaFoe1H2BrJSkH/T/UXAJ8CdXdw7DvY3Hs8CXKYkDWX7RiP5KOg==",
"engines": {
"node": ">=10"
}
},
"node_modules/get-intrinsic": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz",
@ -13703,6 +13712,11 @@
"integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=",
"dev": true
},
"fuse.js": {
"version": "6.5.3",
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-6.5.3.tgz",
"integrity": "sha512-sA5etGE7yD/pOqivZRBvUBd/NaL2sjAu6QuSaFoe1H2BrJSkH/T/UXAJ8CdXdw7DvY3Hs8CXKYkDWX7RiP5KOg=="
},
"get-intrinsic": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz",

View File

@ -41,6 +41,7 @@
"cookie-session": "^2.0.0",
"cross-env": "^7.0.3",
"express": "^4.17.3",
"fuse.js": "^6.5.3",
"just-clone": "^5.0.1",
"just-shuffle": "^4.0.1",
"morgan": "^1.10.0",