diff --git a/app/components/Label.module.css b/app/components/Label.module.css index 5778cbb45..15db8c10c 100644 --- a/app/components/Label.module.css +++ b/app/components/Label.module.css @@ -10,6 +10,7 @@ & > label { margin: 0; + text-box: trim-start cap alphabetic; } } diff --git a/app/components/match-page/MatchActionPickBanTab.module.css b/app/components/match-page/MatchActionPickBanTab.module.css new file mode 100644 index 000000000..ddfbf8c1c --- /dev/null +++ b/app/components/match-page/MatchActionPickBanTab.module.css @@ -0,0 +1,185 @@ +.root { + display: grid; + grid-template-columns: 1fr; + grid-template-areas: + "header" + "options" + "prompt" + "submit"; + justify-items: center; + align-items: center; + gap: var(--s-6); + container-type: inline-size; +} + +.title { + grid-area: header; + font-size: var(--font-md); + font-weight: var(--weight-semi); + text-align: center; + text-box: trim-start cap alphabetic; +} + +.options { + grid-area: options; + display: flex; + flex-direction: column; + gap: var(--s-6); + width: 100%; +} + +.prompt { + grid-area: prompt; + margin: 0; + font-size: var(--font-sm); + color: var(--color-text-lighter); + text-align: center; +} + +.verbPick { + color: var(--color-success); + font-weight: var(--weight-semi); +} + +.verbBan { + color: var(--color-error); + font-weight: var(--weight-semi); +} + +.submit { + grid-area: submit; +} + +.modeGroup { + display: flex; + flex-direction: column; + gap: var(--s-2); +} + +.divider { + font-size: var(--font-xs); + font-weight: var(--weight-semi); + text-transform: uppercase; + display: flex; + gap: var(--s-2); + + &::before, + &::after { + border-bottom: 2px dotted var(--color-bg-higher); + } +} + +.stageGrid { + --tile-width: 90px; + --tile-height: 50px; + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: var(--s-4) var(--s-3); +} + +.modeGrid { + --tile-width: 90px; + --tile-height: 90px; + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: var(--s-4) var(--s-3); +} + +.tileContainer { + display: flex; + flex-direction: column; + align-items: center; + position: relative; + width: var(--tile-width); + text-align: center; +} + +.tileWrapper { + position: relative; + width: var(--tile-width); + height: var(--tile-height); +} + +.tile { + height: var(--tile-height); + width: var(--tile-width); + border: none; + background-color: transparent; + transition: + filter, + opacity 0.2s; + border-radius: var(--radius-box); + cursor: pointer; + + &:active { + transform: none; + } +} + +.stageTile { + background-image: var(--map-image-url); + background-size: cover; +} + +.modeTile { + display: flex; + align-items: center; + justify-content: center; + background-color: var(--color-bg-higher); +} + +.tileSelected { + filter: grayscale(100%); + opacity: 0.4; +} + +.tileIcon { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 48px; + height: 48px; + pointer-events: none; +} + +.tileIconPick { + color: var(--color-success); +} + +.tileIconBan { + color: var(--color-error); +} + +.tileNumber { + position: absolute; + background-color: var(--color-text-accent); + border-radius: 100%; + width: 18px; + height: 18px; + display: grid; + place-items: center; + color: var(--color-text-inverse); + font-size: var(--font-2xs); + font-weight: var(--weight-semi); + top: -5px; + left: 0; + pointer-events: none; +} + +.tileFrom { + font-size: var(--font-2xs); + font-weight: var(--weight-bold); + text-transform: uppercase; + line-height: 1; + margin-block-start: var(--s-0-5); +} + +.tileLabel { + font-size: var(--font-2xs); + color: var(--color-text-high); + font-weight: var(--weight-semi); + margin-block-start: var(--s-1); +} diff --git a/app/components/match-page/MatchActionPickBanTab.tsx b/app/components/match-page/MatchActionPickBanTab.tsx new file mode 100644 index 000000000..bf12d04ef --- /dev/null +++ b/app/components/match-page/MatchActionPickBanTab.tsx @@ -0,0 +1,347 @@ +import clsx from "clsx"; +import { Check, X } from "lucide-react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { SendouButton } from "~/components/elements/Button"; +import { shortStageName } from "~/modules/in-game-lists/stage-ids"; +import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; +import { stageImageUrl } from "~/utils/urls"; +import { Divider } from "../Divider"; +import { SendouTabPanel } from "../elements/Tabs"; +import { ModeImage } from "../Image"; +import styles from "./MatchActionPickBanTab.module.css"; +import { TAB_KEYS } from "./MatchTabs"; +import { WeaponReporter, type WeaponReporterProps } from "./WeaponReporter"; + +interface PickBanMapOption { + stageId?: StageId; + mode?: ModeShort; + picker?: "US" | "THEM" | "BOTH"; + nth?: number; +} + +interface PickBanSubmission { + type: "PICK" | "BAN"; + map: PickBanMapOption; +} + +interface MatchActionPickBanTabProps { + options: PickBanMapOption[]; + type: "PICK" | "BAN"; + onSubmit?: (data: PickBanSubmission) => void; + isSubmitting?: boolean; + weaponReport?: WeaponReporterProps; +} + +export function MatchActionPickBanTab({ + options, + type, + onSubmit, + isSubmitting, + weaponReport, +}: MatchActionPickBanTabProps) { + const { t } = useTranslation(["q", "common", "game-misc"]); + const [selected, setSelected] = useState(); + + const hasStage = options.every((option) => option.stageId !== undefined); + const hasMode = options.every((option) => option.mode !== undefined); + const layout: "STAGE_BY_MODE" | "STAGE_ONLY" | "MODE_ONLY" = + hasStage && hasMode + ? "STAGE_BY_MODE" + : hasStage + ? "STAGE_ONLY" + : "MODE_ONLY"; + + const titleKey = + layout === "MODE_ONLY" + ? type === "PICK" + ? "q:match.action.pickMode" + : "q:match.action.banMode" + : type === "PICK" + ? "q:match.action.pickStage" + : "q:match.action.banStage"; + + const selectedLabel = (() => { + if (!selected) return null; + const stageName = + selected.stageId !== undefined + ? t(`game-misc:STAGE_${selected.stageId}`) + : null; + const modeName = + selected.mode !== undefined + ? t( + selected.stageId !== undefined + ? `game-misc:MODE_SHORT_${selected.mode}` + : `game-misc:MODE_LONG_${selected.mode}`, + ) + : null; + if (stageName && modeName) return `${stageName} (${modeName})`; + return stageName ?? modeName; + })(); + + return ( + +
+
{t(titleKey)}
+ +
+ {layout === "STAGE_BY_MODE" ? ( + + ) : layout === "STAGE_ONLY" ? ( + + ) : ( + + )} +
+ +

