mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-04-25 07:32:19 -05:00
Tier list maker feature (#2634)
This commit is contained in:
parent
40c5f96cfe
commit
187e1aa105
|
|
@ -4,14 +4,18 @@ import type { TierName } from "~/features/mmr/mmr-constants";
|
|||
import type {
|
||||
MainWeaponId,
|
||||
ModeShortWithSpecial,
|
||||
SpecialWeaponId,
|
||||
StageId,
|
||||
SubWeaponId,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import {
|
||||
mainWeaponImageUrl,
|
||||
modeImageUrl,
|
||||
outlinedFiveStarMainWeaponImageUrl,
|
||||
outlinedMainWeaponImageUrl,
|
||||
specialWeaponImageUrl,
|
||||
stageImageUrl,
|
||||
subWeaponImageUrl,
|
||||
TIER_PLUS_URL,
|
||||
tierImageUrl,
|
||||
} from "~/utils/urls";
|
||||
|
|
@ -145,6 +149,50 @@ export function StageImage({ stageId, testId, ...rest }: StageImageProps) {
|
|||
);
|
||||
}
|
||||
|
||||
type SubWeaponImageProps = {
|
||||
subWeaponId: SubWeaponId;
|
||||
} & Omit<ImageProps, "path" | "alt" | "title">;
|
||||
|
||||
export function SubWeaponImage({
|
||||
subWeaponId,
|
||||
testId,
|
||||
...rest
|
||||
}: SubWeaponImageProps) {
|
||||
const { t } = useTranslation(["weapons"]);
|
||||
|
||||
return (
|
||||
<Image
|
||||
{...rest}
|
||||
alt={t(`weapons:SUB_${subWeaponId}`)}
|
||||
title={t(`weapons:SUB_${subWeaponId}`)}
|
||||
testId={testId}
|
||||
path={subWeaponImageUrl(subWeaponId)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type SpecialWeaponImageProps = {
|
||||
specialWeaponId: SpecialWeaponId;
|
||||
} & Omit<ImageProps, "path" | "alt" | "title">;
|
||||
|
||||
export function SpecialWeaponImage({
|
||||
specialWeaponId,
|
||||
testId,
|
||||
...rest
|
||||
}: SpecialWeaponImageProps) {
|
||||
const { t } = useTranslation(["weapons"]);
|
||||
|
||||
return (
|
||||
<Image
|
||||
{...rest}
|
||||
alt={t(`weapons:SPECIAL_${specialWeaponId}`)}
|
||||
title={t(`weapons:SPECIAL_${specialWeaponId}`)}
|
||||
testId={testId}
|
||||
path={specialWeaponImageUrl(specialWeaponId)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type TierImageProps = {
|
||||
tier: { name: TierName; isPlus: boolean };
|
||||
} & Omit<ImageProps, "path" | "alt" | "title" | "size" | "height">;
|
||||
|
|
|
|||
17
app/components/icons/ChevronDown.tsx
Normal file
17
app/components/icons/ChevronDown.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
export function ChevronDownIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M19.5 8.25l-7.5 7.5-7.5-7.5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
17
app/components/icons/ChevronUp.tsx
Normal file
17
app/components/icons/ChevronUp.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
export function ChevronUpIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4.5 15.75l7.5-7.5 7.5 7.5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -96,6 +96,11 @@ export const navItems = [
|
|||
url: "t",
|
||||
prefetch: false,
|
||||
},
|
||||
{
|
||||
name: "tier-list-maker",
|
||||
url: "tier-list-maker",
|
||||
prefetch: false,
|
||||
},
|
||||
{
|
||||
name: "links",
|
||||
url: "links",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { getUser } from "~/features/auth/core/user.server";
|
||||
import { i18next } from "~/modules/i18n/i18next.server";
|
||||
import { weaponIdIsNotAlt } from "~/modules/in-game-lists/weapon-ids";
|
||||
import { weaponIdToType } from "~/modules/in-game-lists/weapon-ids";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { weaponNameSlugToId } from "~/utils/unslugify.server";
|
||||
import { mySlugify } from "~/utils/urls";
|
||||
|
|
@ -21,7 +21,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
|||
});
|
||||
const weaponId = weaponNameSlugToId(params.slug);
|
||||
|
||||
if (typeof weaponId !== "number" || !weaponIdIsNotAlt(weaponId)) {
|
||||
if (typeof weaponId !== "number" || weaponIdToType(weaponId) === "ALT_SKIN") {
|
||||
throw new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { useUser } from "~/features/auth/core/user";
|
|||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import {
|
||||
weaponCategories,
|
||||
weaponIdIsNotAlt,
|
||||
weaponIdToType,
|
||||
} from "~/modules/in-game-lists/weapon-ids";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import {
|
||||
|
|
@ -72,7 +72,7 @@ export default function BuildsPage() {
|
|||
</div>
|
||||
<div className={styles.categoryWeapons}>
|
||||
{(category.weaponIds as readonly MainWeaponId[])
|
||||
.filter(weaponIdIsNotAlt)
|
||||
.filter((weaponId) => weaponIdToType(weaponId) !== "ALT_SKIN")
|
||||
.map((weaponId, i) => (
|
||||
<React.Fragment key={weaponId}>
|
||||
{i !== 0 && weaponId % 10 === 0 ? (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
.item {
|
||||
cursor: move;
|
||||
touch-action: none;
|
||||
}
|
||||
39
app/features/tier-list-maker/components/DraggableItem.tsx
Normal file
39
app/features/tier-list-maker/components/DraggableItem.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import type { TierListItem } from "../tier-list-maker-schemas";
|
||||
import { tierListItemId } from "../tier-list-maker-utils";
|
||||
import styles from "./DraggableItem.module.css";
|
||||
import { TierListItemImage } from "./TierListItemImage";
|
||||
|
||||
interface DraggableItemProps {
|
||||
item: TierListItem;
|
||||
}
|
||||
|
||||
export function DraggableItem({ item }: DraggableItemProps) {
|
||||
const uniqueId = tierListItemId(item);
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: uniqueId,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.3 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} className={styles.item} style={style}>
|
||||
<div data-item-id={uniqueId} {...listeners} {...attributes}>
|
||||
<TierListItemImage item={item} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
.preview {
|
||||
opacity: 0.8;
|
||||
cursor: grabbing;
|
||||
}
|
||||
15
app/features/tier-list-maker/components/ItemDragPreview.tsx
Normal file
15
app/features/tier-list-maker/components/ItemDragPreview.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import type { TierListItem } from "../tier-list-maker-schemas";
|
||||
import styles from "./ItemDragPreview.module.css";
|
||||
import { TierListItemImage } from "./TierListItemImage";
|
||||
|
||||
interface ItemDragPreviewProps {
|
||||
item: TierListItem;
|
||||
}
|
||||
|
||||
export function ItemDragPreview({ item }: ItemDragPreviewProps) {
|
||||
return (
|
||||
<div className={styles.preview}>
|
||||
<TierListItemImage item={item} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
.pool {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s-2-5);
|
||||
}
|
||||
19
app/features/tier-list-maker/components/ItemPool.tsx
Normal file
19
app/features/tier-list-maker/components/ItemPool.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { useDroppable } from "@dnd-kit/core";
|
||||
import { useTierListState } from "../contexts/TierListContext";
|
||||
import { DraggableItem } from "./DraggableItem";
|
||||
import styles from "./ItemPool.module.css";
|
||||
|
||||
export function ItemPool() {
|
||||
const { availableItems } = useTierListState();
|
||||
const { setNodeRef } = useDroppable({
|
||||
id: "item-pool",
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} className={styles.pool}>
|
||||
{availableItems.map((item) => (
|
||||
<DraggableItem key={`${item.type}:${item.id}`} item={item} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
.imageWrapper img {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
.modeOverlay {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
right: 2px;
|
||||
background-color: var(--bg-lighter-solid);
|
||||
border-radius: 100%;
|
||||
padding: var(--s-1);
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import {
|
||||
ModeImage,
|
||||
SpecialWeaponImage,
|
||||
StageImage,
|
||||
SubWeaponImage,
|
||||
WeaponImage,
|
||||
} from "~/components/Image";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
import type { TierListItem } from "../tier-list-maker-schemas";
|
||||
import styles from "./TierListItemImage.module.css";
|
||||
|
||||
interface TierListItemImageProps {
|
||||
item: TierListItem;
|
||||
}
|
||||
|
||||
export function TierListItemImage({ item }: TierListItemImageProps) {
|
||||
switch (item.type) {
|
||||
case "main-weapon":
|
||||
return (
|
||||
<div className={styles.imageWrapper}>
|
||||
<WeaponImage weaponSplId={item.id} variant="badge" size={48} />
|
||||
</div>
|
||||
);
|
||||
case "sub-weapon":
|
||||
return (
|
||||
<div className={styles.imageWrapper}>
|
||||
<SubWeaponImage subWeaponId={item.id} size={48} />
|
||||
</div>
|
||||
);
|
||||
case "special-weapon":
|
||||
return (
|
||||
<div className={styles.imageWrapper}>
|
||||
<SpecialWeaponImage specialWeaponId={item.id} size={48} />
|
||||
</div>
|
||||
);
|
||||
case "stage":
|
||||
return (
|
||||
<div className={styles.imageWrapper}>
|
||||
<StageImage stageId={item.id} width={80} className="rounded-sm" />
|
||||
</div>
|
||||
);
|
||||
case "mode":
|
||||
return (
|
||||
<div className={styles.imageWrapper}>
|
||||
<ModeImage mode={item.id} width={48} height={48} />
|
||||
</div>
|
||||
);
|
||||
case "stage-mode": {
|
||||
const [stageIdStr, mode] = item.id.split("-");
|
||||
const stageId = Number(stageIdStr) as StageId;
|
||||
return (
|
||||
<div className={styles.imageWrapper}>
|
||||
<div className="relative">
|
||||
<StageImage stageId={stageId} width={80} className="rounded-sm" />
|
||||
<ModeImage
|
||||
mode={mode as ModeShort}
|
||||
width={24}
|
||||
height={24}
|
||||
className={styles.modeOverlay}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
121
app/features/tier-list-maker/components/TierRow.module.css
Normal file
121
app/features/tier-list-maker/components/TierRow.module.css
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
.container {
|
||||
display: flex;
|
||||
gap: var(--s-1);
|
||||
min-height: 68px;
|
||||
}
|
||||
|
||||
.tierLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 68px;
|
||||
padding: var(--s-2);
|
||||
font-size: var(--fonts-lg);
|
||||
font-weight: var(--bold);
|
||||
color: var(--text);
|
||||
border-radius: var(--rounded-sm);
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.tierLabel:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.tierName {
|
||||
font-size: var(--fonts-xl);
|
||||
color: var(--button-text);
|
||||
}
|
||||
|
||||
.popupContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-6);
|
||||
padding: var(--s-2);
|
||||
}
|
||||
|
||||
.nameInput {
|
||||
max-width: 125px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.targetZone {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s-1-5);
|
||||
padding: var(--s-2);
|
||||
background: var(--bg-lighter);
|
||||
border: 2px dashed transparent;
|
||||
border-radius: var(--rounded-sm) 0 0 var(--rounded-sm);
|
||||
align-content: flex-start;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.targetZoneOver {
|
||||
border-color: var(--theme-transparent);
|
||||
background: var(--bg-lightest);
|
||||
}
|
||||
|
||||
.targetZoneFullRadius {
|
||||
border-radius: var(--rounded-sm);
|
||||
}
|
||||
|
||||
.emptyMessage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
margin: auto;
|
||||
color: var(--text-lighter);
|
||||
font-size: var(--fonts-md);
|
||||
font-weight: var(--semi-bold);
|
||||
font-style: italic;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.arrowControls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1);
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.arrowButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: var(--s-1);
|
||||
background: var(--bg-lighter);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.arrowButtonUpper {
|
||||
border-radius: 0 var(--rounded-sm) 0 0;
|
||||
}
|
||||
|
||||
.arrowButtonLower {
|
||||
border-radius: 0 0 var(--rounded-sm) 0;
|
||||
}
|
||||
|
||||
.arrowButton:hover:not(:disabled) {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.arrowButton:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.arrowIcon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
stroke-width: 3;
|
||||
color: var(--text);
|
||||
}
|
||||
143
app/features/tier-list-maker/components/TierRow.tsx
Normal file
143
app/features/tier-list-maker/components/TierRow.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { useDroppable } from "@dnd-kit/core";
|
||||
import {
|
||||
horizontalListSortingStrategy,
|
||||
SortableContext,
|
||||
} from "@dnd-kit/sortable";
|
||||
import clsx from "clsx";
|
||||
import { Button } from "react-aria-components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { SendouPopover } from "~/components/elements/Popover";
|
||||
import { ChevronDownIcon } from "~/components/icons/ChevronDown";
|
||||
import { ChevronUpIcon } from "~/components/icons/ChevronUp";
|
||||
import { TrashIcon } from "~/components/icons/Trash";
|
||||
import { useTierListState } from "../contexts/TierListContext";
|
||||
import type { TierListMakerTier } from "../tier-list-maker-schemas";
|
||||
import { tierListItemId } from "../tier-list-maker-utils";
|
||||
import { DraggableItem } from "./DraggableItem";
|
||||
import styles from "./TierRow.module.css";
|
||||
|
||||
interface TierRowProps {
|
||||
tier: TierListMakerTier;
|
||||
}
|
||||
|
||||
export function TierRow({ tier }: TierRowProps) {
|
||||
const {
|
||||
state,
|
||||
getItemsInTier,
|
||||
handleRemoveTier,
|
||||
handleRenameTier,
|
||||
handleChangeTierColor,
|
||||
handleMoveTierUp,
|
||||
handleMoveTierDown,
|
||||
showTierHeaders,
|
||||
showArrowControls,
|
||||
} = useTierListState();
|
||||
|
||||
const items = getItemsInTier(tier.id);
|
||||
const { t } = useTranslation(["tier-list-maker", "common"]);
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: tier.id,
|
||||
});
|
||||
|
||||
const tierIndex = state.tiers.findIndex((t) => t.id === tier.id);
|
||||
const isFirstTier = tierIndex === 0;
|
||||
const isLastTier = tierIndex === state.tiers.length - 1;
|
||||
|
||||
const handleDelete = () => {
|
||||
handleRemoveTier(tier.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{showTierHeaders ? (
|
||||
<SendouPopover
|
||||
trigger={
|
||||
<Button
|
||||
className={styles.tierLabel}
|
||||
style={{ backgroundColor: tier.color }}
|
||||
>
|
||||
<span className={styles.tierName}>{tier.name}</span>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className={styles.popupContent}>
|
||||
<div className="stack horizontal justify-between">
|
||||
<span className="font-bold text-md">
|
||||
{t("tier-list-maker:editingTier")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="stack md">
|
||||
<input
|
||||
type="text"
|
||||
value={tier.name}
|
||||
onChange={(e) => handleRenameTier(tier.id, e.target.value)}
|
||||
className={styles.nameInput}
|
||||
maxLength={10}
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
value={tier.color}
|
||||
onChange={(e) => handleChangeTierColor(tier.id, e.target.value)}
|
||||
className="plain"
|
||||
/>
|
||||
</div>
|
||||
<div className="stack horizontal justify-end">
|
||||
<SendouButton
|
||||
onPress={handleDelete}
|
||||
variant="minimal-destructive"
|
||||
icon={<TrashIcon />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SendouPopover>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={clsx(styles.targetZone, {
|
||||
[styles.targetZoneOver]: isOver,
|
||||
[styles.targetZoneFullRadius]: !showArrowControls,
|
||||
})}
|
||||
>
|
||||
{items.length === 0 ? (
|
||||
<div className={styles.emptyMessage}>
|
||||
{t("tier-list-maker:dropItems")}
|
||||
</div>
|
||||
) : (
|
||||
<SortableContext
|
||||
items={items.map(tierListItemId)}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<DraggableItem key={tierListItemId(item)} item={item} />
|
||||
))}
|
||||
</SortableContext>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showArrowControls ? (
|
||||
<div className={styles.arrowControls}>
|
||||
<button
|
||||
className={clsx(styles.arrowButton, styles.arrowButtonUpper)}
|
||||
onClick={() => handleMoveTierUp(tier.id)}
|
||||
disabled={isFirstTier}
|
||||
type="button"
|
||||
aria-label="Move tier up"
|
||||
>
|
||||
<ChevronUpIcon className={styles.arrowIcon} />
|
||||
</button>
|
||||
<button
|
||||
className={clsx(styles.arrowButton, styles.arrowButtonLower)}
|
||||
onClick={() => handleMoveTierDown(tier.id)}
|
||||
disabled={isLastTier}
|
||||
type="button"
|
||||
aria-label="Move tier down"
|
||||
>
|
||||
<ChevronDownIcon className={styles.arrowIcon} />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
app/features/tier-list-maker/contexts/TierListContext.tsx
Normal file
25
app/features/tier-list-maker/contexts/TierListContext.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { createContext, useContext } from "react";
|
||||
import { useTierList } from "../hooks/useTierListState";
|
||||
|
||||
type TierListContextType = ReturnType<typeof useTierList>;
|
||||
|
||||
const TierListContext = createContext<TierListContextType | null>(null);
|
||||
|
||||
export function TierListProvider({ children }: { children: ReactNode }) {
|
||||
const state = useTierList();
|
||||
|
||||
return (
|
||||
<TierListContext.Provider value={state}>
|
||||
{children}
|
||||
</TierListContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTierListState() {
|
||||
const context = useContext(TierListContext);
|
||||
if (!context) {
|
||||
throw new Error("useTierListState must be used within TierListProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
480
app/features/tier-list-maker/hooks/useTierListState.ts
Normal file
480
app/features/tier-list-maker/hooks/useTierListState.ts
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
import type {
|
||||
DragEndEvent,
|
||||
DragOverEvent,
|
||||
DragStartEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { arrayMove } from "@dnd-kit/sortable";
|
||||
import { useSearchParams } from "@remix-run/react";
|
||||
import JSONCrush from "jsoncrush";
|
||||
import * as React from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { useSearchParamState } from "~/hooks/useSearchParamState";
|
||||
import { modesShort } from "~/modules/in-game-lists/modes";
|
||||
import { stageIds } from "~/modules/in-game-lists/stage-ids";
|
||||
import {
|
||||
mainWeaponIds,
|
||||
specialWeaponIds,
|
||||
subWeaponIds,
|
||||
weaponIdToType,
|
||||
} from "~/modules/in-game-lists/weapon-ids";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { DEFAULT_TIERS } from "../tier-list-maker-constants";
|
||||
import {
|
||||
type TierListItem,
|
||||
type TierListMakerTier,
|
||||
type TierListState,
|
||||
tierListItemTypeSchema,
|
||||
tierListStateSerializedSchema,
|
||||
} from "../tier-list-maker-schemas";
|
||||
import { getNextNthForItem } from "../tier-list-maker-utils";
|
||||
|
||||
export function useTierList() {
|
||||
const [itemType, setItemType] = useSearchParamState<TierListItem["type"]>({
|
||||
name: "type",
|
||||
defaultValue: "main-weapon",
|
||||
revive: (value) => {
|
||||
const parsed = tierListItemTypeSchema.safeParse(value);
|
||||
return parsed.success ? parsed.data : "main-weapon";
|
||||
},
|
||||
});
|
||||
|
||||
const { tiers, setTiers, persistTiersStateToParams } =
|
||||
useSearchParamTiersState();
|
||||
const [activeItem, setActiveItem] = React.useState<TierListItem | null>(null);
|
||||
|
||||
const [hideAltKits, setHideAltKits] = useSearchParamState({
|
||||
name: "hideAltKits",
|
||||
defaultValue: false,
|
||||
revive: (value) => value === "true",
|
||||
});
|
||||
|
||||
const [hideAltSkins, setHideAltSkins] = useSearchParamState({
|
||||
name: "hideAltSkins",
|
||||
defaultValue: false,
|
||||
revive: (value) => value === "true",
|
||||
});
|
||||
|
||||
const [canAddDuplicates, setCanAddDuplicates] = useSearchParamState({
|
||||
name: "canAddDuplicates",
|
||||
defaultValue: true,
|
||||
revive: (value) => value === "true",
|
||||
});
|
||||
|
||||
const [showTierHeaders, setShowTierHeaders] = useSearchParamState({
|
||||
name: "showTierHeaders",
|
||||
defaultValue: true,
|
||||
revive: (value) => value === "true",
|
||||
});
|
||||
|
||||
const [showArrowControls, setShowArrowControls] = useSearchParamState({
|
||||
name: "showArrowControls",
|
||||
defaultValue: true,
|
||||
revive: (value) => value === "true",
|
||||
});
|
||||
|
||||
const parseItemFromId = (id: string): TierListItem | null => {
|
||||
const [type, idStr, nth] = String(id).split(":");
|
||||
if (!type || !idStr) return null;
|
||||
|
||||
if (type === "mode" || type === "stage-mode") {
|
||||
return {
|
||||
type: type as TierListItem["type"],
|
||||
id: idStr,
|
||||
nth: nth ? Number(nth) : undefined,
|
||||
} as TierListItem;
|
||||
}
|
||||
|
||||
return {
|
||||
type: type as TierListItem["type"],
|
||||
id: Number(idStr),
|
||||
nth: nth ? Number(nth) : undefined,
|
||||
} as TierListItem;
|
||||
};
|
||||
|
||||
const findContainer = (item: TierListItem): string | null => {
|
||||
for (const [tierId, items] of tiers.tierItems.entries()) {
|
||||
if (
|
||||
items.some(
|
||||
(i) => i.id === item.id && i.type === item.type && i.nth === item.nth,
|
||||
)
|
||||
) {
|
||||
return tierId;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
const item = parseItemFromId(String(event.active.id));
|
||||
if (item) {
|
||||
setActiveItem(item);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (event: DragOverEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (!over) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeItem = parseItemFromId(String(active.id));
|
||||
if (!activeItem) return;
|
||||
|
||||
const overId = over.id;
|
||||
|
||||
const activeContainer = findContainer(activeItem);
|
||||
const overItem = parseItemFromId(String(overId));
|
||||
const overContainer = String(overId).startsWith("tier-")
|
||||
? String(overId)
|
||||
: overItem
|
||||
? findContainer(overItem)
|
||||
: null;
|
||||
|
||||
if (!overContainer || activeContainer === overContainer) {
|
||||
if (activeContainer && overContainer === activeContainer) {
|
||||
const newTierItems = new Map(tiers.tierItems);
|
||||
const containerItems = newTierItems.get(activeContainer) || [];
|
||||
const oldIndex = containerItems.findIndex(
|
||||
(item) =>
|
||||
item.id === activeItem.id &&
|
||||
item.type === activeItem.type &&
|
||||
item.nth === activeItem.nth,
|
||||
);
|
||||
const newIndex = overItem
|
||||
? containerItems.findIndex(
|
||||
(item) =>
|
||||
item.id === overItem.id &&
|
||||
item.type === overItem.type &&
|
||||
item.nth === overItem.nth,
|
||||
)
|
||||
: -1;
|
||||
|
||||
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
|
||||
newTierItems.set(
|
||||
activeContainer,
|
||||
arrayMove(containerItems, oldIndex, newIndex),
|
||||
);
|
||||
}
|
||||
|
||||
setTiers({
|
||||
...tiers,
|
||||
tierItems: newTierItems,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const newTierItems = new Map(tiers.tierItems);
|
||||
const activeItems = activeContainer
|
||||
? newTierItems.get(activeContainer) || []
|
||||
: [];
|
||||
const overItems = newTierItems.get(overContainer) || [];
|
||||
|
||||
const overIndex = overItem
|
||||
? overItems.findIndex(
|
||||
(item) =>
|
||||
item.id === overItem.id &&
|
||||
item.type === overItem.type &&
|
||||
item.nth === overItem.nth,
|
||||
)
|
||||
: overItems.length;
|
||||
|
||||
if (activeContainer) {
|
||||
newTierItems.set(
|
||||
activeContainer,
|
||||
activeItems.filter(
|
||||
(item) =>
|
||||
!(
|
||||
item.id === activeItem.id &&
|
||||
item.type === activeItem.type &&
|
||||
item.nth === activeItem.nth
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const newOverItems = [...overItems];
|
||||
newOverItems.splice(
|
||||
overIndex === -1 ? newOverItems.length : overIndex,
|
||||
0,
|
||||
activeItem,
|
||||
);
|
||||
newTierItems.set(overContainer, newOverItems);
|
||||
|
||||
setTiers({
|
||||
...tiers,
|
||||
tierItems: newTierItems,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
setActiveItem(null);
|
||||
|
||||
if (!over) {
|
||||
return;
|
||||
}
|
||||
|
||||
const item = parseItemFromId(String(active.id));
|
||||
if (!item) return;
|
||||
|
||||
const overId = over.id;
|
||||
|
||||
const overItem = parseItemFromId(String(overId));
|
||||
const isDroppedInPool =
|
||||
overId === "item-pool" || (overItem && !findContainer(overItem));
|
||||
|
||||
if (isDroppedInPool) {
|
||||
const newTierItems = new Map(tiers.tierItems);
|
||||
const currentContainer = findContainer(item);
|
||||
|
||||
if (currentContainer) {
|
||||
const containerItems = newTierItems.get(currentContainer) || [];
|
||||
newTierItems.set(
|
||||
currentContainer,
|
||||
containerItems.filter(
|
||||
(i) =>
|
||||
!(i.id === item.id && i.type === item.type && i.nth === item.nth),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
flushSync(() => {
|
||||
setTiers({
|
||||
...tiers,
|
||||
tierItems: newTierItems,
|
||||
});
|
||||
});
|
||||
}
|
||||
persistTiersStateToParams();
|
||||
};
|
||||
|
||||
const handleAddTier = () => {
|
||||
const newTier: TierListMakerTier = {
|
||||
id: `tier-${Date.now()}`,
|
||||
name: "New",
|
||||
color: "#888888",
|
||||
};
|
||||
|
||||
setTiers({
|
||||
...tiers,
|
||||
tiers: [...tiers.tiers, newTier],
|
||||
});
|
||||
persistTiersStateToParams();
|
||||
};
|
||||
|
||||
const handleRemoveTier = (tierId: string) => {
|
||||
const newTierItems = new Map(tiers.tierItems);
|
||||
newTierItems.delete(tierId);
|
||||
|
||||
setTiers({
|
||||
tiers: tiers.tiers.filter((tier) => tier.id !== tierId),
|
||||
tierItems: newTierItems,
|
||||
});
|
||||
persistTiersStateToParams();
|
||||
};
|
||||
|
||||
const handleRenameTier = (tierId: string, newName: string) => {
|
||||
setTiers({
|
||||
...tiers,
|
||||
tiers: tiers.tiers.map((tier) =>
|
||||
tier.id === tierId ? { ...tier, name: newName } : tier,
|
||||
),
|
||||
});
|
||||
persistTiersStateToParams();
|
||||
};
|
||||
|
||||
const handleChangeTierColor = (tierId: string, newColor: string) => {
|
||||
setTiers({
|
||||
...tiers,
|
||||
tiers: tiers.tiers.map((tier) =>
|
||||
tier.id === tierId ? { ...tier, color: newColor } : tier,
|
||||
),
|
||||
});
|
||||
persistTiersStateToParams();
|
||||
};
|
||||
|
||||
const getItemsInTier = (tierId: string): TierListItem[] => {
|
||||
return tiers.tierItems.get(tierId) || [];
|
||||
};
|
||||
|
||||
const getAllItemIdsForType = (type: TierListItem["type"]) => {
|
||||
switch (type) {
|
||||
case "main-weapon":
|
||||
return [...mainWeaponIds];
|
||||
case "sub-weapon":
|
||||
return [...subWeaponIds];
|
||||
case "special-weapon":
|
||||
return [...specialWeaponIds];
|
||||
case "stage":
|
||||
return [...stageIds];
|
||||
case "mode":
|
||||
return [...modesShort];
|
||||
case "stage-mode": {
|
||||
const combinations: string[] = [];
|
||||
for (const stageId of stageIds) {
|
||||
for (const mode of modesShort) {
|
||||
combinations.push(`${stageId}-${mode}`);
|
||||
}
|
||||
}
|
||||
return combinations;
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(type);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getAvailableItems = (): TierListItem[] => {
|
||||
const placedItems = new Set<string>();
|
||||
for (const items of tiers.tierItems.values()) {
|
||||
for (const item of items) {
|
||||
placedItems.add(`${item.type}:${item.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
const allItemIds = getAllItemIdsForType(itemType);
|
||||
return allItemIds
|
||||
.map((id) => ({ id, type: itemType }) as TierListItem)
|
||||
.flatMap((item) => {
|
||||
if (placedItems.has(`${item.type}:${item.id}`)) {
|
||||
if (!canAddDuplicates) return [];
|
||||
|
||||
return {
|
||||
...item,
|
||||
nth: getNextNthForItem(item, tiers),
|
||||
};
|
||||
}
|
||||
|
||||
if (item.type === "main-weapon" && typeof item.id === "number") {
|
||||
const weaponType = weaponIdToType(item.id);
|
||||
if (hideAltKits && weaponType === "ALT_KIT") return [];
|
||||
if (hideAltSkins && weaponType === "ALT_SKIN") return [];
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
};
|
||||
|
||||
const handleMoveTierUp = (tierId: string) => {
|
||||
const currentIndex = tiers.tiers.findIndex((tier) => tier.id === tierId);
|
||||
if (currentIndex <= 0) return;
|
||||
|
||||
const newTiers = [...tiers.tiers];
|
||||
[newTiers[currentIndex - 1], newTiers[currentIndex]] = [
|
||||
newTiers[currentIndex],
|
||||
newTiers[currentIndex - 1],
|
||||
];
|
||||
|
||||
setTiers({
|
||||
...tiers,
|
||||
tiers: newTiers,
|
||||
});
|
||||
};
|
||||
|
||||
const handleMoveTierDown = (tierId: string) => {
|
||||
const currentIndex = tiers.tiers.findIndex((tier) => tier.id === tierId);
|
||||
if (currentIndex === -1 || currentIndex >= tiers.tiers.length - 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newTiers = [...tiers.tiers];
|
||||
[newTiers[currentIndex], newTiers[currentIndex + 1]] = [
|
||||
newTiers[currentIndex + 1],
|
||||
newTiers[currentIndex],
|
||||
];
|
||||
|
||||
setTiers({
|
||||
...tiers,
|
||||
tiers: newTiers,
|
||||
});
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setTiers({
|
||||
tiers: DEFAULT_TIERS,
|
||||
tierItems: new Map(),
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
itemType,
|
||||
setItemType,
|
||||
state: tiers,
|
||||
activeItem,
|
||||
handleDragStart,
|
||||
handleDragOver,
|
||||
handleDragEnd,
|
||||
handleAddTier,
|
||||
handleRemoveTier,
|
||||
handleRenameTier,
|
||||
handleChangeTierColor,
|
||||
handleMoveTierUp,
|
||||
handleMoveTierDown,
|
||||
handleReset,
|
||||
getItemsInTier,
|
||||
availableItems: getAvailableItems(),
|
||||
hideAltKits,
|
||||
setHideAltKits,
|
||||
hideAltSkins,
|
||||
setHideAltSkins,
|
||||
canAddDuplicates,
|
||||
setCanAddDuplicates,
|
||||
showTierHeaders,
|
||||
setShowTierHeaders,
|
||||
showArrowControls,
|
||||
setShowArrowControls,
|
||||
};
|
||||
}
|
||||
|
||||
const TIER_SEARCH_PARAM_NAME = "state";
|
||||
|
||||
export function useSearchParamTiersState() {
|
||||
const [initialSearchParams] = useSearchParams();
|
||||
const [tiers, setTiers] = React.useState<TierListState>(() => {
|
||||
const param = initialSearchParams.get(TIER_SEARCH_PARAM_NAME);
|
||||
|
||||
try {
|
||||
if (param) {
|
||||
const uncrushed = JSONCrush.uncrush(param);
|
||||
|
||||
const parsed = tierListStateSerializedSchema.parse(
|
||||
JSON.parse(uncrushed),
|
||||
);
|
||||
|
||||
return {
|
||||
tiers: parsed.tiers,
|
||||
tierItems: new Map(parsed.tierItems),
|
||||
};
|
||||
}
|
||||
} catch {} // ignored on purpose
|
||||
|
||||
return {
|
||||
tiers: DEFAULT_TIERS,
|
||||
tierItems: new Map(),
|
||||
};
|
||||
});
|
||||
|
||||
const persistTiersStateToParams = () => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
|
||||
const serializedState = JSON.stringify({
|
||||
tiers: tiers.tiers,
|
||||
tierItems: Array.from(tiers.tierItems.entries()),
|
||||
});
|
||||
|
||||
searchParams.set(TIER_SEARCH_PARAM_NAME, JSONCrush.crush(serializedState));
|
||||
window.history.replaceState(
|
||||
{},
|
||||
"",
|
||||
`${window.location.pathname}?${String(searchParams)}`,
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
tiers,
|
||||
setTiers,
|
||||
persistTiersStateToParams,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
.container {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.tierList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
margin-bottom: var(--s-6);
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: var(--s-4);
|
||||
}
|
||||
257
app/features/tier-list-maker/routes/tier-list-maker.tsx
Normal file
257
app/features/tier-list-maker/routes/tier-list-maker.tsx
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
pointerWithin,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import { sortableKeyboardCoordinates } from "@dnd-kit/sortable";
|
||||
import type { MetaFunction } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { SendouPopover } from "~/components/elements/Popover";
|
||||
import { SendouSwitch } from "~/components/elements/Switch";
|
||||
import {
|
||||
SendouTab,
|
||||
SendouTabList,
|
||||
SendouTabPanel,
|
||||
SendouTabs,
|
||||
} from "~/components/elements/Tabs";
|
||||
import { PlusIcon } from "~/components/icons/Plus";
|
||||
import { RefreshIcon } from "~/components/icons/Refresh";
|
||||
import { Main } from "~/components/Main";
|
||||
import { Placeholder } from "~/components/Placeholder";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import { navIconUrl, TIER_LIST_MAKER_URL } from "~/utils/urls";
|
||||
import { ItemDragPreview } from "../components/ItemDragPreview";
|
||||
import { ItemPool } from "../components/ItemPool";
|
||||
import { TierRow } from "../components/TierRow";
|
||||
import {
|
||||
TierListProvider,
|
||||
useTierListState,
|
||||
} from "../contexts/TierListContext";
|
||||
import type { TierListItem } from "../tier-list-maker-schemas";
|
||||
import styles from "./tier-list-maker.module.css";
|
||||
|
||||
export const meta: MetaFunction = (args) => {
|
||||
return metaTags({
|
||||
title: "Tier List Maker",
|
||||
ogTitle: "Splatoon 3 tier list maker",
|
||||
description:
|
||||
"Generate Splatoon tier lists featuring main weapons, sub weapons, special weapons or stages.",
|
||||
location: args.location,
|
||||
});
|
||||
};
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: "tier-list-maker",
|
||||
breadcrumb: () => ({
|
||||
imgPath: navIconUrl("tier-list-maker"),
|
||||
href: TIER_LIST_MAKER_URL,
|
||||
type: "IMAGE",
|
||||
}),
|
||||
};
|
||||
|
||||
export default function TierListMakerPage() {
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
if (!isMounted)
|
||||
return (
|
||||
<Main bigger>
|
||||
<Placeholder />
|
||||
</Main>
|
||||
);
|
||||
|
||||
return (
|
||||
<TierListProvider>
|
||||
<TierListMakerContent />
|
||||
</TierListProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function TierListMakerContent() {
|
||||
const { t } = useTranslation(["tier-list-maker"]);
|
||||
const {
|
||||
itemType,
|
||||
setItemType,
|
||||
state,
|
||||
activeItem,
|
||||
handleDragStart,
|
||||
handleDragOver,
|
||||
handleDragEnd,
|
||||
handleAddTier,
|
||||
handleReset,
|
||||
hideAltKits,
|
||||
setHideAltKits,
|
||||
hideAltSkins,
|
||||
setHideAltSkins,
|
||||
canAddDuplicates,
|
||||
setCanAddDuplicates,
|
||||
showTierHeaders,
|
||||
setShowTierHeaders,
|
||||
showArrowControls,
|
||||
setShowArrowControls,
|
||||
} = useTierListState();
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<Main bigger className={clsx(styles.container, "stack lg")}>
|
||||
<div className={styles.header}>
|
||||
<SendouButton onPress={handleAddTier} size="small" icon={<PlusIcon />}>
|
||||
{t("tier-list-maker:addTier")}
|
||||
</SendouButton>
|
||||
<ResetPopover key={state.tierItems.size} handleReset={handleReset} />
|
||||
</div>
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={pointerWithin}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="stack">
|
||||
<div className={styles.tierList}>
|
||||
{state.tiers.map((tier) => (
|
||||
<TierRow key={tier.id} tier={tier} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="stack horizontal md flex-wrap">
|
||||
<SendouSwitch
|
||||
isSelected={canAddDuplicates}
|
||||
onChange={setCanAddDuplicates}
|
||||
size="small"
|
||||
>
|
||||
{t("tier-list-maker:allowDuplicates")}
|
||||
</SendouSwitch>
|
||||
<SendouSwitch
|
||||
isSelected={showTierHeaders}
|
||||
onChange={setShowTierHeaders}
|
||||
size="small"
|
||||
>
|
||||
{t("tier-list-maker:showTierHeaders")}
|
||||
</SendouSwitch>
|
||||
<SendouSwitch
|
||||
isSelected={showArrowControls}
|
||||
onChange={setShowArrowControls}
|
||||
size="small"
|
||||
>
|
||||
{t("tier-list-maker:showArrowControls")}
|
||||
</SendouSwitch>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SendouTabs
|
||||
selectedKey={itemType}
|
||||
onSelectionChange={(key) => setItemType(key as TierListItem["type"])}
|
||||
>
|
||||
<SendouTabList>
|
||||
<SendouTab id="main-weapon">
|
||||
{t("tier-list-maker:mainWeapons")}
|
||||
</SendouTab>
|
||||
<SendouTab id="sub-weapon">
|
||||
{t("tier-list-maker:subWeapons")}
|
||||
</SendouTab>
|
||||
<SendouTab id="special-weapon">
|
||||
{t("tier-list-maker:specialWeapons")}
|
||||
</SendouTab>
|
||||
<SendouTab id="stage">{t("tier-list-maker:stages")}</SendouTab>
|
||||
<SendouTab id="mode">{t("tier-list-maker:modes")}</SendouTab>
|
||||
<SendouTab id="stage-mode">
|
||||
{t("tier-list-maker:stageModes")}
|
||||
</SendouTab>
|
||||
</SendouTabList>
|
||||
|
||||
<SendouTabPanel id="main-weapon">
|
||||
<div className="stack md">
|
||||
<ItemPool />
|
||||
<div className={styles.filters}>
|
||||
<SendouSwitch
|
||||
isSelected={hideAltKits}
|
||||
onChange={setHideAltKits}
|
||||
>
|
||||
{t("tier-list-maker:hideAltKits")}
|
||||
</SendouSwitch>
|
||||
<SendouSwitch
|
||||
isSelected={hideAltSkins}
|
||||
onChange={setHideAltSkins}
|
||||
>
|
||||
{t("tier-list-maker:hideAltSkins")}
|
||||
</SendouSwitch>
|
||||
</div>
|
||||
</div>
|
||||
</SendouTabPanel>
|
||||
|
||||
<SendouTabPanel id="sub-weapon">
|
||||
<ItemPool />
|
||||
</SendouTabPanel>
|
||||
|
||||
<SendouTabPanel id="special-weapon">
|
||||
<ItemPool />
|
||||
</SendouTabPanel>
|
||||
|
||||
<SendouTabPanel id="stage">
|
||||
<ItemPool />
|
||||
</SendouTabPanel>
|
||||
|
||||
<SendouTabPanel id="mode">
|
||||
<ItemPool />
|
||||
</SendouTabPanel>
|
||||
|
||||
<SendouTabPanel id="stage-mode">
|
||||
<ItemPool />
|
||||
</SendouTabPanel>
|
||||
</SendouTabs>
|
||||
|
||||
<DragOverlay>
|
||||
{activeItem ? <ItemDragPreview item={activeItem} /> : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
function ResetPopover({ handleReset }: { handleReset: () => void }) {
|
||||
const { t } = useTranslation(["tier-list-maker", "common"]);
|
||||
|
||||
return (
|
||||
<SendouPopover
|
||||
trigger={
|
||||
<SendouButton
|
||||
size="small"
|
||||
icon={<RefreshIcon />}
|
||||
variant="minimal-destructive"
|
||||
>
|
||||
{t("common:actions.reset")}
|
||||
</SendouButton>
|
||||
}
|
||||
>
|
||||
<div className="stack sm items-center">
|
||||
<div>{t("tier-list-maker:resetConfirmation")}</div>
|
||||
<div className="stack horizontal sm">
|
||||
<SendouButton
|
||||
size="miniscule"
|
||||
variant="destructive"
|
||||
onPress={() => {
|
||||
handleReset();
|
||||
}}
|
||||
>
|
||||
{t("common:actions.reset")}
|
||||
</SendouButton>
|
||||
</div>
|
||||
</div>
|
||||
</SendouPopover>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import type { TierListMakerTier } from "./tier-list-maker-schemas";
|
||||
|
||||
export const DEFAULT_TIERS: TierListMakerTier[] = [
|
||||
{ id: "tier-x", name: "X", color: "#ff4655" },
|
||||
{ id: "tier-s", name: "S", color: "#ff8c42" },
|
||||
{ id: "tier-a", name: "A", color: "#ffd23f" },
|
||||
{ id: "tier-b", name: "B", color: "#bfe84d" },
|
||||
{ id: "tier-c", name: "C", color: "#5dbb63" },
|
||||
];
|
||||
75
app/features/tier-list-maker/tier-list-maker-schemas.ts
Normal file
75
app/features/tier-list-maker/tier-list-maker-schemas.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { z } from "zod/v4";
|
||||
import { assertType } from "~/utils/types";
|
||||
import {
|
||||
hexCode,
|
||||
modeShort,
|
||||
specialWeaponId,
|
||||
stageId,
|
||||
subWeaponId,
|
||||
weaponSplId,
|
||||
} from "~/utils/zod";
|
||||
|
||||
export const tierListItemTypeSchema = z.enum([
|
||||
"main-weapon",
|
||||
"sub-weapon",
|
||||
"special-weapon",
|
||||
"stage",
|
||||
"mode",
|
||||
"stage-mode",
|
||||
]);
|
||||
assertType<z.infer<typeof tierListItemTypeSchema>, TierListItem["type"]>();
|
||||
|
||||
const tierListItemSchema = z.union([
|
||||
z.object({
|
||||
id: weaponSplId,
|
||||
nth: z.number().optional(),
|
||||
type: z.literal("main-weapon"),
|
||||
}),
|
||||
z.object({
|
||||
id: subWeaponId,
|
||||
nth: z.number().optional(),
|
||||
type: z.literal("sub-weapon"),
|
||||
}),
|
||||
z.object({
|
||||
id: specialWeaponId,
|
||||
nth: z.number().optional(),
|
||||
type: z.literal("special-weapon"),
|
||||
}),
|
||||
z.object({
|
||||
id: stageId,
|
||||
nth: z.number().optional(),
|
||||
type: z.literal("stage"),
|
||||
}),
|
||||
z.object({
|
||||
id: modeShort,
|
||||
nth: z.number().optional(),
|
||||
type: z.literal("mode"),
|
||||
}),
|
||||
z.object({
|
||||
id: z.string(),
|
||||
nth: z.number().optional(),
|
||||
type: z.literal("stage-mode"),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type TierListItem = z.infer<typeof tierListItemSchema>;
|
||||
|
||||
const tierSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
color: hexCode,
|
||||
});
|
||||
|
||||
export type TierListMakerTier = z.infer<typeof tierSchema>;
|
||||
|
||||
type TierListItemSchemaType = z.infer<typeof tierListItemSchema>;
|
||||
|
||||
export const tierListStateSerializedSchema = z.object({
|
||||
tiers: z.array(tierSchema),
|
||||
tierItems: z.array(z.tuple([z.string(), z.array(tierListItemSchema)])),
|
||||
});
|
||||
|
||||
export type TierListState = {
|
||||
tiers: Array<TierListMakerTier>;
|
||||
tierItems: Map<string, TierListItemSchemaType[]>;
|
||||
};
|
||||
28
app/features/tier-list-maker/tier-list-maker-utils.ts
Normal file
28
app/features/tier-list-maker/tier-list-maker-utils.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import type { TierListItem, TierListState } from "./tier-list-maker-schemas";
|
||||
|
||||
export function tierListItemId(item: TierListItem) {
|
||||
return `${item.type}:${item.id}${item.nth ? `:${item.nth}` : ""}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the next nth value for a duplicate item in the tier list.
|
||||
* Searches through all tiers to find the maximum nth value for items
|
||||
* with the same id and type, then returns max + 1.
|
||||
*/
|
||||
export function getNextNthForItem(
|
||||
item: TierListItem,
|
||||
tiers: TierListState,
|
||||
): number {
|
||||
return (
|
||||
Array.from(tiers.tierItems.values()).reduce((maxNth, items) => {
|
||||
const matchingItems = items.filter(
|
||||
(i) => i.id === item.id && i.type === item.type,
|
||||
);
|
||||
const currentMax = Math.max(
|
||||
...matchingItems.map((i) => i.nth ?? 0),
|
||||
maxNth,
|
||||
);
|
||||
return currentMax;
|
||||
}, 0) + 1
|
||||
);
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import orgDa from "../../../locales/da/org.json";
|
|||
import qDa from "../../../locales/da/q.json";
|
||||
import scrimsDa from "../../../locales/da/scrims.json";
|
||||
import teamDa from "../../../locales/da/team.json";
|
||||
import tierListMakerDa from "../../../locales/da/tier-list-maker.json";
|
||||
import tournamentDa from "../../../locales/da/tournament.json";
|
||||
import userDa from "../../../locales/da/user.json";
|
||||
import vodsDa from "../../../locales/da/vods.json";
|
||||
|
|
@ -34,6 +35,7 @@ import orgDe from "../../../locales/de/org.json";
|
|||
import qDe from "../../../locales/de/q.json";
|
||||
import scrimsDe from "../../../locales/de/scrims.json";
|
||||
import teamDe from "../../../locales/de/team.json";
|
||||
import tierListMakerDe from "../../../locales/de/tier-list-maker.json";
|
||||
import tournamentDe from "../../../locales/de/tournament.json";
|
||||
import userDe from "../../../locales/de/user.json";
|
||||
import vodsDe from "../../../locales/de/vods.json";
|
||||
|
|
@ -54,6 +56,7 @@ import org from "../../../locales/en/org.json";
|
|||
import q from "../../../locales/en/q.json";
|
||||
import scrimsEn from "../../../locales/en/scrims.json";
|
||||
import team from "../../../locales/en/team.json";
|
||||
import tierListMaker from "../../../locales/en/tier-list-maker.json";
|
||||
import tournament from "../../../locales/en/tournament.json";
|
||||
import user from "../../../locales/en/user.json";
|
||||
import vods from "../../../locales/en/vods.json";
|
||||
|
|
@ -74,6 +77,7 @@ import orgEsEs from "../../../locales/es-ES/org.json";
|
|||
import qEsEs from "../../../locales/es-ES/q.json";
|
||||
import scrimsEsEs from "../../../locales/es-ES/scrims.json";
|
||||
import teamEsEs from "../../../locales/es-ES/team.json";
|
||||
import tierListMakerEsEs from "../../../locales/es-ES/tier-list-maker.json";
|
||||
import tournamentEsEs from "../../../locales/es-ES/tournament.json";
|
||||
import userEsEs from "../../../locales/es-ES/user.json";
|
||||
import vodsEsEs from "../../../locales/es-ES/vods.json";
|
||||
|
|
@ -94,6 +98,7 @@ import orgEsUs from "../../../locales/es-US/org.json";
|
|||
import qEsUs from "../../../locales/es-US/q.json";
|
||||
import scrimsEsUs from "../../../locales/es-US/scrims.json";
|
||||
import teamEsUs from "../../../locales/es-US/team.json";
|
||||
import tierListMakerEsUs from "../../../locales/es-US/tier-list-maker.json";
|
||||
import tournamentEsUs from "../../../locales/es-US/tournament.json";
|
||||
import userEsUs from "../../../locales/es-US/user.json";
|
||||
import vodsEsUs from "../../../locales/es-US/vods.json";
|
||||
|
|
@ -114,6 +119,7 @@ import orgFrCa from "../../../locales/fr-CA/org.json";
|
|||
import qFrCa from "../../../locales/fr-CA/q.json";
|
||||
import scrimsFrCa from "../../../locales/fr-CA/scrims.json";
|
||||
import teamFrCa from "../../../locales/fr-CA/team.json";
|
||||
import tierListMakerFrCa from "../../../locales/fr-CA/tier-list-maker.json";
|
||||
import tournamentFrCa from "../../../locales/fr-CA/tournament.json";
|
||||
import userFrCa from "../../../locales/fr-CA/user.json";
|
||||
import vodsFrCa from "../../../locales/fr-CA/vods.json";
|
||||
|
|
@ -134,6 +140,7 @@ import orgFrEu from "../../../locales/fr-EU/org.json";
|
|||
import qFrEu from "../../../locales/fr-EU/q.json";
|
||||
import scrimsFrEu from "../../../locales/fr-EU/scrims.json";
|
||||
import teamFrEu from "../../../locales/fr-EU/team.json";
|
||||
import tierListMakerFrEu from "../../../locales/fr-EU/tier-list-maker.json";
|
||||
import tournamentFrEu from "../../../locales/fr-EU/tournament.json";
|
||||
import userFrEu from "../../../locales/fr-EU/user.json";
|
||||
import vodsFrEu from "../../../locales/fr-EU/vods.json";
|
||||
|
|
@ -154,6 +161,7 @@ import orgHe from "../../../locales/he/org.json";
|
|||
import qHe from "../../../locales/he/q.json";
|
||||
import scrimsHe from "../../../locales/he/scrims.json";
|
||||
import teamHe from "../../../locales/he/team.json";
|
||||
import tierListMakerHe from "../../../locales/he/tier-list-maker.json";
|
||||
import tournamentHe from "../../../locales/he/tournament.json";
|
||||
import userHe from "../../../locales/he/user.json";
|
||||
import vodsHe from "../../../locales/he/vods.json";
|
||||
|
|
@ -174,6 +182,7 @@ import orgIt from "../../../locales/it/org.json";
|
|||
import qIt from "../../../locales/it/q.json";
|
||||
import scrimsIt from "../../../locales/it/scrims.json";
|
||||
import teamIt from "../../../locales/it/team.json";
|
||||
import tierListMakerIt from "../../../locales/it/tier-list-maker.json";
|
||||
import tournamentIt from "../../../locales/it/tournament.json";
|
||||
import userIt from "../../../locales/it/user.json";
|
||||
import vodsIt from "../../../locales/it/vods.json";
|
||||
|
|
@ -194,6 +203,7 @@ import orgJa from "../../../locales/ja/org.json";
|
|||
import qJa from "../../../locales/ja/q.json";
|
||||
import scrimsJa from "../../../locales/ja/scrims.json";
|
||||
import teamJa from "../../../locales/ja/team.json";
|
||||
import tierListMakerJa from "../../../locales/ja/tier-list-maker.json";
|
||||
import tournamentJa from "../../../locales/ja/tournament.json";
|
||||
import userJa from "../../../locales/ja/user.json";
|
||||
import vodsJa from "../../../locales/ja/vods.json";
|
||||
|
|
@ -214,6 +224,7 @@ import orgKo from "../../../locales/ko/org.json";
|
|||
import qKo from "../../../locales/ko/q.json";
|
||||
import scrimsKo from "../../../locales/ko/scrims.json";
|
||||
import teamKo from "../../../locales/ko/team.json";
|
||||
import tierListMakerKo from "../../../locales/ko/tier-list-maker.json";
|
||||
import tournamentKo from "../../../locales/ko/tournament.json";
|
||||
import userKo from "../../../locales/ko/user.json";
|
||||
import vodsKo from "../../../locales/ko/vods.json";
|
||||
|
|
@ -234,6 +245,7 @@ import orgNl from "../../../locales/nl/org.json";
|
|||
import qNl from "../../../locales/nl/q.json";
|
||||
import scrimsNl from "../../../locales/nl/scrims.json";
|
||||
import teamNl from "../../../locales/nl/team.json";
|
||||
import tierListMakerNl from "../../../locales/nl/tier-list-maker.json";
|
||||
import tournamentNl from "../../../locales/nl/tournament.json";
|
||||
import userNl from "../../../locales/nl/user.json";
|
||||
import vodsNl from "../../../locales/nl/vods.json";
|
||||
|
|
@ -254,6 +266,7 @@ import orgPl from "../../../locales/pl/org.json";
|
|||
import qPl from "../../../locales/pl/q.json";
|
||||
import scrimsPl from "../../../locales/pl/scrims.json";
|
||||
import teamPl from "../../../locales/pl/team.json";
|
||||
import tierListMakerPl from "../../../locales/pl/tier-list-maker.json";
|
||||
import tournamentPl from "../../../locales/pl/tournament.json";
|
||||
import userPl from "../../../locales/pl/user.json";
|
||||
import vodsPl from "../../../locales/pl/vods.json";
|
||||
|
|
@ -274,6 +287,7 @@ import orgPtBr from "../../../locales/pt-BR/org.json";
|
|||
import qPtBr from "../../../locales/pt-BR/q.json";
|
||||
import scrimsPtBr from "../../../locales/pt-BR/scrims.json";
|
||||
import teamPtBr from "../../../locales/pt-BR/team.json";
|
||||
import tierListMakerPtBr from "../../../locales/pt-BR/tier-list-maker.json";
|
||||
import tournamentPtBr from "../../../locales/pt-BR/tournament.json";
|
||||
import userPtBr from "../../../locales/pt-BR/user.json";
|
||||
import vodsPtBr from "../../../locales/pt-BR/vods.json";
|
||||
|
|
@ -294,6 +308,7 @@ import orgRu from "../../../locales/ru/org.json";
|
|||
import qRu from "../../../locales/ru/q.json";
|
||||
import scrimsRu from "../../../locales/ru/scrims.json";
|
||||
import teamRu from "../../../locales/ru/team.json";
|
||||
import tierListMakerRu from "../../../locales/ru/tier-list-maker.json";
|
||||
import tournamentRu from "../../../locales/ru/tournament.json";
|
||||
import userRu from "../../../locales/ru/user.json";
|
||||
import vodsRu from "../../../locales/ru/vods.json";
|
||||
|
|
@ -314,6 +329,7 @@ import orgZh from "../../../locales/zh/org.json";
|
|||
import qZh from "../../../locales/zh/q.json";
|
||||
import scrimsZh from "../../../locales/zh/scrims.json";
|
||||
import teamZh from "../../../locales/zh/team.json";
|
||||
import tierListMakerZh from "../../../locales/zh/tier-list-maker.json";
|
||||
import tournamentZh from "../../../locales/zh/tournament.json";
|
||||
import userZh from "../../../locales/zh/user.json";
|
||||
import vodsZh from "../../../locales/zh/vods.json";
|
||||
|
|
@ -340,6 +356,7 @@ export const resources = {
|
|||
badges: badgesEsUs,
|
||||
contributions: contributionsEsUs,
|
||||
team: teamEsUs,
|
||||
"tier-list-maker": tierListMakerEsUs,
|
||||
analyzer: analyzerEsUs,
|
||||
},
|
||||
en: {
|
||||
|
|
@ -362,6 +379,7 @@ export const resources = {
|
|||
badges: badges,
|
||||
contributions: contributions,
|
||||
team: team,
|
||||
"tier-list-maker": tierListMaker,
|
||||
analyzer: analyzer,
|
||||
},
|
||||
ko: {
|
||||
|
|
@ -384,6 +402,7 @@ export const resources = {
|
|||
badges: badgesKo,
|
||||
contributions: contributionsKo,
|
||||
team: teamKo,
|
||||
"tier-list-maker": tierListMakerKo,
|
||||
analyzer: analyzerKo,
|
||||
},
|
||||
de: {
|
||||
|
|
@ -406,6 +425,7 @@ export const resources = {
|
|||
badges: badgesDe,
|
||||
contributions: contributionsDe,
|
||||
team: teamDe,
|
||||
"tier-list-maker": tierListMakerDe,
|
||||
analyzer: analyzerDe,
|
||||
},
|
||||
nl: {
|
||||
|
|
@ -428,6 +448,7 @@ export const resources = {
|
|||
badges: badgesNl,
|
||||
contributions: contributionsNl,
|
||||
team: teamNl,
|
||||
"tier-list-maker": tierListMakerNl,
|
||||
analyzer: analyzerNl,
|
||||
},
|
||||
"pt-BR": {
|
||||
|
|
@ -450,6 +471,7 @@ export const resources = {
|
|||
badges: badgesPtBr,
|
||||
contributions: contributionsPtBr,
|
||||
team: teamPtBr,
|
||||
"tier-list-maker": tierListMakerPtBr,
|
||||
analyzer: analyzerPtBr,
|
||||
},
|
||||
zh: {
|
||||
|
|
@ -472,6 +494,7 @@ export const resources = {
|
|||
badges: badgesZh,
|
||||
contributions: contributionsZh,
|
||||
team: teamZh,
|
||||
"tier-list-maker": tierListMakerZh,
|
||||
analyzer: analyzerZh,
|
||||
},
|
||||
"fr-CA": {
|
||||
|
|
@ -494,6 +517,7 @@ export const resources = {
|
|||
badges: badgesFrCa,
|
||||
contributions: contributionsFrCa,
|
||||
team: teamFrCa,
|
||||
"tier-list-maker": tierListMakerFrCa,
|
||||
analyzer: analyzerFrCa,
|
||||
},
|
||||
ru: {
|
||||
|
|
@ -516,6 +540,7 @@ export const resources = {
|
|||
badges: badgesRu,
|
||||
contributions: contributionsRu,
|
||||
team: teamRu,
|
||||
"tier-list-maker": tierListMakerRu,
|
||||
analyzer: analyzerRu,
|
||||
},
|
||||
it: {
|
||||
|
|
@ -538,6 +563,7 @@ export const resources = {
|
|||
badges: badgesIt,
|
||||
contributions: contributionsIt,
|
||||
team: teamIt,
|
||||
"tier-list-maker": tierListMakerIt,
|
||||
analyzer: analyzerIt,
|
||||
},
|
||||
ja: {
|
||||
|
|
@ -560,6 +586,7 @@ export const resources = {
|
|||
badges: badgesJa,
|
||||
contributions: contributionsJa,
|
||||
team: teamJa,
|
||||
"tier-list-maker": tierListMakerJa,
|
||||
analyzer: analyzerJa,
|
||||
},
|
||||
da: {
|
||||
|
|
@ -582,6 +609,7 @@ export const resources = {
|
|||
badges: badgesDa,
|
||||
contributions: contributionsDa,
|
||||
team: teamDa,
|
||||
"tier-list-maker": tierListMakerDa,
|
||||
analyzer: analyzerDa,
|
||||
},
|
||||
"es-ES": {
|
||||
|
|
@ -604,6 +632,7 @@ export const resources = {
|
|||
badges: badgesEsEs,
|
||||
contributions: contributionsEsEs,
|
||||
team: teamEsEs,
|
||||
"tier-list-maker": tierListMakerEsEs,
|
||||
analyzer: analyzerEsEs,
|
||||
},
|
||||
he: {
|
||||
|
|
@ -626,6 +655,7 @@ export const resources = {
|
|||
badges: badgesHe,
|
||||
contributions: contributionsHe,
|
||||
team: teamHe,
|
||||
"tier-list-maker": tierListMakerHe,
|
||||
analyzer: analyzerHe,
|
||||
},
|
||||
"fr-EU": {
|
||||
|
|
@ -648,6 +678,7 @@ export const resources = {
|
|||
badges: badgesFrEu,
|
||||
contributions: contributionsFrEu,
|
||||
team: teamFrEu,
|
||||
"tier-list-maker": tierListMakerFrEu,
|
||||
analyzer: analyzerFrEu,
|
||||
},
|
||||
pl: {
|
||||
|
|
@ -670,6 +701,7 @@ export const resources = {
|
|||
badges: badgesPl,
|
||||
contributions: contributionsPl,
|
||||
team: teamPl,
|
||||
"tier-list-maker": tierListMakerPl,
|
||||
analyzer: analyzerPl,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -129,9 +129,27 @@ export function weaponIdToArrayWithAlts(weaponId: MainWeaponId) {
|
|||
return [weaponId];
|
||||
}
|
||||
|
||||
const altWeaponIds = new Set(altWeaponIdToId.keys());
|
||||
export const weaponIdIsNotAlt = (weaponId: MainWeaponId) =>
|
||||
!altWeaponIds.has(weaponId);
|
||||
/**
|
||||
* Determines the type of weapon based on its ID
|
||||
*
|
||||
* @returns "ALT_SKIN" if the weapon is an alternate skin, "BASE" if it's a base weapon, or "ALT_KIT" if it's an alternate kit
|
||||
*
|
||||
* @example
|
||||
* // Splattershot is a base weapon
|
||||
* weaponIdToType(40); // -> "BASE"
|
||||
*
|
||||
* // Tentatek Splattershot is an alternate kit
|
||||
* weaponIdToType(41); // -> "ALT_KIT"
|
||||
*
|
||||
* // Hero Shot Replica is an alternate skin
|
||||
* weaponIdToType(45); // -> "ALT_SKIN"
|
||||
*/
|
||||
export const weaponIdToType = (weaponId: MainWeaponId) => {
|
||||
if (altWeaponIdToId.has(weaponId)) return "ALT_SKIN";
|
||||
if (weaponId === weaponIdToBaseWeaponId(weaponId)) return "BASE";
|
||||
|
||||
return "ALT_KIT";
|
||||
};
|
||||
|
||||
/** Returns true if the weapon ID has alternate skins
|
||||
*
|
||||
|
|
|
|||
|
|
@ -265,6 +265,7 @@ export const namespaceJsonsToPreloadObj: Record<Namespace, boolean> = {
|
|||
scrims: true,
|
||||
tournament: true,
|
||||
team: true,
|
||||
"tier-list-maker": true,
|
||||
vods: true,
|
||||
art: true,
|
||||
q: true,
|
||||
|
|
|
|||
|
|
@ -188,6 +188,11 @@ export default [
|
|||
|
||||
route("/tiers", "features/sendouq/routes/tiers.tsx"),
|
||||
|
||||
route(
|
||||
"/tier-list-maker",
|
||||
"features/tier-list-maker/routes/tier-list-maker.tsx",
|
||||
),
|
||||
|
||||
...prefix("/lfg", [
|
||||
index("features/lfg/routes/lfg.tsx"),
|
||||
route("new", "features/lfg/routes/lfg.new.tsx"),
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ export const STOP_IMPERSONATING_URL = "/auth/impersonate/stop";
|
|||
export const SEED_URL = "/seed";
|
||||
export const PLANNER_URL = "/plans";
|
||||
export const MAPS_URL = "/maps";
|
||||
export const TIER_LIST_MAKER_URL = "/tier-list-maker";
|
||||
export const ANALYZER_URL = "/analyzer";
|
||||
export const OBJECT_DAMAGE_CALCULATOR_URL = "/object-damage-calculator";
|
||||
export const VODS_PAGE = "/vods";
|
||||
|
|
|
|||
|
|
@ -6,7 +6,11 @@ import {
|
|||
type abilitiesShort,
|
||||
} from "~/modules/in-game-lists/abilities";
|
||||
import { stageIds } from "~/modules/in-game-lists/stage-ids";
|
||||
import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids";
|
||||
import {
|
||||
mainWeaponIds,
|
||||
specialWeaponIds,
|
||||
subWeaponIds,
|
||||
} from "~/modules/in-game-lists/weapon-ids";
|
||||
import { FRIEND_CODE_REGEXP } from "../features/sendouq/q-constants";
|
||||
import { SHORT_NANOID_LENGTH } from "./id";
|
||||
import type { Unpacked } from "./types";
|
||||
|
|
@ -108,6 +112,10 @@ export const weaponSplId = z.preprocess(
|
|||
numericEnum(mainWeaponIds),
|
||||
);
|
||||
|
||||
export const subWeaponId = numericEnum(subWeaponIds);
|
||||
|
||||
export const specialWeaponId = numericEnum(specialWeaponIds);
|
||||
|
||||
export const qWeapon = z.object({
|
||||
weaponSplId,
|
||||
isFavorite: z.union([z.literal(0), z.literal(1)]),
|
||||
|
|
|
|||
63
e2e/tier-list-maker.spec.ts
Normal file
63
e2e/tier-list-maker.spec.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { expect, type Locator, type Page, test } from "@playwright/test";
|
||||
import { navigate } from "~/utils/playwright";
|
||||
import { TIER_LIST_MAKER_URL } from "~/utils/urls";
|
||||
|
||||
test.describe("Tier List Maker", () => {
|
||||
test("toggles work, items can be dragged, and state persists after reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
await navigate({ page, url: TIER_LIST_MAKER_URL });
|
||||
|
||||
const emptyTiers = page.getByText("Drop items here");
|
||||
|
||||
await expect(emptyTiers).toHaveCount(5);
|
||||
|
||||
// Test that toggles are clickable (just verify they work)
|
||||
await page.getByText("Allow duplicates").click();
|
||||
await page.getByText("Show tier headers").click();
|
||||
await page.getByText("Show arrow controls").click();
|
||||
await page.getByText("Hide alt kits").click();
|
||||
await page.getByText("Hide alt skins").click();
|
||||
|
||||
// Drag first weapon to first tier
|
||||
const firstWeapon = page.locator('[data-item-id^="main-weapon:"]').first();
|
||||
await dragItemToTier(page, firstWeapon);
|
||||
|
||||
// Drag second weapon to second tier
|
||||
const secondWeapon = page.locator('[data-item-id^="main-weapon:"]').first();
|
||||
await dragItemToTier(page, secondWeapon);
|
||||
|
||||
// Switch to Stages tab and drag a stage
|
||||
await page.getByText("Stages").click();
|
||||
|
||||
const firstStage = page.locator('[data-item-id^="stage:"]').first();
|
||||
await expect(firstStage).toBeVisible();
|
||||
await dragItemToTier(page, firstStage);
|
||||
|
||||
// Wait for state to settle
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Reload the page
|
||||
await page.reload();
|
||||
|
||||
// Verify items persisted in the tier list (not in the pool)
|
||||
// Should have fewer than 5 "Drop items here" (meaning at least 1 tier is filled)
|
||||
await expect(emptyTiers).toHaveCount(3);
|
||||
});
|
||||
});
|
||||
|
||||
async function dragItemToTier(page: Page, item: Locator) {
|
||||
await item.hover();
|
||||
await page.mouse.down();
|
||||
const tier = page.getByText("Drop items here").last();
|
||||
const tierBox = await tier.boundingBox();
|
||||
if (tierBox) {
|
||||
await page.mouse.move(
|
||||
tierBox.x + tierBox.width / 2,
|
||||
tierBox.y + tierBox.height / 2,
|
||||
{ steps: 10 },
|
||||
);
|
||||
}
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@
|
|||
"pages.lfg": "",
|
||||
"pages.scrims": "",
|
||||
"pages.settings": "",
|
||||
"pages.tier-list-maker": "",
|
||||
"pages.luti": "",
|
||||
"header.profile": "Profil",
|
||||
"header.logout": "Log ud",
|
||||
|
|
|
|||
17
locales/da/tier-list-maker.json
Normal file
17
locales/da/tier-list-maker.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"addTier": "",
|
||||
"mainWeapons": "",
|
||||
"subWeapons": "",
|
||||
"specialWeapons": "",
|
||||
"stages": "",
|
||||
"modes": "",
|
||||
"stageModes": "",
|
||||
"dropItems": "",
|
||||
"editingTier": "",
|
||||
"hideAltKits": "",
|
||||
"hideAltSkins": "",
|
||||
"resetConfirmation": "",
|
||||
"allowDuplicates": "",
|
||||
"showTierHeaders": "",
|
||||
"showArrowControls": ""
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@
|
|||
"pages.lfg": "",
|
||||
"pages.scrims": "",
|
||||
"pages.settings": "",
|
||||
"pages.tier-list-maker": "",
|
||||
"pages.luti": "",
|
||||
"header.profile": "Profil",
|
||||
"header.logout": "Ausloggen",
|
||||
|
|
|
|||
17
locales/de/tier-list-maker.json
Normal file
17
locales/de/tier-list-maker.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"addTier": "",
|
||||
"mainWeapons": "",
|
||||
"subWeapons": "",
|
||||
"specialWeapons": "",
|
||||
"stages": "",
|
||||
"modes": "",
|
||||
"stageModes": "",
|
||||
"dropItems": "",
|
||||
"editingTier": "",
|
||||
"hideAltKits": "",
|
||||
"hideAltSkins": "",
|
||||
"resetConfirmation": "",
|
||||
"allowDuplicates": "",
|
||||
"showTierHeaders": "",
|
||||
"showArrowControls": ""
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@
|
|||
"pages.lfg": "LFG",
|
||||
"pages.scrims": "Scrims",
|
||||
"pages.settings": "Settings",
|
||||
"pages.tier-list-maker": "Tier List Maker",
|
||||
"pages.luti": "LUTI",
|
||||
"header.profile": "Profile",
|
||||
"header.logout": "Log out",
|
||||
|
|
|
|||
17
locales/en/tier-list-maker.json
Normal file
17
locales/en/tier-list-maker.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"addTier": "Add tier",
|
||||
"mainWeapons": "Main Weapons",
|
||||
"subWeapons": "Sub Weapons",
|
||||
"specialWeapons": "Special Weapons",
|
||||
"stages": "Stages",
|
||||
"modes": "Modes",
|
||||
"stageModes": "Stage + Modes",
|
||||
"dropItems": "Drop items here",
|
||||
"editingTier": "Editing tier",
|
||||
"hideAltKits": "Hide alt kits",
|
||||
"hideAltSkins": "Hide alt skins",
|
||||
"resetConfirmation": "Are you sure you want to reset the tier list? This will remove all items and restore default tiers.",
|
||||
"allowDuplicates": "Allow duplicates",
|
||||
"showTierHeaders": "Show tier headers",
|
||||
"showArrowControls": "Show arrow controls"
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@
|
|||
"pages.lfg": "",
|
||||
"pages.scrims": "",
|
||||
"pages.settings": "",
|
||||
"pages.tier-list-maker": "",
|
||||
"pages.luti": "",
|
||||
"header.profile": "Perfil",
|
||||
"header.logout": "Cerrar sesión",
|
||||
|
|
|
|||
17
locales/es-ES/tier-list-maker.json
Normal file
17
locales/es-ES/tier-list-maker.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"addTier": "",
|
||||
"mainWeapons": "",
|
||||
"subWeapons": "",
|
||||
"specialWeapons": "",
|
||||
"stages": "",
|
||||
"modes": "",
|
||||
"stageModes": "",
|
||||
"dropItems": "",
|
||||
"editingTier": "",
|
||||
"hideAltKits": "",
|
||||
"hideAltSkins": "",
|
||||
"resetConfirmation": "",
|
||||
"allowDuplicates": "",
|
||||
"showTierHeaders": "",
|
||||
"showArrowControls": ""
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@
|
|||
"pages.lfg": "Buscando grupo",
|
||||
"pages.scrims": "",
|
||||
"pages.settings": "",
|
||||
"pages.tier-list-maker": "",
|
||||
"pages.luti": "",
|
||||
"header.profile": "Perfil",
|
||||
"header.logout": "Cerrar sesión",
|
||||
|
|
|
|||
17
locales/es-US/tier-list-maker.json
Normal file
17
locales/es-US/tier-list-maker.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"addTier": "",
|
||||
"mainWeapons": "",
|
||||
"subWeapons": "",
|
||||
"specialWeapons": "",
|
||||
"stages": "",
|
||||
"modes": "",
|
||||
"stageModes": "",
|
||||
"dropItems": "",
|
||||
"editingTier": "",
|
||||
"hideAltKits": "",
|
||||
"hideAltSkins": "",
|
||||
"resetConfirmation": "",
|
||||
"allowDuplicates": "",
|
||||
"showTierHeaders": "",
|
||||
"showArrowControls": ""
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@
|
|||
"pages.lfg": "",
|
||||
"pages.scrims": "",
|
||||
"pages.settings": "",
|
||||
"pages.tier-list-maker": "",
|
||||
"pages.luti": "",
|
||||
"header.profile": "Profil",
|
||||
"header.logout": "Déconnexion",
|
||||
|
|
|
|||
17
locales/fr-CA/tier-list-maker.json
Normal file
17
locales/fr-CA/tier-list-maker.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"addTier": "",
|
||||
"mainWeapons": "",
|
||||
"subWeapons": "",
|
||||
"specialWeapons": "",
|
||||
"stages": "",
|
||||
"modes": "",
|
||||
"stageModes": "",
|
||||
"dropItems": "",
|
||||
"editingTier": "",
|
||||
"hideAltKits": "",
|
||||
"hideAltSkins": "",
|
||||
"resetConfirmation": "",
|
||||
"allowDuplicates": "",
|
||||
"showTierHeaders": "",
|
||||
"showArrowControls": ""
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@
|
|||
"pages.lfg": "LFG",
|
||||
"pages.scrims": "Scrims",
|
||||
"pages.settings": "Paramètres",
|
||||
"pages.tier-list-maker": "",
|
||||
"pages.luti": "LUTI",
|
||||
"header.profile": "Profil",
|
||||
"header.logout": "Déconnexion",
|
||||
|
|
|
|||
17
locales/fr-EU/tier-list-maker.json
Normal file
17
locales/fr-EU/tier-list-maker.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"addTier": "",
|
||||
"mainWeapons": "",
|
||||
"subWeapons": "",
|
||||
"specialWeapons": "",
|
||||
"stages": "",
|
||||
"modes": "",
|
||||
"stageModes": "",
|
||||
"dropItems": "",
|
||||
"editingTier": "",
|
||||
"hideAltKits": "",
|
||||
"hideAltSkins": "",
|
||||
"resetConfirmation": "",
|
||||
"allowDuplicates": "",
|
||||
"showTierHeaders": "",
|
||||
"showArrowControls": ""
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@
|
|||
"pages.lfg": "",
|
||||
"pages.scrims": "",
|
||||
"pages.settings": "",
|
||||
"pages.tier-list-maker": "",
|
||||
"pages.luti": "",
|
||||
"header.profile": "פרופיל",
|
||||
"header.logout": "התנתקות",
|
||||
|
|
|
|||
17
locales/he/tier-list-maker.json
Normal file
17
locales/he/tier-list-maker.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"addTier": "",
|
||||
"mainWeapons": "",
|
||||
"subWeapons": "",
|
||||
"specialWeapons": "",
|
||||
"stages": "",
|
||||
"modes": "",
|
||||
"stageModes": "",
|
||||
"dropItems": "",
|
||||
"editingTier": "",
|
||||
"hideAltKits": "",
|
||||
"hideAltSkins": "",
|
||||
"resetConfirmation": "",
|
||||
"allowDuplicates": "",
|
||||
"showTierHeaders": "",
|
||||
"showArrowControls": ""
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@
|
|||
"pages.lfg": "LFG",
|
||||
"pages.scrims": "",
|
||||
"pages.settings": "Impostazioni",
|
||||
"pages.tier-list-maker": "",
|
||||
"pages.luti": "LUTI",
|
||||
"header.profile": "Profilo",
|
||||
"header.logout": "Esci",
|
||||
|
|
|
|||
17
locales/it/tier-list-maker.json
Normal file
17
locales/it/tier-list-maker.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"addTier": "",
|
||||
"mainWeapons": "",
|
||||
"subWeapons": "",
|
||||
"specialWeapons": "",
|
||||
"stages": "",
|
||||
"modes": "",
|
||||
"stageModes": "",
|
||||
"dropItems": "",
|
||||
"editingTier": "",
|
||||
"hideAltKits": "",
|
||||
"hideAltSkins": "",
|
||||
"resetConfirmation": "",
|
||||
"allowDuplicates": "",
|
||||
"showTierHeaders": "",
|
||||
"showArrowControls": ""
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@
|
|||
"pages.lfg": "グループ探し",
|
||||
"pages.scrims": "",
|
||||
"pages.settings": "設定",
|
||||
"pages.tier-list-maker": "",
|
||||
"pages.luti": "",
|
||||
"header.profile": "プロファイル",
|
||||
"header.logout": "ログアウト",
|
||||
|
|
|
|||
17
locales/ja/tier-list-maker.json
Normal file
17
locales/ja/tier-list-maker.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"addTier": "",
|
||||
"mainWeapons": "",
|
||||
"subWeapons": "",
|
||||
"specialWeapons": "",
|
||||
"stages": "",
|
||||
"modes": "",
|
||||
"stageModes": "",
|
||||
"dropItems": "",
|
||||
"editingTier": "",
|
||||
"hideAltKits": "",
|
||||
"hideAltSkins": "",
|
||||
"resetConfirmation": "",
|
||||
"allowDuplicates": "",
|
||||
"showTierHeaders": "",
|
||||
"showArrowControls": ""
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@
|
|||
"pages.lfg": "",
|
||||
"pages.scrims": "",
|
||||
"pages.settings": "",
|
||||
"pages.tier-list-maker": "",
|
||||
"pages.luti": "",
|
||||
"header.profile": "프로필",
|
||||
"header.logout": "로그아웃",
|
||||
|
|
|
|||
17
locales/ko/tier-list-maker.json
Normal file
17
locales/ko/tier-list-maker.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"addTier": "",
|
||||
"mainWeapons": "",
|
||||
"subWeapons": "",
|
||||
"specialWeapons": "",
|
||||
"stages": "",
|
||||
"modes": "",
|
||||
"stageModes": "",
|
||||
"dropItems": "",
|
||||
"editingTier": "",
|
||||
"hideAltKits": "",
|
||||
"hideAltSkins": "",
|
||||
"resetConfirmation": "",
|
||||
"allowDuplicates": "",
|
||||
"showTierHeaders": "",
|
||||
"showArrowControls": ""
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@
|
|||
"pages.lfg": "",
|
||||
"pages.scrims": "",
|
||||
"pages.settings": "",
|
||||
"pages.tier-list-maker": "",
|
||||
"pages.luti": "",
|
||||
"header.profile": "Profiel",
|
||||
"header.logout": "Log uit",
|
||||
|
|
|
|||
17
locales/nl/tier-list-maker.json
Normal file
17
locales/nl/tier-list-maker.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"addTier": "",
|
||||
"mainWeapons": "",
|
||||
"subWeapons": "",
|
||||
"specialWeapons": "",
|
||||
"stages": "",
|
||||
"modes": "",
|
||||
"stageModes": "",
|
||||
"dropItems": "",
|
||||
"editingTier": "",
|
||||
"hideAltKits": "",
|
||||
"hideAltSkins": "",
|
||||
"resetConfirmation": "",
|
||||
"allowDuplicates": "",
|
||||
"showTierHeaders": "",
|
||||
"showArrowControls": ""
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@
|
|||
"pages.lfg": "",
|
||||
"pages.scrims": "",
|
||||
"pages.settings": "",
|
||||
"pages.tier-list-maker": "",
|
||||
"pages.luti": "",
|
||||
"header.profile": "Profil",
|
||||
"header.logout": "Wyloguj się",
|
||||
|
|
|
|||
17
locales/pl/tier-list-maker.json
Normal file
17
locales/pl/tier-list-maker.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"addTier": "",
|
||||
"mainWeapons": "",
|
||||
"subWeapons": "",
|
||||
"specialWeapons": "",
|
||||
"stages": "",
|
||||
"modes": "",
|
||||
"stageModes": "",
|
||||
"dropItems": "",
|
||||
"editingTier": "",
|
||||
"hideAltKits": "",
|
||||
"hideAltSkins": "",
|
||||
"resetConfirmation": "",
|
||||
"allowDuplicates": "",
|
||||
"showTierHeaders": "",
|
||||
"showArrowControls": ""
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@
|
|||
"pages.lfg": "Formação de Times",
|
||||
"pages.scrims": "",
|
||||
"pages.settings": "",
|
||||
"pages.tier-list-maker": "",
|
||||
"pages.luti": "",
|
||||
"header.profile": "Perfil",
|
||||
"header.logout": "Sair",
|
||||
|
|
|
|||
17
locales/pt-BR/tier-list-maker.json
Normal file
17
locales/pt-BR/tier-list-maker.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"addTier": "",
|
||||
"mainWeapons": "",
|
||||
"subWeapons": "",
|
||||
"specialWeapons": "",
|
||||
"stages": "",
|
||||
"modes": "",
|
||||
"stageModes": "",
|
||||
"dropItems": "",
|
||||
"editingTier": "",
|
||||
"hideAltKits": "",
|
||||
"hideAltSkins": "",
|
||||
"resetConfirmation": "",
|
||||
"allowDuplicates": "",
|
||||
"showTierHeaders": "",
|
||||
"showArrowControls": ""
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@
|
|||
"pages.lfg": "LFG",
|
||||
"pages.scrims": "Скримы",
|
||||
"pages.settings": "Настройки",
|
||||
"pages.tier-list-maker": "",
|
||||
"pages.luti": "LUTI",
|
||||
"header.profile": "Профиль",
|
||||
"header.logout": "Выйти",
|
||||
|
|
|
|||
17
locales/ru/tier-list-maker.json
Normal file
17
locales/ru/tier-list-maker.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"addTier": "",
|
||||
"mainWeapons": "",
|
||||
"subWeapons": "",
|
||||
"specialWeapons": "",
|
||||
"stages": "",
|
||||
"modes": "",
|
||||
"stageModes": "",
|
||||
"dropItems": "",
|
||||
"editingTier": "",
|
||||
"hideAltKits": "",
|
||||
"hideAltSkins": "",
|
||||
"resetConfirmation": "",
|
||||
"allowDuplicates": "",
|
||||
"showTierHeaders": "",
|
||||
"showArrowControls": ""
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@
|
|||
"pages.lfg": "招募板",
|
||||
"pages.scrims": "",
|
||||
"pages.settings": "",
|
||||
"pages.tier-list-maker": "",
|
||||
"pages.luti": "",
|
||||
"header.profile": "个人资料",
|
||||
"header.logout": "登出",
|
||||
|
|
|
|||
17
locales/zh/tier-list-maker.json
Normal file
17
locales/zh/tier-list-maker.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"addTier": "",
|
||||
"mainWeapons": "",
|
||||
"subWeapons": "",
|
||||
"specialWeapons": "",
|
||||
"stages": "",
|
||||
"modes": "",
|
||||
"stageModes": "",
|
||||
"dropItems": "",
|
||||
"editingTier": "",
|
||||
"hideAltKits": "",
|
||||
"hideAltSkins": "",
|
||||
"resetConfirmation": "",
|
||||
"allowDuplicates": "",
|
||||
"showTierHeaders": "",
|
||||
"showArrowControls": ""
|
||||
}
|
||||
13
package-lock.json
generated
13
package-lock.json
generated
|
|
@ -33,6 +33,7 @@
|
|||
"i18next-http-backend": "^2.6.2",
|
||||
"ics": "^3.8.1",
|
||||
"isbot": "^5.1.27",
|
||||
"jsoncrush": "^1.1.8",
|
||||
"kysely": "^0.28.2",
|
||||
"lru-cache": "^11.1.0",
|
||||
"markdown-to-jsx": "^7.7.6",
|
||||
|
|
@ -11483,6 +11484,12 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsoncrush": {
|
||||
"version": "1.1.8",
|
||||
"resolved": "https://registry.npmjs.org/jsoncrush/-/jsoncrush-1.1.8.tgz",
|
||||
"integrity": "sha512-lvIMGzMUA0fjuqwNcxlTNRq2bibPZ9auqT/LyGdlR5hvydJtA/BasSgkx4qclqTKVeTidrJvsS/oVjlTCPQ4Nw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsonfile": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
|
||||
|
|
@ -18695,9 +18702,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.61",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.61.tgz",
|
||||
"integrity": "sha512-fzfJgUw78LTNnHujj9re1Ov/JJQkRZZGDMcYqSx7Hp4rPOkKywaFHq0S6GoHeXs0wGNE/sIOutkXgnwzrVOGCQ==",
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@
|
|||
"i18next-http-backend": "^2.6.2",
|
||||
"ics": "^3.8.1",
|
||||
"isbot": "^5.1.27",
|
||||
"jsoncrush": "^1.1.8",
|
||||
"kysely": "^0.28.2",
|
||||
"lru-cache": "^11.1.0",
|
||||
"markdown-to-jsx": "^7.7.6",
|
||||
|
|
|
|||
BIN
public/static-assets/img/layout/tier-list-maker.avif
Normal file
BIN
public/static-assets/img/layout/tier-list-maker.avif
Normal file
Binary file not shown.
BIN
public/static-assets/img/layout/tier-list-maker.png
Normal file
BIN
public/static-assets/img/layout/tier-list-maker.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
Loading…
Reference in New Issue
Block a user