Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/game/src/components/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
Image as ImageGlyph,
Info,
Lightbulb,
Link,
List,
LoaderCircle,
LogIn,
Expand Down Expand Up @@ -92,6 +93,7 @@ export const ShareIcon = glyph(Share)
export const RotateIcon = glyph(RotateCw)
export const GroupIcon = glyph(Users)
export const LightbulbIcon = glyph(Lightbulb)
export const LinkIcon = glyph(Link)
export const UndoIcon = glyph(Undo2)
export const HistoryIcon = glyph(History)
export const VolumeUpIcon = glyph(Volume2)
Expand Down
65 changes: 65 additions & 0 deletions apps/game/src/components/online/game-summary-row.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { PlayableSetup } from "@repo/abalone-engine/board-setups"
import { BOARD_SETUPS } from "@repo/abalone-engine/board-setups"
import { useTranslation } from "react-i18next"
import type { ColorChoiceValue } from "@/components/color-choice"
import { BoardThumb } from "@/components/online/board-thumb"
import { Button } from "@/components/ui/button"
import { getSetupName } from "@/i18n/game-text"

export type GameSummaryRowProps = {
setupType: PlayableSetup
side: ColorChoiceValue
/** Opens the panel with the carousel and the side picker. */
onChange: () => void
}

/**
* The terms of the game in one line: the opening, your side, and a way to
* change them.
*
* Most invites are the standard opening with a random side, and asking about
* both every time put a board preview and three tiles between the name and the
* send button. The terms are still the sender's to set; they are one press away
* instead of in the way.
*/
export function GameSummaryRow({
setupType,
side,
onChange,
}: GameSummaryRowProps) {
const { t } = useTranslation()
const opening = BOARD_SETUPS[setupType]

return (
<div className="flex items-center gap-3 rounded-xl bg-surface-2 py-1.5 ps-3 pe-1">
<div className="size-10 shrink-0">
<BoardThumb
blackCells={opening.black.map(
([row, column]) => `${row},${column}`,
)}
whiteCells={opening.white.map(
([row, column]) => `${row},${column}`,
)}
/>
</div>

<span className="block min-w-0 flex-1">
<span className="block truncate font-display text-[15px] font-bold text-white">
{getSetupName(setupType)}
</span>
<span className="block truncate text-[13px] text-muted">
{t(`online:compose.side_summary.${side}`)}
</span>
</span>

<Button
variant="ghost"
size="sm"
className="font-display font-semibold text-brand-lighter"
onClick={onChange}
>
{t("online:compose.change")}
</Button>
</div>
)
}
242 changes: 175 additions & 67 deletions apps/game/src/components/online/invite-composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,18 @@ import { useTranslation } from "react-i18next"
import type { ColorChoiceValue } from "@/components/color-choice"
import { ColorChoice } from "@/components/color-choice"
import { GameCanvas } from "@/components/game-canvas"
import { InviteLinkMaker } from "@/components/online/invite-link-maker"
import { BackIcon } from "@/components/icons"
import { GameSummaryRow } from "@/components/online/game-summary-row"
import { InviteLinkCard } from "@/components/online/invite-link-card"
import { RecentOpponents } from "@/components/online/recent-opponents"
import { SetupCarousel } from "@/components/setup-carousel"
import { Button } from "@/components/ui/button"
import { Sheet } from "@/components/ui/sheet"
import { StatusLine } from "@/components/ui/status-line"
import { TextField } from "@/components/ui/text-field"
import type { SendInviteInput } from "@/data/online/mutations"
import { useMarbleDesign } from "@/hooks/use-marble-design"
import type { RecentOpponent } from "@/utils/recent-opponents"

