mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-14 14:22:38 -05:00
Migrate Prettier/Eslint/Stylelint setup to Biome (#1772)
* Initial * CSS lint * Test CI * Add 1v1, 2v2, and 3v3 Tags (#1771) * Initial * CSS lint * Test CI * Rename step --------- Co-authored-by: xi <104683822+ximk@users.noreply.github.com>
This commit is contained in:
@@ -1,44 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
tsconfigRootDir: __dirname,
|
||||
project: ["./tsconfig.json"],
|
||||
},
|
||||
plugins: ["@typescript-eslint"],
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:@typescript-eslint/recommended-requiring-type-checking",
|
||||
"plugin:react/recommended",
|
||||
"plugin:react/jsx-runtime",
|
||||
"plugin:react-hooks/recommended",
|
||||
],
|
||||
rules: {
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{ argsIgnorePattern: "^_", ignoreRestSiblings: true },
|
||||
],
|
||||
"no-constant-condition": ["error", { checkLoops: false }],
|
||||
"no-console": ["warn", { allow: ["warn", "error"] }],
|
||||
"no-warning-comments": ["warn", { terms: ["xxx"] }],
|
||||
"no-var": 0,
|
||||
"no-useless-escape": 0,
|
||||
"@typescript-eslint/no-unsafe-return": 0,
|
||||
"@typescript-eslint/no-unsafe-member-access": 0,
|
||||
"@typescript-eslint/no-unsafe-assignment": 0,
|
||||
"@typescript-eslint/no-unsafe-call": 0,
|
||||
"@typescript-eslint/no-unsafe-argument": 0,
|
||||
"@typescript-eslint/no-non-null-assertion": 0,
|
||||
"@typescript-eslint/no-explicit-any": 0,
|
||||
"@typescript-eslint/unbound-method": 0,
|
||||
"@typescript-eslint/consistent-type-imports": "warn",
|
||||
"react/prop-types": 0,
|
||||
"react/display-name": 0,
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: "detect",
|
||||
},
|
||||
},
|
||||
};
|
||||
8
.github/workflows/main.yml
vendored
8
.github/workflows/main.yml
vendored
@@ -27,12 +27,8 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Prettier
|
||||
run: npm run prettier:check
|
||||
- name: Lint TS
|
||||
run: npm run lint:ts
|
||||
- name: Stylelint
|
||||
run: npm run lint:css
|
||||
- name: Formatter/Linter
|
||||
run: npm run biome:check
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
- name: Check translations jsons
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
build
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"extends": ["stylelint-config-standard"],
|
||||
"rules": {
|
||||
"selector-pseudo-class-no-unknown": [
|
||||
true,
|
||||
{
|
||||
"ignorePseudoClasses": ["global"]
|
||||
}
|
||||
],
|
||||
"selector-class-pattern": ["[a-z-_]+"],
|
||||
"custom-property-pattern": "(?<=_?)",
|
||||
"media-feature-range-notation": "prefix",
|
||||
"property-no-vendor-prefix": [
|
||||
true,
|
||||
{
|
||||
"ignoreProperties": ["backdrop-filter"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -228,7 +228,7 @@ Note: This is only useful if you have access to a production running on Render.c
|
||||
|
||||
### Fix errors from the CI Pipeline
|
||||
|
||||
If you change any files and the CI pipeline errors out on certain formatting/linting steps (e.g. the `Prettier` or `Stylelint` step), run this command in the repo's root directory:
|
||||
If you change any files and the CI pipeline errors out on certain formatting/linting steps (Biome) run this command in the repo's root directory:
|
||||
|
||||
```sh
|
||||
npm run cf
|
||||
|
||||
@@ -1,209 +1,209 @@
|
||||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { abilities } from "~/modules/in-game-lists";
|
||||
import type { BuildAbilitiesTupleWithUnknown } from "~/modules/in-game-lists/types";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { abilityImageUrl } from "~/utils/urls";
|
||||
import { Ability } from "./Ability";
|
||||
import { Image } from "./Image";
|
||||
|
||||
interface AbilitiesSelectorProps {
|
||||
selectedAbilities: BuildAbilitiesTupleWithUnknown;
|
||||
onChange: (newAbilities: BuildAbilitiesTupleWithUnknown) => void;
|
||||
selectedAbilities: BuildAbilitiesTupleWithUnknown;
|
||||
onChange: (newAbilities: BuildAbilitiesTupleWithUnknown) => void;
|
||||
}
|
||||
|
||||
export function AbilitiesSelector({
|
||||
selectedAbilities,
|
||||
onChange,
|
||||
selectedAbilities,
|
||||
onChange,
|
||||
}: AbilitiesSelectorProps) {
|
||||
const [, startTransition] = React.useTransition();
|
||||
const [, startTransition] = React.useTransition();
|
||||
|
||||
const onSlotClick = ({
|
||||
rowI,
|
||||
abilityI,
|
||||
}: {
|
||||
rowI: number;
|
||||
abilityI: number;
|
||||
}) => {
|
||||
const abilitiesClone = JSON.parse(
|
||||
JSON.stringify(selectedAbilities),
|
||||
) as BuildAbilitiesTupleWithUnknown;
|
||||
const onSlotClick = ({
|
||||
rowI,
|
||||
abilityI,
|
||||
}: {
|
||||
rowI: number;
|
||||
abilityI: number;
|
||||
}) => {
|
||||
const abilitiesClone = JSON.parse(
|
||||
JSON.stringify(selectedAbilities),
|
||||
) as BuildAbilitiesTupleWithUnknown;
|
||||
|
||||
const row = abilitiesClone[rowI];
|
||||
invariant(row);
|
||||
invariant(row.length === 4);
|
||||
const row = abilitiesClone[rowI];
|
||||
invariant(row);
|
||||
invariant(row.length === 4);
|
||||
|
||||
// no need to trigger a rerender
|
||||
if (row[abilityI] === "UNKNOWN") return;
|
||||
// no need to trigger a rerender
|
||||
if (row[abilityI] === "UNKNOWN") return;
|
||||
|
||||
row[abilityI] = "UNKNOWN";
|
||||
row[abilityI] = "UNKNOWN";
|
||||
|
||||
onChange(abilitiesClone);
|
||||
};
|
||||
const onButtonClick = (ability: (typeof abilities)[number]) => {
|
||||
startTransition(() => {
|
||||
onChange(addAbility({ oldAbilities: selectedAbilities, ability }));
|
||||
});
|
||||
};
|
||||
onChange(abilitiesClone);
|
||||
};
|
||||
const onButtonClick = (ability: (typeof abilities)[number]) => {
|
||||
startTransition(() => {
|
||||
onChange(addAbility({ oldAbilities: selectedAbilities, ability }));
|
||||
});
|
||||
};
|
||||
|
||||
const [draggingAbility, setDraggingAbility] = React.useState<
|
||||
(typeof abilities)[number] | undefined
|
||||
>();
|
||||
const [draggingAbility, setDraggingAbility] = React.useState<
|
||||
(typeof abilities)[number] | undefined
|
||||
>();
|
||||
|
||||
const onDragStart =
|
||||
(ability: (typeof abilities)[number]) => (event: React.DragEvent) => {
|
||||
setDraggingAbility(ability);
|
||||
event.dataTransfer.setData("text/plain", JSON.stringify(ability));
|
||||
};
|
||||
const onDragStart =
|
||||
(ability: (typeof abilities)[number]) => (event: React.DragEvent) => {
|
||||
setDraggingAbility(ability);
|
||||
event.dataTransfer.setData("text/plain", JSON.stringify(ability));
|
||||
};
|
||||
|
||||
const onDragEnd = () => {
|
||||
setDraggingAbility(undefined);
|
||||
};
|
||||
const onDragEnd = () => {
|
||||
setDraggingAbility(undefined);
|
||||
};
|
||||
|
||||
const onDrop =
|
||||
(atRowIndex: number, atAbilityIndex: number) =>
|
||||
(event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
const ability = JSON.parse(
|
||||
event.dataTransfer.getData("text/plain"),
|
||||
) as (typeof abilities)[number];
|
||||
const onDrop =
|
||||
(atRowIndex: number, atAbilityIndex: number) =>
|
||||
(event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
const ability = JSON.parse(
|
||||
event.dataTransfer.getData("text/plain"),
|
||||
) as (typeof abilities)[number];
|
||||
|
||||
onChange(
|
||||
addAbility({
|
||||
oldAbilities: selectedAbilities,
|
||||
ability,
|
||||
atRowIndex,
|
||||
atAbilityIndex,
|
||||
}),
|
||||
);
|
||||
};
|
||||
onChange(
|
||||
addAbility({
|
||||
oldAbilities: selectedAbilities,
|
||||
ability,
|
||||
atRowIndex,
|
||||
atAbilityIndex,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ability-selector__container" data-testid="ability-selector">
|
||||
<div className="ability-selector__slots">
|
||||
{selectedAbilities.map((row, rowI) =>
|
||||
row.map((ability, abilityI) => (
|
||||
<Ability
|
||||
key={abilityI}
|
||||
ability={ability}
|
||||
size={abilityI === 0 ? "MAIN" : "SUB"}
|
||||
onClick={() => onSlotClick({ rowI, abilityI })}
|
||||
dragStarted={!!draggingAbility}
|
||||
dropAllowed={canPlaceAbilityAtSlot(
|
||||
rowI,
|
||||
abilityI,
|
||||
draggingAbility,
|
||||
)}
|
||||
onDrop={onDrop(rowI, abilityI)}
|
||||
/>
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
<div className="ability-selector__ability-buttons">
|
||||
{abilities.map((ability) => (
|
||||
<button
|
||||
key={ability.name}
|
||||
className={clsx("ability-selector__ability-button", {
|
||||
"is-dragging": ability.name === draggingAbility?.name,
|
||||
})}
|
||||
type="button"
|
||||
onClick={() => onButtonClick(ability)}
|
||||
data-testid={`${ability.name}-ability-button`}
|
||||
draggable="true"
|
||||
onDragStart={onDragStart(ability)}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<Image
|
||||
alt=""
|
||||
path={abilityImageUrl(ability.name)}
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="ability-selector__container" data-testid="ability-selector">
|
||||
<div className="ability-selector__slots">
|
||||
{selectedAbilities.map((row, rowI) =>
|
||||
row.map((ability, abilityI) => (
|
||||
<Ability
|
||||
key={abilityI}
|
||||
ability={ability}
|
||||
size={abilityI === 0 ? "MAIN" : "SUB"}
|
||||
onClick={() => onSlotClick({ rowI, abilityI })}
|
||||
dragStarted={!!draggingAbility}
|
||||
dropAllowed={canPlaceAbilityAtSlot(
|
||||
rowI,
|
||||
abilityI,
|
||||
draggingAbility,
|
||||
)}
|
||||
onDrop={onDrop(rowI, abilityI)}
|
||||
/>
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
<div className="ability-selector__ability-buttons">
|
||||
{abilities.map((ability) => (
|
||||
<button
|
||||
key={ability.name}
|
||||
className={clsx("ability-selector__ability-button", {
|
||||
"is-dragging": ability.name === draggingAbility?.name,
|
||||
})}
|
||||
type="button"
|
||||
onClick={() => onButtonClick(ability)}
|
||||
data-testid={`${ability.name}-ability-button`}
|
||||
draggable="true"
|
||||
onDragStart={onDragStart(ability)}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<Image
|
||||
alt=""
|
||||
path={abilityImageUrl(ability.name)}
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const canPlaceAbilityAtSlot = (
|
||||
rowIndex: number,
|
||||
abilityIndex: number,
|
||||
ability?: (typeof abilities)[number],
|
||||
rowIndex: number,
|
||||
abilityIndex: number,
|
||||
ability?: (typeof abilities)[number],
|
||||
) => {
|
||||
if (!ability) {
|
||||
return false;
|
||||
}
|
||||
if (!ability) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const legalGearTypeForMain =
|
||||
rowIndex === 0
|
||||
? "HEAD_MAIN_ONLY"
|
||||
: rowIndex === 1
|
||||
? "CLOTHES_MAIN_ONLY"
|
||||
: "SHOES_MAIN_ONLY";
|
||||
const legalGearTypeForMain =
|
||||
rowIndex === 0
|
||||
? "HEAD_MAIN_ONLY"
|
||||
: rowIndex === 1
|
||||
? "CLOTHES_MAIN_ONLY"
|
||||
: "SHOES_MAIN_ONLY";
|
||||
|
||||
const isMainSlot = abilityIndex === 0;
|
||||
const isMainSlot = abilityIndex === 0;
|
||||
|
||||
if (
|
||||
!["STACKABLE", legalGearTypeForMain].includes(ability.type) &&
|
||||
isMainSlot
|
||||
) {
|
||||
// Can't put this type of gear in main slot
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!["STACKABLE", legalGearTypeForMain].includes(ability.type) &&
|
||||
isMainSlot
|
||||
) {
|
||||
// Can't put this type of gear in main slot
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isMainSlot && ability.type !== "STACKABLE") {
|
||||
// Can't put main slot only gear to sub slots
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
if (!isMainSlot && ability.type !== "STACKABLE") {
|
||||
// Can't put main slot only gear to sub slots
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
function addAbility({
|
||||
oldAbilities,
|
||||
ability,
|
||||
atRowIndex,
|
||||
atAbilityIndex,
|
||||
oldAbilities,
|
||||
ability,
|
||||
atRowIndex,
|
||||
atAbilityIndex,
|
||||
}: {
|
||||
oldAbilities: BuildAbilitiesTupleWithUnknown;
|
||||
ability: (typeof abilities)[number];
|
||||
atRowIndex?: number;
|
||||
atAbilityIndex?: number;
|
||||
oldAbilities: BuildAbilitiesTupleWithUnknown;
|
||||
ability: (typeof abilities)[number];
|
||||
atRowIndex?: number;
|
||||
atAbilityIndex?: number;
|
||||
}): BuildAbilitiesTupleWithUnknown {
|
||||
const abilitiesClone = JSON.parse(
|
||||
JSON.stringify(oldAbilities),
|
||||
) as BuildAbilitiesTupleWithUnknown;
|
||||
const abilitiesClone = JSON.parse(
|
||||
JSON.stringify(oldAbilities),
|
||||
) as BuildAbilitiesTupleWithUnknown;
|
||||
|
||||
if (atRowIndex !== undefined && atAbilityIndex !== undefined) {
|
||||
// Attempt to place the ability at a specific slot since we
|
||||
// were given an atRowIndex and atAbilityIndex
|
||||
if (canPlaceAbilityAtSlot(atRowIndex, atAbilityIndex, ability)) {
|
||||
// Assign this ability to the slot
|
||||
abilitiesClone[atRowIndex][atAbilityIndex] = ability.name;
|
||||
}
|
||||
} else {
|
||||
// Loop through all slots and attempt to place this ability
|
||||
// in the first empty one
|
||||
for (const [rowIndex, row] of abilitiesClone.entries()) {
|
||||
for (const [abilityIndex, oldAbility] of row.entries()) {
|
||||
if (oldAbility !== "UNKNOWN") {
|
||||
// Skip any filled slots in this loop until we arrive at an empty one.
|
||||
continue;
|
||||
}
|
||||
if (atRowIndex !== undefined && atAbilityIndex !== undefined) {
|
||||
// Attempt to place the ability at a specific slot since we
|
||||
// were given an atRowIndex and atAbilityIndex
|
||||
if (canPlaceAbilityAtSlot(atRowIndex, atAbilityIndex, ability)) {
|
||||
// Assign this ability to the slot
|
||||
abilitiesClone[atRowIndex][atAbilityIndex] = ability.name;
|
||||
}
|
||||
} else {
|
||||
// Loop through all slots and attempt to place this ability
|
||||
// in the first empty one
|
||||
for (const [rowIndex, row] of abilitiesClone.entries()) {
|
||||
for (const [abilityIndex, oldAbility] of row.entries()) {
|
||||
if (oldAbility !== "UNKNOWN") {
|
||||
// Skip any filled slots in this loop until we arrive at an empty one.
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!canPlaceAbilityAtSlot(rowIndex, abilityIndex, ability)) {
|
||||
// This ability isn't valid for this slot
|
||||
continue;
|
||||
}
|
||||
if (!canPlaceAbilityAtSlot(rowIndex, abilityIndex, ability)) {
|
||||
// This ability isn't valid for this slot
|
||||
continue;
|
||||
}
|
||||
|
||||
// Assign this ability to the slot
|
||||
abilitiesClone[rowIndex][abilityIndex] = ability.name;
|
||||
// Assign this ability to the slot
|
||||
abilitiesClone[rowIndex][abilityIndex] = ability.name;
|
||||
|
||||
return abilitiesClone;
|
||||
}
|
||||
}
|
||||
}
|
||||
return abilitiesClone;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// no-op if no available slots
|
||||
return abilitiesClone;
|
||||
// no-op if no available slots
|
||||
return abilitiesClone;
|
||||
}
|
||||
|
||||
@@ -1,89 +1,89 @@
|
||||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AbilityWithUnknown } from "~/modules/in-game-lists/types";
|
||||
import { abilityImageUrl } from "~/utils/urls";
|
||||
import { Image } from "./Image";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const sizeMap = {
|
||||
MAIN: 42,
|
||||
SUB: 32,
|
||||
SUBTINY: 26,
|
||||
TINY: 22,
|
||||
MAIN: 42,
|
||||
SUB: 32,
|
||||
SUBTINY: 26,
|
||||
TINY: 22,
|
||||
} as const;
|
||||
|
||||
export function Ability({
|
||||
ability,
|
||||
size,
|
||||
dragStarted = false,
|
||||
dropAllowed = false,
|
||||
onClick,
|
||||
onDrop,
|
||||
className,
|
||||
ability,
|
||||
size,
|
||||
dragStarted = false,
|
||||
dropAllowed = false,
|
||||
onClick,
|
||||
onDrop,
|
||||
className,
|
||||
}: {
|
||||
ability: AbilityWithUnknown;
|
||||
size: keyof typeof sizeMap;
|
||||
dragStarted?: boolean;
|
||||
dropAllowed?: boolean;
|
||||
onClick?: () => void;
|
||||
onDrop?: (event: React.DragEvent) => void;
|
||||
className?: string;
|
||||
ability: AbilityWithUnknown;
|
||||
size: keyof typeof sizeMap;
|
||||
dragStarted?: boolean;
|
||||
dropAllowed?: boolean;
|
||||
onClick?: () => void;
|
||||
onDrop?: (event: React.DragEvent) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation(["game-misc", "builds"]);
|
||||
const sizeNumber = sizeMap[size];
|
||||
const { t } = useTranslation(["game-misc", "builds"]);
|
||||
const sizeNumber = sizeMap[size];
|
||||
|
||||
const [isDragTarget, setIsDragTarget] = React.useState(false);
|
||||
const [isDragTarget, setIsDragTarget] = React.useState(false);
|
||||
|
||||
const onDragOver = (event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
setIsDragTarget(true);
|
||||
};
|
||||
const onDragOver = (event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
setIsDragTarget(true);
|
||||
};
|
||||
|
||||
const onDragLeave = () => {
|
||||
setIsDragTarget(false);
|
||||
};
|
||||
const onDragLeave = () => {
|
||||
setIsDragTarget(false);
|
||||
};
|
||||
|
||||
const readonly = typeof onClick === "undefined" || ability === "UNKNOWN"; // Force "UNKNOWN" ability icons to be readonly
|
||||
const readonly = typeof onClick === "undefined" || ability === "UNKNOWN"; // Force "UNKNOWN" ability icons to be readonly
|
||||
|
||||
// Render an ability as a button only if it is meant to be draggable (i.e., not readonly)
|
||||
const AbilityTag = readonly ? "div" : "button";
|
||||
// Render an ability as a button only if it is meant to be draggable (i.e., not readonly)
|
||||
const AbilityTag = readonly ? "div" : "button";
|
||||
|
||||
const altText =
|
||||
ability !== "UNKNOWN"
|
||||
? t(`game-misc:ABILITY_${ability}`)
|
||||
: t("builds:emptyAbilitySlot");
|
||||
const altText =
|
||||
ability !== "UNKNOWN"
|
||||
? t(`game-misc:ABILITY_${ability}`)
|
||||
: t("builds:emptyAbilitySlot");
|
||||
|
||||
return (
|
||||
<AbilityTag
|
||||
className={clsx(
|
||||
"build__ability",
|
||||
{
|
||||
"is-drag-target": isDragTarget,
|
||||
"drag-started": dragStarted,
|
||||
"drop-allowed": dropAllowed,
|
||||
readonly,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
"--ability-size": `${sizeNumber}px`,
|
||||
}}
|
||||
onClick={onClick}
|
||||
data-testid={`${ability}-ability`}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={(event) => {
|
||||
setIsDragTarget(false);
|
||||
onDrop?.(event);
|
||||
}}
|
||||
type={readonly ? undefined : "button"}
|
||||
>
|
||||
<Image
|
||||
alt={altText}
|
||||
title={altText}
|
||||
path={abilityImageUrl(ability)}
|
||||
size={sizeNumber}
|
||||
/>
|
||||
</AbilityTag>
|
||||
);
|
||||
return (
|
||||
<AbilityTag
|
||||
className={clsx(
|
||||
"build__ability",
|
||||
{
|
||||
"is-drag-target": isDragTarget,
|
||||
"drag-started": dragStarted,
|
||||
"drop-allowed": dropAllowed,
|
||||
readonly,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
"--ability-size": `${sizeNumber}px`,
|
||||
}}
|
||||
onClick={onClick}
|
||||
data-testid={`${ability}-ability`}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={(event) => {
|
||||
setIsDragTarget(false);
|
||||
onDrop?.(event);
|
||||
}}
|
||||
type={readonly ? undefined : "button"}
|
||||
>
|
||||
<Image
|
||||
alt={altText}
|
||||
title={altText}
|
||||
path={abilityImageUrl(ability)}
|
||||
size={sizeNumber}
|
||||
/>
|
||||
</AbilityTag>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,51 +1,51 @@
|
||||
import clsx from "clsx";
|
||||
import type * as React from "react";
|
||||
import { AlertIcon } from "./icons/Alert";
|
||||
import { ErrorIcon } from "./icons/Error";
|
||||
import { CheckmarkIcon } from "./icons/Checkmark";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { AlertIcon } from "./icons/Alert";
|
||||
import { CheckmarkIcon } from "./icons/Checkmark";
|
||||
import { ErrorIcon } from "./icons/Error";
|
||||
|
||||
export type AlertVariation = "INFO" | "WARNING" | "ERROR" | "SUCCESS";
|
||||
|
||||
export function Alert({
|
||||
children,
|
||||
textClassName,
|
||||
alertClassName,
|
||||
variation = "INFO",
|
||||
tiny = false,
|
||||
children,
|
||||
textClassName,
|
||||
alertClassName,
|
||||
variation = "INFO",
|
||||
tiny = false,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
textClassName?: string;
|
||||
alertClassName?: string;
|
||||
variation?: AlertVariation;
|
||||
tiny?: boolean;
|
||||
children: React.ReactNode;
|
||||
textClassName?: string;
|
||||
alertClassName?: string;
|
||||
variation?: AlertVariation;
|
||||
tiny?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={clsx("alert", alertClassName, {
|
||||
tiny,
|
||||
warning: variation === "WARNING",
|
||||
error: variation === "ERROR",
|
||||
success: variation === "SUCCESS",
|
||||
})}
|
||||
>
|
||||
<Icon variation={variation} />{" "}
|
||||
<div className={textClassName}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={clsx("alert", alertClassName, {
|
||||
tiny,
|
||||
warning: variation === "WARNING",
|
||||
error: variation === "ERROR",
|
||||
success: variation === "SUCCESS",
|
||||
})}
|
||||
>
|
||||
<Icon variation={variation} />{" "}
|
||||
<div className={textClassName}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Icon({ variation }: { variation: AlertVariation }) {
|
||||
switch (variation) {
|
||||
case "INFO":
|
||||
return <AlertIcon />;
|
||||
case "WARNING":
|
||||
return <AlertIcon />;
|
||||
case "ERROR":
|
||||
return <ErrorIcon />;
|
||||
case "SUCCESS":
|
||||
return <CheckmarkIcon />;
|
||||
default:
|
||||
assertUnreachable(variation);
|
||||
}
|
||||
switch (variation) {
|
||||
case "INFO":
|
||||
return <AlertIcon />;
|
||||
case "WARNING":
|
||||
return <AlertIcon />;
|
||||
case "ERROR":
|
||||
return <ErrorIcon />;
|
||||
case "SUCCESS":
|
||||
return <CheckmarkIcon />;
|
||||
default:
|
||||
assertUnreachable(variation);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,62 +1,62 @@
|
||||
import clsx from "clsx";
|
||||
import type { User } from "~/db/types";
|
||||
import * as React from "react";
|
||||
import type { User } from "~/db/types";
|
||||
import { BLANK_IMAGE_URL } from "~/utils/urls";
|
||||
|
||||
const dimensions = {
|
||||
xxxs: 16,
|
||||
xxs: 24,
|
||||
xs: 36,
|
||||
sm: 44,
|
||||
xsm: 62,
|
||||
md: 81,
|
||||
lg: 125,
|
||||
xxxs: 16,
|
||||
xxs: 24,
|
||||
xs: 36,
|
||||
sm: 44,
|
||||
xsm: 62,
|
||||
md: 81,
|
||||
lg: 125,
|
||||
} as const;
|
||||
|
||||
function _Avatar({
|
||||
user,
|
||||
url,
|
||||
size = "sm",
|
||||
className,
|
||||
alt = "",
|
||||
...rest
|
||||
user,
|
||||
url,
|
||||
size = "sm",
|
||||
className,
|
||||
alt = "",
|
||||
...rest
|
||||
}: {
|
||||
user?: Pick<User, "discordId" | "discordAvatar">;
|
||||
url?: string;
|
||||
className?: string;
|
||||
alt?: string;
|
||||
size: keyof typeof dimensions;
|
||||
user?: Pick<User, "discordId" | "discordAvatar">;
|
||||
url?: string;
|
||||
className?: string;
|
||||
alt?: string;
|
||||
size: keyof typeof dimensions;
|
||||
} & React.ButtonHTMLAttributes<HTMLImageElement>) {
|
||||
const [isErrored, setIsErrored] = React.useState(false);
|
||||
// TODO: just show text... my profile?
|
||||
// TODO: also show this if discordAvatar is stale and 404's
|
||||
const [isErrored, setIsErrored] = React.useState(false);
|
||||
// TODO: just show text... my profile?
|
||||
// TODO: also show this if discordAvatar is stale and 404's
|
||||
|
||||
React.useEffect(() => {
|
||||
setIsErrored(false);
|
||||
}, [user?.discordAvatar]);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: every avatar error state is unique and we want to avoid using key on every avatar
|
||||
React.useEffect(() => {
|
||||
setIsErrored(false);
|
||||
}, [user?.discordAvatar]);
|
||||
|
||||
const src =
|
||||
url ??
|
||||
(user?.discordAvatar && !isErrored
|
||||
? `https://cdn.discordapp.com/avatars/${user.discordId}/${
|
||||
user.discordAvatar
|
||||
}.webp${size === "lg" ? "?size=240" : "?size=80"}`
|
||||
: BLANK_IMAGE_URL); // avoid broken image placeholder
|
||||
const src =
|
||||
url ??
|
||||
(user?.discordAvatar && !isErrored
|
||||
? `https://cdn.discordapp.com/avatars/${user.discordId}/${
|
||||
user.discordAvatar
|
||||
}.webp${size === "lg" ? "?size=240" : "?size=80"}`
|
||||
: BLANK_IMAGE_URL); // avoid broken image placeholder
|
||||
|
||||
return (
|
||||
<img
|
||||
className={clsx("avatar", className)}
|
||||
src={src}
|
||||
alt={alt}
|
||||
title={alt ? alt : undefined}
|
||||
width={dimensions[size]}
|
||||
height={dimensions[size]}
|
||||
// https://github.com/jsx-eslint/eslint-plugin-react/issues/3388
|
||||
// eslint-disable-next-line react/no-unknown-property
|
||||
onError={() => setIsErrored(true)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
// biome-ignore lint/a11y/useAltText: spread messes it up https://github.com/biomejs/biome/issues/3081
|
||||
<img
|
||||
className={clsx("avatar", className)}
|
||||
src={src}
|
||||
alt={alt}
|
||||
title={alt ? alt : undefined}
|
||||
width={dimensions[size]}
|
||||
height={dimensions[size]}
|
||||
onError={() => setIsErrored(true)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const Avatar = React.memo(_Avatar);
|
||||
|
||||
@@ -2,40 +2,41 @@ import { badgeUrl } from "~/utils/urls";
|
||||
import { Image } from "./Image";
|
||||
|
||||
export function Badge({
|
||||
badge,
|
||||
onClick,
|
||||
isAnimated,
|
||||
size,
|
||||
badge,
|
||||
onClick,
|
||||
isAnimated,
|
||||
size,
|
||||
}: {
|
||||
badge: { displayName: string; hue?: number | null; code: string };
|
||||
onClick?: () => void;
|
||||
isAnimated: boolean;
|
||||
size: number;
|
||||
badge: { displayName: string; hue?: number | null; code: string };
|
||||
onClick?: () => void;
|
||||
isAnimated: boolean;
|
||||
size: number;
|
||||
}) {
|
||||
const commonProps = {
|
||||
title: badge.displayName,
|
||||
onClick,
|
||||
width: size,
|
||||
height: size,
|
||||
style: badge.hue ? { filter: `hue-rotate(${badge.hue}deg)` } : undefined,
|
||||
};
|
||||
const commonProps = {
|
||||
title: badge.displayName,
|
||||
onClick,
|
||||
width: size,
|
||||
height: size,
|
||||
style: badge.hue ? { filter: `hue-rotate(${badge.hue}deg)` } : undefined,
|
||||
};
|
||||
|
||||
if (isAnimated) {
|
||||
return (
|
||||
<img
|
||||
src={badgeUrl({ code: badge.code, extension: "gif" })}
|
||||
alt={badge.displayName}
|
||||
{...commonProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isAnimated) {
|
||||
return (
|
||||
// biome-ignore lint/a11y/useAltText: false positive..?
|
||||
<img
|
||||
src={badgeUrl({ code: badge.code, extension: "gif" })}
|
||||
alt={badge.displayName}
|
||||
{...commonProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Image
|
||||
path={badgeUrl({ code: badge.code })}
|
||||
alt={badge.displayName}
|
||||
loading="lazy"
|
||||
{...commonProps}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Image
|
||||
path={badgeUrl({ code: badge.code })}
|
||||
alt={badge.displayName}
|
||||
loading="lazy"
|
||||
{...commonProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,296 +2,296 @@ import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import type {
|
||||
Build,
|
||||
BuildWeapon,
|
||||
GearType,
|
||||
UserWithPlusTier,
|
||||
Build,
|
||||
BuildWeapon,
|
||||
GearType,
|
||||
UserWithPlusTier,
|
||||
} from "~/db/types";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import type { BuildWeaponWithTop500Info } from "~/features/builds";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import type {
|
||||
Ability as AbilityType,
|
||||
ModeShort,
|
||||
Ability as AbilityType,
|
||||
ModeShort,
|
||||
} from "~/modules/in-game-lists";
|
||||
import type { BuildAbilitiesTuple } from "~/modules/in-game-lists/types";
|
||||
import { altWeaponIdToId } from "~/modules/in-game-lists/weapon-ids";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { gearTypeToInitial } from "~/utils/strings";
|
||||
import {
|
||||
analyzerPage,
|
||||
gearImageUrl,
|
||||
mainWeaponImageUrl,
|
||||
modeImageUrl,
|
||||
mySlugify,
|
||||
navIconUrl,
|
||||
userBuildsPage,
|
||||
weaponBuildPage,
|
||||
analyzerPage,
|
||||
gearImageUrl,
|
||||
mainWeaponImageUrl,
|
||||
modeImageUrl,
|
||||
mySlugify,
|
||||
navIconUrl,
|
||||
userBuildsPage,
|
||||
weaponBuildPage,
|
||||
} from "~/utils/urls";
|
||||
import { Ability } from "./Ability";
|
||||
import { Button, LinkButton } from "./Button";
|
||||
import { FormWithConfirm } from "./FormWithConfirm";
|
||||
import { TrashIcon } from "./icons/Trash";
|
||||
import { EditIcon } from "./icons/Edit";
|
||||
import { Image } from "./Image";
|
||||
import { Popover } from "./Popover";
|
||||
import { EditIcon } from "./icons/Edit";
|
||||
import { InfoIcon } from "./icons/Info";
|
||||
import { LockIcon } from "./icons/Lock";
|
||||
import type { BuildWeaponWithTop500Info } from "~/features/builds";
|
||||
import { altWeaponIdToId } from "~/modules/in-game-lists/weapon-ids";
|
||||
import { TrashIcon } from "./icons/Trash";
|
||||
|
||||
interface BuildProps {
|
||||
build: Pick<
|
||||
Build,
|
||||
| "id"
|
||||
| "title"
|
||||
| "description"
|
||||
| "clothesGearSplId"
|
||||
| "headGearSplId"
|
||||
| "shoesGearSplId"
|
||||
| "updatedAt"
|
||||
| "private"
|
||||
> & {
|
||||
abilities: BuildAbilitiesTuple;
|
||||
modes: ModeShort[] | null;
|
||||
weapons: Array<{
|
||||
weaponSplId: BuildWeapon["weaponSplId"];
|
||||
minRank: number | null;
|
||||
maxPower: number | null;
|
||||
}>;
|
||||
};
|
||||
owner?: Pick<UserWithPlusTier, "discordId" | "username" | "plusTier">;
|
||||
canEdit?: boolean;
|
||||
build: Pick<
|
||||
Build,
|
||||
| "id"
|
||||
| "title"
|
||||
| "description"
|
||||
| "clothesGearSplId"
|
||||
| "headGearSplId"
|
||||
| "shoesGearSplId"
|
||||
| "updatedAt"
|
||||
| "private"
|
||||
> & {
|
||||
abilities: BuildAbilitiesTuple;
|
||||
modes: ModeShort[] | null;
|
||||
weapons: Array<{
|
||||
weaponSplId: BuildWeapon["weaponSplId"];
|
||||
minRank: number | null;
|
||||
maxPower: number | null;
|
||||
}>;
|
||||
};
|
||||
owner?: Pick<UserWithPlusTier, "discordId" | "username" | "plusTier">;
|
||||
canEdit?: boolean;
|
||||
}
|
||||
|
||||
export function BuildCard({ build, owner, canEdit = false }: BuildProps) {
|
||||
const user = useUser();
|
||||
const { t } = useTranslation(["weapons", "builds", "common", "game-misc"]);
|
||||
const { i18n } = useTranslation();
|
||||
const isMounted = useIsMounted();
|
||||
const user = useUser();
|
||||
const { t } = useTranslation(["weapons", "builds", "common", "game-misc"]);
|
||||
const { i18n } = useTranslation();
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
const {
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
clothesGearSplId,
|
||||
headGearSplId,
|
||||
shoesGearSplId,
|
||||
updatedAt,
|
||||
abilities,
|
||||
modes,
|
||||
weapons,
|
||||
} = build;
|
||||
const {
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
clothesGearSplId,
|
||||
headGearSplId,
|
||||
shoesGearSplId,
|
||||
updatedAt,
|
||||
abilities,
|
||||
modes,
|
||||
weapons,
|
||||
} = build;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx("build", { build__private: build.private })}
|
||||
data-testid="build-card"
|
||||
>
|
||||
<div>
|
||||
<div className="build__top-row">
|
||||
{modes && modes.length > 0 && (
|
||||
<div className="build__modes">
|
||||
{modes.map((mode) => (
|
||||
<Image
|
||||
key={mode}
|
||||
alt={t(`game-misc:MODE_LONG_${mode}` as any)}
|
||||
title={t(`game-misc:MODE_LONG_${mode}` as any)}
|
||||
path={modeImageUrl(mode)}
|
||||
width={18}
|
||||
height={18}
|
||||
testId={`build-mode-${mode}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<h2 className="build__title" data-testid="build-title">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="build__date-author-row">
|
||||
{owner && (
|
||||
<>
|
||||
<Link
|
||||
to={userBuildsPage(owner)}
|
||||
className="build__date-author-row__owner"
|
||||
>
|
||||
{owner.username}
|
||||
</Link>
|
||||
<div>•</div>
|
||||
</>
|
||||
)}
|
||||
{owner?.plusTier ? (
|
||||
<>
|
||||
<span>+{owner.plusTier}</span>
|
||||
<div>•</div>
|
||||
</>
|
||||
) : null}
|
||||
<div className="stack horizontal sm">
|
||||
{build.private ? (
|
||||
<div className="build__private-text">
|
||||
<LockIcon className="build__private-icon" />{" "}
|
||||
{t("common:build.private")}
|
||||
</div>
|
||||
) : null}
|
||||
<time
|
||||
className={clsx("whitespace-nowrap", { invisible: !isMounted })}
|
||||
>
|
||||
{isMounted
|
||||
? databaseTimestampToDate(updatedAt).toLocaleDateString(
|
||||
i18n.language,
|
||||
{
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
},
|
||||
)
|
||||
: "t"}
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="build__weapons">
|
||||
{weapons.map((weapon) => (
|
||||
<RoundWeaponImage key={weapon.weaponSplId} weapon={weapon} />
|
||||
))}
|
||||
{weapons.length === 1 && (
|
||||
<div className="build__weapon-text">
|
||||
{t(`weapons:MAIN_${weapons[0].weaponSplId}` as any)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="build__gear-abilities">
|
||||
<AbilitiesRowWithGear
|
||||
gearType="HEAD"
|
||||
abilities={abilities[0]}
|
||||
gearId={headGearSplId}
|
||||
/>
|
||||
<AbilitiesRowWithGear
|
||||
gearType="CLOTHES"
|
||||
abilities={abilities[1]}
|
||||
gearId={clothesGearSplId}
|
||||
/>
|
||||
<AbilitiesRowWithGear
|
||||
gearType="SHOES"
|
||||
abilities={abilities[2]}
|
||||
gearId={shoesGearSplId}
|
||||
/>
|
||||
</div>
|
||||
<div className="build__bottom-row">
|
||||
<Link
|
||||
to={analyzerPage({
|
||||
weaponId: weapons[0].weaponSplId,
|
||||
abilities: abilities.flat(),
|
||||
})}
|
||||
>
|
||||
<Image
|
||||
alt={t("common:pages.analyzer")}
|
||||
className="build__icon"
|
||||
path={navIconUrl("analyzer")}
|
||||
/>
|
||||
</Link>
|
||||
{description ? (
|
||||
<Popover
|
||||
buttonChildren={<InfoIcon className="build__icon" />}
|
||||
triggerClassName="minimal tiny build__small-text"
|
||||
>
|
||||
{description}
|
||||
</Popover>
|
||||
) : null}
|
||||
{canEdit && (
|
||||
<>
|
||||
<LinkButton
|
||||
className="build__small-text"
|
||||
variant="minimal"
|
||||
size="tiny"
|
||||
to={`new?buildId=${id}&userId=${user!.id}`}
|
||||
testId="edit-build"
|
||||
>
|
||||
<EditIcon className="build__icon" />
|
||||
</LinkButton>
|
||||
<FormWithConfirm
|
||||
dialogHeading={t("builds:deleteConfirm", { title })}
|
||||
fields={[["buildToDeleteId", id]]}
|
||||
>
|
||||
<Button
|
||||
className="build__small-text"
|
||||
variant="minimal-destructive"
|
||||
size="tiny"
|
||||
type="submit"
|
||||
>
|
||||
<TrashIcon className="build__icon" />
|
||||
</Button>
|
||||
</FormWithConfirm>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={clsx("build", { build__private: build.private })}
|
||||
data-testid="build-card"
|
||||
>
|
||||
<div>
|
||||
<div className="build__top-row">
|
||||
{modes && modes.length > 0 && (
|
||||
<div className="build__modes">
|
||||
{modes.map((mode) => (
|
||||
<Image
|
||||
key={mode}
|
||||
alt={t(`game-misc:MODE_LONG_${mode}` as any)}
|
||||
title={t(`game-misc:MODE_LONG_${mode}` as any)}
|
||||
path={modeImageUrl(mode)}
|
||||
width={18}
|
||||
height={18}
|
||||
testId={`build-mode-${mode}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<h2 className="build__title" data-testid="build-title">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="build__date-author-row">
|
||||
{owner && (
|
||||
<>
|
||||
<Link
|
||||
to={userBuildsPage(owner)}
|
||||
className="build__date-author-row__owner"
|
||||
>
|
||||
{owner.username}
|
||||
</Link>
|
||||
<div>•</div>
|
||||
</>
|
||||
)}
|
||||
{owner?.plusTier ? (
|
||||
<>
|
||||
<span>+{owner.plusTier}</span>
|
||||
<div>•</div>
|
||||
</>
|
||||
) : null}
|
||||
<div className="stack horizontal sm">
|
||||
{build.private ? (
|
||||
<div className="build__private-text">
|
||||
<LockIcon className="build__private-icon" />{" "}
|
||||
{t("common:build.private")}
|
||||
</div>
|
||||
) : null}
|
||||
<time
|
||||
className={clsx("whitespace-nowrap", { invisible: !isMounted })}
|
||||
>
|
||||
{isMounted
|
||||
? databaseTimestampToDate(updatedAt).toLocaleDateString(
|
||||
i18n.language,
|
||||
{
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
},
|
||||
)
|
||||
: "t"}
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="build__weapons">
|
||||
{weapons.map((weapon) => (
|
||||
<RoundWeaponImage key={weapon.weaponSplId} weapon={weapon} />
|
||||
))}
|
||||
{weapons.length === 1 && (
|
||||
<div className="build__weapon-text">
|
||||
{t(`weapons:MAIN_${weapons[0].weaponSplId}` as any)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="build__gear-abilities">
|
||||
<AbilitiesRowWithGear
|
||||
gearType="HEAD"
|
||||
abilities={abilities[0]}
|
||||
gearId={headGearSplId}
|
||||
/>
|
||||
<AbilitiesRowWithGear
|
||||
gearType="CLOTHES"
|
||||
abilities={abilities[1]}
|
||||
gearId={clothesGearSplId}
|
||||
/>
|
||||
<AbilitiesRowWithGear
|
||||
gearType="SHOES"
|
||||
abilities={abilities[2]}
|
||||
gearId={shoesGearSplId}
|
||||
/>
|
||||
</div>
|
||||
<div className="build__bottom-row">
|
||||
<Link
|
||||
to={analyzerPage({
|
||||
weaponId: weapons[0].weaponSplId,
|
||||
abilities: abilities.flat(),
|
||||
})}
|
||||
>
|
||||
<Image
|
||||
alt={t("common:pages.analyzer")}
|
||||
className="build__icon"
|
||||
path={navIconUrl("analyzer")}
|
||||
/>
|
||||
</Link>
|
||||
{description ? (
|
||||
<Popover
|
||||
buttonChildren={<InfoIcon className="build__icon" />}
|
||||
triggerClassName="minimal tiny build__small-text"
|
||||
>
|
||||
{description}
|
||||
</Popover>
|
||||
) : null}
|
||||
{canEdit && (
|
||||
<>
|
||||
<LinkButton
|
||||
className="build__small-text"
|
||||
variant="minimal"
|
||||
size="tiny"
|
||||
to={`new?buildId=${id}&userId=${user!.id}`}
|
||||
testId="edit-build"
|
||||
>
|
||||
<EditIcon className="build__icon" />
|
||||
</LinkButton>
|
||||
<FormWithConfirm
|
||||
dialogHeading={t("builds:deleteConfirm", { title })}
|
||||
fields={[["buildToDeleteId", id]]}
|
||||
>
|
||||
<Button
|
||||
className="build__small-text"
|
||||
variant="minimal-destructive"
|
||||
size="tiny"
|
||||
type="submit"
|
||||
>
|
||||
<TrashIcon className="build__icon" />
|
||||
</Button>
|
||||
</FormWithConfirm>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RoundWeaponImage({ weapon }: { weapon: BuildWeaponWithTop500Info }) {
|
||||
const { weaponSplId, maxPower, minRank } = weapon;
|
||||
const normalizedWeaponSplId = altWeaponIdToId.get(weaponSplId) ?? weaponSplId;
|
||||
const { weaponSplId, maxPower, minRank } = weapon;
|
||||
const normalizedWeaponSplId = altWeaponIdToId.get(weaponSplId) ?? weaponSplId;
|
||||
|
||||
const { t } = useTranslation(["weapons"]);
|
||||
const slug = mySlugify(
|
||||
t(`weapons:MAIN_${normalizedWeaponSplId}`, { lng: "en" }),
|
||||
);
|
||||
const { t } = useTranslation(["weapons"]);
|
||||
const slug = mySlugify(
|
||||
t(`weapons:MAIN_${normalizedWeaponSplId}`, { lng: "en" }),
|
||||
);
|
||||
|
||||
const isTop500 = typeof maxPower === "number" && typeof minRank === "number";
|
||||
const isTop500 = typeof maxPower === "number" && typeof minRank === "number";
|
||||
|
||||
return (
|
||||
<div key={weaponSplId} className="build__weapon">
|
||||
{isTop500 ? (
|
||||
<Image
|
||||
className="build__top500"
|
||||
path={navIconUrl("xsearch")}
|
||||
alt=""
|
||||
title={`Max X Power: ${maxPower} | Best Rank: ${minRank}`}
|
||||
height={24}
|
||||
width={24}
|
||||
testId="top500-crown"
|
||||
/>
|
||||
) : null}
|
||||
<Link to={weaponBuildPage(slug)}>
|
||||
<Image
|
||||
path={mainWeaponImageUrl(weaponSplId)}
|
||||
alt={t(`weapons:MAIN_${weaponSplId}` as any)}
|
||||
title={t(`weapons:MAIN_${weaponSplId}` as any)}
|
||||
height={36}
|
||||
width={36}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div key={weaponSplId} className="build__weapon">
|
||||
{isTop500 ? (
|
||||
<Image
|
||||
className="build__top500"
|
||||
path={navIconUrl("xsearch")}
|
||||
alt=""
|
||||
title={`Max X Power: ${maxPower} | Best Rank: ${minRank}`}
|
||||
height={24}
|
||||
width={24}
|
||||
testId="top500-crown"
|
||||
/>
|
||||
) : null}
|
||||
<Link to={weaponBuildPage(slug)}>
|
||||
<Image
|
||||
path={mainWeaponImageUrl(weaponSplId)}
|
||||
alt={t(`weapons:MAIN_${weaponSplId}` as any)}
|
||||
title={t(`weapons:MAIN_${weaponSplId}` as any)}
|
||||
height={36}
|
||||
width={36}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AbilitiesRowWithGear({
|
||||
gearType,
|
||||
abilities,
|
||||
gearId,
|
||||
gearType,
|
||||
abilities,
|
||||
gearId,
|
||||
}: {
|
||||
gearType: GearType;
|
||||
abilities: AbilityType[];
|
||||
gearId: number;
|
||||
gearType: GearType;
|
||||
abilities: AbilityType[];
|
||||
gearId: number;
|
||||
}) {
|
||||
const { t } = useTranslation(["gear"]);
|
||||
const translatedGearName = t(
|
||||
`gear:${gearTypeToInitial(gearType)}_${gearId}` as any,
|
||||
);
|
||||
const { t } = useTranslation(["gear"]);
|
||||
const translatedGearName = t(
|
||||
`gear:${gearTypeToInitial(gearType)}_${gearId}` as any,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Image
|
||||
height={64}
|
||||
width={64}
|
||||
alt={translatedGearName}
|
||||
title={translatedGearName}
|
||||
path={gearImageUrl(gearType, gearId)}
|
||||
className="build__gear"
|
||||
/>
|
||||
{abilities.map((ability, i) => (
|
||||
<Ability key={i} ability={ability} size={i === 0 ? "MAIN" : "SUB"} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Image
|
||||
height={64}
|
||||
width={64}
|
||||
alt={translatedGearName}
|
||||
title={translatedGearName}
|
||||
path={gearImageUrl(gearType, gearId)}
|
||||
className="build__gear"
|
||||
/>
|
||||
{abilities.map((ability, i) => (
|
||||
<Ability key={i} ability={ability} size={i === 0 ? "MAIN" : "SUB"} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,128 +4,128 @@ import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?:
|
||||
| "success"
|
||||
| "outlined"
|
||||
| "outlined-success"
|
||||
| "destructive"
|
||||
| "minimal"
|
||||
| "minimal-success"
|
||||
| "minimal-destructive";
|
||||
size?: "miniscule" | "tiny" | "big";
|
||||
loading?: boolean;
|
||||
loadingText?: string;
|
||||
icon?: JSX.Element;
|
||||
testId?: string;
|
||||
_ref?: React.LegacyRef<HTMLButtonElement> | React.ForwardedRef<unknown>;
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?:
|
||||
| "success"
|
||||
| "outlined"
|
||||
| "outlined-success"
|
||||
| "destructive"
|
||||
| "minimal"
|
||||
| "minimal-success"
|
||||
| "minimal-destructive";
|
||||
size?: "miniscule" | "tiny" | "big";
|
||||
loading?: boolean;
|
||||
loadingText?: string;
|
||||
icon?: JSX.Element;
|
||||
testId?: string;
|
||||
_ref?: React.LegacyRef<HTMLButtonElement> | React.ForwardedRef<unknown>;
|
||||
}
|
||||
|
||||
export function Button(props: ButtonProps) {
|
||||
const {
|
||||
variant,
|
||||
loading,
|
||||
children,
|
||||
loadingText,
|
||||
size,
|
||||
className,
|
||||
icon,
|
||||
type = "button",
|
||||
testId,
|
||||
_ref,
|
||||
...rest
|
||||
} = props;
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
variant,
|
||||
{
|
||||
"disabled-opaque": props.disabled,
|
||||
loading,
|
||||
tiny: size === "tiny",
|
||||
big: size === "big",
|
||||
miniscule: size === "miniscule",
|
||||
},
|
||||
className,
|
||||
)}
|
||||
disabled={props.disabled || loading}
|
||||
type={type}
|
||||
data-testid={testId}
|
||||
ref={props._ref as React.LegacyRef<HTMLButtonElement>}
|
||||
{...rest}
|
||||
>
|
||||
{icon &&
|
||||
React.cloneElement(icon, {
|
||||
className: clsx("button-icon", { lonely: !children }),
|
||||
})}
|
||||
{loading && loadingText ? loadingText : children}
|
||||
</button>
|
||||
);
|
||||
const {
|
||||
variant,
|
||||
loading,
|
||||
children,
|
||||
loadingText,
|
||||
size,
|
||||
className,
|
||||
icon,
|
||||
type = "button",
|
||||
testId,
|
||||
_ref,
|
||||
...rest
|
||||
} = props;
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
variant,
|
||||
{
|
||||
"disabled-opaque": props.disabled,
|
||||
loading,
|
||||
tiny: size === "tiny",
|
||||
big: size === "big",
|
||||
miniscule: size === "miniscule",
|
||||
},
|
||||
className,
|
||||
)}
|
||||
disabled={props.disabled || loading}
|
||||
type={type}
|
||||
data-testid={testId}
|
||||
ref={props._ref as React.LegacyRef<HTMLButtonElement>}
|
||||
{...rest}
|
||||
>
|
||||
{icon &&
|
||||
React.cloneElement(icon, {
|
||||
className: clsx("button-icon", { lonely: !children }),
|
||||
})}
|
||||
{loading && loadingText ? loadingText : children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
type LinkButtonProps = Pick<
|
||||
ButtonProps,
|
||||
"variant" | "children" | "className" | "size" | "testId" | "icon"
|
||||
ButtonProps,
|
||||
"variant" | "children" | "className" | "size" | "testId" | "icon"
|
||||
> &
|
||||
Pick<LinkProps, "to" | "prefetch" | "state"> & { "data-cy"?: string } & {
|
||||
isExternal?: boolean;
|
||||
};
|
||||
Pick<LinkProps, "to" | "prefetch" | "state"> & { "data-cy"?: string } & {
|
||||
isExternal?: boolean;
|
||||
};
|
||||
|
||||
export function LinkButton({
|
||||
variant,
|
||||
children,
|
||||
size,
|
||||
className,
|
||||
to,
|
||||
prefetch,
|
||||
isExternal,
|
||||
state,
|
||||
testId,
|
||||
icon,
|
||||
variant,
|
||||
children,
|
||||
size,
|
||||
className,
|
||||
to,
|
||||
prefetch,
|
||||
isExternal,
|
||||
state,
|
||||
testId,
|
||||
icon,
|
||||
}: LinkButtonProps) {
|
||||
if (isExternal) {
|
||||
return (
|
||||
<a
|
||||
className={clsx(
|
||||
"button",
|
||||
variant,
|
||||
{ tiny: size === "tiny", big: size === "big" },
|
||||
className,
|
||||
)}
|
||||
href={to as string}
|
||||
data-testid={testId}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{icon &&
|
||||
React.cloneElement(icon, {
|
||||
className: clsx("button-icon", {
|
||||
lonely: !children,
|
||||
}),
|
||||
})}
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
if (isExternal) {
|
||||
return (
|
||||
<a
|
||||
className={clsx(
|
||||
"button",
|
||||
variant,
|
||||
{ tiny: size === "tiny", big: size === "big" },
|
||||
className,
|
||||
)}
|
||||
href={to as string}
|
||||
data-testid={testId}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{icon &&
|
||||
React.cloneElement(icon, {
|
||||
className: clsx("button-icon", {
|
||||
lonely: !children,
|
||||
}),
|
||||
})}
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
className={clsx(
|
||||
"button",
|
||||
variant,
|
||||
{ tiny: size === "tiny", big: size === "big" },
|
||||
className,
|
||||
)}
|
||||
to={to}
|
||||
data-testid={testId}
|
||||
prefetch={prefetch}
|
||||
state={state}
|
||||
>
|
||||
{icon &&
|
||||
React.cloneElement(icon, {
|
||||
className: clsx("button-icon", { lonely: !children }),
|
||||
})}
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
return (
|
||||
<Link
|
||||
className={clsx(
|
||||
"button",
|
||||
variant,
|
||||
{ tiny: size === "tiny", big: size === "big" },
|
||||
className,
|
||||
)}
|
||||
to={to}
|
||||
data-testid={testId}
|
||||
prefetch={prefetch}
|
||||
state={state}
|
||||
>
|
||||
{icon &&
|
||||
React.cloneElement(icon, {
|
||||
className: clsx("button-icon", { lonely: !children }),
|
||||
})}
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,84 +2,84 @@ import { isRouteErrorResponse, useRouteError } from "@remix-run/react";
|
||||
import { Button } from "~/components/Button";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import {
|
||||
ERROR_GIRL_IMAGE_PATH,
|
||||
LOG_IN_URL,
|
||||
SENDOU_INK_DISCORD_URL,
|
||||
ERROR_GIRL_IMAGE_PATH,
|
||||
LOG_IN_URL,
|
||||
SENDOU_INK_DISCORD_URL,
|
||||
} from "~/utils/urls";
|
||||
import { Image } from "./Image";
|
||||
import { Main } from "./Main";
|
||||
|
||||
export function Catcher() {
|
||||
const error = useRouteError();
|
||||
const user = useUser();
|
||||
const error = useRouteError();
|
||||
const user = useUser();
|
||||
|
||||
if (!isRouteErrorResponse(error))
|
||||
return (
|
||||
<Main>
|
||||
<Image
|
||||
className="m-0-auto"
|
||||
path={ERROR_GIRL_IMAGE_PATH}
|
||||
width={292}
|
||||
height={243.5}
|
||||
alt=""
|
||||
/>
|
||||
<h2 className="text-center">Error happened</h2>
|
||||
<p className="text-center">
|
||||
It seems like you encountered a bug. Sorry about that! Please report
|
||||
details (your browser? what were you doing?) on{" "}
|
||||
<a href={SENDOU_INK_DISCORD_URL}>our Discord</a> so it can be fixed.
|
||||
</p>
|
||||
</Main>
|
||||
);
|
||||
if (!isRouteErrorResponse(error))
|
||||
return (
|
||||
<Main>
|
||||
<Image
|
||||
className="m-0-auto"
|
||||
path={ERROR_GIRL_IMAGE_PATH}
|
||||
width={292}
|
||||
height={243.5}
|
||||
alt=""
|
||||
/>
|
||||
<h2 className="text-center">Error happened</h2>
|
||||
<p className="text-center">
|
||||
It seems like you encountered a bug. Sorry about that! Please report
|
||||
details (your browser? what were you doing?) on{" "}
|
||||
<a href={SENDOU_INK_DISCORD_URL}>our Discord</a> so it can be fixed.
|
||||
</p>
|
||||
</Main>
|
||||
);
|
||||
|
||||
switch (error.status) {
|
||||
case 401:
|
||||
return (
|
||||
<Main>
|
||||
<h2>Error 401 Unauthorized</h2>
|
||||
{user ? (
|
||||
<GetHelp />
|
||||
) : (
|
||||
<form action={LOG_IN_URL} method="post">
|
||||
<p className="button-text-paragraph">
|
||||
You should try{" "}
|
||||
<Button type="submit" variant="minimal">
|
||||
logging in
|
||||
</Button>
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</Main>
|
||||
);
|
||||
case 404:
|
||||
return (
|
||||
<Main>
|
||||
<h2>Error {error.status} - Page not found</h2>
|
||||
<GetHelp />
|
||||
</Main>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Main>
|
||||
<h2>Error {error.status}</h2>
|
||||
<GetHelp />
|
||||
<div className="text-sm text-lighter font-semi-bold">
|
||||
Please include the message below if any and an explanation on what
|
||||
you were doing:
|
||||
</div>
|
||||
{error.data ? (
|
||||
<pre>{JSON.stringify(JSON.parse(error.data), null, 2)}</pre>
|
||||
) : null}
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
switch (error.status) {
|
||||
case 401:
|
||||
return (
|
||||
<Main>
|
||||
<h2>Error 401 Unauthorized</h2>
|
||||
{user ? (
|
||||
<GetHelp />
|
||||
) : (
|
||||
<form action={LOG_IN_URL} method="post">
|
||||
<p className="button-text-paragraph">
|
||||
You should try{" "}
|
||||
<Button type="submit" variant="minimal">
|
||||
logging in
|
||||
</Button>
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</Main>
|
||||
);
|
||||
case 404:
|
||||
return (
|
||||
<Main>
|
||||
<h2>Error {error.status} - Page not found</h2>
|
||||
<GetHelp />
|
||||
</Main>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Main>
|
||||
<h2>Error {error.status}</h2>
|
||||
<GetHelp />
|
||||
<div className="text-sm text-lighter font-semi-bold">
|
||||
Please include the message below if any and an explanation on what
|
||||
you were doing:
|
||||
</div>
|
||||
{error.data ? (
|
||||
<pre>{JSON.stringify(JSON.parse(error.data), null, 2)}</pre>
|
||||
) : null}
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function GetHelp() {
|
||||
return (
|
||||
<p className="mt-2">
|
||||
If you need assistance you can ask for help on{" "}
|
||||
<a href={SENDOU_INK_DISCORD_URL}>our Discord</a>
|
||||
</p>
|
||||
);
|
||||
return (
|
||||
<p className="mt-2">
|
||||
If you need assistance you can ask for help on{" "}
|
||||
<a href={SENDOU_INK_DISCORD_URL}>our Discord</a>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,156 +3,156 @@ import * as React from "react";
|
||||
import { type AxisOptions, Chart as ReactChart } from "react-charts";
|
||||
import type { TooltipRendererProps } from "react-charts/types/components/TooltipRenderer";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { Theme, useTheme } from "~/features/theme/core/provider";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
|
||||
export default function Chart({
|
||||
options,
|
||||
containerClassName,
|
||||
headerSuffix,
|
||||
valueSuffix,
|
||||
xAxis,
|
||||
options,
|
||||
containerClassName,
|
||||
headerSuffix,
|
||||
valueSuffix,
|
||||
xAxis,
|
||||
}: {
|
||||
options: [
|
||||
{ label: string; data: Array<{ primary: Date; secondary: number }> },
|
||||
];
|
||||
containerClassName?: string;
|
||||
headerSuffix?: string;
|
||||
valueSuffix?: string;
|
||||
xAxis: "linear" | "localTime";
|
||||
options: [
|
||||
{ label: string; data: Array<{ primary: Date; secondary: number }> },
|
||||
];
|
||||
containerClassName?: string;
|
||||
headerSuffix?: string;
|
||||
valueSuffix?: string;
|
||||
xAxis: "linear" | "localTime";
|
||||
}) {
|
||||
const { i18n } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const isMounted = useIsMounted();
|
||||
const { i18n } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
const primaryAxis = React.useMemo<
|
||||
AxisOptions<(typeof options)[number]["data"][number]>
|
||||
>(
|
||||
// @ts-expect-error TODO: type this
|
||||
() => ({
|
||||
getValue: (datum) => datum.primary,
|
||||
scaleType: xAxis,
|
||||
shouldNice: false,
|
||||
formatters: {
|
||||
scale: (val: any) => {
|
||||
if (val instanceof Date) {
|
||||
return val.toLocaleDateString(i18n.language, {
|
||||
day: "numeric",
|
||||
month: "numeric",
|
||||
});
|
||||
}
|
||||
const primaryAxis = React.useMemo<
|
||||
AxisOptions<(typeof options)[number]["data"][number]>
|
||||
>(
|
||||
// @ts-expect-error TODO: type this
|
||||
() => ({
|
||||
getValue: (datum) => datum.primary,
|
||||
scaleType: xAxis,
|
||||
shouldNice: false,
|
||||
formatters: {
|
||||
scale: (val: any) => {
|
||||
if (val instanceof Date) {
|
||||
return val.toLocaleDateString(i18n.language, {
|
||||
day: "numeric",
|
||||
month: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
return val;
|
||||
},
|
||||
},
|
||||
}),
|
||||
[i18n.language, xAxis],
|
||||
);
|
||||
return val;
|
||||
},
|
||||
},
|
||||
}),
|
||||
[i18n.language, xAxis],
|
||||
);
|
||||
|
||||
const secondaryAxes = React.useMemo<
|
||||
AxisOptions<(typeof options)[number]["data"][number]>[]
|
||||
>(
|
||||
() => [
|
||||
{
|
||||
getValue: (datum) => datum.secondary,
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
const secondaryAxes = React.useMemo<
|
||||
AxisOptions<(typeof options)[number]["data"][number]>[]
|
||||
>(
|
||||
() => [
|
||||
{
|
||||
getValue: (datum) => datum.secondary,
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
if (!isMounted) {
|
||||
return <div className={clsx("chart__container", containerClassName)} />;
|
||||
}
|
||||
if (!isMounted) {
|
||||
return <div className={clsx("chart__container", containerClassName)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx("chart__container", containerClassName)}>
|
||||
<ReactChart
|
||||
options={{
|
||||
data: options,
|
||||
tooltip: {
|
||||
render: (props) => (
|
||||
<ChartTooltip
|
||||
{...props}
|
||||
headerSuffix={headerSuffix}
|
||||
valueSuffix={valueSuffix}
|
||||
/>
|
||||
),
|
||||
},
|
||||
primaryCursor: false,
|
||||
secondaryCursor: false,
|
||||
primaryAxis,
|
||||
secondaryAxes,
|
||||
dark: theme.htmlThemeClass === Theme.DARK,
|
||||
defaultColors: [
|
||||
"var(--theme)",
|
||||
"var(--theme-secondary)",
|
||||
"var(--theme-info)",
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className={clsx("chart__container", containerClassName)}>
|
||||
<ReactChart
|
||||
options={{
|
||||
data: options,
|
||||
tooltip: {
|
||||
render: (props) => (
|
||||
<ChartTooltip
|
||||
{...props}
|
||||
headerSuffix={headerSuffix}
|
||||
valueSuffix={valueSuffix}
|
||||
/>
|
||||
),
|
||||
},
|
||||
primaryCursor: false,
|
||||
secondaryCursor: false,
|
||||
primaryAxis,
|
||||
secondaryAxes,
|
||||
dark: theme.htmlThemeClass === Theme.DARK,
|
||||
defaultColors: [
|
||||
"var(--theme)",
|
||||
"var(--theme-secondary)",
|
||||
"var(--theme-info)",
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChartTooltipProps extends TooltipRendererProps<any> {
|
||||
headerSuffix?: string;
|
||||
valueSuffix?: string;
|
||||
headerSuffix?: string;
|
||||
valueSuffix?: string;
|
||||
}
|
||||
|
||||
function ChartTooltip({
|
||||
focusedDatum,
|
||||
headerSuffix = "",
|
||||
valueSuffix = "",
|
||||
focusedDatum,
|
||||
headerSuffix = "",
|
||||
valueSuffix = "",
|
||||
}: ChartTooltipProps) {
|
||||
const { i18n } = useTranslation();
|
||||
const dataPoints = focusedDatum?.interactiveGroup ?? [];
|
||||
const { i18n } = useTranslation();
|
||||
const dataPoints = focusedDatum?.interactiveGroup ?? [];
|
||||
|
||||
const header = () => {
|
||||
const primaryValue = dataPoints[0]?.primaryValue;
|
||||
if (!primaryValue) return null;
|
||||
const header = () => {
|
||||
const primaryValue = dataPoints[0]?.primaryValue;
|
||||
if (!primaryValue) return null;
|
||||
|
||||
if (primaryValue instanceof Date) {
|
||||
return primaryValue.toLocaleDateString(i18n.language, {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
});
|
||||
}
|
||||
if (primaryValue instanceof Date) {
|
||||
return primaryValue.toLocaleDateString(i18n.language, {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
});
|
||||
}
|
||||
|
||||
return primaryValue;
|
||||
};
|
||||
return primaryValue;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="chart__tooltip">
|
||||
<h3 className="text-center text-md">
|
||||
{header()}
|
||||
{headerSuffix}
|
||||
</h3>
|
||||
{dataPoints.map((dataPoint, index) => {
|
||||
const color = dataPoint.style?.fill ?? "var(--theme)";
|
||||
return (
|
||||
<div className="chart__tooltip">
|
||||
<h3 className="text-center text-md">
|
||||
{header()}
|
||||
{headerSuffix}
|
||||
</h3>
|
||||
{dataPoints.map((dataPoint, index) => {
|
||||
const color = dataPoint.style?.fill ?? "var(--theme)";
|
||||
|
||||
return (
|
||||
<div key={index} className="stack horizontal items-center sm">
|
||||
<div
|
||||
className={clsx("chart__dot", {
|
||||
chart__dot__focused:
|
||||
focusedDatum?.seriesId === dataPoint.seriesId,
|
||||
})}
|
||||
style={{
|
||||
"--dot-color": color,
|
||||
"--dot-color-outline": color.replace(")", "-transparent)"),
|
||||
}}
|
||||
/>
|
||||
<div className="chart__tooltip__label">
|
||||
{dataPoint.originalSeries.label}
|
||||
</div>
|
||||
<div className="chart__tooltip__value">
|
||||
{dataPoint.secondaryValue}
|
||||
{valueSuffix}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div key={index} className="stack horizontal items-center sm">
|
||||
<div
|
||||
className={clsx("chart__dot", {
|
||||
chart__dot__focused:
|
||||
focusedDatum?.seriesId === dataPoint.seriesId,
|
||||
})}
|
||||
style={{
|
||||
"--dot-color": color,
|
||||
"--dot-color-outline": color.replace(")", "-transparent)"),
|
||||
}}
|
||||
/>
|
||||
<div className="chart__tooltip__label">
|
||||
{dataPoint.originalSeries.label}
|
||||
</div>
|
||||
<div className="chart__tooltip__value">
|
||||
{dataPoint.secondaryValue}
|
||||
{valueSuffix}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,447 +2,447 @@ import { Combobox as HeadlessCombobox } from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
import Fuse, { type IFuseOptions } from "fuse.js";
|
||||
import * as React from "react";
|
||||
import type { GearType } from "~/db/types";
|
||||
import { useAllEventsWithMapPools } from "~/hooks/swr";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { GearType } from "~/db/types";
|
||||
import type { SerializedMapPoolEvent } from "~/features/calendar/routes/map-pool-events";
|
||||
import { useAllEventsWithMapPools } from "~/hooks/swr";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists";
|
||||
import {
|
||||
clothesGearIds,
|
||||
headGearIds,
|
||||
mainWeaponIds,
|
||||
shoesGearIds,
|
||||
subWeaponIds,
|
||||
weaponCategories,
|
||||
clothesGearIds,
|
||||
headGearIds,
|
||||
mainWeaponIds,
|
||||
shoesGearIds,
|
||||
subWeaponIds,
|
||||
weaponCategories,
|
||||
} from "~/modules/in-game-lists";
|
||||
import { weaponAltNames } from "~/modules/in-game-lists/weapon-alt-names";
|
||||
import {
|
||||
nonBombSubWeaponIds,
|
||||
nonDamagingSpecialWeaponIds,
|
||||
specialWeaponIds,
|
||||
nonBombSubWeaponIds,
|
||||
nonDamagingSpecialWeaponIds,
|
||||
specialWeaponIds,
|
||||
} from "~/modules/in-game-lists/weapon-ids";
|
||||
import { type SerializedMapPoolEvent } from "~/features/calendar/routes/map-pool-events";
|
||||
import type { Unpacked } from "~/utils/types";
|
||||
import {
|
||||
gearImageUrl,
|
||||
mainWeaponImageUrl,
|
||||
specialWeaponImageUrl,
|
||||
subWeaponImageUrl,
|
||||
gearImageUrl,
|
||||
mainWeaponImageUrl,
|
||||
specialWeaponImageUrl,
|
||||
subWeaponImageUrl,
|
||||
} from "~/utils/urls";
|
||||
import { Image } from "./Image";
|
||||
import { weaponAltNames } from "~/modules/in-game-lists/weapon-alt-names";
|
||||
|
||||
const MAX_RESULTS_SHOWN = 6;
|
||||
|
||||
interface ComboboxBaseOption {
|
||||
label: string;
|
||||
/** Alternative text other than label to match by */
|
||||
alt?: string[];
|
||||
value: string;
|
||||
imgPath?: string;
|
||||
label: string;
|
||||
/** Alternative text other than label to match by */
|
||||
alt?: string[];
|
||||
value: string;
|
||||
imgPath?: string;
|
||||
}
|
||||
|
||||
type ComboboxOption<T> = ComboboxBaseOption & T;
|
||||
interface ComboboxProps<T> {
|
||||
options: ComboboxOption<T>[];
|
||||
quickSelectOptions?: ComboboxOption<T>[];
|
||||
inputName: string;
|
||||
placeholder: string;
|
||||
className?: string;
|
||||
id?: string;
|
||||
isLoading?: boolean;
|
||||
required?: boolean;
|
||||
value?: ComboboxOption<T> | null;
|
||||
initialValue: ComboboxOption<T> | null;
|
||||
onChange?: (selectedOption: ComboboxOption<T> | null) => void;
|
||||
fullWidth?: boolean;
|
||||
nullable?: true;
|
||||
fuseOptions?: IFuseOptions<ComboboxOption<T>>;
|
||||
options: ComboboxOption<T>[];
|
||||
quickSelectOptions?: ComboboxOption<T>[];
|
||||
inputName: string;
|
||||
placeholder: string;
|
||||
className?: string;
|
||||
id?: string;
|
||||
isLoading?: boolean;
|
||||
required?: boolean;
|
||||
value?: ComboboxOption<T> | null;
|
||||
initialValue: ComboboxOption<T> | null;
|
||||
onChange?: (selectedOption: ComboboxOption<T> | null) => void;
|
||||
fullWidth?: boolean;
|
||||
nullable?: true;
|
||||
fuseOptions?: IFuseOptions<ComboboxOption<T>>;
|
||||
}
|
||||
|
||||
export function Combobox<
|
||||
T extends Record<string, string | string[] | null | undefined | number>,
|
||||
T extends Record<string, string | string[] | null | undefined | number>,
|
||||
>({
|
||||
options,
|
||||
quickSelectOptions,
|
||||
inputName,
|
||||
placeholder,
|
||||
value,
|
||||
initialValue,
|
||||
onChange,
|
||||
required,
|
||||
className,
|
||||
id,
|
||||
nullable,
|
||||
isLoading = false,
|
||||
fullWidth = false,
|
||||
fuseOptions = {},
|
||||
options,
|
||||
quickSelectOptions,
|
||||
inputName,
|
||||
placeholder,
|
||||
value,
|
||||
initialValue,
|
||||
onChange,
|
||||
required,
|
||||
className,
|
||||
id,
|
||||
nullable,
|
||||
isLoading = false,
|
||||
fullWidth = false,
|
||||
fuseOptions = {},
|
||||
}: ComboboxProps<T>) {
|
||||
const { t } = useTranslation();
|
||||
const buttonRef = React.useRef<HTMLButtonElement>(null);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const { t } = useTranslation();
|
||||
const buttonRef = React.useRef<HTMLButtonElement>(null);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const [_selectedOption, setSelectedOption] = React.useState<Unpacked<
|
||||
typeof options
|
||||
> | null>(initialValue);
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [_selectedOption, setSelectedOption] = React.useState<Unpacked<
|
||||
typeof options
|
||||
> | null>(initialValue);
|
||||
const [query, setQuery] = React.useState("");
|
||||
|
||||
const fuse = new Fuse(options, {
|
||||
...fuseOptions,
|
||||
keys: ["label", "alt"],
|
||||
});
|
||||
const fuse = new Fuse(options, {
|
||||
...fuseOptions,
|
||||
keys: ["label", "alt"],
|
||||
});
|
||||
|
||||
const filteredOptions = (() => {
|
||||
if (!query) {
|
||||
if (quickSelectOptions) return quickSelectOptions;
|
||||
const filteredOptions = (() => {
|
||||
if (!query) {
|
||||
if (quickSelectOptions) return quickSelectOptions;
|
||||
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
return fuse
|
||||
.search(query)
|
||||
.slice(0, MAX_RESULTS_SHOWN)
|
||||
.map((res) => res.item);
|
||||
})();
|
||||
return fuse
|
||||
.search(query)
|
||||
.slice(0, MAX_RESULTS_SHOWN)
|
||||
.map((res) => res.item);
|
||||
})();
|
||||
|
||||
const noMatches = filteredOptions.length === 0;
|
||||
const noMatches = filteredOptions.length === 0;
|
||||
|
||||
const displayValue = (option: Unpacked<typeof options>) => {
|
||||
return option?.label ?? "";
|
||||
};
|
||||
const displayValue = (option: Unpacked<typeof options>) => {
|
||||
return option?.label ?? "";
|
||||
};
|
||||
|
||||
const selectedOption = value ?? _selectedOption;
|
||||
const selectedOption = value ?? _selectedOption;
|
||||
|
||||
const showComboboxOptions = () => {
|
||||
if (!quickSelectOptions || quickSelectOptions.length === 0) return;
|
||||
const showComboboxOptions = () => {
|
||||
if (!quickSelectOptions || quickSelectOptions.length === 0) return;
|
||||
|
||||
buttonRef.current?.click();
|
||||
};
|
||||
buttonRef.current?.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="combobox-wrapper">
|
||||
<HeadlessCombobox
|
||||
value={selectedOption}
|
||||
onChange={(selected) => {
|
||||
onChange?.(selected);
|
||||
setSelectedOption(selected);
|
||||
// https://github.com/tailwindlabs/headlessui/issues/1555
|
||||
// note that this still seems to be a problem despite what the issue says
|
||||
setTimeout(() => inputRef.current?.blur(), 0);
|
||||
}}
|
||||
name={inputName}
|
||||
disabled={!selectedOption && isLoading}
|
||||
// TODO: remove hack that prevents TS from freaking out. probably related: https://github.com/tailwindlabs/headlessui/issues/1895
|
||||
nullable={nullable as true}
|
||||
>
|
||||
<HeadlessCombobox.Input
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={isLoading ? t("actions.loading") : placeholder}
|
||||
className={clsx("combobox-input", className, {
|
||||
fullWidth,
|
||||
})}
|
||||
defaultValue={initialValue}
|
||||
displayValue={displayValue}
|
||||
data-testid={`${inputName}-combobox-input`}
|
||||
id={id}
|
||||
required={required}
|
||||
autoComplete="off"
|
||||
onFocus={showComboboxOptions}
|
||||
ref={inputRef}
|
||||
/>
|
||||
<HeadlessCombobox.Options
|
||||
className={clsx("combobox-options", {
|
||||
empty: noMatches,
|
||||
fullWidth,
|
||||
hidden:
|
||||
!query &&
|
||||
(!quickSelectOptions || quickSelectOptions.length === 0),
|
||||
})}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="combobox-no-matches">{t("actions.loading")}</div>
|
||||
) : noMatches ? (
|
||||
<div className="combobox-no-matches">
|
||||
{t("forms.errors.noSearchMatches")}{" "}
|
||||
<span className="combobox-emoji">🤔</span>
|
||||
</div>
|
||||
) : (
|
||||
filteredOptions.map((option) => (
|
||||
<HeadlessCombobox.Option
|
||||
key={option.value}
|
||||
value={option}
|
||||
as={React.Fragment}
|
||||
>
|
||||
{({ active }) => (
|
||||
<li className={clsx("combobox-item", { active })}>
|
||||
{option.imgPath && (
|
||||
<Image
|
||||
alt=""
|
||||
path={option.imgPath}
|
||||
width={24}
|
||||
height={24}
|
||||
className="combobox-item-image"
|
||||
/>
|
||||
)}
|
||||
<span className="combobox-item-label">{option.label}</span>
|
||||
</li>
|
||||
)}
|
||||
</HeadlessCombobox.Option>
|
||||
))
|
||||
)}
|
||||
</HeadlessCombobox.Options>
|
||||
<HeadlessCombobox.Button ref={buttonRef} className="hidden" />
|
||||
</HeadlessCombobox>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="combobox-wrapper">
|
||||
<HeadlessCombobox
|
||||
value={selectedOption}
|
||||
onChange={(selected) => {
|
||||
onChange?.(selected);
|
||||
setSelectedOption(selected);
|
||||
// https://github.com/tailwindlabs/headlessui/issues/1555
|
||||
// note that this still seems to be a problem despite what the issue says
|
||||
setTimeout(() => inputRef.current?.blur(), 0);
|
||||
}}
|
||||
name={inputName}
|
||||
disabled={!selectedOption && isLoading}
|
||||
// TODO: remove hack that prevents TS from freaking out. probably related: https://github.com/tailwindlabs/headlessui/issues/1895
|
||||
nullable={nullable as true}
|
||||
>
|
||||
<HeadlessCombobox.Input
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={isLoading ? t("actions.loading") : placeholder}
|
||||
className={clsx("combobox-input", className, {
|
||||
fullWidth,
|
||||
})}
|
||||
defaultValue={initialValue}
|
||||
displayValue={displayValue}
|
||||
data-testid={`${inputName}-combobox-input`}
|
||||
id={id}
|
||||
required={required}
|
||||
autoComplete="off"
|
||||
onFocus={showComboboxOptions}
|
||||
ref={inputRef}
|
||||
/>
|
||||
<HeadlessCombobox.Options
|
||||
className={clsx("combobox-options", {
|
||||
empty: noMatches,
|
||||
fullWidth,
|
||||
hidden:
|
||||
!query &&
|
||||
(!quickSelectOptions || quickSelectOptions.length === 0),
|
||||
})}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="combobox-no-matches">{t("actions.loading")}</div>
|
||||
) : noMatches ? (
|
||||
<div className="combobox-no-matches">
|
||||
{t("forms.errors.noSearchMatches")}{" "}
|
||||
<span className="combobox-emoji">🤔</span>
|
||||
</div>
|
||||
) : (
|
||||
filteredOptions.map((option) => (
|
||||
<HeadlessCombobox.Option
|
||||
key={option.value}
|
||||
value={option}
|
||||
as={React.Fragment}
|
||||
>
|
||||
{({ active }) => (
|
||||
<li className={clsx("combobox-item", { active })}>
|
||||
{option.imgPath && (
|
||||
<Image
|
||||
alt=""
|
||||
path={option.imgPath}
|
||||
width={24}
|
||||
height={24}
|
||||
className="combobox-item-image"
|
||||
/>
|
||||
)}
|
||||
<span className="combobox-item-label">{option.label}</span>
|
||||
</li>
|
||||
)}
|
||||
</HeadlessCombobox.Option>
|
||||
))
|
||||
)}
|
||||
</HeadlessCombobox.Options>
|
||||
<HeadlessCombobox.Button ref={buttonRef} className="hidden" />
|
||||
</HeadlessCombobox>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WeaponCombobox({
|
||||
id,
|
||||
required,
|
||||
className,
|
||||
inputName,
|
||||
onChange,
|
||||
initialWeaponId,
|
||||
weaponIdsToOmit,
|
||||
fullWidth,
|
||||
nullable,
|
||||
value,
|
||||
quickSelectWeaponIds,
|
||||
id,
|
||||
required,
|
||||
className,
|
||||
inputName,
|
||||
onChange,
|
||||
initialWeaponId,
|
||||
weaponIdsToOmit,
|
||||
fullWidth,
|
||||
nullable,
|
||||
value,
|
||||
quickSelectWeaponIds,
|
||||
}: Pick<
|
||||
ComboboxProps<ComboboxBaseOption>,
|
||||
| "inputName"
|
||||
| "onChange"
|
||||
| "className"
|
||||
| "id"
|
||||
| "required"
|
||||
| "fullWidth"
|
||||
| "nullable"
|
||||
ComboboxProps<ComboboxBaseOption>,
|
||||
| "inputName"
|
||||
| "onChange"
|
||||
| "className"
|
||||
| "id"
|
||||
| "required"
|
||||
| "fullWidth"
|
||||
| "nullable"
|
||||
> & {
|
||||
initialWeaponId?: (typeof mainWeaponIds)[number];
|
||||
weaponIdsToOmit?: Set<MainWeaponId>;
|
||||
value?: MainWeaponId | null;
|
||||
/** Weapons to show when there is focus but no query */
|
||||
quickSelectWeaponIds?: MainWeaponId[];
|
||||
initialWeaponId?: (typeof mainWeaponIds)[number];
|
||||
weaponIdsToOmit?: Set<MainWeaponId>;
|
||||
value?: MainWeaponId | null;
|
||||
/** Weapons to show when there is focus but no query */
|
||||
quickSelectWeaponIds?: MainWeaponId[];
|
||||
}) {
|
||||
const { t, i18n } = useTranslation("weapons");
|
||||
const { t, i18n } = useTranslation("weapons");
|
||||
|
||||
const alt = (id: (typeof mainWeaponIds)[number]) => {
|
||||
const result: string[] = [];
|
||||
const alt = (id: (typeof mainWeaponIds)[number]) => {
|
||||
const result: string[] = [];
|
||||
|
||||
if (i18n.language !== "en") {
|
||||
result.push(t(`MAIN_${id}`, { lng: "en" }));
|
||||
}
|
||||
if (i18n.language !== "en") {
|
||||
result.push(t(`MAIN_${id}`, { lng: "en" }));
|
||||
}
|
||||
|
||||
const altNames = weaponAltNames.get(id);
|
||||
if (typeof altNames === "string") {
|
||||
result.push(altNames);
|
||||
} else if (Array.isArray(altNames)) {
|
||||
result.push(...altNames);
|
||||
}
|
||||
const altNames = weaponAltNames.get(id);
|
||||
if (typeof altNames === "string") {
|
||||
result.push(altNames);
|
||||
} else if (Array.isArray(altNames)) {
|
||||
result.push(...altNames);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
const idToWeapon = (id: (typeof mainWeaponIds)[number]) => ({
|
||||
value: String(id),
|
||||
label: t(`MAIN_${id}`),
|
||||
imgPath: mainWeaponImageUrl(id),
|
||||
alt: alt(id),
|
||||
});
|
||||
return result;
|
||||
};
|
||||
const idToWeapon = (id: (typeof mainWeaponIds)[number]) => ({
|
||||
value: String(id),
|
||||
label: t(`MAIN_${id}`),
|
||||
imgPath: mainWeaponImageUrl(id),
|
||||
alt: alt(id),
|
||||
});
|
||||
|
||||
const options = mainWeaponIds
|
||||
.filter((id) => !weaponIdsToOmit?.has(id))
|
||||
.map(idToWeapon);
|
||||
const options = mainWeaponIds
|
||||
.filter((id) => !weaponIdsToOmit?.has(id))
|
||||
.map(idToWeapon);
|
||||
|
||||
const quickSelectOptions = quickSelectWeaponIds?.flatMap((weaponId) => {
|
||||
return options.find((option) => option.value === String(weaponId)) ?? [];
|
||||
});
|
||||
const quickSelectOptions = quickSelectWeaponIds?.flatMap((weaponId) => {
|
||||
return options.find((option) => option.value === String(weaponId)) ?? [];
|
||||
});
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
inputName={inputName}
|
||||
options={options}
|
||||
quickSelectOptions={quickSelectOptions}
|
||||
value={typeof value === "number" ? idToWeapon(value) : null}
|
||||
initialValue={
|
||||
typeof initialWeaponId === "number" ? idToWeapon(initialWeaponId) : null
|
||||
}
|
||||
placeholder={t(`MAIN_${weaponCategories[0].weaponIds[0]}`)}
|
||||
onChange={onChange}
|
||||
className={className}
|
||||
id={id}
|
||||
required={required}
|
||||
fullWidth={fullWidth}
|
||||
nullable={nullable}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Combobox
|
||||
inputName={inputName}
|
||||
options={options}
|
||||
quickSelectOptions={quickSelectOptions}
|
||||
value={typeof value === "number" ? idToWeapon(value) : null}
|
||||
initialValue={
|
||||
typeof initialWeaponId === "number" ? idToWeapon(initialWeaponId) : null
|
||||
}
|
||||
placeholder={t(`MAIN_${weaponCategories[0].weaponIds[0]}`)}
|
||||
onChange={onChange}
|
||||
className={className}
|
||||
id={id}
|
||||
required={required}
|
||||
fullWidth={fullWidth}
|
||||
nullable={nullable}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AllWeaponCombobox({
|
||||
id,
|
||||
inputName,
|
||||
onChange,
|
||||
fullWidth,
|
||||
id,
|
||||
inputName,
|
||||
onChange,
|
||||
fullWidth,
|
||||
}: Pick<
|
||||
ComboboxProps<ComboboxBaseOption>,
|
||||
"inputName" | "onChange" | "id" | "fullWidth"
|
||||
ComboboxProps<ComboboxBaseOption>,
|
||||
"inputName" | "onChange" | "id" | "fullWidth"
|
||||
>) {
|
||||
const { t } = useTranslation("weapons");
|
||||
const { t } = useTranslation("weapons");
|
||||
|
||||
const options = () => {
|
||||
const result: ComboboxProps<
|
||||
Record<string, string | null | number>
|
||||
>["options"] = [];
|
||||
const options = () => {
|
||||
const result: ComboboxProps<
|
||||
Record<string, string | null | number>
|
||||
>["options"] = [];
|
||||
|
||||
for (const mainWeaponId of mainWeaponIds) {
|
||||
result.push({
|
||||
value: `MAIN_${mainWeaponId}`,
|
||||
label: t(`MAIN_${mainWeaponId}`),
|
||||
imgPath: mainWeaponImageUrl(mainWeaponId),
|
||||
});
|
||||
}
|
||||
for (const mainWeaponId of mainWeaponIds) {
|
||||
result.push({
|
||||
value: `MAIN_${mainWeaponId}`,
|
||||
label: t(`MAIN_${mainWeaponId}`),
|
||||
imgPath: mainWeaponImageUrl(mainWeaponId),
|
||||
});
|
||||
}
|
||||
|
||||
for (const subWeaponId of subWeaponIds) {
|
||||
if (nonBombSubWeaponIds.includes(subWeaponId)) continue;
|
||||
for (const subWeaponId of subWeaponIds) {
|
||||
if (nonBombSubWeaponIds.includes(subWeaponId)) continue;
|
||||
|
||||
result.push({
|
||||
value: `SUB_${subWeaponId}`,
|
||||
label: t(`SUB_${subWeaponId}`),
|
||||
imgPath: subWeaponImageUrl(subWeaponId),
|
||||
});
|
||||
}
|
||||
result.push({
|
||||
value: `SUB_${subWeaponId}`,
|
||||
label: t(`SUB_${subWeaponId}`),
|
||||
imgPath: subWeaponImageUrl(subWeaponId),
|
||||
});
|
||||
}
|
||||
|
||||
for (const specialWeaponId of specialWeaponIds) {
|
||||
if (nonDamagingSpecialWeaponIds.includes(specialWeaponId)) continue;
|
||||
for (const specialWeaponId of specialWeaponIds) {
|
||||
if (nonDamagingSpecialWeaponIds.includes(specialWeaponId)) continue;
|
||||
|
||||
result.push({
|
||||
value: `SPECIAL_${specialWeaponId}`,
|
||||
label: t(`SPECIAL_${specialWeaponId}`),
|
||||
imgPath: specialWeaponImageUrl(specialWeaponId),
|
||||
});
|
||||
}
|
||||
result.push({
|
||||
value: `SPECIAL_${specialWeaponId}`,
|
||||
label: t(`SPECIAL_${specialWeaponId}`),
|
||||
imgPath: specialWeaponImageUrl(specialWeaponId),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
return result;
|
||||
};
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
inputName={inputName}
|
||||
options={options()}
|
||||
initialValue={null}
|
||||
placeholder={t(`MAIN_${weaponCategories[0].weaponIds[0]}`)}
|
||||
onChange={onChange}
|
||||
id={id}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Combobox
|
||||
inputName={inputName}
|
||||
options={options()}
|
||||
initialValue={null}
|
||||
placeholder={t(`MAIN_${weaponCategories[0].weaponIds[0]}`)}
|
||||
onChange={onChange}
|
||||
id={id}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function GearCombobox({
|
||||
id,
|
||||
required,
|
||||
className,
|
||||
inputName,
|
||||
onChange,
|
||||
gearType,
|
||||
initialGearId,
|
||||
id,
|
||||
required,
|
||||
className,
|
||||
inputName,
|
||||
onChange,
|
||||
gearType,
|
||||
initialGearId,
|
||||
}: Pick<
|
||||
ComboboxProps<ComboboxBaseOption>,
|
||||
"inputName" | "onChange" | "className" | "id" | "required"
|
||||
ComboboxProps<ComboboxBaseOption>,
|
||||
"inputName" | "onChange" | "className" | "id" | "required"
|
||||
> & { gearType: GearType; initialGearId?: number }) {
|
||||
const { t } = useTranslation("gear");
|
||||
const { t } = useTranslation("gear");
|
||||
|
||||
const translationPrefix =
|
||||
gearType === "HEAD" ? "H" : gearType === "CLOTHES" ? "C" : "S";
|
||||
const ids =
|
||||
gearType === "HEAD"
|
||||
? headGearIds
|
||||
: gearType === "CLOTHES"
|
||||
? clothesGearIds
|
||||
: shoesGearIds;
|
||||
const translationPrefix =
|
||||
gearType === "HEAD" ? "H" : gearType === "CLOTHES" ? "C" : "S";
|
||||
const ids =
|
||||
gearType === "HEAD"
|
||||
? headGearIds
|
||||
: gearType === "CLOTHES"
|
||||
? clothesGearIds
|
||||
: shoesGearIds;
|
||||
|
||||
const idToGear = (id: (typeof ids)[number]) => ({
|
||||
value: String(id),
|
||||
label: t(`${translationPrefix}_${id}` as any),
|
||||
imgPath: gearImageUrl(gearType, id),
|
||||
});
|
||||
const idToGear = (id: (typeof ids)[number]) => ({
|
||||
value: String(id),
|
||||
label: t(`${translationPrefix}_${id}` as any),
|
||||
imgPath: gearImageUrl(gearType, id),
|
||||
});
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
inputName={inputName}
|
||||
options={ids.map(idToGear)}
|
||||
placeholder={idToGear(ids[0]).label}
|
||||
initialValue={initialGearId ? idToGear(initialGearId as any) : null}
|
||||
onChange={onChange}
|
||||
className={className}
|
||||
id={id}
|
||||
required={required}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Combobox
|
||||
inputName={inputName}
|
||||
options={ids.map(idToGear)}
|
||||
placeholder={idToGear(ids[0]).label}
|
||||
initialValue={initialGearId ? idToGear(initialGearId as any) : null}
|
||||
onChange={onChange}
|
||||
className={className}
|
||||
id={id}
|
||||
required={required}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const mapPoolEventToOption = (
|
||||
e: SerializedMapPoolEvent,
|
||||
e: SerializedMapPoolEvent,
|
||||
): ComboboxOption<Pick<SerializedMapPoolEvent, "serializedMapPool">> => ({
|
||||
serializedMapPool: e.serializedMapPool,
|
||||
label: e.name,
|
||||
value: e.id.toString(),
|
||||
serializedMapPool: e.serializedMapPool,
|
||||
label: e.name,
|
||||
value: e.id.toString(),
|
||||
});
|
||||
|
||||
type MapPoolEventsComboboxProps = Pick<
|
||||
ComboboxProps<Pick<SerializedMapPoolEvent, "serializedMapPool">>,
|
||||
"inputName" | "className" | "id" | "required"
|
||||
ComboboxProps<Pick<SerializedMapPoolEvent, "serializedMapPool">>,
|
||||
"inputName" | "className" | "id" | "required"
|
||||
> & {
|
||||
initialEvent?: SerializedMapPoolEvent;
|
||||
onChange: (event: SerializedMapPoolEvent | null) => void;
|
||||
initialEvent?: SerializedMapPoolEvent;
|
||||
onChange: (event: SerializedMapPoolEvent | null) => void;
|
||||
};
|
||||
|
||||
export function MapPoolEventsCombobox({
|
||||
id,
|
||||
required,
|
||||
className,
|
||||
inputName,
|
||||
onChange,
|
||||
initialEvent,
|
||||
id,
|
||||
required,
|
||||
className,
|
||||
inputName,
|
||||
onChange,
|
||||
initialEvent,
|
||||
}: MapPoolEventsComboboxProps) {
|
||||
const { t } = useTranslation();
|
||||
const { events, isLoading, isError } = useAllEventsWithMapPools();
|
||||
const { t } = useTranslation();
|
||||
const { events, isLoading, isError } = useAllEventsWithMapPools();
|
||||
|
||||
const options = React.useMemo(
|
||||
() => (events ? events.map(mapPoolEventToOption) : []),
|
||||
[events],
|
||||
);
|
||||
const options = React.useMemo(
|
||||
() => (events ? events.map(mapPoolEventToOption) : []),
|
||||
[events],
|
||||
);
|
||||
|
||||
// this is important so that we don't trigger the reset to the initialEvent every time
|
||||
const initialOption = React.useMemo(
|
||||
() => initialEvent && mapPoolEventToOption(initialEvent),
|
||||
[initialEvent],
|
||||
);
|
||||
// this is important so that we don't trigger the reset to the initialEvent every time
|
||||
const initialOption = React.useMemo(
|
||||
() => initialEvent && mapPoolEventToOption(initialEvent),
|
||||
[initialEvent],
|
||||
);
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="text-sm text-error">{t("errors.genericReload")}</div>
|
||||
);
|
||||
}
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="text-sm text-error">{t("errors.genericReload")}</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
inputName={inputName}
|
||||
options={isLoading && initialOption ? [initialOption] : options}
|
||||
placeholder={t("actions.search")}
|
||||
initialValue={initialOption ?? null}
|
||||
onChange={(e) => {
|
||||
onChange(
|
||||
e && {
|
||||
id: parseInt(e.value, 10),
|
||||
name: e.label,
|
||||
serializedMapPool: e.serializedMapPool,
|
||||
},
|
||||
);
|
||||
}}
|
||||
className={className}
|
||||
id={id}
|
||||
required={required}
|
||||
isLoading={isLoading}
|
||||
fullWidth
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Combobox
|
||||
inputName={inputName}
|
||||
options={isLoading && initialOption ? [initialOption] : options}
|
||||
placeholder={t("actions.search")}
|
||||
initialValue={initialOption ?? null}
|
||||
onChange={(e) => {
|
||||
onChange(
|
||||
e && {
|
||||
id: Number.parseInt(e.value, 10),
|
||||
name: e.label,
|
||||
serializedMapPool: e.serializedMapPool,
|
||||
},
|
||||
);
|
||||
}}
|
||||
className={className}
|
||||
id={id}
|
||||
required={required}
|
||||
isLoading={isLoading}
|
||||
fullWidth
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,21 +5,21 @@ import { ScrollRestoration, useLocation } from "@remix-run/react";
|
||||
import * as React from "react";
|
||||
|
||||
export function ConditionalScrollRestoration() {
|
||||
const isFirstRenderRef = React.useRef(true);
|
||||
const location = useLocation();
|
||||
const isFirstRenderRef = React.useRef(true);
|
||||
const location = useLocation();
|
||||
|
||||
React.useEffect(() => {
|
||||
isFirstRenderRef.current = false;
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
isFirstRenderRef.current = false;
|
||||
}, []);
|
||||
|
||||
if (
|
||||
!isFirstRenderRef.current &&
|
||||
location.state != null &&
|
||||
typeof location.state === "object" &&
|
||||
(location.state as { scroll: boolean }).scroll === false
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
!isFirstRenderRef.current &&
|
||||
location.state != null &&
|
||||
typeof location.state === "object" &&
|
||||
(location.state as { scroll: boolean }).scroll === false
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <ScrollRestoration getKey={(location) => location.pathname} />;
|
||||
return <ScrollRestoration getKey={(location) => location.pathname} />;
|
||||
}
|
||||
|
||||
@@ -1,71 +1,73 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Label } from "./Label";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "./Button";
|
||||
import { Label } from "./Label";
|
||||
|
||||
const CUSTOM_COLORS = [
|
||||
"bg",
|
||||
"bg-darker",
|
||||
"bg-lighter",
|
||||
"text",
|
||||
"text-lighter",
|
||||
"theme",
|
||||
"chat",
|
||||
"bg",
|
||||
"bg-darker",
|
||||
"bg-lighter",
|
||||
"text",
|
||||
"text-lighter",
|
||||
"theme",
|
||||
"chat",
|
||||
] as const;
|
||||
|
||||
type CustomColorsRecord = Partial<
|
||||
Record<(typeof CUSTOM_COLORS)[number], string>
|
||||
Record<(typeof CUSTOM_COLORS)[number], string>
|
||||
>;
|
||||
|
||||
export function CustomizedColorsInput({
|
||||
initialColors,
|
||||
initialColors,
|
||||
}: {
|
||||
initialColors?: Record<string, string> | null;
|
||||
initialColors?: Record<string, string> | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [colors, setColors] = React.useState<CustomColorsRecord>(
|
||||
initialColors ?? {},
|
||||
);
|
||||
const { t } = useTranslation();
|
||||
const [colors, setColors] = React.useState<CustomColorsRecord>(
|
||||
initialColors ?? {},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Label>{t("custom.colors.title")}</Label>
|
||||
<input type="hidden" name="css" value={JSON.stringify(colors)} />
|
||||
<div className="colors__grid">
|
||||
{CUSTOM_COLORS.map((cssVar) => {
|
||||
return (
|
||||
<React.Fragment key={cssVar}>
|
||||
<div>{t(`custom.colors.${cssVar}`)}</div>
|
||||
<input
|
||||
type="color"
|
||||
className="plain"
|
||||
value={colors[cssVar]}
|
||||
onChange={(e) => {
|
||||
const extras: Record<string, string> = {};
|
||||
if (cssVar === "bg-lighter") {
|
||||
extras["bg-lightest"] = `${e.target.value}80`;
|
||||
}
|
||||
setColors({ ...colors, ...extras, [cssVar]: e.target.value });
|
||||
}}
|
||||
data-testid={`color-input-${cssVar}`}
|
||||
/>
|
||||
<Button
|
||||
size="tiny"
|
||||
variant="minimal-destructive"
|
||||
onClick={() => {
|
||||
const newColors: Record<string, string> = { ...colors };
|
||||
if (cssVar === "bg-lighter") {
|
||||
delete newColors["bg-lightest"];
|
||||
}
|
||||
setColors({ ...newColors, [cssVar]: undefined });
|
||||
}}
|
||||
>
|
||||
{t("actions.reset")}
|
||||
</Button>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Label>{t("custom.colors.title")}</Label>
|
||||
<input type="hidden" name="css" value={JSON.stringify(colors)} />
|
||||
<div className="colors__grid">
|
||||
{CUSTOM_COLORS.map((cssVar) => {
|
||||
return (
|
||||
<React.Fragment key={cssVar}>
|
||||
<div>{t(`custom.colors.${cssVar}`)}</div>
|
||||
<input
|
||||
type="color"
|
||||
className="plain"
|
||||
value={colors[cssVar]}
|
||||
onChange={(e) => {
|
||||
const extras: Record<string, string> = {};
|
||||
if (cssVar === "bg-lighter") {
|
||||
extras["bg-lightest"] = `${e.target.value}80`;
|
||||
}
|
||||
setColors({ ...colors, ...extras, [cssVar]: e.target.value });
|
||||
}}
|
||||
data-testid={`color-input-${cssVar}`}
|
||||
/>
|
||||
<Button
|
||||
size="tiny"
|
||||
variant="minimal-destructive"
|
||||
onClick={() => {
|
||||
const newColors: Record<string, string | undefined> = {
|
||||
...colors,
|
||||
};
|
||||
if (cssVar === "bg-lighter") {
|
||||
newColors["bg-lightest"] = undefined;
|
||||
}
|
||||
setColors({ ...newColors, [cssVar]: undefined });
|
||||
}}
|
||||
>
|
||||
{t("actions.reset")}
|
||||
</Button>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,70 +1,70 @@
|
||||
import * as React from "react";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { dateToYearMonthDayHourMinuteString, isValidDate } from "~/utils/dates";
|
||||
import * as React from "react";
|
||||
|
||||
export interface DateInputProps
|
||||
extends Omit<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
"defaultValue" | "min" | "max" | "onChange" | "value"
|
||||
> {
|
||||
defaultValue?: Date;
|
||||
min?: Date;
|
||||
max?: Date;
|
||||
onChange?: (newDate: Date | null) => void;
|
||||
extends Omit<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
"defaultValue" | "min" | "max" | "onChange" | "value"
|
||||
> {
|
||||
defaultValue?: Date;
|
||||
min?: Date;
|
||||
max?: Date;
|
||||
onChange?: (newDate: Date | null) => void;
|
||||
}
|
||||
|
||||
export function DateInput({
|
||||
name,
|
||||
defaultValue,
|
||||
min,
|
||||
max,
|
||||
onChange,
|
||||
...inputProps
|
||||
name,
|
||||
defaultValue,
|
||||
min,
|
||||
max,
|
||||
onChange,
|
||||
...inputProps
|
||||
}: DateInputProps) {
|
||||
// Keeping track of the value as a string is a nice fallback for browsers that
|
||||
// don't show a date picker but actually expect the user to type in the date
|
||||
// as a text. This was Safari Desktop until recently, but nowadays all current
|
||||
// versions of the main browsers set the input to either a valid date string
|
||||
// or "". (The browser will handle transitional invalid states internally).
|
||||
const [[parsedDate, valueString], setDate] = React.useState<
|
||||
[Date | null, string]
|
||||
>(() => {
|
||||
if (defaultValue) {
|
||||
if (isValidDate(defaultValue)) {
|
||||
return [defaultValue, dateToYearMonthDayHourMinuteString(defaultValue)];
|
||||
}
|
||||
console.warn("DateInput got invalid date as defaultValue");
|
||||
}
|
||||
return [null, ""];
|
||||
});
|
||||
const isMounted = useIsMounted();
|
||||
// Keeping track of the value as a string is a nice fallback for browsers that
|
||||
// don't show a date picker but actually expect the user to type in the date
|
||||
// as a text. This was Safari Desktop until recently, but nowadays all current
|
||||
// versions of the main browsers set the input to either a valid date string
|
||||
// or "". (The browser will handle transitional invalid states internally).
|
||||
const [[parsedDate, valueString], setDate] = React.useState<
|
||||
[Date | null, string]
|
||||
>(() => {
|
||||
if (defaultValue) {
|
||||
if (isValidDate(defaultValue)) {
|
||||
return [defaultValue, dateToYearMonthDayHourMinuteString(defaultValue)];
|
||||
}
|
||||
console.warn("DateInput got invalid date as defaultValue");
|
||||
}
|
||||
return [null, ""];
|
||||
});
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
return (
|
||||
<>
|
||||
{parsedDate && isMounted && (
|
||||
<input name={name} type="hidden" value={parsedDate.getTime() ?? ""} />
|
||||
)}
|
||||
<input
|
||||
{...inputProps}
|
||||
type="datetime-local"
|
||||
disabled={!isMounted || inputProps.disabled}
|
||||
// This is important, because SSR will likely have a date in the wrong
|
||||
// timezone. We can only fill in a value once hydration is over.
|
||||
value={isMounted ? valueString : ""}
|
||||
min={min ? dateToYearMonthDayHourMinuteString(min) : undefined}
|
||||
max={max ? dateToYearMonthDayHourMinuteString(max) : undefined}
|
||||
onChange={(e) => {
|
||||
const newValueString = e.target.value;
|
||||
const parsedValue = new Date(newValueString);
|
||||
const newDate = isValidDate(parsedValue) ? parsedValue : null;
|
||||
return (
|
||||
<>
|
||||
{parsedDate && isMounted && (
|
||||
<input name={name} type="hidden" value={parsedDate.getTime() ?? ""} />
|
||||
)}
|
||||
<input
|
||||
{...inputProps}
|
||||
type="datetime-local"
|
||||
disabled={!isMounted || inputProps.disabled}
|
||||
// This is important, because SSR will likely have a date in the wrong
|
||||
// timezone. We can only fill in a value once hydration is over.
|
||||
value={isMounted ? valueString : ""}
|
||||
min={min ? dateToYearMonthDayHourMinuteString(min) : undefined}
|
||||
max={max ? dateToYearMonthDayHourMinuteString(max) : undefined}
|
||||
onChange={(e) => {
|
||||
const newValueString = e.target.value;
|
||||
const parsedValue = new Date(newValueString);
|
||||
const newDate = isValidDate(parsedValue) ? parsedValue : null;
|
||||
|
||||
setDate([newDate, newValueString]);
|
||||
onChange?.(newDate);
|
||||
}}
|
||||
// Firefox fix for hydration error "prop `disabled` did not match" */
|
||||
// https://github.com/facebook/react/issues/21459
|
||||
autoComplete="off"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
setDate([newDate, newValueString]);
|
||||
onChange?.(newDate);
|
||||
}}
|
||||
// Firefox fix for hydration error "prop `disabled` did not match" */
|
||||
// https://github.com/facebook/react/issues/21459
|
||||
autoComplete="off"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,21 +2,21 @@ import clsx from "clsx";
|
||||
import type * as React from "react";
|
||||
|
||||
export function Details({
|
||||
children,
|
||||
className,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <details className={className}>{children}</details>;
|
||||
return <details className={className}>{children}</details>;
|
||||
}
|
||||
|
||||
export function Summary({
|
||||
children,
|
||||
className,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <summary className={clsx("summary", className)}>{children}</summary>;
|
||||
return <summary className={clsx("summary", className)}>{children}</summary>;
|
||||
}
|
||||
|
||||
@@ -2,108 +2,107 @@ import React from "react";
|
||||
import invariant from "~/utils/invariant";
|
||||
|
||||
export function Dialog({
|
||||
children,
|
||||
isOpen,
|
||||
close,
|
||||
className,
|
||||
closeOnAnyClick,
|
||||
children,
|
||||
isOpen,
|
||||
close,
|
||||
className,
|
||||
closeOnAnyClick,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
isOpen: boolean;
|
||||
close?: () => void;
|
||||
className?: string;
|
||||
closeOnAnyClick?: boolean;
|
||||
children: React.ReactNode;
|
||||
isOpen: boolean;
|
||||
close?: () => void;
|
||||
className?: string;
|
||||
closeOnAnyClick?: boolean;
|
||||
}) {
|
||||
const ref = useDOMSync(isOpen);
|
||||
useControlledEsc({ ref, isOpen, close });
|
||||
const ref = useDOMSync(isOpen);
|
||||
useControlledEsc({ ref, isOpen, close });
|
||||
|
||||
// https://stackoverflow.com/a/26984690
|
||||
const closeOnOutsideClick = close
|
||||
? (event: React.MouseEvent<HTMLDialogElement, MouseEvent>) => {
|
||||
if (closeOnAnyClick) return close();
|
||||
const rect: DOMRect = ref.current.getBoundingClientRect();
|
||||
const isInDialog =
|
||||
rect.top <= event.clientY &&
|
||||
event.clientY <= rect.top + rect.height &&
|
||||
rect.left <= event.clientX &&
|
||||
event.clientX <= rect.left + rect.width;
|
||||
if (!isInDialog) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
: undefined;
|
||||
// https://stackoverflow.com/a/26984690
|
||||
const closeOnOutsideClick = close
|
||||
? (event: React.MouseEvent<HTMLDialogElement, MouseEvent>) => {
|
||||
if (closeOnAnyClick) return close();
|
||||
const rect: DOMRect = ref.current.getBoundingClientRect();
|
||||
const isInDialog =
|
||||
rect.top <= event.clientY &&
|
||||
event.clientY <= rect.top + rect.height &&
|
||||
rect.left <= event.clientX &&
|
||||
event.clientX <= rect.left + rect.width;
|
||||
if (!isInDialog) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<dialog className={className} ref={ref} onClick={closeOnOutsideClick}>
|
||||
{children}
|
||||
</dialog>
|
||||
);
|
||||
return (
|
||||
<dialog className={className} ref={ref} onClick={closeOnOutsideClick}>
|
||||
{children}
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function useDOMSync(isOpen: boolean) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const ref = React.useRef<any>(null);
|
||||
const ref = React.useRef<any>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const dialog = ref.current;
|
||||
React.useEffect(() => {
|
||||
const dialog = ref.current;
|
||||
|
||||
if (dialog.open && isOpen) return;
|
||||
if (!dialog.open && !isOpen) return;
|
||||
if (dialog.open && isOpen) return;
|
||||
if (!dialog.open && !isOpen) return;
|
||||
|
||||
const html = document.getElementsByTagName("html")[0];
|
||||
invariant(html);
|
||||
const html = document.getElementsByTagName("html")[0];
|
||||
invariant(html);
|
||||
|
||||
if (isOpen) {
|
||||
dialog.showModal();
|
||||
// TODO: can be replaced with https://twitter.com/argyleink/status/1529869352660439048 once gets control
|
||||
html.classList.add("lock-scroll");
|
||||
} else {
|
||||
dialog.close();
|
||||
html.classList.remove("lock-scroll");
|
||||
}
|
||||
if (isOpen) {
|
||||
dialog.showModal();
|
||||
// TODO: can be replaced with https://twitter.com/argyleink/status/1529869352660439048 once gets control
|
||||
html.classList.add("lock-scroll");
|
||||
} else {
|
||||
dialog.close();
|
||||
html.classList.remove("lock-scroll");
|
||||
}
|
||||
|
||||
return () => {
|
||||
dialog.close();
|
||||
html.classList.remove("lock-scroll");
|
||||
};
|
||||
}, [isOpen]);
|
||||
return () => {
|
||||
dialog.close();
|
||||
html.classList.remove("lock-scroll");
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
return ref;
|
||||
return ref;
|
||||
}
|
||||
|
||||
function useControlledEsc({
|
||||
ref,
|
||||
isOpen,
|
||||
close,
|
||||
ref,
|
||||
isOpen,
|
||||
close,
|
||||
}: {
|
||||
ref: React.MutableRefObject<any>;
|
||||
isOpen: boolean;
|
||||
close?: () => void;
|
||||
ref: React.MutableRefObject<any>;
|
||||
isOpen: boolean;
|
||||
close?: () => void;
|
||||
}) {
|
||||
React.useEffect(() => {
|
||||
const dialog = ref.current;
|
||||
if (!dialog) return;
|
||||
React.useEffect(() => {
|
||||
const dialog = ref.current;
|
||||
if (!dialog) return;
|
||||
|
||||
const preventDefault = (event: KeyboardEvent) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
dialog.addEventListener("cancel", preventDefault);
|
||||
const preventDefault = (event: KeyboardEvent) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
dialog.addEventListener("cancel", preventDefault);
|
||||
|
||||
return () => {
|
||||
dialog.removeEventListener("cancel", preventDefault);
|
||||
};
|
||||
}, [ref]);
|
||||
return () => {
|
||||
dialog.removeEventListener("cancel", preventDefault);
|
||||
};
|
||||
}, [ref]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isOpen || !close) return;
|
||||
React.useEffect(() => {
|
||||
if (!isOpen || !close) return;
|
||||
|
||||
const closeOnEsc = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
close();
|
||||
}
|
||||
};
|
||||
const closeOnEsc = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", closeOnEsc);
|
||||
return () => document.removeEventListener("keydown", closeOnEsc);
|
||||
}, [isOpen, close]);
|
||||
document.addEventListener("keydown", closeOnEsc);
|
||||
return () => document.removeEventListener("keydown", closeOnEsc);
|
||||
}, [isOpen, close]);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import clsx from "clsx";
|
||||
|
||||
export function Divider({
|
||||
children,
|
||||
className,
|
||||
smallText,
|
||||
children,
|
||||
className,
|
||||
smallText,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
smallText?: boolean;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
smallText?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={clsx("divider", className, { "text-sm": smallText })}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className={clsx("divider", className, { "text-sm": smallText })}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,33 +3,33 @@ import { CSS } from "@dnd-kit/utilities";
|
||||
import type * as React from "react";
|
||||
|
||||
export function Draggable({
|
||||
id,
|
||||
disabled,
|
||||
liClassName,
|
||||
children,
|
||||
id,
|
||||
disabled,
|
||||
liClassName,
|
||||
children,
|
||||
}: {
|
||||
id: number;
|
||||
disabled: boolean;
|
||||
liClassName: string;
|
||||
children: React.ReactNode;
|
||||
id: number;
|
||||
disabled: boolean;
|
||||
liClassName: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({ id, disabled });
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({ id, disabled });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<li
|
||||
className={liClassName}
|
||||
style={style}
|
||||
ref={setNodeRef}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
>
|
||||
{children}
|
||||
</li>
|
||||
);
|
||||
return (
|
||||
<li
|
||||
className={liClassName}
|
||||
style={style}
|
||||
ref={setNodeRef}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
>
|
||||
{children}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import clsx from "clsx";
|
||||
|
||||
export function Flag({
|
||||
countryCode,
|
||||
tiny = false,
|
||||
countryCode,
|
||||
tiny = false,
|
||||
}: {
|
||||
countryCode: string;
|
||||
tiny?: boolean;
|
||||
countryCode: string;
|
||||
tiny?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(`twf twf-${countryCode.toLowerCase()}`, {
|
||||
"twf-s": tiny,
|
||||
})}
|
||||
data-testid={`flag-${countryCode}`}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={clsx(`twf twf-${countryCode.toLowerCase()}`, {
|
||||
"twf-s": tiny,
|
||||
})}
|
||||
data-testid={`flag-${countryCode}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,25 +3,25 @@ import type { CustomTypeOptions } from "react-i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function FormErrors({
|
||||
namespace,
|
||||
namespace,
|
||||
}: {
|
||||
namespace: keyof CustomTypeOptions["resources"];
|
||||
namespace: keyof CustomTypeOptions["resources"];
|
||||
}) {
|
||||
const { t } = useTranslation(["common", namespace]);
|
||||
const actionData = useActionData<{ errors?: string[] }>();
|
||||
const { t } = useTranslation(["common", namespace]);
|
||||
const actionData = useActionData<{ errors?: string[] }>();
|
||||
|
||||
if (!actionData?.errors || actionData.errors.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (!actionData?.errors || actionData.errors.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="form-errors">
|
||||
<h4>{t("common:forms.errors.title")}:</h4>
|
||||
<ol>
|
||||
{actionData.errors.map((error) => (
|
||||
<li key={error}>{t(`${namespace}:${error}` as any)}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="form-errors">
|
||||
<h4>{t("common:forms.errors.title")}:</h4>
|
||||
<ol>
|
||||
{actionData.errors.map((error) => (
|
||||
<li key={error}>{t(`${namespace}:${error}` as any)}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,22 +2,22 @@ import clsx from "clsx";
|
||||
import type * as React from "react";
|
||||
|
||||
export function FormMessage({
|
||||
children,
|
||||
type,
|
||||
className,
|
||||
children,
|
||||
type,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
type: "error" | "info";
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
type: "error" | "info";
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
{ "info-message": type === "info", "error-message": type === "error" },
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
{ "info-message": type === "info", "error-message": type === "error" },
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,98 +1,98 @@
|
||||
import { type FetcherWithComponents, useFetcher } from "@remix-run/react";
|
||||
import * as React from "react";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { Button, type ButtonProps } from "./Button";
|
||||
import { Dialog } from "./Dialog";
|
||||
import { SubmitButton } from "./SubmitButton";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export function FormWithConfirm({
|
||||
fields,
|
||||
children,
|
||||
dialogHeading,
|
||||
deleteButtonText,
|
||||
cancelButtonText,
|
||||
action,
|
||||
submitButtonTestId = "submit-button",
|
||||
submitButtonVariant = "destructive",
|
||||
fetcher: _fetcher,
|
||||
fields,
|
||||
children,
|
||||
dialogHeading,
|
||||
deleteButtonText,
|
||||
cancelButtonText,
|
||||
action,
|
||||
submitButtonTestId = "submit-button",
|
||||
submitButtonVariant = "destructive",
|
||||
fetcher: _fetcher,
|
||||
}: {
|
||||
fields?: (
|
||||
| [name: string, value: string | number]
|
||||
| readonly [name: string, value: string | number]
|
||||
)[];
|
||||
children: React.ReactNode;
|
||||
dialogHeading: string;
|
||||
deleteButtonText?: string;
|
||||
cancelButtonText?: string;
|
||||
action?: string;
|
||||
submitButtonTestId?: string;
|
||||
submitButtonVariant?: ButtonProps["variant"];
|
||||
fetcher?: FetcherWithComponents<any>;
|
||||
fields?: (
|
||||
| [name: string, value: string | number]
|
||||
| readonly [name: string, value: string | number]
|
||||
)[];
|
||||
children: React.ReactNode;
|
||||
dialogHeading: string;
|
||||
deleteButtonText?: string;
|
||||
cancelButtonText?: string;
|
||||
action?: string;
|
||||
submitButtonTestId?: string;
|
||||
submitButtonVariant?: ButtonProps["variant"];
|
||||
fetcher?: FetcherWithComponents<any>;
|
||||
}) {
|
||||
const componentsFetcher = useFetcher();
|
||||
const fetcher = _fetcher ?? componentsFetcher;
|
||||
const componentsFetcher = useFetcher();
|
||||
const fetcher = _fetcher ?? componentsFetcher;
|
||||
|
||||
const isMounted = useIsMounted();
|
||||
const { t } = useTranslation(["common"]);
|
||||
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||
const formRef = React.useRef<HTMLFormElement>(null);
|
||||
const id = React.useId();
|
||||
const isMounted = useIsMounted();
|
||||
const { t } = useTranslation(["common"]);
|
||||
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||
const formRef = React.useRef<HTMLFormElement>(null);
|
||||
const id = React.useId();
|
||||
|
||||
const openDialog = () => setDialogOpen(true);
|
||||
const closeDialog = () => setDialogOpen(false);
|
||||
const openDialog = React.useCallback(() => setDialogOpen(true), []);
|
||||
const closeDialog = React.useCallback(() => setDialogOpen(false), []);
|
||||
|
||||
invariant(React.isValidElement(children));
|
||||
invariant(React.isValidElement(children));
|
||||
|
||||
React.useEffect(() => {
|
||||
if (fetcher.state === "loading") {
|
||||
closeDialog();
|
||||
}
|
||||
}, [fetcher.state]);
|
||||
React.useEffect(() => {
|
||||
if (fetcher.state === "loading") {
|
||||
closeDialog();
|
||||
}
|
||||
}, [fetcher.state, closeDialog]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isMounted
|
||||
? // using portal here makes nesting this component in another form work
|
||||
createPortal(
|
||||
<fetcher.Form
|
||||
id={id}
|
||||
className="hidden"
|
||||
ref={formRef}
|
||||
method="post"
|
||||
action={action}
|
||||
>
|
||||
{fields?.map(([name, value]) => (
|
||||
<input type="hidden" key={name} name={name} value={value} />
|
||||
))}
|
||||
</fetcher.Form>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
<Dialog isOpen={dialogOpen} close={closeDialog} className="text-center">
|
||||
<div className="stack md">
|
||||
<h2 className="text-sm">{dialogHeading}</h2>
|
||||
<div className="stack horizontal md justify-center">
|
||||
<SubmitButton
|
||||
form={id}
|
||||
variant={submitButtonVariant}
|
||||
testId={dialogOpen ? "confirm-button" : submitButtonTestId}
|
||||
>
|
||||
{deleteButtonText ?? t("common:actions.delete")}
|
||||
</SubmitButton>
|
||||
<Button onClick={closeDialog}>
|
||||
{cancelButtonText ?? t("common:actions.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
{React.cloneElement(children, {
|
||||
// @ts-expect-error broke with @types/react upgrade. TODO: figure out narrower type than React.ReactNode
|
||||
onClick: openDialog,
|
||||
type: "button",
|
||||
})}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{isMounted
|
||||
? // using portal here makes nesting this component in another form work
|
||||
createPortal(
|
||||
<fetcher.Form
|
||||
id={id}
|
||||
className="hidden"
|
||||
ref={formRef}
|
||||
method="post"
|
||||
action={action}
|
||||
>
|
||||
{fields?.map(([name, value]) => (
|
||||
<input type="hidden" key={name} name={name} value={value} />
|
||||
))}
|
||||
</fetcher.Form>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
<Dialog isOpen={dialogOpen} close={closeDialog} className="text-center">
|
||||
<div className="stack md">
|
||||
<h2 className="text-sm">{dialogHeading}</h2>
|
||||
<div className="stack horizontal md justify-center">
|
||||
<SubmitButton
|
||||
form={id}
|
||||
variant={submitButtonVariant}
|
||||
testId={dialogOpen ? "confirm-button" : submitButtonTestId}
|
||||
>
|
||||
{deleteButtonText ?? t("common:actions.delete")}
|
||||
</SubmitButton>
|
||||
<Button onClick={closeDialog}>
|
||||
{cancelButtonText ?? t("common:actions.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
{React.cloneElement(children, {
|
||||
// @ts-expect-error broke with @types/react upgrade. TODO: figure out narrower type than React.ReactNode
|
||||
onClick: openDialog,
|
||||
type: "button",
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,42 +8,42 @@ import { FRIEND_CODE_REGEXP_PATTERN } from "~/features/sendouq/q-constants";
|
||||
import { SENDOUQ_PAGE } from "~/utils/urls";
|
||||
|
||||
export function FriendCodeInput({
|
||||
friendCode,
|
||||
friendCode,
|
||||
}: {
|
||||
friendCode?: string | null;
|
||||
friendCode?: string | null;
|
||||
}) {
|
||||
const fetcher = useFetcher();
|
||||
const { t } = useTranslation(["common"]);
|
||||
const fetcher = useFetcher();
|
||||
const { t } = useTranslation(["common"]);
|
||||
|
||||
return (
|
||||
<fetcher.Form method="post" action={SENDOUQ_PAGE}>
|
||||
<div
|
||||
className={clsx("stack sm horizontal items-end", {
|
||||
"justify-center": friendCode,
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
{!friendCode ? (
|
||||
<Label htmlFor="friendCode">{t("common:fc.title")}</Label>
|
||||
) : null}
|
||||
{friendCode ? (
|
||||
<div className="font-bold">SW-{friendCode}</div>
|
||||
) : (
|
||||
<Input
|
||||
leftAddon="SW-"
|
||||
id="friendCode"
|
||||
name="friendCode"
|
||||
pattern={FRIEND_CODE_REGEXP_PATTERN}
|
||||
placeholder="1234-5678-9012"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!friendCode ? (
|
||||
<SubmitButton _action="ADD_FRIEND_CODE" state={fetcher.state}>
|
||||
Save
|
||||
</SubmitButton>
|
||||
) : null}
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
);
|
||||
return (
|
||||
<fetcher.Form method="post" action={SENDOUQ_PAGE}>
|
||||
<div
|
||||
className={clsx("stack sm horizontal items-end", {
|
||||
"justify-center": friendCode,
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
{!friendCode ? (
|
||||
<Label htmlFor="friendCode">{t("common:fc.title")}</Label>
|
||||
) : null}
|
||||
{friendCode ? (
|
||||
<div className="font-bold">SW-{friendCode}</div>
|
||||
) : (
|
||||
<Input
|
||||
leftAddon="SW-"
|
||||
id="friendCode"
|
||||
name="friendCode"
|
||||
pattern={FRIEND_CODE_REGEXP_PATTERN}
|
||||
placeholder="1234-5678-9012"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!friendCode ? (
|
||||
<SubmitButton _action="ADD_FRIEND_CODE" state={fetcher.state}>
|
||||
Save
|
||||
</SubmitButton>
|
||||
) : null}
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,174 +1,174 @@
|
||||
import type { TierName } from "~/features/mmr/mmr-constants";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TierName } from "~/features/mmr/mmr-constants";
|
||||
import type { MainWeaponId, ModeShort, StageId } from "~/modules/in-game-lists";
|
||||
import {
|
||||
TIER_PLUS_URL,
|
||||
mainWeaponImageUrl,
|
||||
modeImageUrl,
|
||||
outlinedFiveStarMainWeaponImageUrl,
|
||||
outlinedMainWeaponImageUrl,
|
||||
stageImageUrl,
|
||||
tierImageUrl,
|
||||
TIER_PLUS_URL,
|
||||
mainWeaponImageUrl,
|
||||
modeImageUrl,
|
||||
outlinedFiveStarMainWeaponImageUrl,
|
||||
outlinedMainWeaponImageUrl,
|
||||
stageImageUrl,
|
||||
tierImageUrl,
|
||||
} from "~/utils/urls";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface ImageProps {
|
||||
path: string;
|
||||
alt: string;
|
||||
title?: string;
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
size?: number;
|
||||
style?: React.CSSProperties;
|
||||
containerStyle?: React.CSSProperties;
|
||||
testId?: string;
|
||||
onClick?: () => void;
|
||||
loading?: "lazy";
|
||||
path: string;
|
||||
alt: string;
|
||||
title?: string;
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
size?: number;
|
||||
style?: React.CSSProperties;
|
||||
containerStyle?: React.CSSProperties;
|
||||
testId?: string;
|
||||
onClick?: () => void;
|
||||
loading?: "lazy";
|
||||
}
|
||||
|
||||
export function Image({
|
||||
path,
|
||||
alt,
|
||||
title,
|
||||
className,
|
||||
width,
|
||||
height,
|
||||
size,
|
||||
style,
|
||||
testId,
|
||||
containerClassName,
|
||||
containerStyle,
|
||||
onClick,
|
||||
loading,
|
||||
path,
|
||||
alt,
|
||||
title,
|
||||
className,
|
||||
width,
|
||||
height,
|
||||
size,
|
||||
style,
|
||||
testId,
|
||||
containerClassName,
|
||||
containerStyle,
|
||||
onClick,
|
||||
loading,
|
||||
}: ImageProps) {
|
||||
return (
|
||||
<picture
|
||||
title={title}
|
||||
className={containerClassName}
|
||||
style={containerStyle}
|
||||
onClick={onClick}
|
||||
>
|
||||
<source
|
||||
type="image/avif"
|
||||
srcSet={`${path}.avif`}
|
||||
width={width}
|
||||
height={height}
|
||||
style={style}
|
||||
/>
|
||||
<img
|
||||
alt={alt}
|
||||
src={`${path}.png`}
|
||||
className={className}
|
||||
width={size ?? width}
|
||||
height={size ?? height}
|
||||
style={style}
|
||||
draggable="false"
|
||||
loading={loading}
|
||||
data-testid={testId}
|
||||
/>
|
||||
</picture>
|
||||
);
|
||||
return (
|
||||
<picture
|
||||
title={title}
|
||||
className={containerClassName}
|
||||
style={containerStyle}
|
||||
onClick={onClick}
|
||||
>
|
||||
<source
|
||||
type="image/avif"
|
||||
srcSet={`${path}.avif`}
|
||||
width={width}
|
||||
height={height}
|
||||
style={style}
|
||||
/>
|
||||
<img
|
||||
alt={alt}
|
||||
src={`${path}.png`}
|
||||
className={className}
|
||||
width={size ?? width}
|
||||
height={size ?? height}
|
||||
style={style}
|
||||
draggable="false"
|
||||
loading={loading}
|
||||
data-testid={testId}
|
||||
/>
|
||||
</picture>
|
||||
);
|
||||
}
|
||||
|
||||
type WeaponImageProps = {
|
||||
weaponSplId: MainWeaponId;
|
||||
variant: "badge" | "badge-5-star" | "build";
|
||||
weaponSplId: MainWeaponId;
|
||||
variant: "badge" | "badge-5-star" | "build";
|
||||
} & Omit<ImageProps, "path" | "alt">;
|
||||
|
||||
export function WeaponImage({
|
||||
weaponSplId,
|
||||
variant,
|
||||
testId,
|
||||
title,
|
||||
...rest
|
||||
weaponSplId,
|
||||
variant,
|
||||
testId,
|
||||
title,
|
||||
...rest
|
||||
}: WeaponImageProps) {
|
||||
const { t } = useTranslation(["weapons"]);
|
||||
const { t } = useTranslation(["weapons"]);
|
||||
|
||||
return (
|
||||
<Image
|
||||
{...rest}
|
||||
alt={title ?? t(`weapons:MAIN_${weaponSplId}`)}
|
||||
title={title ?? t(`weapons:MAIN_${weaponSplId}`)}
|
||||
testId={testId}
|
||||
path={
|
||||
variant === "badge"
|
||||
? outlinedMainWeaponImageUrl(weaponSplId)
|
||||
: variant == "badge-5-star"
|
||||
? outlinedFiveStarMainWeaponImageUrl(weaponSplId)
|
||||
: mainWeaponImageUrl(weaponSplId)
|
||||
}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Image
|
||||
{...rest}
|
||||
alt={title ?? t(`weapons:MAIN_${weaponSplId}`)}
|
||||
title={title ?? t(`weapons:MAIN_${weaponSplId}`)}
|
||||
testId={testId}
|
||||
path={
|
||||
variant === "badge"
|
||||
? outlinedMainWeaponImageUrl(weaponSplId)
|
||||
: variant === "badge-5-star"
|
||||
? outlinedFiveStarMainWeaponImageUrl(weaponSplId)
|
||||
: mainWeaponImageUrl(weaponSplId)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type ModeImageProps = {
|
||||
mode: ModeShort;
|
||||
mode: ModeShort;
|
||||
} & Omit<ImageProps, "path" | "alt">;
|
||||
|
||||
export function ModeImage({ mode, testId, title, ...rest }: ModeImageProps) {
|
||||
const { t } = useTranslation(["game-misc"]);
|
||||
const { t } = useTranslation(["game-misc"]);
|
||||
|
||||
return (
|
||||
<Image
|
||||
{...rest}
|
||||
alt={title ?? t(`game-misc:MODE_LONG_${mode}`)}
|
||||
title={title ?? t(`game-misc:MODE_LONG_${mode}`)}
|
||||
testId={testId}
|
||||
path={modeImageUrl(mode)}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Image
|
||||
{...rest}
|
||||
alt={title ?? t(`game-misc:MODE_LONG_${mode}`)}
|
||||
title={title ?? t(`game-misc:MODE_LONG_${mode}`)}
|
||||
testId={testId}
|
||||
path={modeImageUrl(mode)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type StageImageProps = {
|
||||
stageId: StageId;
|
||||
stageId: StageId;
|
||||
} & Omit<ImageProps, "path" | "alt" | "title">;
|
||||
|
||||
export function StageImage({ stageId, testId, ...rest }: StageImageProps) {
|
||||
const { t } = useTranslation(["game-misc"]);
|
||||
const { t } = useTranslation(["game-misc"]);
|
||||
|
||||
return (
|
||||
<Image
|
||||
{...rest}
|
||||
alt={t(`game-misc:STAGE_${stageId}`)}
|
||||
title={t(`game-misc:STAGE_${stageId}`)}
|
||||
testId={testId}
|
||||
path={stageImageUrl(stageId)}
|
||||
height={rest.height ?? (rest.width ? rest.width * 0.5625 : undefined)}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Image
|
||||
{...rest}
|
||||
alt={t(`game-misc:STAGE_${stageId}`)}
|
||||
title={t(`game-misc:STAGE_${stageId}`)}
|
||||
testId={testId}
|
||||
path={stageImageUrl(stageId)}
|
||||
height={rest.height ?? (rest.width ? rest.width * 0.5625 : undefined)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type TierImageProps = {
|
||||
tier: { name: TierName; isPlus: boolean };
|
||||
tier: { name: TierName; isPlus: boolean };
|
||||
} & Omit<ImageProps, "path" | "alt" | "title" | "size" | "height">;
|
||||
|
||||
export function TierImage({ tier, className, width = 200 }: TierImageProps) {
|
||||
const title = `${tier.name}${tier.isPlus ? "+" : ""}`;
|
||||
const title = `${tier.name}${tier.isPlus ? "+" : ""}`;
|
||||
|
||||
const height = width * 0.8675;
|
||||
const height = width * 0.8675;
|
||||
|
||||
return (
|
||||
<div className={clsx("tier__container", className)} style={{ width }}>
|
||||
<Image
|
||||
path={tierImageUrl(tier.name)}
|
||||
width={width}
|
||||
height={height}
|
||||
alt={title}
|
||||
title={title}
|
||||
containerClassName="tier__img"
|
||||
/>
|
||||
{tier.isPlus ? (
|
||||
<Image
|
||||
path={TIER_PLUS_URL}
|
||||
width={width}
|
||||
height={height}
|
||||
alt={title}
|
||||
title={title}
|
||||
containerClassName="tier__img"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className={clsx("tier__container", className)} style={{ width }}>
|
||||
<Image
|
||||
path={tierImageUrl(tier.name)}
|
||||
width={width}
|
||||
height={height}
|
||||
alt={title}
|
||||
title={title}
|
||||
containerClassName="tier__img"
|
||||
/>
|
||||
{tier.isPlus ? (
|
||||
<Image
|
||||
path={TIER_PLUS_URL}
|
||||
width={width}
|
||||
height={height}
|
||||
alt={title}
|
||||
title={title}
|
||||
containerClassName="tier__img"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Popover } from "./Popover";
|
||||
|
||||
export function InfoPopover({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Popover buttonChildren={<>?</>} triggerClassName="info-popover__trigger">
|
||||
{children}
|
||||
</Popover>
|
||||
);
|
||||
return (
|
||||
<Popover buttonChildren={<>?</>} triggerClassName="info-popover__trigger">
|
||||
{children}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,78 +1,78 @@
|
||||
import clsx from "clsx";
|
||||
|
||||
export function Input({
|
||||
name,
|
||||
id,
|
||||
className,
|
||||
minLength,
|
||||
maxLength,
|
||||
required,
|
||||
defaultValue,
|
||||
leftAddon,
|
||||
icon,
|
||||
type,
|
||||
min,
|
||||
max,
|
||||
pattern,
|
||||
list,
|
||||
testId,
|
||||
"aria-label": ariaLabel,
|
||||
value,
|
||||
placeholder,
|
||||
onChange,
|
||||
disableAutoComplete = false,
|
||||
readOnly,
|
||||
name,
|
||||
id,
|
||||
className,
|
||||
minLength,
|
||||
maxLength,
|
||||
required,
|
||||
defaultValue,
|
||||
leftAddon,
|
||||
icon,
|
||||
type,
|
||||
min,
|
||||
max,
|
||||
pattern,
|
||||
list,
|
||||
testId,
|
||||
"aria-label": ariaLabel,
|
||||
value,
|
||||
placeholder,
|
||||
onChange,
|
||||
disableAutoComplete = false,
|
||||
readOnly,
|
||||
}: {
|
||||
name?: string;
|
||||
id?: string;
|
||||
className?: string;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
required?: boolean;
|
||||
defaultValue?: string;
|
||||
leftAddon?: string;
|
||||
icon?: React.ReactNode;
|
||||
type?: "number" | "date";
|
||||
min?: number;
|
||||
max?: number | string;
|
||||
pattern?: string;
|
||||
list?: string;
|
||||
testId?: string;
|
||||
"aria-label"?: string;
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
disableAutoComplete?: boolean;
|
||||
readOnly?: boolean;
|
||||
name?: string;
|
||||
id?: string;
|
||||
className?: string;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
required?: boolean;
|
||||
defaultValue?: string;
|
||||
leftAddon?: string;
|
||||
icon?: React.ReactNode;
|
||||
type?: "number" | "date";
|
||||
min?: number;
|
||||
max?: number | string;
|
||||
pattern?: string;
|
||||
list?: string;
|
||||
testId?: string;
|
||||
"aria-label"?: string;
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
disableAutoComplete?: boolean;
|
||||
readOnly?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={clsx("input-container", className, {
|
||||
"input__read-only": readOnly,
|
||||
})}
|
||||
>
|
||||
{leftAddon ? <div className="input-addon">{leftAddon}</div> : null}
|
||||
<input
|
||||
name={name}
|
||||
id={id}
|
||||
minLength={minLength}
|
||||
maxLength={maxLength}
|
||||
min={min}
|
||||
max={max}
|
||||
defaultValue={defaultValue}
|
||||
pattern={pattern}
|
||||
list={list}
|
||||
data-testid={testId}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
aria-label={ariaLabel}
|
||||
required={required}
|
||||
placeholder={placeholder}
|
||||
type={type}
|
||||
autoComplete={disableAutoComplete ? "one-time-code" : undefined}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
{icon}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={clsx("input-container", className, {
|
||||
"input__read-only": readOnly,
|
||||
})}
|
||||
>
|
||||
{leftAddon ? <div className="input-addon">{leftAddon}</div> : null}
|
||||
<input
|
||||
name={name}
|
||||
id={id}
|
||||
minLength={minLength}
|
||||
maxLength={maxLength}
|
||||
min={min}
|
||||
max={max}
|
||||
defaultValue={defaultValue}
|
||||
pattern={pattern}
|
||||
list={list}
|
||||
data-testid={testId}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
aria-label={ariaLabel}
|
||||
required={required}
|
||||
placeholder={placeholder}
|
||||
type={type}
|
||||
autoComplete={disableAutoComplete ? "one-time-code" : undefined}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
{icon}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
import clsx from "clsx";
|
||||
|
||||
type LabelProps = Pick<
|
||||
React.DetailedHTMLProps<
|
||||
React.LabelHTMLAttributes<HTMLLabelElement>,
|
||||
HTMLLabelElement
|
||||
>,
|
||||
"children" | "htmlFor"
|
||||
React.DetailedHTMLProps<
|
||||
React.LabelHTMLAttributes<HTMLLabelElement>,
|
||||
HTMLLabelElement
|
||||
>,
|
||||
"children" | "htmlFor"
|
||||
> & {
|
||||
valueLimits?: {
|
||||
current: number;
|
||||
max: number;
|
||||
};
|
||||
required?: boolean;
|
||||
className?: string;
|
||||
labelClassName?: string;
|
||||
spaced?: boolean;
|
||||
valueLimits?: {
|
||||
current: number;
|
||||
max: number;
|
||||
};
|
||||
required?: boolean;
|
||||
className?: string;
|
||||
labelClassName?: string;
|
||||
spaced?: boolean;
|
||||
};
|
||||
|
||||
export function Label({
|
||||
valueLimits,
|
||||
required,
|
||||
children,
|
||||
htmlFor,
|
||||
className,
|
||||
labelClassName,
|
||||
spaced = true,
|
||||
valueLimits,
|
||||
required,
|
||||
children,
|
||||
htmlFor,
|
||||
className,
|
||||
labelClassName,
|
||||
spaced = true,
|
||||
}: LabelProps) {
|
||||
return (
|
||||
<div className={clsx("label__container", className, { "mb-0": !spaced })}>
|
||||
<label htmlFor={htmlFor} className={labelClassName}>
|
||||
{children} {required && <span className="text-error">*</span>}
|
||||
</label>
|
||||
{valueLimits ? (
|
||||
<div className={clsx("label__value", lengthWarning(valueLimits))}>
|
||||
{valueLimits.current}/{valueLimits.max}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className={clsx("label__container", className, { "mb-0": !spaced })}>
|
||||
<label htmlFor={htmlFor} className={labelClassName}>
|
||||
{children} {required && <span className="text-error">*</span>}
|
||||
</label>
|
||||
{valueLimits ? (
|
||||
<div className={clsx("label__value", lengthWarning(valueLimits))}>
|
||||
{valueLimits.current}/{valueLimits.max}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function lengthWarning(valueLimits: NonNullable<LabelProps["valueLimits"]>) {
|
||||
if (valueLimits.current >= valueLimits.max) return "error";
|
||||
if (valueLimits.current / valueLimits.max >= 0.9) return "warning";
|
||||
if (valueLimits.current >= valueLimits.max) return "error";
|
||||
if (valueLimits.current / valueLimits.max >= 0.9) return "warning";
|
||||
|
||||
return;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,64 +1,64 @@
|
||||
import {
|
||||
useLocation,
|
||||
useRouteError,
|
||||
isRouteErrorResponse,
|
||||
isRouteErrorResponse,
|
||||
useLocation,
|
||||
useRouteError,
|
||||
} from "@remix-run/react";
|
||||
import { SideNav } from "app/components/layout/SideNav";
|
||||
import clsx from "clsx";
|
||||
import type * as React from "react";
|
||||
import { useMatches } from "react-router";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import type { RootLoaderData } from "~/root";
|
||||
import { SideNav } from "app/components/layout/SideNav";
|
||||
|
||||
export const Main = ({
|
||||
children,
|
||||
className,
|
||||
classNameOverwrite,
|
||||
halfWidth,
|
||||
bigger,
|
||||
style,
|
||||
children,
|
||||
className,
|
||||
classNameOverwrite,
|
||||
halfWidth,
|
||||
bigger,
|
||||
style,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
classNameOverwrite?: string;
|
||||
halfWidth?: boolean;
|
||||
bigger?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
classNameOverwrite?: string;
|
||||
halfWidth?: boolean;
|
||||
bigger?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
}) => {
|
||||
const error = useRouteError();
|
||||
const data = useMatches()[0]?.data as RootLoaderData | undefined;
|
||||
const user = useUser();
|
||||
const showLeaderboard =
|
||||
data?.publisherId && !user?.patronTier && !isRouteErrorResponse(error);
|
||||
const error = useRouteError();
|
||||
const data = useMatches()[0]?.data as RootLoaderData | undefined;
|
||||
const user = useUser();
|
||||
const showLeaderboard =
|
||||
data?.publisherId && !user?.patronTier && !isRouteErrorResponse(error);
|
||||
|
||||
const location = useLocation();
|
||||
const isFrontPage = location.pathname === "/";
|
||||
const location = useLocation();
|
||||
const isFrontPage = location.pathname === "/";
|
||||
|
||||
return (
|
||||
<div className="layout__main-container">
|
||||
{!isFrontPage ? <SideNav /> : null}
|
||||
<main
|
||||
className={
|
||||
classNameOverwrite
|
||||
? clsx(classNameOverwrite, {
|
||||
"half-width": halfWidth,
|
||||
"pt-8-forced": showLeaderboard,
|
||||
})
|
||||
: clsx(
|
||||
"layout__main",
|
||||
"main",
|
||||
{
|
||||
"half-width": halfWidth,
|
||||
bigger,
|
||||
"pt-8-forced": showLeaderboard,
|
||||
},
|
||||
className,
|
||||
)
|
||||
}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="layout__main-container">
|
||||
{!isFrontPage ? <SideNav /> : null}
|
||||
<main
|
||||
className={
|
||||
classNameOverwrite
|
||||
? clsx(classNameOverwrite, {
|
||||
"half-width": halfWidth,
|
||||
"pt-8-forced": showLeaderboard,
|
||||
})
|
||||
: clsx(
|
||||
"layout__main",
|
||||
"main",
|
||||
{
|
||||
"half-width": halfWidth,
|
||||
bigger,
|
||||
"pt-8-forced": showLeaderboard,
|
||||
},
|
||||
className,
|
||||
)
|
||||
}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,356 +1,356 @@
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Image } from "~/components/Image";
|
||||
import {
|
||||
type ModeShort,
|
||||
modesShort,
|
||||
type StageId,
|
||||
} from "~/modules/in-game-lists";
|
||||
import { modes, stageIds } from "~/modules/in-game-lists";
|
||||
import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
import { modeImageUrl, stageImageUrl } from "~/utils/urls";
|
||||
import { Button } from "~/components/Button";
|
||||
import { split, startsWith } from "~/utils/strings";
|
||||
import { CrossIcon } from "./icons/Cross";
|
||||
import { ArrowLongLeftIcon } from "./icons/ArrowLongLeft";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "~/components/Button";
|
||||
import { Image } from "~/components/Image";
|
||||
import type { CalendarEvent } from "~/db/types";
|
||||
import type { SerializedMapPoolEvent } from "~/features/calendar/routes/map-pool-events";
|
||||
import { assertType } from "~/utils/types";
|
||||
import { MapPoolEventsCombobox } from "./Combobox";
|
||||
import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
import { BANNED_MAPS } from "~/features/sendouq-settings/banned-maps";
|
||||
import {
|
||||
type ModeShort,
|
||||
type StageId,
|
||||
modesShort,
|
||||
} from "~/modules/in-game-lists";
|
||||
import { modes, stageIds } from "~/modules/in-game-lists";
|
||||
import { split, startsWith } from "~/utils/strings";
|
||||
import { assertType } from "~/utils/types";
|
||||
import { modeImageUrl, stageImageUrl } from "~/utils/urls";
|
||||
import { MapPoolEventsCombobox } from "./Combobox";
|
||||
import { ArrowLongLeftIcon } from "./icons/ArrowLongLeft";
|
||||
import { CrossIcon } from "./icons/Cross";
|
||||
|
||||
export type MapPoolSelectorProps = {
|
||||
mapPool: MapPool;
|
||||
preselectedMapPool?: MapPool;
|
||||
handleRemoval?: () => void;
|
||||
handleMapPoolChange: (
|
||||
mapPool: MapPool,
|
||||
event?: Pick<CalendarEvent, "id" | "name">,
|
||||
) => void;
|
||||
className?: string;
|
||||
recentEvents?: SerializedMapPoolEvent[];
|
||||
initialEvent?: Pick<CalendarEvent, "id" | "name">;
|
||||
title?: string;
|
||||
modesToInclude?: ModeShort[];
|
||||
info?: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
/** Enables clear button, template selection, and toggling a whole stage */
|
||||
allowBulkEdit?: boolean;
|
||||
hideBanned?: boolean;
|
||||
mapPool: MapPool;
|
||||
preselectedMapPool?: MapPool;
|
||||
handleRemoval?: () => void;
|
||||
handleMapPoolChange: (
|
||||
mapPool: MapPool,
|
||||
event?: Pick<CalendarEvent, "id" | "name">,
|
||||
) => void;
|
||||
className?: string;
|
||||
recentEvents?: SerializedMapPoolEvent[];
|
||||
initialEvent?: Pick<CalendarEvent, "id" | "name">;
|
||||
title?: string;
|
||||
modesToInclude?: ModeShort[];
|
||||
info?: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
/** Enables clear button, template selection, and toggling a whole stage */
|
||||
allowBulkEdit?: boolean;
|
||||
hideBanned?: boolean;
|
||||
};
|
||||
|
||||
export function MapPoolSelector({
|
||||
mapPool,
|
||||
preselectedMapPool,
|
||||
handleMapPoolChange,
|
||||
handleRemoval,
|
||||
className,
|
||||
recentEvents,
|
||||
initialEvent,
|
||||
title,
|
||||
modesToInclude,
|
||||
info,
|
||||
footer,
|
||||
allowBulkEdit = false,
|
||||
hideBanned = false,
|
||||
mapPool,
|
||||
preselectedMapPool,
|
||||
handleMapPoolChange,
|
||||
handleRemoval,
|
||||
className,
|
||||
recentEvents,
|
||||
initialEvent,
|
||||
title,
|
||||
modesToInclude,
|
||||
info,
|
||||
footer,
|
||||
allowBulkEdit = false,
|
||||
hideBanned = false,
|
||||
}: MapPoolSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [template, setTemplate] = React.useState<MapPoolTemplateValue>(
|
||||
initialEvent ? "event" : detectTemplate(mapPool),
|
||||
);
|
||||
const [template, setTemplate] = React.useState<MapPoolTemplateValue>(
|
||||
initialEvent ? "event" : detectTemplate(mapPool),
|
||||
);
|
||||
|
||||
const [initialSerializedEvent, setInitialSerializedEvent] = React.useState(
|
||||
(): SerializedMapPoolEvent | undefined =>
|
||||
initialEvent && {
|
||||
...initialEvent,
|
||||
serializedMapPool: mapPool.serialized,
|
||||
},
|
||||
);
|
||||
const [initialSerializedEvent, setInitialSerializedEvent] = React.useState(
|
||||
(): SerializedMapPoolEvent | undefined =>
|
||||
initialEvent && {
|
||||
...initialEvent,
|
||||
serializedMapPool: mapPool.serialized,
|
||||
},
|
||||
);
|
||||
|
||||
const handleStageModesChange = (newMapPool: MapPool) => {
|
||||
setTemplate(detectTemplate(newMapPool));
|
||||
handleMapPoolChange(newMapPool);
|
||||
};
|
||||
const handleStageModesChange = (newMapPool: MapPool) => {
|
||||
setTemplate(detectTemplate(newMapPool));
|
||||
handleMapPoolChange(newMapPool);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setTemplate("none");
|
||||
handleMapPoolChange(MapPool.EMPTY);
|
||||
};
|
||||
const handleClear = () => {
|
||||
setTemplate("none");
|
||||
handleMapPoolChange(MapPool.EMPTY);
|
||||
};
|
||||
|
||||
const handleTemplateChange = (template: MapPoolTemplateValue) => {
|
||||
setTemplate(template);
|
||||
const handleTemplateChange = (template: MapPoolTemplateValue) => {
|
||||
setTemplate(template);
|
||||
|
||||
if (template === "none") {
|
||||
return;
|
||||
}
|
||||
if (template === "none") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (template === "event") {
|
||||
// If the user selected the "event" option, the _initial_ event passed via
|
||||
// props is likely not the current state and should not be prefilled
|
||||
// anymore.
|
||||
setInitialSerializedEvent(undefined);
|
||||
return;
|
||||
}
|
||||
if (template === "event") {
|
||||
// If the user selected the "event" option, the _initial_ event passed via
|
||||
// props is likely not the current state and should not be prefilled
|
||||
// anymore.
|
||||
setInitialSerializedEvent(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (startsWith(template, "preset:")) {
|
||||
const [, presetId] = split(template, ":");
|
||||
if (startsWith(template, "preset:")) {
|
||||
const [, presetId] = split(template, ":");
|
||||
|
||||
handleMapPoolChange(MapPool[presetId]);
|
||||
return;
|
||||
}
|
||||
handleMapPoolChange(MapPool[presetId]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (startsWith(template, "recent-event:")) {
|
||||
const [, eventId] = split(template, ":");
|
||||
if (startsWith(template, "recent-event:")) {
|
||||
const [, eventId] = split(template, ":");
|
||||
|
||||
const event = recentEvents?.find((e) => e.id.toString() === eventId);
|
||||
const event = recentEvents?.find((e) => e.id.toString() === eventId);
|
||||
|
||||
if (event) {
|
||||
handleMapPoolChange(new MapPool(event.serializedMapPool), event);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event) {
|
||||
handleMapPoolChange(new MapPool(event.serializedMapPool), event);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
assertType<never, typeof template>();
|
||||
};
|
||||
assertType<never, typeof template>();
|
||||
};
|
||||
|
||||
return (
|
||||
<fieldset className={className}>
|
||||
{Boolean(title) && <legend>{title}</legend>}
|
||||
{Boolean(handleRemoval || allowBulkEdit) && (
|
||||
<div className="stack horizontal sm justify-end">
|
||||
{handleRemoval && (
|
||||
<Button variant="minimal" onClick={handleRemoval}>
|
||||
{t("actions.remove")}
|
||||
</Button>
|
||||
)}
|
||||
{allowBulkEdit && (
|
||||
<Button
|
||||
variant="minimal-destructive"
|
||||
disabled={mapPool.isEmpty()}
|
||||
onClick={handleClear}
|
||||
>
|
||||
{t("actions.clear")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="stack md">
|
||||
{allowBulkEdit && (
|
||||
<div className="maps__template-selection">
|
||||
<MapPoolTemplateSelect
|
||||
value={template}
|
||||
handleChange={handleTemplateChange}
|
||||
recentEvents={recentEvents}
|
||||
/>
|
||||
{template === "event" && (
|
||||
<TemplateEventSelection
|
||||
initialEvent={initialSerializedEvent}
|
||||
handleEventChange={handleMapPoolChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{info}
|
||||
<MapPoolStages
|
||||
mapPool={mapPool}
|
||||
handleMapPoolChange={handleStageModesChange}
|
||||
allowBulkEdit={allowBulkEdit}
|
||||
modesToInclude={modesToInclude}
|
||||
preselectedMapPool={preselectedMapPool}
|
||||
hideBanned={hideBanned}
|
||||
/>
|
||||
{footer}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
return (
|
||||
<fieldset className={className}>
|
||||
{Boolean(title) && <legend>{title}</legend>}
|
||||
{Boolean(handleRemoval || allowBulkEdit) && (
|
||||
<div className="stack horizontal sm justify-end">
|
||||
{handleRemoval && (
|
||||
<Button variant="minimal" onClick={handleRemoval}>
|
||||
{t("actions.remove")}
|
||||
</Button>
|
||||
)}
|
||||
{allowBulkEdit && (
|
||||
<Button
|
||||
variant="minimal-destructive"
|
||||
disabled={mapPool.isEmpty()}
|
||||
onClick={handleClear}
|
||||
>
|
||||
{t("actions.clear")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="stack md">
|
||||
{allowBulkEdit && (
|
||||
<div className="maps__template-selection">
|
||||
<MapPoolTemplateSelect
|
||||
value={template}
|
||||
handleChange={handleTemplateChange}
|
||||
recentEvents={recentEvents}
|
||||
/>
|
||||
{template === "event" && (
|
||||
<TemplateEventSelection
|
||||
initialEvent={initialSerializedEvent}
|
||||
handleEventChange={handleMapPoolChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{info}
|
||||
<MapPoolStages
|
||||
mapPool={mapPool}
|
||||
handleMapPoolChange={handleStageModesChange}
|
||||
allowBulkEdit={allowBulkEdit}
|
||||
modesToInclude={modesToInclude}
|
||||
preselectedMapPool={preselectedMapPool}
|
||||
hideBanned={hideBanned}
|
||||
/>
|
||||
{footer}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
export type MapPoolStagesProps = {
|
||||
mapPool: MapPool;
|
||||
handleMapPoolChange?: (newMapPool: MapPool) => void;
|
||||
allowBulkEdit?: boolean;
|
||||
modesToInclude?: ModeShort[];
|
||||
preselectedMapPool?: MapPool;
|
||||
hideBanned?: boolean;
|
||||
mapPool: MapPool;
|
||||
handleMapPoolChange?: (newMapPool: MapPool) => void;
|
||||
allowBulkEdit?: boolean;
|
||||
modesToInclude?: ModeShort[];
|
||||
preselectedMapPool?: MapPool;
|
||||
hideBanned?: boolean;
|
||||
};
|
||||
|
||||
export function MapPoolStages({
|
||||
mapPool,
|
||||
handleMapPoolChange,
|
||||
allowBulkEdit = false,
|
||||
modesToInclude,
|
||||
preselectedMapPool,
|
||||
hideBanned = false,
|
||||
mapPool,
|
||||
handleMapPoolChange,
|
||||
allowBulkEdit = false,
|
||||
modesToInclude,
|
||||
preselectedMapPool,
|
||||
hideBanned = false,
|
||||
}: MapPoolStagesProps) {
|
||||
const { t } = useTranslation(["game-misc", "common"]);
|
||||
const { t } = useTranslation(["game-misc", "common"]);
|
||||
|
||||
const isPresentational = !handleMapPoolChange;
|
||||
const isPresentational = !handleMapPoolChange;
|
||||
|
||||
const stageRowIsVisible = (stageId: StageId) => {
|
||||
if (!isPresentational) return true;
|
||||
const stageRowIsVisible = (stageId: StageId) => {
|
||||
if (!isPresentational) return true;
|
||||
|
||||
return mapPool.hasStage(stageId);
|
||||
};
|
||||
return mapPool.hasStage(stageId);
|
||||
};
|
||||
|
||||
const handleModeChange = ({
|
||||
mode,
|
||||
stageId,
|
||||
}: {
|
||||
mode: ModeShort;
|
||||
stageId: StageId;
|
||||
}) => {
|
||||
const newMapPool = mapPool.parsed[mode].includes(stageId)
|
||||
? new MapPool({
|
||||
...mapPool.parsed,
|
||||
[mode]: mapPool.parsed[mode].filter((id) => id !== stageId),
|
||||
})
|
||||
: new MapPool({
|
||||
...mapPool.parsed,
|
||||
[mode]: [...mapPool.parsed[mode], stageId],
|
||||
});
|
||||
const handleModeChange = ({
|
||||
mode,
|
||||
stageId,
|
||||
}: {
|
||||
mode: ModeShort;
|
||||
stageId: StageId;
|
||||
}) => {
|
||||
const newMapPool = mapPool.parsed[mode].includes(stageId)
|
||||
? new MapPool({
|
||||
...mapPool.parsed,
|
||||
[mode]: mapPool.parsed[mode].filter((id) => id !== stageId),
|
||||
})
|
||||
: new MapPool({
|
||||
...mapPool.parsed,
|
||||
[mode]: [...mapPool.parsed[mode], stageId],
|
||||
});
|
||||
|
||||
handleMapPoolChange?.(newMapPool);
|
||||
};
|
||||
handleMapPoolChange?.(newMapPool);
|
||||
};
|
||||
|
||||
const handleStageClear = (stageId: StageId) => {
|
||||
const newMapPool = new MapPool({
|
||||
TW: mapPool.parsed.TW.filter((id) => id !== stageId),
|
||||
SZ: mapPool.parsed.SZ.filter((id) => id !== stageId),
|
||||
TC: mapPool.parsed.TC.filter((id) => id !== stageId),
|
||||
RM: mapPool.parsed.RM.filter((id) => id !== stageId),
|
||||
CB: mapPool.parsed.CB.filter((id) => id !== stageId),
|
||||
});
|
||||
const handleStageClear = (stageId: StageId) => {
|
||||
const newMapPool = new MapPool({
|
||||
TW: mapPool.parsed.TW.filter((id) => id !== stageId),
|
||||
SZ: mapPool.parsed.SZ.filter((id) => id !== stageId),
|
||||
TC: mapPool.parsed.TC.filter((id) => id !== stageId),
|
||||
RM: mapPool.parsed.RM.filter((id) => id !== stageId),
|
||||
CB: mapPool.parsed.CB.filter((id) => id !== stageId),
|
||||
});
|
||||
|
||||
handleMapPoolChange?.(newMapPool);
|
||||
};
|
||||
handleMapPoolChange?.(newMapPool);
|
||||
};
|
||||
|
||||
const handleStageAdd = (stageId: StageId) => {
|
||||
const newMapPool = new MapPool({
|
||||
TW: [...mapPool.parsed.TW, stageId],
|
||||
SZ: [...mapPool.parsed.SZ, stageId],
|
||||
TC: [...mapPool.parsed.TC, stageId],
|
||||
RM: [...mapPool.parsed.RM, stageId],
|
||||
CB: [...mapPool.parsed.CB, stageId],
|
||||
});
|
||||
const handleStageAdd = (stageId: StageId) => {
|
||||
const newMapPool = new MapPool({
|
||||
TW: [...mapPool.parsed.TW, stageId],
|
||||
SZ: [...mapPool.parsed.SZ, stageId],
|
||||
TC: [...mapPool.parsed.TC, stageId],
|
||||
RM: [...mapPool.parsed.RM, stageId],
|
||||
CB: [...mapPool.parsed.CB, stageId],
|
||||
});
|
||||
|
||||
handleMapPoolChange?.(newMapPool);
|
||||
};
|
||||
handleMapPoolChange?.(newMapPool);
|
||||
};
|
||||
|
||||
const id = React.useId();
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<div className="stack md">
|
||||
{stageIds.filter(stageRowIsVisible).map((stageId) => (
|
||||
<div key={stageId} className="maps__stage-row">
|
||||
<Image
|
||||
className="maps__stage-image"
|
||||
alt=""
|
||||
path={stageImageUrl(stageId)}
|
||||
width={80}
|
||||
height={45}
|
||||
/>
|
||||
<div
|
||||
className="maps__stage-name-row"
|
||||
role="group"
|
||||
aria-labelledby={`${id}-stage-name-${stageId}`}
|
||||
>
|
||||
<div id={`${id}-stage-name-${stageId}`}>
|
||||
{t(`game-misc:STAGE_${stageId}`)}
|
||||
</div>
|
||||
<div className="maps__mode-buttons-container">
|
||||
{modes
|
||||
.filter(
|
||||
(mode) =>
|
||||
!modesToInclude || modesToInclude.includes(mode.short),
|
||||
)
|
||||
.map((mode) => {
|
||||
const selected = mapPool.has({ stageId, mode: mode.short });
|
||||
return (
|
||||
<div className="stack md">
|
||||
{stageIds.filter(stageRowIsVisible).map((stageId) => (
|
||||
<div key={stageId} className="maps__stage-row">
|
||||
<Image
|
||||
className="maps__stage-image"
|
||||
alt=""
|
||||
path={stageImageUrl(stageId)}
|
||||
width={80}
|
||||
height={45}
|
||||
/>
|
||||
<div
|
||||
className="maps__stage-name-row"
|
||||
role="group"
|
||||
aria-labelledby={`${id}-stage-name-${stageId}`}
|
||||
>
|
||||
<div id={`${id}-stage-name-${stageId}`}>
|
||||
{t(`game-misc:STAGE_${stageId}`)}
|
||||
</div>
|
||||
<div className="maps__mode-buttons-container">
|
||||
{modes
|
||||
.filter(
|
||||
(mode) =>
|
||||
!modesToInclude || modesToInclude.includes(mode.short),
|
||||
)
|
||||
.map((mode) => {
|
||||
const selected = mapPool.has({ stageId, mode: mode.short });
|
||||
|
||||
if (isPresentational && !selected) return null;
|
||||
if (isPresentational && selected) {
|
||||
return (
|
||||
<Image
|
||||
key={mode.short}
|
||||
className={clsx("maps__mode", {
|
||||
selected,
|
||||
})}
|
||||
title={t(`game-misc:MODE_LONG_${mode.short}`)}
|
||||
alt={t(`game-misc:MODE_LONG_${mode.short}`)}
|
||||
path={modeImageUrl(mode.short)}
|
||||
width={33}
|
||||
height={33}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isPresentational && !selected) return null;
|
||||
if (isPresentational && selected) {
|
||||
return (
|
||||
<Image
|
||||
key={mode.short}
|
||||
className={clsx("maps__mode", {
|
||||
selected,
|
||||
})}
|
||||
title={t(`game-misc:MODE_LONG_${mode.short}`)}
|
||||
alt={t(`game-misc:MODE_LONG_${mode.short}`)}
|
||||
path={modeImageUrl(mode.short)}
|
||||
width={33}
|
||||
height={33}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const preselected = preselectedMapPool?.has({
|
||||
stageId,
|
||||
mode: mode.short,
|
||||
});
|
||||
const preselected = preselectedMapPool?.has({
|
||||
stageId,
|
||||
mode: mode.short,
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
key={mode.short}
|
||||
className={clsx("maps__mode-button", "outline-theme", {
|
||||
selected,
|
||||
preselected,
|
||||
invisible:
|
||||
hideBanned &&
|
||||
BANNED_MAPS[mode.short].includes(stageId),
|
||||
})}
|
||||
onClick={() =>
|
||||
handleModeChange?.({ mode: mode.short, stageId })
|
||||
}
|
||||
type="button"
|
||||
title={t(`game-misc:MODE_LONG_${mode.short}`)}
|
||||
aria-describedby={`${id}-stage-name-${stageId}`}
|
||||
aria-pressed={selected}
|
||||
disabled={preselected}
|
||||
>
|
||||
<Image
|
||||
className={clsx("maps__mode", {
|
||||
selected,
|
||||
preselected,
|
||||
})}
|
||||
alt={t(`game-misc:MODE_LONG_${mode.short}`)}
|
||||
path={modeImageUrl(mode.short)}
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{!isPresentational &&
|
||||
allowBulkEdit &&
|
||||
(mapPool.hasStage(stageId) ? (
|
||||
<Button
|
||||
key="clear"
|
||||
onClick={() => handleStageClear(stageId)}
|
||||
icon={<CrossIcon />}
|
||||
variant="minimal"
|
||||
aria-label={t("common:actions.remove")}
|
||||
title={t("common:actions.remove")}
|
||||
size="tiny"
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
key="select-all"
|
||||
onClick={() => handleStageAdd(stageId)}
|
||||
icon={<ArrowLongLeftIcon />}
|
||||
variant="minimal"
|
||||
aria-label={t("common:actions.selectAll")}
|
||||
title={t("common:actions.selectAll")}
|
||||
size="tiny"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<button
|
||||
key={mode.short}
|
||||
className={clsx("maps__mode-button", "outline-theme", {
|
||||
selected,
|
||||
preselected,
|
||||
invisible:
|
||||
hideBanned &&
|
||||
BANNED_MAPS[mode.short].includes(stageId),
|
||||
})}
|
||||
onClick={() =>
|
||||
handleModeChange?.({ mode: mode.short, stageId })
|
||||
}
|
||||
type="button"
|
||||
title={t(`game-misc:MODE_LONG_${mode.short}`)}
|
||||
aria-describedby={`${id}-stage-name-${stageId}`}
|
||||
aria-pressed={selected}
|
||||
disabled={preselected}
|
||||
>
|
||||
<Image
|
||||
className={clsx("maps__mode", {
|
||||
selected,
|
||||
preselected,
|
||||
})}
|
||||
alt={t(`game-misc:MODE_LONG_${mode.short}`)}
|
||||
path={modeImageUrl(mode.short)}
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{!isPresentational &&
|
||||
allowBulkEdit &&
|
||||
(mapPool.hasStage(stageId) ? (
|
||||
<Button
|
||||
key="clear"
|
||||
onClick={() => handleStageClear(stageId)}
|
||||
icon={<CrossIcon />}
|
||||
variant="minimal"
|
||||
aria-label={t("common:actions.remove")}
|
||||
title={t("common:actions.remove")}
|
||||
size="tiny"
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
key="select-all"
|
||||
onClick={() => handleStageAdd(stageId)}
|
||||
icon={<ArrowLongLeftIcon />}
|
||||
variant="minimal"
|
||||
aria-label={t("common:actions.selectAll")}
|
||||
title={t("common:actions.selectAll")}
|
||||
size="tiny"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type MapModePresetId = "ANARCHY" | "ALL" | ModeShort;
|
||||
@@ -358,102 +358,102 @@ type MapModePresetId = "ANARCHY" | "ALL" | ModeShort;
|
||||
const presetIds: MapModePresetId[] = ["ANARCHY", "ALL", ...modesShort];
|
||||
|
||||
type MapPoolTemplateValue =
|
||||
| "none"
|
||||
| `preset:${MapModePresetId}`
|
||||
| `recent-event:${string}`
|
||||
| "event";
|
||||
| "none"
|
||||
| `preset:${MapModePresetId}`
|
||||
| `recent-event:${string}`
|
||||
| "event";
|
||||
|
||||
function detectTemplate(mapPool: MapPool): MapPoolTemplateValue {
|
||||
for (const presetId of presetIds) {
|
||||
if (MapPool[presetId].serialized === mapPool.serialized) {
|
||||
return `preset:${presetId}`;
|
||||
}
|
||||
}
|
||||
return "none";
|
||||
for (const presetId of presetIds) {
|
||||
if (MapPool[presetId].serialized === mapPool.serialized) {
|
||||
return `preset:${presetId}`;
|
||||
}
|
||||
}
|
||||
return "none";
|
||||
}
|
||||
|
||||
type MapPoolTemplateSelectProps = {
|
||||
value: MapPoolTemplateValue;
|
||||
handleChange: (newValue: MapPoolTemplateValue) => void;
|
||||
recentEvents?: Pick<CalendarEvent, "id" | "name">[];
|
||||
value: MapPoolTemplateValue;
|
||||
handleChange: (newValue: MapPoolTemplateValue) => void;
|
||||
recentEvents?: Pick<CalendarEvent, "id" | "name">[];
|
||||
};
|
||||
|
||||
function MapPoolTemplateSelect({
|
||||
handleChange,
|
||||
value,
|
||||
recentEvents,
|
||||
handleChange,
|
||||
value,
|
||||
recentEvents,
|
||||
}: MapPoolTemplateSelectProps) {
|
||||
const { t } = useTranslation(["game-misc", "common"]);
|
||||
const { t } = useTranslation(["game-misc", "common"]);
|
||||
|
||||
return (
|
||||
<label className="stack sm">
|
||||
{t("common:maps.template")}
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
handleChange(e.currentTarget.value as MapPoolTemplateValue);
|
||||
}}
|
||||
>
|
||||
<option value="none">{t("common:maps.template.none")}</option>
|
||||
<option value="event">{t("common:maps.template.event")}</option>
|
||||
<optgroup label={t("common:maps.template.presets")}>
|
||||
{(["ANARCHY", "ALL"] as const).map((presetId) => (
|
||||
<option key={presetId} value={`preset:${presetId}`}>
|
||||
{t(`common:maps.template.preset.${presetId}`)}
|
||||
</option>
|
||||
))}
|
||||
{modes.map((mode) => (
|
||||
<option key={mode.short} value={`preset:${mode.short}`}>
|
||||
{t(`common:maps.template.preset.onlyMode`, {
|
||||
modeName: t(`game-misc:MODE_LONG_${mode.short}`),
|
||||
})}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
{recentEvents && recentEvents.length > 0 && (
|
||||
<optgroup label={t("common:maps.template.yourRecentEvents")}>
|
||||
{recentEvents.map((event) => (
|
||||
<option key={event.id} value={`recent-event:${event.id}`}>
|
||||
{event.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
return (
|
||||
<label className="stack sm">
|
||||
{t("common:maps.template")}
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
handleChange(e.currentTarget.value as MapPoolTemplateValue);
|
||||
}}
|
||||
>
|
||||
<option value="none">{t("common:maps.template.none")}</option>
|
||||
<option value="event">{t("common:maps.template.event")}</option>
|
||||
<optgroup label={t("common:maps.template.presets")}>
|
||||
{(["ANARCHY", "ALL"] as const).map((presetId) => (
|
||||
<option key={presetId} value={`preset:${presetId}`}>
|
||||
{t(`common:maps.template.preset.${presetId}`)}
|
||||
</option>
|
||||
))}
|
||||
{modes.map((mode) => (
|
||||
<option key={mode.short} value={`preset:${mode.short}`}>
|
||||
{t("common:maps.template.preset.onlyMode", {
|
||||
modeName: t(`game-misc:MODE_LONG_${mode.short}`),
|
||||
})}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
{recentEvents && recentEvents.length > 0 && (
|
||||
<optgroup label={t("common:maps.template.yourRecentEvents")}>
|
||||
{recentEvents.map((event) => (
|
||||
<option key={event.id} value={`recent-event:${event.id}`}>
|
||||
{event.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
type TemplateEventSelectionProps = {
|
||||
handleEventChange: (
|
||||
mapPool: MapPool,
|
||||
event?: Pick<CalendarEvent, "id" | "name">,
|
||||
) => void;
|
||||
initialEvent?: SerializedMapPoolEvent;
|
||||
handleEventChange: (
|
||||
mapPool: MapPool,
|
||||
event?: Pick<CalendarEvent, "id" | "name">,
|
||||
) => void;
|
||||
initialEvent?: SerializedMapPoolEvent;
|
||||
};
|
||||
function TemplateEventSelection({
|
||||
handleEventChange,
|
||||
initialEvent,
|
||||
handleEventChange,
|
||||
initialEvent,
|
||||
}: TemplateEventSelectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const id = React.useId();
|
||||
const { t } = useTranslation();
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<label className="stack sm">
|
||||
{t("maps.template.event")}
|
||||
<MapPoolEventsCombobox
|
||||
id={id}
|
||||
inputName={id}
|
||||
onChange={(e) => {
|
||||
if (e) {
|
||||
handleEventChange(new MapPool(e.serializedMapPool), {
|
||||
id: e.id,
|
||||
name: e.name,
|
||||
});
|
||||
}
|
||||
}}
|
||||
initialEvent={initialEvent}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
return (
|
||||
<label className="stack sm">
|
||||
{t("maps.template.event")}
|
||||
<MapPoolEventsCombobox
|
||||
id={id}
|
||||
inputName={id}
|
||||
onChange={(e) => {
|
||||
if (e) {
|
||||
handleEventChange(new MapPool(e.serializedMapPool), {
|
||||
id: e.id,
|
||||
name: e.name,
|
||||
});
|
||||
}
|
||||
}}
|
||||
initialEvent={initialEvent}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,59 +1,60 @@
|
||||
import { Menu as HeadlessUIMenu, Transition } from "@headlessui/react";
|
||||
import * as React from "react";
|
||||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
|
||||
export function Menu({
|
||||
button,
|
||||
items,
|
||||
className,
|
||||
button,
|
||||
items,
|
||||
className,
|
||||
}: {
|
||||
button: React.ElementType;
|
||||
items: {
|
||||
// type: "button"; TODO: type: "link"
|
||||
text: string;
|
||||
id: string;
|
||||
icon?: React.ReactNode;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}[];
|
||||
className?: string;
|
||||
button: React.ElementType;
|
||||
items: {
|
||||
// type: "button"; TODO: type: "link"
|
||||
text: string;
|
||||
id: string;
|
||||
icon?: React.ReactNode;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}[];
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<HeadlessUIMenu as="div" className={clsx("menu-container", className)}>
|
||||
<HeadlessUIMenu.Button as={button} />
|
||||
<Transition
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<HeadlessUIMenu.Items className="menu__items-container">
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<HeadlessUIMenu.Item key={item.id} disabled={item.disabled}>
|
||||
{({ active }) => (
|
||||
<button
|
||||
className={clsx("menu__item", {
|
||||
menu__item__active: active,
|
||||
menu__item__disabled: item.disabled,
|
||||
})}
|
||||
onClick={item.onClick}
|
||||
data-testid={`menu-item-${item.id}`}
|
||||
>
|
||||
{item.icon ? (
|
||||
<span className="menu__item__icon">{item.icon}</span>
|
||||
) : null}
|
||||
{item.text}
|
||||
</button>
|
||||
)}
|
||||
</HeadlessUIMenu.Item>
|
||||
);
|
||||
})}
|
||||
</HeadlessUIMenu.Items>
|
||||
</Transition>
|
||||
</HeadlessUIMenu>
|
||||
);
|
||||
return (
|
||||
<HeadlessUIMenu as="div" className={clsx("menu-container", className)}>
|
||||
<HeadlessUIMenu.Button as={button} />
|
||||
<Transition
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<HeadlessUIMenu.Items className="menu__items-container">
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<HeadlessUIMenu.Item key={item.id} disabled={item.disabled}>
|
||||
{({ active }) => (
|
||||
<button
|
||||
className={clsx("menu__item", {
|
||||
menu__item__active: active,
|
||||
menu__item__disabled: item.disabled,
|
||||
})}
|
||||
onClick={item.onClick}
|
||||
data-testid={`menu-item-${item.id}`}
|
||||
type="button"
|
||||
>
|
||||
{item.icon ? (
|
||||
<span className="menu__item__icon">{item.icon}</span>
|
||||
) : null}
|
||||
{item.text}
|
||||
</button>
|
||||
)}
|
||||
</HeadlessUIMenu.Item>
|
||||
);
|
||||
})}
|
||||
</HeadlessUIMenu.Items>
|
||||
</Transition>
|
||||
</HeadlessUIMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,135 +3,135 @@ import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
|
||||
interface NewTabsProps {
|
||||
tabs: {
|
||||
label: string;
|
||||
number?: number;
|
||||
hidden?: boolean;
|
||||
}[];
|
||||
content: {
|
||||
key: string;
|
||||
element: React.ReactNode;
|
||||
hidden?: boolean;
|
||||
unmount?: boolean;
|
||||
}[];
|
||||
scrolling?: boolean;
|
||||
selectedIndex?: number;
|
||||
setSelectedIndex?: (index: number) => void;
|
||||
/** Don't take space when no tabs to show? */
|
||||
disappearing?: boolean;
|
||||
type?: "divider";
|
||||
sticky?: boolean;
|
||||
tabs: {
|
||||
label: string;
|
||||
number?: number;
|
||||
hidden?: boolean;
|
||||
}[];
|
||||
content: {
|
||||
key: string;
|
||||
element: React.ReactNode;
|
||||
hidden?: boolean;
|
||||
unmount?: boolean;
|
||||
}[];
|
||||
scrolling?: boolean;
|
||||
selectedIndex?: number;
|
||||
setSelectedIndex?: (index: number) => void;
|
||||
/** Don't take space when no tabs to show? */
|
||||
disappearing?: boolean;
|
||||
type?: "divider";
|
||||
sticky?: boolean;
|
||||
}
|
||||
|
||||
export function NewTabs(args: NewTabsProps) {
|
||||
if (args.type === "divider") {
|
||||
return <DividerTabs {...args} />;
|
||||
}
|
||||
if (args.type === "divider") {
|
||||
return <DividerTabs {...args} />;
|
||||
}
|
||||
|
||||
const {
|
||||
tabs,
|
||||
content,
|
||||
scrolling = true,
|
||||
selectedIndex,
|
||||
setSelectedIndex,
|
||||
disappearing = false,
|
||||
} = args;
|
||||
const {
|
||||
tabs,
|
||||
content,
|
||||
scrolling = true,
|
||||
selectedIndex,
|
||||
setSelectedIndex,
|
||||
disappearing = false,
|
||||
} = args;
|
||||
|
||||
const cantSwitchTabs = tabs.filter((t) => !t.hidden).length <= 1;
|
||||
const cantSwitchTabs = tabs.filter((t) => !t.hidden).length <= 1;
|
||||
|
||||
return (
|
||||
<Tab.Group selectedIndex={selectedIndex} onChange={setSelectedIndex}>
|
||||
<Tab.List
|
||||
className={clsx("tab__buttons-container", {
|
||||
"overflow-x-auto": scrolling,
|
||||
invisible: cantSwitchTabs && !disappearing,
|
||||
hidden: cantSwitchTabs && disappearing,
|
||||
"tab__buttons-container__sticky": args.sticky,
|
||||
})}
|
||||
>
|
||||
{tabs
|
||||
.filter((t) => !t.hidden)
|
||||
.map((tab) => {
|
||||
return (
|
||||
<Tab
|
||||
key={tab.label}
|
||||
className="tab__button"
|
||||
data-testid={`tab-${tab.label}`}
|
||||
>
|
||||
{tab.label}
|
||||
{typeof tab.number === "number" && tab.number !== 0 && (
|
||||
<span className="tab__number">{tab.number}</span>
|
||||
)}
|
||||
</Tab>
|
||||
);
|
||||
})}
|
||||
</Tab.List>
|
||||
<Tab.Panels
|
||||
className={clsx({ "mt-4": !cantSwitchTabs || !disappearing })}
|
||||
>
|
||||
{content
|
||||
.filter((c) => !c.hidden)
|
||||
.map((c) => {
|
||||
return (
|
||||
<Tab.Panel key={c.key} unmount={c.unmount}>
|
||||
{c.element}
|
||||
</Tab.Panel>
|
||||
);
|
||||
})}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
);
|
||||
return (
|
||||
<Tab.Group selectedIndex={selectedIndex} onChange={setSelectedIndex}>
|
||||
<Tab.List
|
||||
className={clsx("tab__buttons-container", {
|
||||
"overflow-x-auto": scrolling,
|
||||
invisible: cantSwitchTabs && !disappearing,
|
||||
hidden: cantSwitchTabs && disappearing,
|
||||
"tab__buttons-container__sticky": args.sticky,
|
||||
})}
|
||||
>
|
||||
{tabs
|
||||
.filter((t) => !t.hidden)
|
||||
.map((tab) => {
|
||||
return (
|
||||
<Tab
|
||||
key={tab.label}
|
||||
className="tab__button"
|
||||
data-testid={`tab-${tab.label}`}
|
||||
>
|
||||
{tab.label}
|
||||
{typeof tab.number === "number" && tab.number !== 0 && (
|
||||
<span className="tab__number">{tab.number}</span>
|
||||
)}
|
||||
</Tab>
|
||||
);
|
||||
})}
|
||||
</Tab.List>
|
||||
<Tab.Panels
|
||||
className={clsx({ "mt-4": !cantSwitchTabs || !disappearing })}
|
||||
>
|
||||
{content
|
||||
.filter((c) => !c.hidden)
|
||||
.map((c) => {
|
||||
return (
|
||||
<Tab.Panel key={c.key} unmount={c.unmount}>
|
||||
{c.element}
|
||||
</Tab.Panel>
|
||||
);
|
||||
})}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
);
|
||||
}
|
||||
|
||||
function DividerTabs({
|
||||
tabs,
|
||||
content,
|
||||
scrolling = true,
|
||||
selectedIndex,
|
||||
setSelectedIndex,
|
||||
disappearing = false,
|
||||
tabs,
|
||||
content,
|
||||
scrolling = true,
|
||||
selectedIndex,
|
||||
setSelectedIndex,
|
||||
disappearing = false,
|
||||
}: NewTabsProps) {
|
||||
const cantSwitchTabs = tabs.filter((t) => !t.hidden).length <= 1;
|
||||
const cantSwitchTabs = tabs.filter((t) => !t.hidden).length <= 1;
|
||||
|
||||
return (
|
||||
<Tab.Group selectedIndex={selectedIndex} onChange={setSelectedIndex}>
|
||||
<Tab.List
|
||||
className={clsx("divider-tab__buttons-container", {
|
||||
"overflow-x-auto": scrolling,
|
||||
invisible: cantSwitchTabs && !disappearing,
|
||||
hidden: cantSwitchTabs && disappearing,
|
||||
})}
|
||||
>
|
||||
{tabs
|
||||
.filter((t) => !t.hidden)
|
||||
.map((tab, i) => {
|
||||
return (
|
||||
<React.Fragment key={tab.label}>
|
||||
<Tab
|
||||
className="divider-tab__button"
|
||||
data-testid={`tab-${tab.label}`}
|
||||
>
|
||||
{tab.label}
|
||||
{typeof tab.number === "number" && tab.number !== 0 && (
|
||||
<span className="ml-1">({tab.number})</span>
|
||||
)}
|
||||
</Tab>
|
||||
{i !== tabs.length - 1 && (
|
||||
<div className="divider-tab__line-guy" />
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</Tab.List>
|
||||
<Tab.Panels
|
||||
className={clsx({ "mt-4": !cantSwitchTabs || !disappearing })}
|
||||
>
|
||||
{content
|
||||
.filter((c) => !c.hidden)
|
||||
.map((c) => {
|
||||
return <Tab.Panel key={c.key}>{c.element}</Tab.Panel>;
|
||||
})}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
);
|
||||
return (
|
||||
<Tab.Group selectedIndex={selectedIndex} onChange={setSelectedIndex}>
|
||||
<Tab.List
|
||||
className={clsx("divider-tab__buttons-container", {
|
||||
"overflow-x-auto": scrolling,
|
||||
invisible: cantSwitchTabs && !disappearing,
|
||||
hidden: cantSwitchTabs && disappearing,
|
||||
})}
|
||||
>
|
||||
{tabs
|
||||
.filter((t) => !t.hidden)
|
||||
.map((tab, i) => {
|
||||
return (
|
||||
<React.Fragment key={tab.label}>
|
||||
<Tab
|
||||
className="divider-tab__button"
|
||||
data-testid={`tab-${tab.label}`}
|
||||
>
|
||||
{tab.label}
|
||||
{typeof tab.number === "number" && tab.number !== 0 && (
|
||||
<span className="ml-1">({tab.number})</span>
|
||||
)}
|
||||
</Tab>
|
||||
{i !== tabs.length - 1 && (
|
||||
<div className="divider-tab__line-guy" />
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</Tab.List>
|
||||
<Tab.Panels
|
||||
className={clsx({ "mt-4": !cantSwitchTabs || !disappearing })}
|
||||
>
|
||||
{content
|
||||
.filter((c) => !c.hidden)
|
||||
.map((c) => {
|
||||
return <Tab.Panel key={c.key}>{c.element}</Tab.Panel>;
|
||||
})}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,48 +5,48 @@ import { ArrowRightIcon } from "~/components/icons/ArrowRight";
|
||||
import { nullFilledArray } from "~/utils/arrays";
|
||||
|
||||
export function Pagination({
|
||||
currentPage,
|
||||
pagesCount,
|
||||
nextPage,
|
||||
previousPage,
|
||||
setPage,
|
||||
currentPage,
|
||||
pagesCount,
|
||||
nextPage,
|
||||
previousPage,
|
||||
setPage,
|
||||
}: {
|
||||
currentPage: number;
|
||||
pagesCount: number;
|
||||
nextPage: () => void;
|
||||
previousPage: () => void;
|
||||
setPage: (page: number) => void;
|
||||
currentPage: number;
|
||||
pagesCount: number;
|
||||
nextPage: () => void;
|
||||
previousPage: () => void;
|
||||
setPage: (page: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="stack sm horizontal items-center justify-center flex-wrap">
|
||||
<Button
|
||||
icon={<ArrowLeftIcon />}
|
||||
variant="outlined"
|
||||
className="fix-rtl"
|
||||
disabled={currentPage === 1}
|
||||
onClick={previousPage}
|
||||
aria-label="Previous page"
|
||||
/>
|
||||
{nullFilledArray(pagesCount).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={clsx("pagination__dot", {
|
||||
pagination__dot__active: i === currentPage - 1,
|
||||
})}
|
||||
onClick={() => setPage(i + 1)}
|
||||
/>
|
||||
))}
|
||||
<div className="pagination__page-count">
|
||||
{currentPage}/{pagesCount}
|
||||
</div>
|
||||
<Button
|
||||
icon={<ArrowRightIcon />}
|
||||
variant="outlined"
|
||||
className="fix-rtl"
|
||||
disabled={currentPage === pagesCount}
|
||||
onClick={nextPage}
|
||||
aria-label="Next page"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="stack sm horizontal items-center justify-center flex-wrap">
|
||||
<Button
|
||||
icon={<ArrowLeftIcon />}
|
||||
variant="outlined"
|
||||
className="fix-rtl"
|
||||
disabled={currentPage === 1}
|
||||
onClick={previousPage}
|
||||
aria-label="Previous page"
|
||||
/>
|
||||
{nullFilledArray(pagesCount).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={clsx("pagination__dot", {
|
||||
pagination__dot__active: i === currentPage - 1,
|
||||
})}
|
||||
onClick={() => setPage(i + 1)}
|
||||
/>
|
||||
))}
|
||||
<div className="pagination__page-count">
|
||||
{currentPage}/{pagesCount}
|
||||
</div>
|
||||
<Button
|
||||
icon={<ArrowRightIcon />}
|
||||
variant="outlined"
|
||||
className="fix-rtl"
|
||||
disabled={currentPage === pagesCount}
|
||||
onClick={nextPage}
|
||||
aria-label="Next page"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,75 +1,75 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
FIRST_PLACEMENT_ICON_PATH,
|
||||
SECOND_PLACEMENT_ICON_PATH,
|
||||
THIRD_PLACEMENT_ICON_PATH,
|
||||
FIRST_PLACEMENT_ICON_PATH,
|
||||
SECOND_PLACEMENT_ICON_PATH,
|
||||
THIRD_PLACEMENT_ICON_PATH,
|
||||
} from "~/utils/urls";
|
||||
|
||||
export type PlacementProps = {
|
||||
placement: number;
|
||||
iconClassName?: string;
|
||||
textClassName?: string;
|
||||
size?: number;
|
||||
textOnly?: boolean;
|
||||
showAsSuperscript?: boolean;
|
||||
placement: number;
|
||||
iconClassName?: string;
|
||||
textClassName?: string;
|
||||
size?: number;
|
||||
textOnly?: boolean;
|
||||
showAsSuperscript?: boolean;
|
||||
};
|
||||
|
||||
const getSpecialPlacementIconPath = (placement: number): string | null => {
|
||||
switch (placement) {
|
||||
case 3:
|
||||
return THIRD_PLACEMENT_ICON_PATH;
|
||||
case 2:
|
||||
return SECOND_PLACEMENT_ICON_PATH;
|
||||
case 1:
|
||||
return FIRST_PLACEMENT_ICON_PATH;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
switch (placement) {
|
||||
case 3:
|
||||
return THIRD_PLACEMENT_ICON_PATH;
|
||||
case 2:
|
||||
return SECOND_PLACEMENT_ICON_PATH;
|
||||
case 1:
|
||||
return FIRST_PLACEMENT_ICON_PATH;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export function Placement({
|
||||
placement,
|
||||
iconClassName,
|
||||
textClassName,
|
||||
size = 20,
|
||||
textOnly = false,
|
||||
showAsSuperscript = true,
|
||||
placement,
|
||||
iconClassName,
|
||||
textClassName,
|
||||
size = 20,
|
||||
textOnly = false,
|
||||
showAsSuperscript = true,
|
||||
}: PlacementProps) {
|
||||
const { t } = useTranslation(undefined, {});
|
||||
const { t } = useTranslation(undefined, {});
|
||||
|
||||
// Remove assertion if types stop claiming result is "never".
|
||||
const ordinalSuffix: string = t("results.placeSuffix", {
|
||||
count: placement,
|
||||
ordinal: true,
|
||||
// no suffix is a better default than english
|
||||
defaultValue: "",
|
||||
fallbackLng: [],
|
||||
});
|
||||
// Remove assertion if types stop claiming result is "never".
|
||||
const ordinalSuffix: string = t("results.placeSuffix", {
|
||||
count: placement,
|
||||
ordinal: true,
|
||||
// no suffix is a better default than english
|
||||
defaultValue: "",
|
||||
fallbackLng: [],
|
||||
});
|
||||
|
||||
const isSuperscript = showAsSuperscript && ordinalSuffix.startsWith("^");
|
||||
const ordinalSuffixText = ordinalSuffix.replace(/^\^/, "");
|
||||
const isSuperscript = showAsSuperscript && ordinalSuffix.startsWith("^");
|
||||
const ordinalSuffixText = ordinalSuffix.replace(/^\^/, "");
|
||||
|
||||
const iconPath = textOnly ? null : getSpecialPlacementIconPath(placement);
|
||||
const iconPath = textOnly ? null : getSpecialPlacementIconPath(placement);
|
||||
|
||||
if (!iconPath) {
|
||||
return (
|
||||
<span className={textClassName}>
|
||||
{placement}
|
||||
{isSuperscript ? <sup>{ordinalSuffixText}</sup> : ordinalSuffixText}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (!iconPath) {
|
||||
return (
|
||||
<span className={textClassName}>
|
||||
{placement}
|
||||
{isSuperscript ? <sup>{ordinalSuffixText}</sup> : ordinalSuffixText}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const placementString = `${placement}${ordinalSuffixText}`;
|
||||
const placementString = `${placement}${ordinalSuffixText}`;
|
||||
|
||||
return (
|
||||
<img
|
||||
alt={placementString}
|
||||
title={placementString}
|
||||
src={iconPath}
|
||||
className={iconClassName}
|
||||
height={size}
|
||||
width={size}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<img
|
||||
alt={placementString}
|
||||
title={placementString}
|
||||
src={iconPath}
|
||||
className={iconClassName}
|
||||
height={size}
|
||||
width={size}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,62 +8,62 @@ import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
|
||||
// TODO: after clicking item in the pop over panel should close it
|
||||
export function Popover({
|
||||
children,
|
||||
buttonChildren,
|
||||
triggerClassName,
|
||||
triggerTestId,
|
||||
containerClassName,
|
||||
contentClassName,
|
||||
placement,
|
||||
children,
|
||||
buttonChildren,
|
||||
triggerClassName,
|
||||
triggerTestId,
|
||||
containerClassName,
|
||||
contentClassName,
|
||||
placement,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
buttonChildren: React.ReactNode;
|
||||
triggerClassName?: string;
|
||||
triggerTestId?: string;
|
||||
containerClassName?: string;
|
||||
contentClassName?: string;
|
||||
placement?: Placement;
|
||||
children: React.ReactNode;
|
||||
buttonChildren: React.ReactNode;
|
||||
triggerClassName?: string;
|
||||
triggerTestId?: string;
|
||||
containerClassName?: string;
|
||||
contentClassName?: string;
|
||||
placement?: Placement;
|
||||
}) {
|
||||
const [referenceElement, setReferenceElement] = React.useState();
|
||||
const isMounted = useIsMounted();
|
||||
const [popperElement, setPopperElement] = React.useState();
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement,
|
||||
modifiers: [
|
||||
{
|
||||
name: "offset",
|
||||
options: {
|
||||
offset: [0, 8],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const [referenceElement, setReferenceElement] = React.useState();
|
||||
const isMounted = useIsMounted();
|
||||
const [popperElement, setPopperElement] = React.useState();
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement,
|
||||
modifiers: [
|
||||
{
|
||||
name: "offset",
|
||||
options: {
|
||||
offset: [0, 8],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return (
|
||||
<HeadlessPopover className={containerClassName}>
|
||||
<HeadlessPopover.Button
|
||||
// @ts-expect-error Popper docs: https://popper.js.org/react-popper/v2/
|
||||
ref={setReferenceElement}
|
||||
className={triggerClassName ?? "minimal tiny"}
|
||||
data-testid={triggerTestId}
|
||||
>
|
||||
{buttonChildren}
|
||||
</HeadlessPopover.Button>
|
||||
return (
|
||||
<HeadlessPopover className={containerClassName}>
|
||||
<HeadlessPopover.Button
|
||||
// @ts-expect-error Popper docs: https://popper.js.org/react-popper/v2/
|
||||
ref={setReferenceElement}
|
||||
className={triggerClassName ?? "minimal tiny"}
|
||||
data-testid={triggerTestId}
|
||||
>
|
||||
{buttonChildren}
|
||||
</HeadlessPopover.Button>
|
||||
|
||||
{isMounted
|
||||
? createPortal(
|
||||
<HeadlessPopover.Panel
|
||||
// @ts-expect-error Popper docs: https://popper.js.org/react-popper/v2/
|
||||
ref={setPopperElement}
|
||||
className={clsx("popover-content", contentClassName)}
|
||||
style={styles["popper"]}
|
||||
{...attributes["popper"]}
|
||||
>
|
||||
{children}
|
||||
</HeadlessPopover.Panel>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</HeadlessPopover>
|
||||
);
|
||||
{isMounted
|
||||
? createPortal(
|
||||
<HeadlessPopover.Panel
|
||||
// @ts-expect-error Popper docs: https://popper.js.org/react-popper/v2/
|
||||
ref={setPopperElement}
|
||||
className={clsx("popover-content", contentClassName)}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
{children}
|
||||
</HeadlessPopover.Panel>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</HeadlessPopover>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@ import { useNavigate } from "@remix-run/react";
|
||||
import * as React from "react";
|
||||
|
||||
export function Redirect({ to }: { to: string }) {
|
||||
const navigate = useNavigate();
|
||||
const navigate = useNavigate();
|
||||
|
||||
React.useEffect(() => {
|
||||
navigate(to);
|
||||
}, [navigate, to]);
|
||||
React.useEffect(() => {
|
||||
navigate(to);
|
||||
}, [navigate, to]);
|
||||
|
||||
return null;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2,29 +2,29 @@ import type * as React from "react";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
|
||||
export function RelativeTime({
|
||||
children,
|
||||
timestamp,
|
||||
children,
|
||||
timestamp,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
timestamp: number;
|
||||
children: React.ReactNode;
|
||||
timestamp: number;
|
||||
}) {
|
||||
const isMounted = useIsMounted();
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
return (
|
||||
<abbr
|
||||
title={
|
||||
isMounted
|
||||
? new Date(timestamp).toLocaleString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
timeZoneName: "short",
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</abbr>
|
||||
);
|
||||
return (
|
||||
<abbr
|
||||
title={
|
||||
isMounted
|
||||
? new Date(timestamp).toLocaleString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
timeZoneName: "short",
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</abbr>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
export function RequiredHiddenInput({
|
||||
value,
|
||||
isValid,
|
||||
name,
|
||||
value,
|
||||
isValid,
|
||||
name,
|
||||
}: {
|
||||
value: string;
|
||||
isValid: boolean;
|
||||
name: string;
|
||||
value: string;
|
||||
isValid: boolean;
|
||||
name: string;
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
className="hidden-input-with-validation"
|
||||
name={name}
|
||||
value={isValid ? value : []}
|
||||
// empty onChange is because otherwise it will give a React error in console
|
||||
// readOnly can't be set as then validation is not active
|
||||
onChange={() => null}
|
||||
required
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<input
|
||||
className="hidden-input-with-validation"
|
||||
name={name}
|
||||
value={isValid ? value : []}
|
||||
// empty onChange is because otherwise it will give a React error in console
|
||||
// readOnly can't be set as then validation is not active
|
||||
onChange={() => null}
|
||||
required
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
export function Section({
|
||||
title,
|
||||
children,
|
||||
className,
|
||||
title,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="section">
|
||||
{title && <h2>{title}</h2>}
|
||||
<div className={className}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
return (
|
||||
<section className="section">
|
||||
{title && <h2>{title}</h2>}
|
||||
<div className={className}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,63 +4,63 @@ import clsx from "clsx";
|
||||
import type * as React from "react";
|
||||
|
||||
export function SubNav({
|
||||
children,
|
||||
secondary,
|
||||
children,
|
||||
secondary,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
secondary?: boolean;
|
||||
children: React.ReactNode;
|
||||
secondary?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<nav
|
||||
className={clsx("sub-nav__container", {
|
||||
"sub-nav__container__secondary": secondary,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<nav
|
||||
className={clsx("sub-nav__container", {
|
||||
"sub-nav__container__secondary": secondary,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SubNavLink({
|
||||
children,
|
||||
className,
|
||||
end = true,
|
||||
secondary = false,
|
||||
controlled = false,
|
||||
active = false,
|
||||
...props
|
||||
children,
|
||||
className,
|
||||
end = true,
|
||||
secondary = false,
|
||||
controlled = false,
|
||||
active = false,
|
||||
...props
|
||||
}: LinkProps & {
|
||||
end?: boolean;
|
||||
children: React.ReactNode;
|
||||
secondary?: boolean;
|
||||
controlled?: boolean;
|
||||
active?: boolean;
|
||||
end?: boolean;
|
||||
children: React.ReactNode;
|
||||
secondary?: boolean;
|
||||
controlled?: boolean;
|
||||
active?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<NavLink
|
||||
className={(state) =>
|
||||
clsx("sub-nav__link__container", {
|
||||
active: controlled ? active : state.isActive,
|
||||
pending: state.isPending,
|
||||
})
|
||||
}
|
||||
end={end}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={clsx("sub-nav__link", className, {
|
||||
"sub-nav__link__secondary": secondary,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<div
|
||||
className={clsx("sub-nav__border-guy", {
|
||||
"sub-nav__border-guy__secondary": secondary,
|
||||
})}
|
||||
/>
|
||||
</NavLink>
|
||||
);
|
||||
return (
|
||||
<NavLink
|
||||
className={(state) =>
|
||||
clsx("sub-nav__link__container", {
|
||||
active: controlled ? active : state.isActive,
|
||||
pending: state.isPending,
|
||||
})
|
||||
}
|
||||
end={end}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={clsx("sub-nav__link", className, {
|
||||
"sub-nav__link__secondary": secondary,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<div
|
||||
className={clsx("sub-nav__border-guy", {
|
||||
"sub-nav__border-guy__secondary": secondary,
|
||||
})}
|
||||
/>
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,46 +2,46 @@ import { type FetcherWithComponents, useNavigation } from "@remix-run/react";
|
||||
import { Button, type ButtonProps } from "./Button";
|
||||
|
||||
interface SubmitButtonProps extends ButtonProps {
|
||||
/** If the page has multiple forms you can pass in fetcher.state to differentiate when this SubmitButton should be in submitting state */
|
||||
state?: FetcherWithComponents<any>["state"];
|
||||
_action?: string;
|
||||
/** If the page has multiple forms you can pass in fetcher.state to differentiate when this SubmitButton should be in submitting state */
|
||||
state?: FetcherWithComponents<any>["state"];
|
||||
_action?: string;
|
||||
}
|
||||
|
||||
export function SubmitButton({
|
||||
children,
|
||||
state,
|
||||
_action,
|
||||
testId,
|
||||
...rest
|
||||
children,
|
||||
state,
|
||||
_action,
|
||||
testId,
|
||||
...rest
|
||||
}: SubmitButtonProps) {
|
||||
const navigation = useNavigation();
|
||||
const navigation = useNavigation();
|
||||
|
||||
const isSubmitting = state ? state !== "idle" : navigation.state !== "idle";
|
||||
const isSubmitting = state ? state !== "idle" : navigation.state !== "idle";
|
||||
|
||||
const name = () => {
|
||||
if (rest.name) return rest.name;
|
||||
if (_action) return "_action";
|
||||
const name = () => {
|
||||
if (rest.name) return rest.name;
|
||||
if (_action) return "_action";
|
||||
|
||||
return undefined;
|
||||
};
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const value = () => {
|
||||
if (rest.value) return rest.value;
|
||||
if (_action) return _action;
|
||||
const value = () => {
|
||||
if (rest.value) return rest.value;
|
||||
if (_action) return _action;
|
||||
|
||||
return undefined;
|
||||
};
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
{...rest}
|
||||
disabled={rest.disabled || isSubmitting}
|
||||
type="submit"
|
||||
name={name()}
|
||||
value={value()}
|
||||
data-testid={testId ?? "submit-button"}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
return (
|
||||
<Button
|
||||
{...rest}
|
||||
disabled={rest.disabled || isSubmitting}
|
||||
type="submit"
|
||||
name={name()}
|
||||
value={value()}
|
||||
data-testid={testId ?? "submit-button"}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export function Table({ children }: { children: React.ReactNode }) {
|
||||
return <table className="my-table">{children}</table>;
|
||||
return <table className="my-table">{children}</table>;
|
||||
}
|
||||
|
||||
@@ -4,46 +4,46 @@ import type * as React from "react";
|
||||
// shares styles with SubNav.tsx
|
||||
|
||||
export function Tabs({
|
||||
children,
|
||||
className,
|
||||
compact = false,
|
||||
children,
|
||||
className,
|
||||
compact = false,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={clsx("sub-nav__container", className, { compact })}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className={clsx("sub-nav__container", className, { compact })}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Tab({
|
||||
children,
|
||||
className,
|
||||
active,
|
||||
onClick,
|
||||
testId,
|
||||
children,
|
||||
className,
|
||||
active,
|
||||
onClick,
|
||||
testId,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
testId?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
testId?: string;
|
||||
}) {
|
||||
// TODO: improve semantic html here, maybe could use tab component from Headless UI?
|
||||
return (
|
||||
<div
|
||||
className={clsx("sub-nav__link__container", { active })}
|
||||
onClick={onClick}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-pressed="false"
|
||||
data-testid={testId}
|
||||
>
|
||||
<div className={clsx("sub-nav__link", className)}>{children}</div>
|
||||
<div className="sub-nav__border-guy" />
|
||||
</div>
|
||||
);
|
||||
// TODO: improve semantic html here, maybe could use tab component from Headless UI?
|
||||
return (
|
||||
<div
|
||||
className={clsx("sub-nav__link__container", { active })}
|
||||
onClick={onClick}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-pressed="false"
|
||||
data-testid={testId}
|
||||
>
|
||||
<div className={clsx("sub-nav__link", className)}>{children}</div>
|
||||
<div className="sub-nav__border-guy" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,31 +2,31 @@ import { Switch } from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
|
||||
export function Toggle({
|
||||
checked,
|
||||
setChecked,
|
||||
tiny,
|
||||
id,
|
||||
name,
|
||||
disabled,
|
||||
checked,
|
||||
setChecked,
|
||||
tiny,
|
||||
id,
|
||||
name,
|
||||
disabled,
|
||||
}: {
|
||||
checked: boolean;
|
||||
setChecked: (checked: boolean) => void;
|
||||
tiny?: boolean;
|
||||
id?: string;
|
||||
name?: string;
|
||||
disabled?: boolean;
|
||||
checked: boolean;
|
||||
setChecked: (checked: boolean) => void;
|
||||
tiny?: boolean;
|
||||
id?: string;
|
||||
name?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={setChecked}
|
||||
className={clsx("toggle", { checked, tiny })}
|
||||
id={id}
|
||||
name={name}
|
||||
data-testid={id ? `toggle-${id}` : null}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className={clsx("toggle-dot", { checked, tiny })} />
|
||||
</Switch>
|
||||
);
|
||||
return (
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={setChecked}
|
||||
className={clsx("toggle", { checked, tiny })}
|
||||
id={id}
|
||||
name={name}
|
||||
data-testid={id ? `toggle-${id}` : null}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className={clsx("toggle-dot", { checked, tiny })} />
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,137 +2,137 @@ import { Combobox } from "@headlessui/react";
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDebounce } from "react-use";
|
||||
import type { UserSearchLoaderData } from "~/features/user-search/routes/u";
|
||||
import { Avatar } from "./Avatar";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type UserSearchUserItem = NonNullable<UserSearchLoaderData>["users"][number];
|
||||
|
||||
export function UserSearch({
|
||||
inputName,
|
||||
onChange,
|
||||
initialUserId,
|
||||
id,
|
||||
className,
|
||||
userIdsToOmit,
|
||||
required,
|
||||
inputName,
|
||||
onChange,
|
||||
initialUserId,
|
||||
id,
|
||||
className,
|
||||
userIdsToOmit,
|
||||
required,
|
||||
}: {
|
||||
inputName: string;
|
||||
onChange?: (user: UserSearchUserItem) => void;
|
||||
initialUserId?: number;
|
||||
id?: string;
|
||||
className?: string;
|
||||
userIdsToOmit?: Set<number>;
|
||||
required?: boolean;
|
||||
inputName: string;
|
||||
onChange?: (user: UserSearchUserItem) => void;
|
||||
initialUserId?: number;
|
||||
id?: string;
|
||||
className?: string;
|
||||
userIdsToOmit?: Set<number>;
|
||||
required?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedUser, setSelectedUser] =
|
||||
React.useState<UserSearchUserItem | null>(null);
|
||||
const queryFetcher = useFetcher<UserSearchLoaderData>();
|
||||
const initialUserFetcher = useFetcher<UserSearchLoaderData>();
|
||||
const [query, setQuery] = React.useState("");
|
||||
useDebounce(
|
||||
() => {
|
||||
if (!query) return;
|
||||
const { t } = useTranslation();
|
||||
const [selectedUser, setSelectedUser] =
|
||||
React.useState<UserSearchUserItem | null>(null);
|
||||
const queryFetcher = useFetcher<UserSearchLoaderData>();
|
||||
const initialUserFetcher = useFetcher<UserSearchLoaderData>();
|
||||
const [query, setQuery] = React.useState("");
|
||||
useDebounce(
|
||||
() => {
|
||||
if (!query) return;
|
||||
|
||||
queryFetcher.load(`/u?q=${query}&limit=6`);
|
||||
},
|
||||
1000,
|
||||
[query],
|
||||
);
|
||||
queryFetcher.load(`/u?q=${query}&limit=6`);
|
||||
},
|
||||
1000,
|
||||
[query],
|
||||
);
|
||||
|
||||
// load initial user
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
!initialUserId ||
|
||||
initialUserFetcher.state !== "idle" ||
|
||||
initialUserFetcher.data
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// load initial user
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
!initialUserId ||
|
||||
initialUserFetcher.state !== "idle" ||
|
||||
initialUserFetcher.data
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
initialUserFetcher.load(`/u?q=${initialUserId}`);
|
||||
}, [initialUserId, initialUserFetcher]);
|
||||
React.useEffect(() => {
|
||||
if (!initialUserFetcher.data) return;
|
||||
initialUserFetcher.load(`/u?q=${initialUserId}`);
|
||||
}, [initialUserId, initialUserFetcher]);
|
||||
React.useEffect(() => {
|
||||
if (!initialUserFetcher.data) return;
|
||||
|
||||
setSelectedUser(initialUserFetcher.data.users[0]);
|
||||
}, [initialUserFetcher.data]);
|
||||
setSelectedUser(initialUserFetcher.data.users[0]);
|
||||
}, [initialUserFetcher.data]);
|
||||
|
||||
const allUsers = queryFetcher.data?.users ?? [];
|
||||
const allUsers = queryFetcher.data?.users ?? [];
|
||||
|
||||
const users = allUsers.filter((u) => !userIdsToOmit?.has(u.id));
|
||||
const noMatches = queryFetcher.data && users.length === 0;
|
||||
const users = allUsers.filter((u) => !userIdsToOmit?.has(u.id));
|
||||
const noMatches = queryFetcher.data && users.length === 0;
|
||||
|
||||
const initialSelectionIsLoading = Boolean(
|
||||
initialUserId && !initialUserFetcher.data,
|
||||
);
|
||||
const initialSelectionIsLoading = Boolean(
|
||||
initialUserId && !initialUserFetcher.data,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="combobox-wrapper">
|
||||
{selectedUser && inputName ? (
|
||||
<input type="hidden" name={inputName} value={selectedUser.id} />
|
||||
) : null}
|
||||
<Combobox
|
||||
value={selectedUser}
|
||||
onChange={(newUser) => {
|
||||
setSelectedUser(newUser);
|
||||
onChange?.(newUser!);
|
||||
}}
|
||||
disabled={initialSelectionIsLoading}
|
||||
>
|
||||
<Combobox.Input
|
||||
placeholder={
|
||||
initialSelectionIsLoading
|
||||
? t("actions.loading")
|
||||
: "Search via name or ID..."
|
||||
}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
displayValue={(user: UserSearchUserItem) => user?.username ?? ""}
|
||||
className={clsx("combobox-input", className)}
|
||||
data-1p-ignore
|
||||
data-testid={`${inputName}-combobox-input`}
|
||||
id={id}
|
||||
required={required}
|
||||
/>
|
||||
<Combobox.Options
|
||||
className={clsx("combobox-options", {
|
||||
empty: noMatches,
|
||||
hidden: !queryFetcher.data,
|
||||
})}
|
||||
>
|
||||
{noMatches ? (
|
||||
<div className="combobox-no-matches">
|
||||
{t("forms.errors.noSearchMatches")}{" "}
|
||||
<span className="combobox-emoji">🤔</span>
|
||||
</div>
|
||||
) : null}
|
||||
{users.map((user, i) => (
|
||||
<Combobox.Option key={user.id} value={user} as={React.Fragment}>
|
||||
{({ active }) => (
|
||||
<li
|
||||
className={clsx("combobox-item", { active })}
|
||||
data-testid={`combobox-option-${i}`}
|
||||
>
|
||||
<Avatar user={user} size="xs" />
|
||||
<div>
|
||||
<div className="stack xs horizontal items-center">
|
||||
<span className="combobox-username">{user.username}</span>{" "}
|
||||
{user.plusTier ? (
|
||||
<span className="text-xxs">+{user.plusTier}</span>
|
||||
) : null}
|
||||
</div>
|
||||
{user.discordUniqueName ? (
|
||||
<div className="text-xs">{user.discordUniqueName}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
</Combobox>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="combobox-wrapper">
|
||||
{selectedUser && inputName ? (
|
||||
<input type="hidden" name={inputName} value={selectedUser.id} />
|
||||
) : null}
|
||||
<Combobox
|
||||
value={selectedUser}
|
||||
onChange={(newUser) => {
|
||||
setSelectedUser(newUser);
|
||||
onChange?.(newUser!);
|
||||
}}
|
||||
disabled={initialSelectionIsLoading}
|
||||
>
|
||||
<Combobox.Input
|
||||
placeholder={
|
||||
initialSelectionIsLoading
|
||||
? t("actions.loading")
|
||||
: "Search via name or ID..."
|
||||
}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
displayValue={(user: UserSearchUserItem) => user?.username ?? ""}
|
||||
className={clsx("combobox-input", className)}
|
||||
data-1p-ignore
|
||||
data-testid={`${inputName}-combobox-input`}
|
||||
id={id}
|
||||
required={required}
|
||||
/>
|
||||
<Combobox.Options
|
||||
className={clsx("combobox-options", {
|
||||
empty: noMatches,
|
||||
hidden: !queryFetcher.data,
|
||||
})}
|
||||
>
|
||||
{noMatches ? (
|
||||
<div className="combobox-no-matches">
|
||||
{t("forms.errors.noSearchMatches")}{" "}
|
||||
<span className="combobox-emoji">🤔</span>
|
||||
</div>
|
||||
) : null}
|
||||
{users.map((user, i) => (
|
||||
<Combobox.Option key={user.id} value={user} as={React.Fragment}>
|
||||
{({ active }) => (
|
||||
<li
|
||||
className={clsx("combobox-item", { active })}
|
||||
data-testid={`combobox-option-${i}`}
|
||||
>
|
||||
<Avatar user={user} size="xs" />
|
||||
<div>
|
||||
<div className="stack xs horizontal items-center">
|
||||
<span className="combobox-username">{user.username}</span>{" "}
|
||||
{user.plusTier ? (
|
||||
<span className="text-xxs">+{user.plusTier}</span>
|
||||
) : null}
|
||||
</div>
|
||||
{user.discordUniqueName ? (
|
||||
<div className="text-xs">{user.discordUniqueName}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
</Combobox>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
export function YouTubeEmbed({
|
||||
id,
|
||||
start,
|
||||
autoplay = false,
|
||||
id,
|
||||
start,
|
||||
autoplay = false,
|
||||
}: {
|
||||
id: string;
|
||||
start?: number;
|
||||
autoplay?: boolean;
|
||||
id: string;
|
||||
start?: number;
|
||||
autoplay?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="youtube__container">
|
||||
<iframe
|
||||
className="youtube__iframe"
|
||||
src={`https://www.youtube.com/embed/${id}?autoplay=${
|
||||
autoplay ? "1" : "0"
|
||||
}&controls=1&rel=0&modestbranding=1&start=${start ?? 0}`}
|
||||
frameBorder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
title="Embedded youtube"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="youtube__container">
|
||||
<iframe
|
||||
className="youtube__iframe"
|
||||
src={`https://www.youtube.com/embed/${id}?autoplay=${
|
||||
autoplay ? "1" : "0"
|
||||
}&controls=1&rel=0&modestbranding=1&start=${start ?? 0}`}
|
||||
frameBorder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
title="Embedded youtube"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
export function AdminIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
export function AlertIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
export function ArchiveBoxIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
export function ArrowLeftIcon({
|
||||
className,
|
||||
style,
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
style={style}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
transform="rotate(180)"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
style={style}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
transform="rotate(180)"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
export function ArrowLongLeftIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6.75 15.75L3 12m0 0l3.75-3.75M3 12h18"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6.75 15.75L3 12m0 0l3.75-3.75M3 12h18"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
export function ArrowRightIcon({
|
||||
className,
|
||||
style,
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
style={style}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
style={style}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
export function ArrowsPointingInIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,38 +1,38 @@
|
||||
export function BattlefyIcon() {
|
||||
return (
|
||||
<svg
|
||||
version="1.1"
|
||||
id="Layer_1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 1152 1152"
|
||||
enableBackground="new 0 0 1152 1152"
|
||||
xmlSpace="preserve"
|
||||
>
|
||||
<path
|
||||
display="none"
|
||||
fill="#151B27"
|
||||
d="M1152,1099.3c0,29.4-23.8,52.7-53.2,52.7H52.8c-29.4,0-52.8-23.4-52.8-52.7V53.2
|
||||
return (
|
||||
<svg
|
||||
version="1.1"
|
||||
id="Layer_1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 1152 1152"
|
||||
enableBackground="new 0 0 1152 1152"
|
||||
xmlSpace="preserve"
|
||||
>
|
||||
<path
|
||||
display="none"
|
||||
fill="#151B27"
|
||||
d="M1152,1099.3c0,29.4-23.8,52.7-53.2,52.7H52.8c-29.4,0-52.8-23.4-52.8-52.7V53.2
|
||||
C0,23.8,23.4,0,52.8,0h1046.1c29.4,0,53.2,23.8,53.2,53.2V1099.3z"
|
||||
/>
|
||||
<g>
|
||||
<path
|
||||
fill="#DD4B5E"
|
||||
d="M222.5,399.8c1.5-18.1,79.5-154.8,99.7-166.1c20.2-11.3,211.5-22.9,211.5-22.9S368.9,346.1,331.5,555.9
|
||||
/>
|
||||
<g>
|
||||
<path
|
||||
fill="#DD4B5E"
|
||||
d="M222.5,399.8c1.5-18.1,79.5-154.8,99.7-166.1c20.2-11.3,211.5-22.9,211.5-22.9S368.9,346.1,331.5,555.9
|
||||
c-37.3,209.8-1.3,374.1-1.3,374.1S218.8,444.5,222.5,399.8z"
|
||||
/>
|
||||
<path
|
||||
fill="#DD4B5E"
|
||||
d="M467.6,753.3c0,0,242.8-431.4,522.1-542.6l-154,520L342.4,941.2c0,0,417.4-276.9,449.6-289.8l93.3-307.7
|
||||
/>
|
||||
<path
|
||||
fill="#DD4B5E"
|
||||
d="M467.6,753.3c0,0,242.8-431.4,522.1-542.6l-154,520L342.4,941.2c0,0,417.4-276.9,449.6-289.8l93.3-307.7
|
||||
C885.4,343.8,548.5,641.2,467.6,753.3z"
|
||||
/>
|
||||
<path
|
||||
fill="#DD4B5E"
|
||||
d="M672.9,400.4c0,0-203.8,193.6-257.9,351.2c0,0-26-108.8-19.5-133.2L672.9,400.4z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
/>
|
||||
<path
|
||||
fill="#DD4B5E"
|
||||
d="M672.9,400.4c0,0-203.8,193.6-257.9,351.2c0,0-26-108.8-19.5-133.2L672.9,400.4z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
export function BeakerIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23-.693L5 14.5m14.8.8l1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.309 48.309 0 0112 21c-2.773 0-5.491-.235-8.135-.687-1.718-.293-2.3-2.379-1.067-3.61L5 14.5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23-.693L5 14.5m14.8.8l1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.309 48.309 0 0112 21c-2.773 0-5.491-.235-8.135-.687-1.718-.293-2.3-2.379-1.067-3.61L5 14.5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
export function BeakerFilledIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10.5 3.798v5.02a3 3 0 0 1-.879 2.121l-2.377 2.377a9.845 9.845 0 0 1 5.091 1.013 8.315 8.315 0 0 0 5.713.636l.285-.071-3.954-3.955a3 3 0 0 1-.879-2.121v-5.02a23.614 23.614 0 0 0-3 0Zm4.5.138a.75.75 0 0 0 .093-1.495A24.837 24.837 0 0 0 12 2.25a25.048 25.048 0 0 0-3.093.191A.75.75 0 0 0 9 3.936v4.882a1.5 1.5 0 0 1-.44 1.06l-6.293 6.294c-1.62 1.621-.903 4.475 1.471 4.88 2.686.46 5.447.698 8.262.698 2.816 0 5.576-.239 8.262-.697 2.373-.406 3.092-3.26 1.47-4.881L15.44 9.879A1.5 1.5 0 0 1 15 8.818V3.936Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10.5 3.798v5.02a3 3 0 0 1-.879 2.121l-2.377 2.377a9.845 9.845 0 0 1 5.091 1.013 8.315 8.315 0 0 0 5.713.636l.285-.071-3.954-3.955a3 3 0 0 1-.879-2.121v-5.02a23.614 23.614 0 0 0-3 0Zm4.5.138a.75.75 0 0 0 .093-1.495A24.837 24.837 0 0 0 12 2.25a25.048 25.048 0 0 0-3.093.191A.75.75 0 0 0 9 3.936v4.882a1.5 1.5 0 0 1-.44 1.06l-6.293 6.294c-1.62 1.621-.903 4.475 1.471 4.88 2.686.46 5.447.698 8.262.698 2.816 0 5.576-.239 8.262-.697 2.373-.406 3.092-3.26 1.47-4.881L15.44 9.879A1.5 1.5 0 0 1 15 8.818V3.936Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
export function CalendarIcon({
|
||||
className,
|
||||
style,
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M6.75 2.25A.75.75 0 0 1 7.5 3v1.5h9V3A.75.75 0 0 1 18 3v1.5h.75a3 3 0 0 1 3 3v11.25a3 3 0 0 1-3 3H5.25a3 3 0 0 1-3-3V7.5a3 3 0 0 1 3-3H6V3a.75.75 0 0 1 .75-.75Zm13.5 9a1.5 1.5 0 0 0-1.5-1.5H5.25a1.5 1.5 0 0 0-1.5 1.5v7.5a1.5 1.5 0 0 0 1.5 1.5h13.5a1.5 1.5 0 0 0 1.5-1.5v-7.5Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M6.75 2.25A.75.75 0 0 1 7.5 3v1.5h9V3A.75.75 0 0 1 18 3v1.5h.75a3 3 0 0 1 3 3v11.25a3 3 0 0 1-3 3H5.25a3 3 0 0 1-3-3V7.5a3 3 0 0 1 3-3H6V3a.75.75 0 0 1 .75-.75Zm13.5 9a1.5 1.5 0 0 0-1.5-1.5H5.25a1.5 1.5 0 0 0-1.5 1.5v7.5a1.5 1.5 0 0 0 1.5 1.5h13.5a1.5 1.5 0 0 0 1.5-1.5v-7.5Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
export function ChartBarIcon({
|
||||
className,
|
||||
style,
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
export function ChatIcon({
|
||||
className,
|
||||
style,
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.848 2.771A49.144 49.144 0 0112 2.25c2.43 0 4.817.178 7.152.52 1.978.292 3.348 2.024 3.348 3.97v6.02c0 1.946-1.37 3.678-3.348 3.97-1.94.284-3.916.455-5.922.505a.39.39 0 00-.266.112L8.78 21.53A.75.75 0 017.5 21v-3.955a48.842 48.842 0 01-2.652-.316c-1.978-.29-3.348-2.024-3.348-3.97V6.741c0-1.946 1.37-3.68 3.348-3.97z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.848 2.771A49.144 49.144 0 0112 2.25c2.43 0 4.817.178 7.152.52 1.978.292 3.348 2.024 3.348 3.97v6.02c0 1.946-1.37 3.678-3.348 3.97-1.94.284-3.916.455-5.922.505a.39.39 0 00-.266.112L8.78 21.53A.75.75 0 017.5 21v-3.955a48.842 48.842 0 01-2.652-.316c-1.978-.29-3.348-2.024-3.348-3.97V6.741c0-1.946 1.37-3.68 3.348-3.97z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
export function CheckInIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M8.707 7.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l2-2a1 1 0 00-1.414-1.414L11 7.586V3a1 1 0 10-2 0v4.586l-.293-.293z" />
|
||||
<path d="M3 5a2 2 0 012-2h1a1 1 0 010 2H5v7h2l1 2h4l1-2h2V5h-1a1 1 0 110-2h1a2 2 0 012 2v10a2 2 0 01-2 2H5a2 2 0 01-2-2V5z" />
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M8.707 7.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l2-2a1 1 0 00-1.414-1.414L11 7.586V3a1 1 0 10-2 0v4.586l-.293-.293z" />
|
||||
<path d="M3 5a2 2 0 012-2h1a1 1 0 010 2H5v7h2l1 2h4l1-2h2V5h-1a1 1 0 110-2h1a2 2 0 012 2v10a2 2 0 01-2 2H5a2 2 0 01-2-2V5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
export function CheckmarkIcon({
|
||||
className,
|
||||
testId,
|
||||
onClick,
|
||||
className,
|
||||
testId,
|
||||
onClick,
|
||||
}: {
|
||||
className?: string;
|
||||
testId?: string;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
data-testid={testId}
|
||||
onClick={onClick}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
data-testid={testId}
|
||||
onClick={onClick}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
export function ClipboardIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M8.25 7.5V6.108c0-1.135.845-2.098 1.976-2.192.373-.03.748-.057 1.123-.08M15.75 18H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08M15.75 18.75v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5A3.375 3.375 0 006.375 7.5H5.25m11.9-3.664A2.251 2.251 0 0015 2.25h-1.5a2.251 2.251 0 00-2.15 1.586m5.8 0c.065.21.1.433.1.664v.75h-6V4.5c0-.231.035-.454.1-.664M6.75 7.5H4.875c-.621 0-1.125.504-1.125 1.125v12c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V16.5a9 9 0 00-9-9z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M8.25 7.5V6.108c0-1.135.845-2.098 1.976-2.192.373-.03.748-.057 1.123-.08M15.75 18H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08M15.75 18.75v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5A3.375 3.375 0 006.375 7.5H5.25m11.9-3.664A2.251 2.251 0 0015 2.25h-1.5a2.251 2.251 0 00-2.15 1.586m5.8 0c.065.21.1.433.1.664v.75h-6V4.5c0-.231.035-.454.1-.664M6.75 7.5H4.875c-.621 0-1.125.504-1.125 1.125v12c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V16.5a9 9 0 00-9-9z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
export function ClockIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
export function CrossIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
export function DiscordIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
strokeWidth="0"
|
||||
viewBox="0 0 640 512"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M524.531,69.836a1.5,1.5,0,0,0-.764-.7A485.065,485.065,0,0,0,404.081,32.03a1.816,1.816,0,0,0-1.923.91,337.461,337.461,0,0,0-14.9,30.6,447.848,447.848,0,0,0-134.426,0,309.541,309.541,0,0,0-15.135-30.6,1.89,1.89,0,0,0-1.924-.91A483.689,483.689,0,0,0,116.085,69.137a1.712,1.712,0,0,0-.788.676C39.068,183.651,18.186,294.69,28.43,404.354a2.016,2.016,0,0,0,.765,1.375A487.666,487.666,0,0,0,176.02,479.918a1.9,1.9,0,0,0,2.063-.676A348.2,348.2,0,0,0,208.12,430.4a1.86,1.86,0,0,0-1.019-2.588,321.173,321.173,0,0,1-45.868-21.853,1.885,1.885,0,0,1-.185-3.126c3.082-2.309,6.166-4.711,9.109-7.137a1.819,1.819,0,0,1,1.9-.256c96.229,43.917,200.41,43.917,295.5,0a1.812,1.812,0,0,1,1.924.233c2.944,2.426,6.027,4.851,9.132,7.16a1.884,1.884,0,0,1-.162,3.126,301.407,301.407,0,0,1-45.89,21.83,1.875,1.875,0,0,0-1,2.611,391.055,391.055,0,0,0,30.014,48.815,1.864,1.864,0,0,0,2.063.7A486.048,486.048,0,0,0,610.7,405.729a1.882,1.882,0,0,0,.765-1.352C623.729,277.594,590.933,167.465,524.531,69.836ZM222.491,337.58c-28.972,0-52.844-26.587-52.844-59.239S193.056,219.1,222.491,219.1c29.665,0,53.306,26.82,52.843,59.239C275.334,310.993,251.924,337.58,222.491,337.58Zm195.38,0c-28.971,0-52.843-26.587-52.843-59.239S388.437,219.1,417.871,219.1c29.667,0,53.307,26.82,52.844,59.239C470.715,310.993,447.538,337.58,417.871,337.58Z"></path>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
strokeWidth="0"
|
||||
viewBox="0 0 640 512"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M524.531,69.836a1.5,1.5,0,0,0-.764-.7A485.065,485.065,0,0,0,404.081,32.03a1.816,1.816,0,0,0-1.923.91,337.461,337.461,0,0,0-14.9,30.6,447.848,447.848,0,0,0-134.426,0,309.541,309.541,0,0,0-15.135-30.6,1.89,1.89,0,0,0-1.924-.91A483.689,483.689,0,0,0,116.085,69.137a1.712,1.712,0,0,0-.788.676C39.068,183.651,18.186,294.69,28.43,404.354a2.016,2.016,0,0,0,.765,1.375A487.666,487.666,0,0,0,176.02,479.918a1.9,1.9,0,0,0,2.063-.676A348.2,348.2,0,0,0,208.12,430.4a1.86,1.86,0,0,0-1.019-2.588,321.173,321.173,0,0,1-45.868-21.853,1.885,1.885,0,0,1-.185-3.126c3.082-2.309,6.166-4.711,9.109-7.137a1.819,1.819,0,0,1,1.9-.256c96.229,43.917,200.41,43.917,295.5,0a1.812,1.812,0,0,1,1.924.233c2.944,2.426,6.027,4.851,9.132,7.16a1.884,1.884,0,0,1-.162,3.126,301.407,301.407,0,0,1-45.89,21.83,1.875,1.875,0,0,0-1,2.611,391.055,391.055,0,0,0,30.014,48.815,1.864,1.864,0,0,0,2.063.7A486.048,486.048,0,0,0,610.7,405.729a1.882,1.882,0,0,0,.765-1.352C623.729,277.594,590.933,167.465,524.531,69.836ZM222.491,337.58c-28.972,0-52.844-26.587-52.844-59.239S193.056,219.1,222.491,219.1c29.665,0,53.306,26.82,52.843,59.239C275.334,310.993,251.924,337.58,222.491,337.58Zm195.38,0c-28.971,0-52.843-26.587-52.843-59.239S388.437,219.1,417.871,219.1c29.667,0,53.307,26.82,52.844,59.239C470.715,310.993,447.538,337.58,417.871,337.58Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
export function DownloadIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9.75v6.75m0 0l-3-3m3 3l3-3m-8.25 6a4.5 4.5 0 01-1.41-8.775 5.25 5.25 0 0110.233-2.33 3 3 0 013.758 3.848A3.752 3.752 0 0118 19.5H6.75z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9.75v6.75m0 0l-3-3m3 3l3-3m-8.25 6a4.5 4.5 0 01-1.41-8.775 5.25 5.25 0 0110.233-2.33 3 3 0 013.758 3.848A3.752 3.752 0 0118 19.5H6.75z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
export function EditIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
export function ErrorIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
export function EyeIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
export function EyeSlashIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
export function FilterIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
export function FireIcon({
|
||||
className,
|
||||
style,
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.362 5.214A8.252 8.252 0 0112 21 8.25 8.25 0 016.038 7.048 8.287 8.287 0 009 9.6a8.983 8.983 0 013.361-6.867 8.21 8.21 0 003 2.48z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 18a3.75 3.75 0 00.495-7.467 5.99 5.99 0 00-1.925 3.546 5.974 5.974 0 01-2.133-1A3.75 3.75 0 0012 18z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.362 5.214A8.252 8.252 0 0112 21 8.25 8.25 0 016.038 7.048 8.287 8.287 0 009 9.6a8.983 8.983 0 013.361-6.867 8.21 8.21 0 003 2.48z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 18a3.75 3.75 0 00.495-7.467 5.99 5.99 0 00-1.925 3.546 5.974 5.974 0 01-2.133-1A3.75 3.75 0 0012 18z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
export function GitHubIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
strokeWidth="0"
|
||||
version="1.1"
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M8 0.198c-4.418 0-8 3.582-8 8 0 3.535 2.292 6.533 5.471 7.591 0.4 0.074 0.547-0.174 0.547-0.385 0-0.191-0.008-0.821-0.011-1.489-2.226 0.484-2.695-0.944-2.695-0.944-0.364-0.925-0.888-1.171-0.888-1.171-0.726-0.497 0.055-0.486 0.055-0.486 0.803 0.056 1.226 0.824 1.226 0.824 0.714 1.223 1.872 0.869 2.328 0.665 0.072-0.517 0.279-0.87 0.508-1.070-1.777-0.202-3.645-0.888-3.645-3.954 0-0.873 0.313-1.587 0.824-2.147-0.083-0.202-0.357-1.015 0.077-2.117 0 0 0.672-0.215 2.201 0.82 0.638-0.177 1.322-0.266 2.002-0.269 0.68 0.003 1.365 0.092 2.004 0.269 1.527-1.035 2.198-0.82 2.198-0.82 0.435 1.102 0.162 1.916 0.079 2.117 0.513 0.56 0.823 1.274 0.823 2.147 0 3.073-1.872 3.749-3.653 3.947 0.287 0.248 0.543 0.735 0.543 1.481 0 1.070-0.009 1.932-0.009 2.195 0 0.213 0.144 0.462 0.55 0.384 3.177-1.059 5.466-4.057 5.466-7.59 0-4.418-3.582-8-8-8z"></path>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
strokeWidth="0"
|
||||
version="1.1"
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M8 0.198c-4.418 0-8 3.582-8 8 0 3.535 2.292 6.533 5.471 7.591 0.4 0.074 0.547-0.174 0.547-0.385 0-0.191-0.008-0.821-0.011-1.489-2.226 0.484-2.695-0.944-2.695-0.944-0.364-0.925-0.888-1.171-0.888-1.171-0.726-0.497 0.055-0.486 0.055-0.486 0.803 0.056 1.226 0.824 1.226 0.824 0.714 1.223 1.872 0.869 2.328 0.665 0.072-0.517 0.279-0.87 0.508-1.070-1.777-0.202-3.645-0.888-3.645-3.954 0-0.873 0.313-1.587 0.824-2.147-0.083-0.202-0.357-1.015 0.077-2.117 0 0 0.672-0.215 2.201 0.82 0.638-0.177 1.322-0.266 2.002-0.269 0.68 0.003 1.365 0.092 2.004 0.269 1.527-1.035 2.198-0.82 2.198-0.82 0.435 1.102 0.162 1.916 0.079 2.117 0.513 0.56 0.823 1.274 0.823 2.147 0 3.073-1.872 3.749-3.653 3.947 0.287 0.248 0.543 0.735 0.543 1.481 0 1.070-0.009 1.932-0.009 2.195 0 0.213 0.144 0.462 0.55 0.384 3.177-1.059 5.466-4.057 5.466-7.59 0-4.418-3.582-8-8-8z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
export function GlobeIcon({
|
||||
className,
|
||||
alt,
|
||||
size,
|
||||
className,
|
||||
alt,
|
||||
size,
|
||||
}: {
|
||||
className?: string;
|
||||
alt: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
alt: string;
|
||||
size?: number;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
role="img"
|
||||
aria-hidden={alt === ""}
|
||||
aria-label={alt !== "" ? alt : undefined}
|
||||
width={size}
|
||||
height={size}
|
||||
>
|
||||
{alt !== "" && <title>{alt}</title>}
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
role="img"
|
||||
aria-hidden={alt === ""}
|
||||
aria-label={alt !== "" ? alt : undefined}
|
||||
width={size}
|
||||
height={size}
|
||||
>
|
||||
{alt !== "" && <title>{alt}</title>}
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
export function HeartIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z" />
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
export function InfoIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm8.706-1.442c1.146-.573 2.437.463 2.126 1.706l-.709 2.836.042-.02a.75.75 0 01.67 1.34l-.04.022c-1.147.573-2.438-.463-2.127-1.706l.71-2.836-.042.02a.75.75 0 11-.671-1.34l.041-.022zM12 9a.75.75 0 100-1.5.75.75 0 000 1.5z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm8.706-1.442c1.146-.573 2.437.463 2.126 1.706l-.709 2.836.042-.02a.75.75 0 01.67 1.34l-.04.022c-1.147.573-2.438-.463-2.127-1.706l.71-2.836-.042.02a.75.75 0 11-.671-1.34l.041-.022zM12 9a.75.75 0 100-1.5.75.75 0 000 1.5z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
export function LinkIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M19.902 4.098a3.75 3.75 0 00-5.304 0l-4.5 4.5a3.75 3.75 0 001.035 6.037.75.75 0 01-.646 1.353 5.25 5.25 0 01-1.449-8.45l4.5-4.5a5.25 5.25 0 117.424 7.424l-1.757 1.757a.75.75 0 11-1.06-1.06l1.757-1.757a3.75 3.75 0 000-5.304zm-7.389 4.267a.75.75 0 011-.353 5.25 5.25 0 011.449 8.45l-4.5 4.5a5.25 5.25 0 11-7.424-7.424l1.757-1.757a.75.75 0 111.06 1.06l-1.757 1.757a3.75 3.75 0 105.304 5.304l4.5-4.5a3.75 3.75 0 00-1.035-6.037.75.75 0 01-.354-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M19.902 4.098a3.75 3.75 0 00-5.304 0l-4.5 4.5a3.75 3.75 0 001.035 6.037.75.75 0 01-.646 1.353 5.25 5.25 0 01-1.449-8.45l4.5-4.5a5.25 5.25 0 117.424 7.424l-1.757 1.757a.75.75 0 11-1.06-1.06l1.757-1.757a3.75 3.75 0 000-5.304zm-7.389 4.267a.75.75 0 011-.353 5.25 5.25 0 011.449 8.45l-4.5 4.5a5.25 5.25 0 11-7.424-7.424l1.757-1.757a.75.75 0 111.06 1.06l-1.757 1.757a3.75 3.75 0 105.304 5.304l4.5-4.5a3.75 3.75 0 00-1.035-6.037.75.75 0 01-.354-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
export function LockIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M12 1.5a5.25 5.25 0 0 0-5.25 5.25v3a3 3 0 0 0-3 3v6.75a3 3 0 0 0 3 3h10.5a3 3 0 0 0 3-3v-6.75a3 3 0 0 0-3-3v-3c0-2.9-2.35-5.25-5.25-5.25Zm3.75 8.25v-3a3.75 3.75 0 1 0-7.5 0v3h7.5Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M12 1.5a5.25 5.25 0 0 0-5.25 5.25v3a3 3 0 0 0-3 3v6.75a3 3 0 0 0 3 3h10.5a3 3 0 0 0 3-3v-6.75a3 3 0 0 0-3-3v-3c0-2.9-2.35-5.25-5.25-5.25Zm3.75 8.25v-3a3.75 3.75 0 1 0-7.5 0v3h7.5Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
export function LogInIcon({
|
||||
className,
|
||||
size,
|
||||
className,
|
||||
size,
|
||||
}: {
|
||||
className?: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
size?: number;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
width={size}
|
||||
height={size}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
width={size}
|
||||
height={size}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
export function LogOutIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
export function MapIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.161 2.58a1.875 1.875 0 011.678 0l4.993 2.498c.106.052.23.052.336 0l3.869-1.935A1.875 1.875 0 0121.75 4.82v12.485c0 .71-.401 1.36-1.037 1.677l-4.875 2.437a1.875 1.875 0 01-1.676 0l-4.994-2.497a.375.375 0 00-.336 0l-3.868 1.935A1.875 1.875 0 012.25 19.18V6.695c0-.71.401-1.36 1.036-1.677l4.875-2.437zM9 6a.75.75 0 01.75.75V15a.75.75 0 01-1.5 0V6.75A.75.75 0 019 6zm6.75 3a.75.75 0 00-1.5 0v8.25a.75.75 0 001.5 0V9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.161 2.58a1.875 1.875 0 011.678 0l4.993 2.498c.106.052.23.052.336 0l3.869-1.935A1.875 1.875 0 0121.75 4.82v12.485c0 .71-.401 1.36-1.037 1.677l-4.875 2.437a1.875 1.875 0 01-1.676 0l-4.994-2.497a.375.375 0 00-.336 0l-3.868 1.935A1.875 1.875 0 012.25 19.18V6.695c0-.71.401-1.36 1.036-1.677l4.875-2.437zM9 6a.75.75 0 01.75.75V15a.75.75 0 01-1.5 0V6.75A.75.75 0 019 6zm6.75 3a.75.75 0 00-1.5 0v8.25a.75.75 0 001.5 0V9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
export function MicrophoneIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 18.75a6 6 0 006-6v-1.5m-6 7.5a6 6 0 01-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 01-3-3V4.5a3 3 0 116 0v8.25a3 3 0 01-3 3z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 18.75a6 6 0 006-6v-1.5m-6 7.5a6 6 0 01-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 01-3-3V4.5a3 3 0 116 0v8.25a3 3 0 01-3 3z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
export function MicrophoneFilledIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path d="M8.25 4.5a3.75 3.75 0 117.5 0v8.25a3.75 3.75 0 11-7.5 0V4.5z" />
|
||||
<path d="M6 10.5a.75.75 0 01.75.75v1.5a5.25 5.25 0 1010.5 0v-1.5a.75.75 0 011.5 0v1.5a6.751 6.751 0 01-6 6.709v2.291h3a.75.75 0 010 1.5h-7.5a.75.75 0 010-1.5h3v-2.291a6.751 6.751 0 01-6-6.709v-1.5A.75.75 0 016 10.5z" />
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path d="M8.25 4.5a3.75 3.75 0 117.5 0v8.25a3.75 3.75 0 11-7.5 0V4.5z" />
|
||||
<path d="M6 10.5a.75.75 0 01.75.75v1.5a5.25 5.25 0 1010.5 0v-1.5a.75.75 0 011.5 0v1.5a6.751 6.751 0 01-6 6.709v2.291h3a.75.75 0 010 1.5h-7.5a.75.75 0 010-1.5h3v-2.291a6.751 6.751 0 01-6-6.709v-1.5A.75.75 0 016 10.5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
export function MinusIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M20 12H4"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M20 12H4"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
export function MoonIcon({
|
||||
className,
|
||||
alt,
|
||||
size,
|
||||
className,
|
||||
alt,
|
||||
size,
|
||||
}: {
|
||||
className?: string;
|
||||
alt: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
alt: string;
|
||||
size?: number;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
role="img"
|
||||
aria-hidden={alt === ""}
|
||||
aria-label={alt !== "" ? alt : undefined}
|
||||
width={size}
|
||||
height={size}
|
||||
>
|
||||
{alt !== "" && <title>{alt}</title>}
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
role="img"
|
||||
aria-hidden={alt === ""}
|
||||
aria-label={alt !== "" ? alt : undefined}
|
||||
width={size}
|
||||
height={size}
|
||||
>
|
||||
{alt !== "" && <title>{alt}</title>}
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
export function PatreonIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
strokeWidth="0"
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<title></title>
|
||||
<path d="M0 .48v23.04h4.22V.48zm15.385 0c-4.764 0-8.641 3.88-8.641 8.65 0 4.755 3.877 8.623 8.641 8.623 4.75 0 8.615-3.868 8.615-8.623C24 4.36 20.136.48 15.385.48z"></path>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
strokeWidth="0"
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<title />
|
||||
<path d="M0 .48v23.04h4.22V.48zm15.385 0c-4.764 0-8.641 3.88-8.641 8.65 0 4.755 3.877 8.623 8.641 8.623 4.75 0 8.615-3.868 8.615-8.623C24 4.36 20.136.48 15.385.48z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
export function PickIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.042 21.672 13.684 16.6m0 0-2.51 2.225.569-9.47 5.227 7.917-3.286-.672ZM12 2.25V4.5m5.834.166-1.591 1.591M20.25 10.5H18M7.757 14.743l-1.59 1.59M6 10.5H3.75m4.007-4.243-1.59-1.59"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.042 21.672 13.684 16.6m0 0-2.51 2.225.569-9.47 5.227 7.917-3.286-.672ZM12 2.25V4.5m5.834.166-1.591 1.591M20.25 10.5H18M7.757 14.743l-1.59 1.59M6 10.5H3.75m4.007-4.243-1.59-1.59"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
export function PlusIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={3}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 4.5v15m7.5-7.5h-15"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={3}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 4.5v15m7.5-7.5h-15"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
export function PuzzleIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path d="M11.25 5.337c0-.355-.186-.676-.401-.959a1.647 1.647 0 01-.349-1.003c0-1.036 1.007-1.875 2.25-1.875S15 2.34 15 3.375c0 .369-.128.713-.349 1.003-.215.283-.401.604-.401.959 0 .332.278.598.61.578 1.91-.114 3.79-.342 5.632-.676a.75.75 0 01.878.645 49.17 49.17 0 01.376 5.452.657.657 0 01-.66.664c-.354 0-.675-.186-.958-.401a1.647 1.647 0 00-1.003-.349c-1.035 0-1.875 1.007-1.875 2.25s.84 2.25 1.875 2.25c.369 0 .713-.128 1.003-.349.283-.215.604-.401.959-.401.31 0 .557.262.534.571a48.774 48.774 0 01-.595 4.845.75.75 0 01-.61.61c-1.82.317-3.673.533-5.555.642a.58.58 0 01-.611-.581c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.035-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959a.641.641 0 01-.658.643 49.118 49.118 0 01-4.708-.36.75.75 0 01-.645-.878c.293-1.614.504-3.257.629-4.924A.53.53 0 005.337 15c-.355 0-.676.186-.959.401-.29.221-.634.349-1.003.349-1.036 0-1.875-1.007-1.875-2.25s.84-2.25 1.875-2.25c.369 0 .713.128 1.003.349.283.215.604.401.959.401a.656.656 0 00.659-.663 47.703 47.703 0 00-.31-4.82.75.75 0 01.83-.832c1.343.155 2.703.254 4.077.294a.64.64 0 00.657-.642z" />
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path d="M11.25 5.337c0-.355-.186-.676-.401-.959a1.647 1.647 0 01-.349-1.003c0-1.036 1.007-1.875 2.25-1.875S15 2.34 15 3.375c0 .369-.128.713-.349 1.003-.215.283-.401.604-.401.959 0 .332.278.598.61.578 1.91-.114 3.79-.342 5.632-.676a.75.75 0 01.878.645 49.17 49.17 0 01.376 5.452.657.657 0 01-.66.664c-.354 0-.675-.186-.958-.401a1.647 1.647 0 00-1.003-.349c-1.035 0-1.875 1.007-1.875 2.25s.84 2.25 1.875 2.25c.369 0 .713-.128 1.003-.349.283-.215.604-.401.959-.401.31 0 .557.262.534.571a48.774 48.774 0 01-.595 4.845.75.75 0 01-.61.61c-1.82.317-3.673.533-5.555.642a.58.58 0 01-.611-.581c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.035-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959a.641.641 0 01-.658.643 49.118 49.118 0 01-4.708-.36.75.75 0 01-.645-.878c.293-1.614.504-3.257.629-4.924A.53.53 0 005.337 15c-.355 0-.676.186-.959.401-.29.221-.634.349-1.003.349-1.036 0-1.875-1.007-1.875-2.25s.84-2.25 1.875-2.25c.369 0 .713.128 1.003.349.283.215.604.401.959.401a.656.656 0 00.659-.663 47.703 47.703 0 00-.31-4.82.75.75 0 01.83-.832c1.343.155 2.703.254 4.077.294a.64.64 0 00.657-.642z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
export function RefreshIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
export function RefreshArrowsIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
export function ScaleIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M12 2.25a.75.75 0 01.75.75v.756a49.106 49.106 0 019.152 1 .75.75 0 01-.152 1.485h-1.918l2.474 10.124a.75.75 0 01-.375.84A6.723 6.723 0 0118.75 18a6.723 6.723 0 01-3.181-.795.75.75 0 01-.375-.84l2.474-10.124H12.75v13.28c1.293.076 2.534.343 3.697.776a.75.75 0 01-.262 1.453h-8.37a.75.75 0 01-.262-1.453c1.162-.433 2.404-.7 3.697-.775V6.24H6.332l2.474 10.124a.75.75 0 01-.375.84A6.723 6.723 0 015.25 18a6.723 6.723 0 01-3.181-.795.75.75 0 01-.375-.84L4.168 6.241H2.25a.75.75 0 01-.152-1.485 49.105 49.105 0 019.152-1V3a.75.75 0 01.75-.75zm4.878 13.543l1.872-7.662 1.872 7.662h-3.744zm-9.756 0L5.25 8.131l-1.872 7.662h3.744z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M12 2.25a.75.75 0 01.75.75v.756a49.106 49.106 0 019.152 1 .75.75 0 01-.152 1.485h-1.918l2.474 10.124a.75.75 0 01-.375.84A6.723 6.723 0 0118.75 18a6.723 6.723 0 01-3.181-.795.75.75 0 01-.375-.84l2.474-10.124H12.75v13.28c1.293.076 2.534.343 3.697.776a.75.75 0 01-.262 1.453h-8.37a.75.75 0 01-.262-1.453c1.162-.433 2.404-.7 3.697-.775V6.24H6.332l2.474 10.124a.75.75 0 01-.375.84A6.723 6.723 0 015.25 18a6.723 6.723 0 01-3.181-.795.75.75 0 01-.375-.84L4.168 6.241H2.25a.75.75 0 01-.152-1.485 49.105 49.105 0 019.152-1V3a.75.75 0 01.75-.75zm4.878 13.543l1.872-7.662 1.872 7.662h-3.744zm-9.756 0L5.25 8.131l-1.872 7.662h3.744z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
export function SearchIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
export function SpeakerIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M19.114 5.636a9 9 0 010 12.728M16.463 8.288a5.25 5.25 0 010 7.424M6.75 8.25l4.72-4.72a.75.75 0 011.28.53v15.88a.75.75 0 01-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.01 9.01 0 012.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M19.114 5.636a9 9 0 010 12.728M16.463 8.288a5.25 5.25 0 010 7.424M6.75 8.25l4.72-4.72a.75.75 0 011.28.53v15.88a.75.75 0 01-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.01 9.01 0 012.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
export function SpeakerFilledIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path d="M13.5 4.06c0-1.336-1.616-2.005-2.56-1.06l-4.5 4.5H4.508c-1.141 0-2.318.664-2.66 1.905A9.76 9.76 0 001.5 12c0 .898.121 1.768.35 2.595.341 1.24 1.518 1.905 2.659 1.905h1.93l4.5 4.5c.945.945 2.561.276 2.561-1.06V4.06zM18.584 5.106a.75.75 0 011.06 0c3.808 3.807 3.808 9.98 0 13.788a.75.75 0 11-1.06-1.06 8.25 8.25 0 000-11.668.75.75 0 010-1.06z" />
|
||||
<path d="M15.932 7.757a.75.75 0 011.061 0 6 6 0 010 8.486.75.75 0 01-1.06-1.061 4.5 4.5 0 000-6.364.75.75 0 010-1.06z" />
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path d="M13.5 4.06c0-1.336-1.616-2.005-2.56-1.06l-4.5 4.5H4.508c-1.141 0-2.318.664-2.66 1.905A9.76 9.76 0 001.5 12c0 .898.121 1.768.35 2.595.341 1.24 1.518 1.905 2.659 1.905h1.93l4.5 4.5c.945.945 2.561.276 2.561-1.06V4.06zM18.584 5.106a.75.75 0 011.06 0c3.808 3.807 3.808 9.98 0 13.788a.75.75 0 11-1.06-1.06 8.25 8.25 0 000-11.668.75.75 0 010-1.06z" />
|
||||
<path d="M15.932 7.757a.75.75 0 011.061 0 6 6 0 010 8.486.75.75 0 01-1.06-1.061 4.5 4.5 0 000-6.364.75.75 0 010-1.06z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
export function SpeakerXIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M17.25 9.75L19.5 12m0 0l2.25 2.25M19.5 12l2.25-2.25M19.5 12l-2.25 2.25m-10.5-6l4.72-4.72a.75.75 0 011.28.531V19.94a.75.75 0 01-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.506-1.938-1.354A9.01 9.01 0 012.25 12c0-.83.112-1.633.322-2.395C2.806 8.757 3.63 8.25 4.51 8.25H6.75z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M17.25 9.75L19.5 12m0 0l2.25 2.25M19.5 12l2.25-2.25M19.5 12l-2.25 2.25m-10.5-6l4.72-4.72a.75.75 0 011.28.531V19.94a.75.75 0 01-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.506-1.938-1.354A9.01 9.01 0 012.25 12c0-.83.112-1.633.322-2.395C2.806 8.757 3.63 8.25 4.51 8.25H6.75z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
export function SpeechBubbleIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user