From d3bb24737d5c82747e50486fc2d31e9261b6a5ab Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 20 Sep 2026 13:57:30 +0300 Subject: [PATCH] Upload chip redesign --- app/features/scanner/README.md | 4 +- .../scanner/components/MatchCard.module.css | 12 +- app/features/scanner/components/MatchCard.tsx | 22 +- .../scanner/components/SessionView.tsx | 2 +- .../scanner/components/UploadChip.module.css | 90 -------- .../scanner/components/UploadChip.tsx | 156 -------------- .../components/UploadStatus.module.css | 104 +++++++++ .../scanner/components/UploadStatus.tsx | 202 ++++++++++++++++++ 8 files changed, 325 insertions(+), 267 deletions(-) delete mode 100644 app/features/scanner/components/UploadChip.module.css delete mode 100644 app/features/scanner/components/UploadChip.tsx create mode 100644 app/features/scanner/components/UploadStatus.module.css create mode 100644 app/features/scanner/components/UploadStatus.tsx diff --git a/app/features/scanner/README.md b/app/features/scanner/README.md index ef2d9d81c..67a5d3fed 100644 --- a/app/features/scanner/README.md +++ b/app/features/scanner/README.md @@ -57,8 +57,8 @@ opens it, for anyone, through the same handoff Inspect uses. unlinked matches on a backoff (`sendou-ingest.ts`) and flushes closed matches whose send was skipped; Stop sends what is left. VoD: the whole scan sends once saved. Both write per-event send statuses to their own - store (`updateEventsSend` takes the store), so the cards' upload chip - (`UploadChip.tsx`) and the per-card Retry/Upload are one code path. + store (`updateEventsSend` takes the store), so the cards' upload status + button (`UploadStatus.tsx`) and its Retry/Upload are one code path. - **CSV** is a normal feature: `⇩ CSV` in every session header offers `Matches` (`core/csv/matches.ts`, one row per game, the rows the cards render) and `Raw detections` (`core/csv/events.ts`, one row per event). diff --git a/app/features/scanner/components/MatchCard.module.css b/app/features/scanner/components/MatchCard.module.css index 805ee41fa..8f92f54eb 100644 --- a/app/features/scanner/components/MatchCard.module.css +++ b/app/features/scanner/components/MatchCard.module.css @@ -266,7 +266,7 @@ } /* the shared circle button, translucent over the banner */ -button.expand { +button.circle { flex-shrink: 0; width: 28px; height: 28px; @@ -276,7 +276,6 @@ button.expand { background: color-mix(in oklab, var(--color-bg) 55%, transparent); backdrop-filter: blur(8px); color: var(--color-text-high); - transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1); & svg { width: var(--field-size-icon); @@ -287,17 +286,16 @@ button.expand { color: var(--color-text); background: color-mix(in oklab, var(--color-bg) 78%, transparent); } +} + +button.expand { + transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1); &.expanded { transform: rotate(180deg); } } -.error { - font-size: var(--font-2xs); - color: var(--color-error); -} - /* the expanded content, indented under its card via a rail */ .details { display: flex; diff --git a/app/features/scanner/components/MatchCard.tsx b/app/features/scanner/components/MatchCard.tsx index a25eab2cc..dfc8137a3 100644 --- a/app/features/scanner/components/MatchCard.tsx +++ b/app/features/scanner/components/MatchCard.tsx @@ -1,7 +1,8 @@ /** * One game of a session or file, the same card in every view. Collapsed it * is one row: mode and stage, the result, the POV weapon and K/D/S, then - * the game's clips and its upload state. Expanded it shows the data and + * the game's clips and, once the game is over, its upload state beside the + * expand arrow. Expanded it shows the data and * nothing interpreted: the scoreboard, the objective + player-status * timeline, deaths and kills (each with a ▶ when a clip covers it) and the * builds read for both teams. @@ -40,7 +41,7 @@ import styles from "./MatchCard.module.css"; import { playerStatusTeams } from "./player-status-view"; import { RawDetections } from "./RawDetections"; import type { ScanEvent, SessionKind } from "./session-data"; -import { UploadChip, type UploadState } from "./UploadChip"; +import { type UploadState, UploadStatusButton } from "./UploadStatus"; /** the game score a knockout wins at */ const KO_MATCH_SCORE = 100; @@ -166,7 +167,6 @@ export function MatchCard({ {pov.ka ?? "?"}/{pov.d ?? "?"}/{pov.s ?? "?"} ) : null} -
@@ -189,24 +189,24 @@ export function MatchCard({ ))} ) : null} - - {expandable ? ( + {expandable ? ( + + } - className={clsx(styles.expand, { [styles.expanded]: expanded })} + className={clsx(styles.circle, styles.expand, { + [styles.expanded]: expanded, + })} aria-expanded={expanded} aria-label={expanded ? "Hide details" : "Show details"} onClick={() => setExpanded(!expanded)} /> - ) : null} - + + ) : null}
- {upload.kind === "failed" && upload.error ? ( -
{upload.error}
- ) : null} ); diff --git a/app/features/scanner/components/SessionView.tsx b/app/features/scanner/components/SessionView.tsx index 310ee2545..2b478662f 100644 --- a/app/features/scanner/components/SessionView.tsx +++ b/app/features/scanner/components/SessionView.tsx @@ -35,7 +35,7 @@ import { MatchCard } from "./MatchCard"; import styles from "./SessionView.module.css"; import { aggregateSendStatus } from "./sendou-ingest"; import type { ScanEvent, SessionKind } from "./session-data"; -import { uploadStateOf } from "./UploadChip"; +import { uploadStateOf } from "./UploadStatus"; import { useDebug } from "./use-debug"; const NO_KEYS: ReadonlySet = new Set(); diff --git a/app/features/scanner/components/UploadChip.module.css b/app/features/scanner/components/UploadChip.module.css deleted file mode 100644 index f5e7f63ae..000000000 --- a/app/features/scanner/components/UploadChip.module.css +++ /dev/null @@ -1,90 +0,0 @@ -.chip { - display: inline-flex; - align-items: center; - gap: var(--s-1-5); - padding: var(--s-0-5) var(--s-2-5); - border-radius: var(--radius-full); - font-size: var(--font-2xs); - font-weight: var(--weight-bold); - border: var(--border-style); - background-color: var(--color-bg); - color: var(--color-text-high); - white-space: nowrap; - - & .dot { - width: 7px; - height: 7px; - border-radius: var(--radius-full); - background: currentColor; - animation: scanner-pulse 1.4s ease-in-out infinite; - } -} - -.uploaded { - color: var(--color-success-high); - border-color: var(--color-success-low); - background-color: var(--color-success-low); -} - -.link { - color: inherit; - text-decoration: underline; - text-underline-offset: 2px; - - &:hover { - color: var(--color-text-high); - } -} - -.busy { - color: var(--color-info-high); - border-color: var(--color-info-low); - background-color: var(--color-info-low); -} - -.waiting { - color: var(--color-warning-high); - border-color: var(--color-warning-low); - background-color: var(--color-warning-low); -} - -.failed { - color: var(--color-error-high); - border-color: var(--color-error-low); - background-color: var(--color-error-low); -} - -.action { - display: inline-flex; - align-items: center; - gap: var(--s-1); - margin-inline-start: var(--s-1); - padding: 0 var(--s-1-5); - border: none; - border-radius: var(--radius-full); - background: var(--color-bg-higher); - color: var(--color-text); - font: inherit; - cursor: pointer; - - &:hover { - color: var(--color-text-accent); - } - - &:focus-visible { - outline: var(--focus-ring); - } -} - -@keyframes scanner-pulse { - 50% { - opacity: 0.25; - transform: scale(0.8); - } -} - -@media (prefers-reduced-motion: reduce) { - .chip .dot { - animation: none; - } -} diff --git a/app/features/scanner/components/UploadChip.tsx b/app/features/scanner/components/UploadChip.tsx deleted file mode 100644 index 8c9d5d9f6..000000000 --- a/app/features/scanner/components/UploadChip.tsx +++ /dev/null @@ -1,156 +0,0 @@ -/** - * A match's upload state, one word and an icon at the card's right: - * `uploaded ✓` · `uploading…` · `waiting for result` · `failed [Retry]` · - * `not uploaded [Upload]` · `skipped: …`. - */ -import clsx from "clsx"; -import { - Check, - CloudOff, - CloudUpload, - Hourglass, - TriangleAlert, -} from "lucide-react"; -import type { IngestedMatchLink } from "~/features/scanner-ingest/scanner-ingest-schemas"; -import { sendouQMatchPage, tournamentMatchPage } from "~/utils/urls"; -import { lobbyLabel } from "../core/labels"; -import type { IngestSkipReason } from "../core/match-builder"; -import type { ScannerLobby } from "../scanner-types"; -import type { SendStatus } from "../store/events"; -import styles from "./UploadChip.module.css"; - -export type UploadState = - | { kind: "uploaded"; link?: IngestedMatchLink } - | { kind: "uploading" } - | { kind: "waiting"; onRetry?: () => void } - | { kind: "failed"; error?: string; onRetry?: () => void } - | { kind: "not-uploaded"; onUpload?: () => void } - | { kind: "skipped"; reason: IngestSkipReason; lobby: ScannerLobby | null }; - -/** Folds a match's aggregated send status, skip reason and the upload toggle into what the chip shows. */ -export function uploadStateOf({ - send, - skipReason, - lobby, - canUpload, - onUpload, -}: { - send: SendStatus | undefined; - skipReason: IngestSkipReason | undefined; - lobby: ScannerLobby | null; - /** logged in: Retry/Upload buttons show */ - canUpload: boolean; - onUpload: () => void; -}): UploadState { - if (skipReason) return { kind: "skipped", reason: skipReason, lobby }; - const action = canUpload ? onUpload : undefined; - switch (send?.state) { - case "sent": - return { kind: "uploaded", link: send.link }; - case "sending": - return { kind: "uploading" }; - case "unlinked": - return { kind: "waiting", onRetry: action }; - case "failed": - return { kind: "failed", error: send.error, onRetry: action }; - default: - return { kind: "not-uploaded", onUpload: action }; - } -} - -export function UploadChip({ state }: { state: UploadState }) { - switch (state.kind) { - case "uploaded": - return ( - - - {state.link ? ( - - uploaded - - ) : ( - "uploaded" - )} - - ); - case "uploading": - return ( - - - uploading… - - ); - case "waiting": - return ( - - - waiting for result - {state.onRetry ? ( - - ) : null} - - ); - case "failed": - return ( - - - failed - {state.onRetry ? ( - - ) : null} - - ); - case "not-uploaded": - return ( - - - not uploaded - {state.onUpload ? ( - - ) : null} - - ); - case "skipped": - return ( - - skipped:{" "} - {state.reason === "disconnect" - ? "disconnect" - : (lobbyLabel(state.lobby) ?? "not a private battle")} - - ); - } -} - -function linkUrl(link: IngestedMatchLink): string { - return link.type === "tournament" - ? tournamentMatchPage({ - tournamentId: link.tournamentId, - matchId: link.matchId, - }) - : sendouQMatchPage(link.groupMatchId); -} diff --git a/app/features/scanner/components/UploadStatus.module.css b/app/features/scanner/components/UploadStatus.module.css new file mode 100644 index 000000000..d21350a2d --- /dev/null +++ b/app/features/scanner/components/UploadStatus.module.css @@ -0,0 +1,104 @@ +/* tone colors win over the card's circle-button color by specificity, hover included */ +.trigger { + &[data-tone="success"] { + &, + &:hover { + color: var(--color-success-high); + } + } + + &[data-tone="info"] { + &, + &:hover { + color: var(--color-info-high); + } + } + + &[data-tone="warning"] { + &, + &:hover { + color: var(--color-warning-high); + } + } + + &[data-tone="error"] { + &, + &:hover { + color: var(--color-error-high); + } + } +} + +.busy { + animation: scanner-pulse 1.4s ease-in-out infinite; +} + +.popover { + width: min(18rem, calc(100vw - 2 * var(--s-4))); + white-space: normal; +} + +.body { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--s-2); + padding: var(--s-1); + font-size: var(--font-xs); +} + +.title { + display: flex; + align-items: center; + gap: var(--s-1-5); + font-weight: var(--weight-bold); + + & svg { + width: 14px; + height: 14px; + flex-shrink: 0; + } +} + +.detail { + margin: 0; + font-size: var(--font-2xs); + font-weight: var(--weight-semi); + color: var(--color-text-high); + line-height: 1.5; +} + +.error { + margin: 0; + font-size: var(--font-2xs); + color: var(--color-error); + line-height: 1.5; + overflow-wrap: anywhere; +} + +.link { + display: inline-flex; + align-items: center; + gap: var(--s-1); + font-size: var(--font-2xs); + font-weight: var(--weight-bold); + color: var(--color-text-accent); + text-decoration: underline; + text-underline-offset: 2px; +} + +.action { + align-self: flex-end; +} + +@keyframes scanner-pulse { + 50% { + opacity: 0.25; + } +} + +@media (prefers-reduced-motion: reduce) { + .busy { + animation: none; + } +} diff --git a/app/features/scanner/components/UploadStatus.tsx b/app/features/scanner/components/UploadStatus.tsx new file mode 100644 index 000000000..4c9cf3bbd --- /dev/null +++ b/app/features/scanner/components/UploadStatus.tsx @@ -0,0 +1,202 @@ +/** + * A finished match's upload state as one icon button beside the card's + * expand arrow: ✓ uploaded · ☁↑ uploading · ⌛ waiting for result · ⚠ failed + * · ☁⃠ not uploaded (or skipped). Clicking it opens what happened in words, + * the match link once there is one, and Retry / Upload where they apply. + */ +import clsx from "clsx"; +import { + Check, + CloudOff, + CloudUpload, + ExternalLink, + Hourglass, + TriangleAlert, +} from "lucide-react"; +import type { JSX } from "react"; +import { SendouButton } from "~/components/elements/Button"; +import { SendouPopover } from "~/components/elements/Popover"; +import type { IngestedMatchLink } from "~/features/scanner-ingest/scanner-ingest-schemas"; +import { sendouQMatchPage, tournamentMatchPage } from "~/utils/urls"; +import { lobbyLabel } from "../core/labels"; +import type { IngestSkipReason } from "../core/match-builder"; +import type { ScannerLobby } from "../scanner-types"; +import type { SendStatus } from "../store/events"; +import styles from "./UploadStatus.module.css"; + +export type UploadState = + | { kind: "uploaded"; link?: IngestedMatchLink } + | { kind: "uploading" } + | { kind: "waiting"; onRetry?: () => void } + | { kind: "failed"; error?: string; onRetry?: () => void } + | { kind: "not-uploaded"; onUpload?: () => void } + | { kind: "skipped"; reason: IngestSkipReason; lobby: ScannerLobby | null }; + +interface UploadView { + icon: JSX.Element; + tone: "success" | "info" | "warning" | "error" | "muted"; + title: string; + detail?: string; + error?: string; + link?: IngestedMatchLink; + action?: { label: string; run: () => void }; +} + +/** Folds a match's aggregated send status, skip reason and the upload toggle into what the button shows. */ +export function uploadStateOf({ + send, + skipReason, + lobby, + canUpload, + onUpload, +}: { + send: SendStatus | undefined; + skipReason: IngestSkipReason | undefined; + lobby: ScannerLobby | null; + /** logged in: Retry/Upload buttons show */ + canUpload: boolean; + onUpload: () => void; +}): UploadState { + if (skipReason) return { kind: "skipped", reason: skipReason, lobby }; + const action = canUpload ? onUpload : undefined; + switch (send?.state) { + case "sent": + return { kind: "uploaded", link: send.link }; + case "sending": + return { kind: "uploading" }; + case "unlinked": + return { kind: "waiting", onRetry: action }; + case "failed": + return { kind: "failed", error: send.error, onRetry: action }; + default: + return { kind: "not-uploaded", onUpload: action }; + } +} + +export function UploadStatusButton({ + state, + className, +}: { + state: UploadState; + /** the card's circle button look, shared with the expand arrow */ + className?: string; +}) { + const view = viewOf(state); + return ( + + } + popoverClassName={styles.popover} + placement="bottom end" + > +
+
+ {view.icon} + {view.title} +
+ {view.detail ?

{view.detail}

: null} + {view.error ?

{view.error}

: null} + {view.link ? ( + + Open match + + + ) : null} + {view.action ? ( + + {view.action.label} + + ) : null} +
+
+ ); +} + +function viewOf(state: UploadState): UploadView { + switch (state.kind) { + case "uploaded": + return { + icon: , + tone: "success", + title: "Uploaded to sendou.ink", + link: state.link, + }; + case "uploading": + return { + icon: , + tone: "info", + title: "Uploading to sendou.ink…", + }; + case "waiting": + return { + icon: , + tone: "warning", + title: "Waiting for the result", + detail: + "sendou.ink has the game but the match isn't reported yet. It's retried on its own once the match is reported.", + action: retryAction(state.onRetry), + }; + case "failed": + return { + icon: , + tone: "error", + title: "Upload failed", + error: state.error, + action: retryAction(state.onRetry), + }; + case "not-uploaded": + return { + icon: , + tone: "muted", + title: "Not uploaded", + detail: state.onUpload + ? "This game hasn't been sent to sendou.ink." + : "Log in to upload results to sendou.ink.", + action: state.onUpload + ? { label: "Upload", run: state.onUpload } + : undefined, + }; + case "skipped": + return { + icon: , + tone: "muted", + title: "Not uploaded", + detail: + state.reason === "disconnect" + ? "A disconnect ended this game before it was decided, so there is no result to upload." + : `Only Private Battle games are uploaded. This one was ${lobbyLabel(state.lobby) ?? "not a private battle"}.`, + }; + } +} + +function retryAction(onRetry: (() => void) | undefined) { + return onRetry ? { label: "Retry", run: onRetry } : undefined; +} + +function linkUrl(link: IngestedMatchLink): string { + return link.type === "tournament" + ? tournamentMatchPage({ + tournamentId: link.tournamentId, + matchId: link.matchId, + }) + : sendouQMatchPage(link.groupMatchId); +}