+ {selectedLabel ? ( + <> + + {type === "PICK" + ? t("q:match.action.picking") + : t("q:match.action.banning")} + {" "} + {selectedLabel} + + ) : ( + t("q:match.action.pickBanPrompt") + )} +

+ + { + if (!selected) return; + onSubmit?.({ type, map: selected }); + }} + > + {t("common:actions.submit")} + +
+ {weaponReport ? : null} +
+ ); +} + +function StageByModeGrid({ + options, + type, + selected, + onSelect, +}: { + options: PickBanMapOption[]; + type: "PICK" | "BAN"; + selected?: PickBanMapOption; + onSelect: (option: PickBanMapOption) => void; +}) { + const modesInOrder: ModeShort[] = []; + const byMode = new Map(); + for (const option of options) { + const mode = option.mode!; + if (!byMode.has(mode)) { + byMode.set(mode, []); + modesInOrder.push(mode); + } + byMode.get(mode)!.push(option); + } + + return ( + <> + {modesInOrder.map((mode) => ( +
+ + + +
+ {byMode.get(mode)!.map((option) => ( + onSelect(option)} + /> + ))} +
+
+ ))} + + ); +} + +function StageOnlyGrid({ + options, + type, + selected, + onSelect, +}: { + options: PickBanMapOption[]; + type: "PICK" | "BAN"; + selected?: PickBanMapOption; + onSelect: (option: PickBanMapOption) => void; +}) { + return ( +
+ {options.map((option) => ( + onSelect(option)} + /> + ))} +
+ ); +} + +function ModeOnlyGrid({ + options, + type, + selected, + onSelect, +}: { + options: PickBanMapOption[]; + type: "PICK" | "BAN"; + selected?: PickBanMapOption; + onSelect: (option: PickBanMapOption) => void; +}) { + return ( +
+ {options.map((option) => ( + onSelect(option)} + /> + ))} +
+ ); +} + +// xxx: maybe we should just have a shared custom component for stage image + label +function StageTile({ + option, + type, + isSelected, + onSelect, +}: { + option: PickBanMapOption; + type: "PICK" | "BAN"; + isSelected: boolean; + onSelect: () => void; +}) { + const { t } = useTranslation(["q", "game-misc"]); + + return ( +
+
+
+
+ {shortStageName(t(`game-misc:STAGE_${option.stageId!}`))} +
+ {option.picker ? ( + + {option.picker === "US" + ? t("q:match.action.pickerUs") + : option.picker === "THEM" + ? t("q:match.action.pickerThem") + : t("q:match.action.pickerBoth")} + + ) : null} +
+ ); +} + +function ModeTile({ + option, + type, + isSelected, + onSelect, +}: { + option: PickBanMapOption; + type: "PICK" | "BAN"; + isSelected: boolean; + onSelect: () => void; +}) { + const { t } = useTranslation(["game-misc"]); + + return ( +
+
+ + {isSelected ? ( + type === "PICK" ? ( + + ) : ( + + ) + ) : null} +
+
+ {t(`game-misc:MODE_LONG_${option.mode!}`)} +
+
+ ); +} + +function isSameOption(a: PickBanMapOption, b: PickBanMapOption | undefined) { + if (!b) return false; + return a.stageId === b.stageId && a.mode === b.mode; +} diff --git a/app/components/match-page/MatchActionTab.module.css b/app/components/match-page/MatchActionTab.module.css index 42654ec71..2e522a4e8 100644 --- a/app/components/match-page/MatchActionTab.module.css +++ b/app/components/match-page/MatchActionTab.module.css @@ -4,25 +4,22 @@ grid-template-areas: "header header header" "actions actions actions" - "alpha stage bravo" - ". label ." + "selection selection selection" "points-alpha ko points-bravo" - ". submit ."; + "submit submit submit"; justify-items: center; align-items: center; - gap: var(--s-4); + gap: var(--s-5); container-type: inline-size; @container (max-width: 599px) { - grid-template-columns: auto 1fr; + grid-template-columns: 1fr; grid-template-areas: - "header header" - "actions actions" - "stage alpha" - "stage bravo" - "label ." - "points points" - "submit submit"; + "header" + "actions" + "selection" + "points" + "submit"; } } @@ -31,6 +28,7 @@ font-size: var(--font-md); font-weight: var(--weight-semi); text-align: center; + text-box: trim-start cap alphabetic; } .actionButtons { @@ -41,7 +39,26 @@ } .selectionRow { - display: contents; + grid-area: selection; + display: grid; + grid-template-columns: 1fr auto 1fr; + grid-template-areas: + "alpha stage bravo" + ". text ."; + column-gap: var(--s-4); + row-gap: var(--s-1); + justify-items: center; + align-items: center; + width: 100%; + + @container (max-width: 599px) { + column-gap: var(--s-2); + grid-template-columns: auto 1fr; + grid-template-areas: + "stage alpha" + "stage bravo" + "text ."; + } } .teamRadioContainer { @@ -96,14 +113,13 @@ } .stageLabel { - grid-area: label; + grid-area: text; display: flex; align-items: center; gap: var(--s-1); font-size: var(--font-3xs); font-weight: var(--weight-semi); color: var(--color-text-high); - margin-block-start: -11px; } .pointsRow { @@ -129,7 +145,6 @@ flex-direction: column; align-items: center; gap: var(--s-1); - align-self: flex-end; } .pointsBravo { @@ -139,7 +154,6 @@ .submit { grid-area: submit; - margin-block-start: calc(-1 * var(--s-2)); } .checkCircle { diff --git a/app/components/match-page/MatchTabs.module.css b/app/components/match-page/MatchTabs.module.css index 6811d2794..83e1fbf17 100644 --- a/app/components/match-page/MatchTabs.module.css +++ b/app/components/match-page/MatchTabs.module.css @@ -2,6 +2,6 @@ & [class*="tabPanel"] { background-color: var(--color-bg-high); border-radius: 0 0 var(--radius-box) var(--radius-box); - padding: var(--s-4) var(--s-4); + padding: var(--s-6) var(--s-4); } } diff --git a/app/features/match-page-test/routes/match-page-test.tsx b/app/features/match-page-test/routes/match-page-test.tsx index cd5eb9a49..51a57f47b 100644 --- a/app/features/match-page-test/routes/match-page-test.tsx +++ b/app/features/match-page-test/routes/match-page-test.tsx @@ -1,7 +1,14 @@ -import { ArrowLeft, Ban } from "lucide-react"; +import { ArrowLeft, Ban, Undo2 } from "lucide-react"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { SendouButton } from "~/components/elements/Button"; +import { + SendouTab, + SendouTabList, + SendouTabs, +} from "~/components/elements/Tabs"; import { Main } from "~/components/Main"; +import { MatchActionPickBanTab } from "~/components/match-page/MatchActionPickBanTab"; import { MatchActionTab } from "~/components/match-page/MatchActionTab"; import { IconBanner, @@ -18,12 +25,21 @@ import { MatchTabs } from "~/components/match-page/MatchTabs"; import { logger } from "~/utils/logger"; import type { SendouRouteHandle } from "~/utils/remix.server"; +type ActionVariant = + | "winner" + | "counterpick-stage" + | "ban-stage" + | "ban-stage-only" + | "pick-mode" + | "ban-mode"; + export const handle: SendouRouteHandle = { i18n: ["q"], }; export default function MatchPageTestRoute() { const { t } = useTranslation(["q"]); + const [actionVariant, setActionVariant] = useState("winner"); return (
@@ -39,6 +55,22 @@ export default function MatchPageTestRoute() { Round 2.1 + setActionVariant(key as ActionVariant)} + disappearing={false} + padded={false} + > + + Winner + Counterpick + Ban stage + Ban stage (any mode) + Pick mode + Ban mode + + + - + {actionVariant === "winner" ? ( + } + > + {t("q:match.undoReport")} + + } + /> + ) : actionVariant === "counterpick-stage" ? ( + logger.info("pick submit", data)} + /> + ) : actionVariant === "ban-stage" ? ( + logger.info("ban submit", data)} + /> + ) : actionVariant === "ban-stage-only" ? ( + logger.info("ban stage-only submit", data)} + /> + ) : actionVariant === "pick-mode" ? ( + logger.info("pick mode submit", data)} + /> + ) : ( + logger.info("ban mode submit", data)} + /> + )}