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 ? (
-
- Retry
-
- ) : null}
-
- );
- case "failed":
- return (
-
-
- failed
- {state.onRetry ? (
-
- Retry
-
- ) : null}
-
- );
- case "not-uploaded":
- return (
-
-
- not uploaded
- {state.onUpload ? (
-
-
- Upload
-
- ) : 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);
+}