/** What the server can say about a name, for its line to hold room for. */
const SEND_ERRORS = [
Expand All @@ -39,24 +43,25 @@ export type InviteComposerProps = {
error?: string
/** A link was made, so whoever takes it opens a game from outside. */
onLinkCreated?: () => void
/** Who the player has played lately. `undefined` while the games load. */
recentOpponents?: RecentOpponent[]
}

type View = "main" | "game"

/**
* Asking somebody to play, in the shape the pregame panel already uses.
*
* The setup carousel and the side picker are the offline panel's own controls,
* so the two ways into a game ask the same questions in the same order. The one
* thing this adds is who you are asking.
* Asking somebody to play: who first, then the terms in one line.
*
* The name is a field and nothing else. There is no lookup in front of it, no
* "player found" card, no second picture of somebody you just named — you type
* a name and press send, and the only thing that can come back is that nobody
* is called that. A search that runs on every keystroke to confirm a name you
* Who is a row of people you have played and a field for anybody else. There is
* no lookup in front of the field, no "player found" card: you pick a face or
* type a name and press send, and the only thing that can come back is that
* nobody is called that. A search on every keystroke to confirm a name you
* already know is a request per letter for a fact you learn anyway.
*
* The link sits under the terms rather than beside the name, because it uses
* them: it is for when there is no name to type, and the board and the side it
* carries are the ones picked above it.
* The opening and the side are the offline panel's own controls, folded into a
* summary row, because most invites leave both as they are. Change swaps the
* sheet to a panel with the carousel and the side picker. The link sits between
* the name and the terms as the other way to ask, and it carries the terms.
*/
export function InviteComposer({
open,
Expand All @@ -65,15 +70,26 @@ export function InviteComposer({
pending,
error,
onLinkCreated,
recentOpponents,
}: InviteComposerProps) {
const { t } = useTranslation()
const errorId = useId()
const [marbleDesign] = useMarbleDesign()
const [view, setView] = useState<View>("main")
const [username, setUsername] = useState("")
const [pickedName, setPickedName] = useState<string | null>(null)
//an error describes the name that was sent, so it ends at the next edit
const [isErrorStale, setIsErrorStale] = useState(false)
const [setupType, setSetupType] = useState<PlayableSetup>(DEFAULT_SETUP)
const [side, setSide] = useState<ColorChoiceValue>("random")

const handle = username.trim()
const typedName = username.trim()
//what the send goes to: the typed name, or the face picked. the two never
//coexist, each clears the other
const target = typedName || pickedName || ""
const shownError = isErrorStale ? undefined : error
const hasChips =
(recentOpponents?.length ?? 0) > 0 || recentOpponents === undefined

//the opening position and nothing after it, so stepping through the setups
//costs one board rather than one board per press
Expand All @@ -82,61 +98,162 @@ export function InviteComposer({
[setupType],
)

function handleType(next: string) {
setUsername(next)
setPickedName(null)
setIsErrorStale(true)
}

function handlePick(name: string) {
setPickedName(name)
setUsername("")
setIsErrorStale(true)
}

function handleSend() {
setIsErrorStale(false)
onSend({ username: target, setupType, side })
}

function handleClose() {
if (view === "game") return setView("main")
onClose()
}

const isGameView = view === "game"
const sendLabel = target
? t("online:compose.send_to", { name: target })
: t("online:compose.send")

return (
<Sheet
open={open}
onClose={onClose}
title={t("online:compose.title")}
onClose={handleClose}
title={
isGameView ? t("online:compose.game") : t("online:compose.title")
}
hideTitle={isGameView}
pinFooter
className="lg:max-w-[476px]"
footer={
<Button
variant="primary"
size="lg"
className="w-full"
//an empty field is the only thing this can rule out on its own. how
//short a name may be is the server's rule, and pre-empting it here
//means a button that will not press and will not say why
disabled={handle.length === 0}
pending={pending}
onClick={() => onSend({ username: handle, setupType, side })}
>
{t("online:compose.send")}
</Button>
<>
{!isGameView && (
<Button
variant="primary"
size="lg"
className="h-auto min-h-[54px] w-full text-balance [overflow-wrap:anywhere]"
//an empty target is the only thing this can rule out on its own.
//how short a name may be is the server's rule, and pre-empting
//it here means a button that will not press and will not say why
disabled={target.length === 0}
pending={pending}
onClick={handleSend}
>
{sendLabel}
</Button>
)}
{isGameView && (
<Button
variant="primary"
size="lg"
className="w-full"
onClick={() => setView("main")}
>
{t("online:compose.done")}
</Button>
)}
</>
}
>
<div className="flex flex-col gap-y-5">
<div className="flex flex-col gap-y-2">
<TextField
label={t("online:compose.username")}
placeholder={t("online:compose.username_placeholder")}
prefix="@"
value={username}
onChange={(event) => setUsername(event.target.value)}
autoComplete="off"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
maxLength={20}
invalid={Boolean(error)}
describedBy={errorId}
{/* Both views stay mounted, one hidden: the link card holds the link it
made, and going to the game panel and back without changing anything
must not throw it away. */}
<div className="flex flex-col gap-y-5" hidden={isGameView}>
<div className="flex flex-col gap-y-3">
<RecentOpponents
opponents={recentOpponents}
value={pickedName ?? typedName}
onChange={handlePick}
disabled={pending}
invalid={Boolean(shownError) && Boolean(pickedName)}
/>

{/* The name is the only thing here that can be wrong, so the line
belongs to it rather than to the sheet.

Always in the layout, with a hint in it until the server has
something to say about the name. A message that arrives into no
space of its own pushes the board preview down and grows the
sheet: a press on Send that moves the thing under the thumb. */}
<StatusLine
id={errorId}
role="alert"
message={error}
messageClassName="text-loss"
hint={t("online:compose.username_hint")}
reserve={SEND_ERRORS.map((key) => t(key))}
<div className="flex flex-col gap-y-2">
<TextField
label={
hasChips
? t("online:compose.someone_else")
: t("online:compose.username")
}
placeholder={t("online:compose.username_placeholder")}
prefix="@"
value={username}
onChange={(event) => handleType(event.target.value)}
autoComplete="off"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
maxLength={20}
disabled={pending}
invalid={Boolean(shownError) && !pickedName}
describedBy={errorId}
/>

{/* The name is the only thing here that can be wrong, so the line
belongs to it rather than to the sheet.

Always in the layout, with a hint in it until the server has
something to say about the name. A message that arrives into no
space of its own pushes the rest down and grows the sheet: a
press on Send that moves the thing under the thumb. */}
<StatusLine
id={errorId}
role="alert"
message={shownError}
messageClassName="text-loss"
hint={t("online:compose.username_hint")}
reserve={SEND_ERRORS.map((key) => t(key))}
/>
</div>
</div>

<div className="flex flex-col gap-y-3">
{/* Keyed on the terms, so changing either one drops a link made on
the old ones rather than leaving it in the field. */}
<InviteLinkCard
key={`${setupType}:${side}`}
setupType={setupType}
side={side}
onCreated={onLinkCreated}
/>

<GameSummaryRow
setupType={setupType}
side={side}
onChange={() => setView("game")}
/>
</div>
</div>

<div className="flex flex-col gap-y-5" hidden={!isGameView}>
<div className="flex items-center gap-x-1">
<Button
variant="ghost"
size="icon"
className="-ms-2"
aria-label={t("online:compose.back")}
title={t("online:compose.back")}
onClick={() => setView("main")}
>
<BackIcon size={22} />
</Button>
<p
aria-hidden="true"
className="font-display text-[22px] font-bold tracking-[-0.02em] text-white"
>
{t("online:compose.game")}
</p>
</div>

<div className="flex flex-col gap-y-2">
<p className="field-label">{t("online:compose.setup")}</p>
Expand Down Expand Up @@ -167,15 +284,6 @@ export function InviteComposer({
marbleDesign={marbleDesign}
/>
</div>

{/* Keyed on the terms, so changing either one drops a link made on
the old ones rather than leaving it in the field. */}
<InviteLinkMaker
key={`${setupType}:${side}`}
setupType={setupType}
side={side}
onCreated={onLinkCreated}
/>
</div>
</Sheet>
)
Expand Down
Loading
Loading