From 05ccac3319f0cc138c4cbe3c242a88244dc2aba5 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:27:11 +0300 Subject: [PATCH] Links --- app/components/FormWithConfirm.tsx | 37 +++++++++---- .../actions/scanner-ingest.server.ts | 42 ++++++++++++--- .../scanner-ingest/scanner-ingest-schemas.ts | 13 +++++ app/features/scanner/components/MatchCard.tsx | 43 ++++++++++++--- app/features/scanner/components/VodPage.tsx | 52 ++++++++++++------- .../scanner/components/sendou-ingest.ts | 30 +++++++++-- app/features/scanner/components/styles.css | 10 ++++ app/features/scanner/store/events.ts | 3 ++ app/features/scanner/store/vods.ts | 3 ++ 9 files changed, 188 insertions(+), 45 deletions(-) diff --git a/app/components/FormWithConfirm.tsx b/app/components/FormWithConfirm.tsx index 8661e677c..bc06fa3bb 100644 --- a/app/components/FormWithConfirm.tsx +++ b/app/components/FormWithConfirm.tsx @@ -2,7 +2,10 @@ import * as React from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { type FetcherWithComponents, useFetcher } from "react-router"; -import type { SendouButtonProps } from "~/components/elements/Button"; +import { + SendouButton, + type SendouButtonProps, +} from "~/components/elements/Button"; import { SendouDialog } from "~/components/elements/Dialog"; import { useHydrated } from "~/hooks/useHydrated"; import invariant from "~/utils/invariant"; @@ -26,6 +29,7 @@ export function FormWithConfirm({ fetcher: _fetcher, isOpen, onOpenChange, + onConfirm, }: { fields?: ( | [name: string, value: string | number] @@ -43,6 +47,8 @@ export function FormWithConfirm({ /** Controls the dialog open state. When provided, no child trigger is needed. */ isOpen?: boolean; onOpenChange?: (isOpen: boolean) => void; + /** Confirming runs this callback instead of submitting a form (client only action) */ + onConfirm?: () => void; }) { const componentsFetcher = useFetcher(); const fetcher = _fetcher ?? componentsFetcher; @@ -69,7 +75,7 @@ export function FormWithConfirm({ return ( <> - {isHydrated + {isHydrated && !onConfirm ? // using portal here makes nesting this component in another form work createPortal( {description} ) : null}
- - {submitButtonText ?? t("common:actions.delete")} - + {onConfirm ? ( + { + closeDialog(); + onConfirm(); + }} + > + {submitButtonText ?? t("common:actions.delete")} + + ) : ( + + {submitButtonText ?? t("common:actions.delete")} + + )}
diff --git a/app/features/scanner-ingest/actions/scanner-ingest.server.ts b/app/features/scanner-ingest/actions/scanner-ingest.server.ts index 17fe4d0e1..0a3c9d63b 100644 --- a/app/features/scanner-ingest/actions/scanner-ingest.server.ts +++ b/app/features/scanner-ingest/actions/scanner-ingest.server.ts @@ -9,7 +9,11 @@ import { logger } from "~/utils/logger"; import { badRequestIfFalsy, forbidden, parseBody } from "~/utils/remix.server"; import * as Scoreboards from "../core/Scoreboards"; import * as ScannerIngestRepository from "../ScannerIngestRepository.server"; -import { ingestBodySchema } from "../scanner-ingest-schemas"; +import { + type IngestedMatchLink, + type IngestResponse, + ingestBodySchema, +} from "../scanner-ingest-schemas"; // xxx: dont only attach scoreboard on ingest, also when score is reported (for e.g. tournament stuff) // xxx: check why http://localhost:7001/to/4066/matches/139247?tab=result layout bad @@ -30,15 +34,17 @@ export const action: ActionFunction = async ({ request }) => { badRequestIfFalsy(await UserRepository.findLeanById(povUserId)); } - const matches = data.matches.filter( - (match) => match.lobby === null || match.lobby === "PRIVATE", - ); + const indexedMatches = data.matches + .map((match, requestIndex) => ({ match, requestIndex })) + .filter(({ match }) => match.lobby === null || match.lobby === "PRIVATE"); + const matches = indexedMatches.map(({ match }) => match); if (matches.length === 0) { return { storedMatchesCount: 0, mergedMatchesCount: 0, linkedGamesCount: 0, - }; + linkedMatches: [], + } satisfies IngestResponse; } const resolved = await resolveIngestContext({ @@ -56,6 +62,7 @@ export const action: ActionFunction = async ({ request }) => { }); let linkedGamesCount = 0; + let linkedMatches: IngestResponse["linkedMatches"] = []; if (resolved) { const matched = Scoreboards.matchedGames({ matches: effectiveMatches.map((effective) => effective.data), @@ -71,6 +78,11 @@ export const action: ActionFunction = async ({ request }) => { povUserId, }); + linkedMatches = matched.map(({ matchIndex, game }) => ({ + matchIndex: indexedMatches[matchIndex]!.requestIndex, + link: ingestedMatchLink(resolved.context, game.target), + })); + logger.debug( `ingest: ${Scoreboards.contextKey(resolved.context)} matched ${matched.length} games, ` + `${linkedGamesCount} newly linked (stored ${insertedCount}, merged ${mergedCount})`, @@ -86,9 +98,27 @@ export const action: ActionFunction = async ({ request }) => { storedMatchesCount: insertedCount, mergedMatchesCount: mergedCount, linkedGamesCount, - }; + linkedMatches, + } satisfies IngestResponse; }; +function ingestedMatchLink( + context: Scoreboards.IngestContext, + target: Scoreboards.IngestableGameTarget, +): IngestedMatchLink { + if (target.type === "tournament" && context.type === "tournament") { + return { + type: "tournament", + tournamentId: context.tournamentId, + matchId: target.tournamentMatchId, + }; + } + if (target.type === "sendouq") { + return { type: "sendouq", groupMatchId: target.groupMatchId }; + } + throw new Error("ingest link target does not match its resolved context"); +} + /** * How far back the POV user's reported games are considered as content- * resolution candidates diff --git a/app/features/scanner-ingest/scanner-ingest-schemas.ts b/app/features/scanner-ingest/scanner-ingest-schemas.ts index 56cbb73ee..0cb880486 100644 --- a/app/features/scanner-ingest/scanner-ingest-schemas.ts +++ b/app/features/scanner-ingest/scanner-ingest-schemas.ts @@ -14,3 +14,16 @@ export const ingestBodySchema = z.object({ povUserId: id.optional(), matches: z.array(scannerMatchSchema).min(1).max(MAX_MATCHES_PER_REQUEST), }); + +/** The sendou.ink match an ingested match's scoreboard was linked to. */ +export type IngestedMatchLink = + | { type: "tournament"; tournamentId: number; matchId: number } + | { type: "sendouq"; groupMatchId: number }; + +export interface IngestResponse { + storedMatchesCount: number; + mergedMatchesCount: number; + linkedGamesCount: number; + /** per request match (by its index in the body's `matches`), the match it linked to */ + linkedMatches: Array<{ matchIndex: number; link: IngestedMatchLink }>; +} diff --git a/app/features/scanner/components/MatchCard.tsx b/app/features/scanner/components/MatchCard.tsx index 5c3be0131..55c34e834 100644 --- a/app/features/scanner/components/MatchCard.tsx +++ b/app/features/scanner/components/MatchCard.tsx @@ -12,17 +12,18 @@ import { useState } from "react"; import { SendouButton } from "~/components/elements/Button"; import { ModeImage, WeaponImage } from "~/components/Image"; import { StageBannerBox } from "~/components/StageBannerBox"; +import type { IngestedMatchLink } from "~/features/scanner-ingest/scanner-ingest-schemas"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; +import { sendouQMatchPage, tournamentMatchPage } from "~/utils/urls"; import type { IngestSkipReason } from "../core/match-builder"; import type { ScannerMatch } from "../core/scanner-match"; import type { SendStatus } from "../store/events"; import { formatTime } from "./format"; import { lobbyLabel, modeLabel, stageLabel } from "./labels"; -const SEND_CHIP_LABELS: Record = { +const SEND_CHIP_LABELS: Record, string> = { queued: "queued", sending: "sending…", - sent: "ingested", failed: "failed", }; @@ -262,20 +263,50 @@ function StatusChip({ ); } + if (send?.state === "sent") { + return ( + + ✓ + {send.link ? ( + + {ingestedMatchLabel(send.link)} + + ) : null} + + ); + } if (send) { return ( {send.state === "queued" || send.state === "sending" ? ( ) : null} - {send.state === "sent" ? "✓ " : null} {SEND_CHIP_LABELS[send.state]} - {send.state === "sent" - ? ` ${new Date(send.at).toLocaleTimeString()}` - : null} ); } if (live) return null; return not sent; } + +function ingestedMatchUrl(link: IngestedMatchLink): string { + return link.type === "tournament" + ? tournamentMatchPage({ + tournamentId: link.tournamentId, + matchId: link.matchId, + }) + : sendouQMatchPage(link.groupMatchId); +} + +function ingestedMatchLabel(link: IngestedMatchLink): string { + return link.type === "tournament" + ? `Match ID #${link.matchId}` + : `SQ Match ID #${link.groupMatchId}`; +} diff --git a/app/features/scanner/components/VodPage.tsx b/app/features/scanner/components/VodPage.tsx index a3243a02f..c96f8476e 100644 --- a/app/features/scanner/components/VodPage.tsx +++ b/app/features/scanner/components/VodPage.tsx @@ -18,6 +18,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link } from "react-router"; import { SendouButton } from "~/components/elements/Button"; import { SendouMenu, SendouMenuItem } from "~/components/elements/Menu"; +import { FormWithConfirm } from "~/components/FormWithConfirm"; import { ObjectiveTimeline } from "~/components/ObjectiveTimeline"; import { openSeekScan, probeWebCodecs } from "../capture/vod-frames"; import { connectAbilities } from "../core/ability-harvest"; @@ -44,6 +45,7 @@ import { loadVodEvents, saveVod, saveVodResultsSend, + type VodResultsSend, type VodSummary, } from "../store/vods"; import { @@ -105,13 +107,7 @@ interface Progress { /** "Send results" progress/outcome shown next to the button. */ type ResultsSend = | { state: "sending"; sent: number; total: number } - | { - state: "done"; - sent: number; - total: number; - error: string | null; - at: number; - }; + | ({ state: "done" } & VodResultsSend); export function VodPage({ sendouUser, @@ -161,6 +157,14 @@ export function VodPage({ const builtMatches = buildScannerMatches(matches.map((m) => m.event)); const skipReasons = ingestSkipReasons(builtMatches); + // mirrors sendVodResults' ingestable-match order, which the send outcome's + // links are keyed by + const ingestableBuilt = builtMatches.filter((b) => !skipReasons.has(b)); + const linkByIngestableIndex = new Map( + (resultsSend?.state === "done" ? (resultsSend.links ?? []) : []).map( + (linked) => [linked.matchIndex, linked.link] as const, + ), + ); const vodMatchByEvent = new Map(matches.map((m) => [m.event, m] as const)); const groupedEvents = new Set(builtMatches.flatMap((b) => b.sources)); const ungroupedMatches = matches.filter((m) => !groupedEvents.has(m.event)); @@ -213,11 +217,12 @@ export function VodPage({ const report = await sendVodResults(events, (sent, total) => setResultsSend({ state: "sending", sent, total }), ); - const outcome = { + const outcome: VodResultsSend = { sent: report.sentMatches, total: report.totalMatches, error: report.error, at: Date.now(), + links: report.links, }; setResultsSend({ state: "done", ...outcome }); // the scan is saved under its file name, so its send outcome can be @@ -518,7 +523,6 @@ export function VodPage({ const removeVod = useCallback( async (name: string) => { - if (!window.confirm(`Delete saved analysis of "${name}"?`)) return; await deleteVod(name); await refreshVods(); }, @@ -665,15 +669,19 @@ export function VodPage({ - } - aria-label="Delete" - onPress={() => void removeVod(vod.name)} - /> + void removeVod(vod.name)} + > + } + aria-label="Delete" + /> + ))} @@ -716,6 +724,10 @@ export function VodPage({ keyOf={(built) => vodMatchByEvent.get(built.sources[0]!)!.key} renderMatch={(built, justFormed) => { const skipReason = skipReasons.get(built); + const link = linkByIngestableIndex.get( + ingestableBuilt.indexOf(built), + ); + const send = skipReason ? undefined : bulkSend; // counter reads render as one timeline chart, not a card each; // a non-SZ match's reads (objective null) are never shown const objectiveEvents = built.match.objective @@ -736,7 +748,9 @@ export function VodPage({ } skipReason={skipReason} justFormed={justFormed} - send={skipReason ? undefined : bulkSend} + send={ + send?.state === "sent" && link ? { ...send, link } : send + } > {objectiveEvents.length > 0 ? ( linked.matchIndex === 0, + )?.link; + await updateEventsSend(ids, { + state: "sent", + at: Date.now(), + ...(link ? { link } : null), + }); result.sentMatches++; } catch (err) { await updateEventsSend(ids, { @@ -83,6 +94,8 @@ export interface VodResultsSendReport { totalMatches: number; /** last failure's message; null when every request went through */ error: string | null; + /** links /ingest reported, keyed by index into the scan's ingestable matches */ + links: Array<{ matchIndex: number; link: IngestedMatchLink }>; } /** @@ -103,17 +116,21 @@ export async function sendVodResults( let sentMatches = 0; let error: string | null = null; + const links: VodResultsSendReport["links"] = []; for (let i = 0; i < matches.length; i += MAX_MATCHES_PER_REQUEST) { const request = matches.slice(i, i + MAX_MATCHES_PER_REQUEST); try { - await postIngestMatches(request); + const response = await postIngestMatches(request); + for (const linked of response.linkedMatches ?? []) { + links.push({ matchIndex: i + linked.matchIndex, link: linked.link }); + } sentMatches += request.length; onProgress?.(sentMatches, matches.length); } catch (err) { error = err instanceof Error ? err.message : String(err); } } - return { sentMatches, totalMatches: matches.length, error }; + return { sentMatches, totalMatches: matches.length, error, links }; } /** The number of matches a set of events would send to /ingest. */ @@ -170,7 +187,9 @@ function ingestableBuilt( return built.filter((match) => !skipped.has(match)); } -async function postIngestMatches(matches: ScannerMatch[]) { +async function postIngestMatches( + matches: ScannerMatch[], +): Promise { const res = await fetch(INGEST_URL, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -181,6 +200,7 @@ async function postIngestMatches(matches: ScannerMatch[]) { res.status === 401 ? "not logged in to sendou.ink" : await errorText(res), ); } + return res.json(); } /** diff --git a/app/features/scanner/components/styles.css b/app/features/scanner/components/styles.css index ed99c3d69..1f82a9775 100644 --- a/app/features/scanner/components/styles.css +++ b/app/features/scanner/components/styles.css @@ -963,6 +963,16 @@ app/styles/vars.css (the emberz copies of those tokens were dropped). color: var(--color-success-high); border-color: var(--color-success-low); background-color: var(--color-success-low); + + & a { + color: inherit; + text-decoration: underline; + text-underline-offset: 2px; + + &:hover { + color: var(--color-text-high); + } + } } &.failed { diff --git a/app/features/scanner/store/events.ts b/app/features/scanner/store/events.ts index 732d5ca5c..92569bc11 100644 --- a/app/features/scanner/store/events.ts +++ b/app/features/scanner/store/events.ts @@ -6,6 +6,7 @@ * so listing the feed never deserializes megabytes of blobs. The store is * capped: saving past MAX_EVENTS evicts the oldest events and their frames. */ +import type { IngestedMatchLink } from "~/features/scanner-ingest/scanner-ingest-schemas"; import type { DetectedEvent } from "../core/detectors/types"; import { db, EVENTS_STORE, FRAMES_STORE, tx } from "./db"; @@ -19,6 +20,8 @@ export interface SendStatus { at: number; /** failure detail, set when state is "failed" */ error?: string; + /** the sendou.ink match /ingest linked the sent match to, when it reported one */ + link?: IngestedMatchLink; } export interface StoredEvent { diff --git a/app/features/scanner/store/vods.ts b/app/features/scanner/store/vods.ts index f8f0257c4..b995624f5 100644 --- a/app/features/scanner/store/vods.ts +++ b/app/features/scanner/store/vods.ts @@ -7,6 +7,7 @@ * their events stays cheap. Re-scanning the same file name overwrites the * previous save. */ +import type { IngestedMatchLink } from "~/features/scanner-ingest/scanner-ingest-schemas"; import { db, tx, VOD_EVENTS_STORE, VOD_FRAMES_STORE, VODS_STORE } from "./db"; /** How a VoD's last "Send results" went; absent = never attempted. */ @@ -18,6 +19,8 @@ export interface VodResultsSend { error: string | null; /** wall-clock time the send finished */ at: number; + /** links /ingest reported, keyed by index into the scan's ingestable matches */ + links?: Array<{ matchIndex: number; link: IngestedMatchLink }>; } export interface VodSummary {