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:
Kalle
2024-06-24 13:07:17 +03:00
committed by GitHub
parent 73e9fca492
commit fd48bced91
1070 changed files with 219485 additions and 224310 deletions

View File

@@ -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",
},
},
};

View File

@@ -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

View File

@@ -1 +0,0 @@
build

View File

@@ -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"]
}
]
}
}

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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}
/>
);
}

View File

@@ -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"} />
))}
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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
/>
);
}

View File

@@ -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} />;
}

View File

@@ -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>
);
}

View File

@@ -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"
/>
</>
);
}

View File

@@ -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>;
}

View File

@@ -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]);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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}`}
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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",
})}
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -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>
);
};

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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}
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>;
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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