mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-10 13:15:47 -05:00
Links
This commit is contained in:
@@ -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(
|
||||
<fetcher.Form
|
||||
@@ -99,13 +105,26 @@ export function FormWithConfirm({
|
||||
<FormMessage type="info">{description}</FormMessage>
|
||||
) : null}
|
||||
<div className="stack horizontal md justify-center mt-2">
|
||||
<SubmitButton
|
||||
form={id}
|
||||
variant={submitButtonVariant}
|
||||
testId={dialogOpen ? "confirm-button" : submitButtonTestId}
|
||||
>
|
||||
{submitButtonText ?? t("common:actions.delete")}
|
||||
</SubmitButton>
|
||||
{onConfirm ? (
|
||||
<SendouButton
|
||||
variant={submitButtonVariant}
|
||||
testId={dialogOpen ? "confirm-button" : submitButtonTestId}
|
||||
onPress={() => {
|
||||
closeDialog();
|
||||
onConfirm();
|
||||
}}
|
||||
>
|
||||
{submitButtonText ?? t("common:actions.delete")}
|
||||
</SendouButton>
|
||||
) : (
|
||||
<SubmitButton
|
||||
form={id}
|
||||
variant={submitButtonVariant}
|
||||
testId={dialogOpen ? "confirm-button" : submitButtonTestId}
|
||||
>
|
||||
{submitButtonText ?? t("common:actions.delete")}
|
||||
</SubmitButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SendouDialog>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }>;
|
||||
}
|
||||
|
||||
@@ -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<SendStatus["state"], string> = {
|
||||
const SEND_CHIP_LABELS: Record<Exclude<SendStatus["state"], "sent">, string> = {
|
||||
queued: "queued",
|
||||
sending: "sending…",
|
||||
sent: "ingested",
|
||||
failed: "failed",
|
||||
};
|
||||
|
||||
@@ -262,20 +263,50 @@ function StatusChip({
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (send?.state === "sent") {
|
||||
return (
|
||||
<span
|
||||
className="match-chip sent"
|
||||
title={`ingested ${new Date(send.at).toLocaleTimeString()}`}
|
||||
>
|
||||
✓
|
||||
{send.link ? (
|
||||
<a
|
||||
href={ingestedMatchUrl(send.link)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{ingestedMatchLabel(send.link)}
|
||||
</a>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (send) {
|
||||
return (
|
||||
<span className={clsx("match-chip", send.state)} title={send.error}>
|
||||
{send.state === "queued" || send.state === "sending" ? (
|
||||
<span className="dot" />
|
||||
) : null}
|
||||
{send.state === "sent" ? "✓ " : null}
|
||||
{SEND_CHIP_LABELS[send.state]}
|
||||
{send.state === "sent"
|
||||
? ` ${new Date(send.at).toLocaleTimeString()}`
|
||||
: null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (live) return null;
|
||||
return <span className="match-chip">not sent</span>;
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
<button type="button" onClick={() => void openStored(vod)}>
|
||||
Open
|
||||
</button>
|
||||
<SendouButton
|
||||
variant="destructive"
|
||||
size="small"
|
||||
shape="square"
|
||||
className="vod-delete"
|
||||
icon={<Trash2 />}
|
||||
aria-label="Delete"
|
||||
onPress={() => void removeVod(vod.name)}
|
||||
/>
|
||||
<FormWithConfirm
|
||||
dialogHeading={`Delete saved analysis of "${vod.name}"?`}
|
||||
onConfirm={() => void removeVod(vod.name)}
|
||||
>
|
||||
<SendouButton
|
||||
variant="destructive"
|
||||
size="small"
|
||||
shape="square"
|
||||
className="vod-delete"
|
||||
icon={<Trash2 />}
|
||||
aria-label="Delete"
|
||||
/>
|
||||
</FormWithConfirm>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -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 ? (
|
||||
<ObjectiveTimeline
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
* hash, merges partials, and scoreboards first-ingest-wins.
|
||||
*/
|
||||
|
||||
import type {
|
||||
IngestedMatchLink,
|
||||
IngestResponse,
|
||||
} from "~/features/scanner-ingest/scanner-ingest-schemas";
|
||||
import type { DetectedEvent } from "../core/detectors/types";
|
||||
import type { BuiltMatch } from "../core/match-builder";
|
||||
import { buildScannerMatches, ingestSkipReasons } from "../core/match-builder";
|
||||
@@ -62,8 +66,15 @@ export async function sendMatches({
|
||||
await updateEventsSend(ids, { state: "sending", at: Date.now() });
|
||||
onStatus();
|
||||
try {
|
||||
await postIngestMatches([built.match]);
|
||||
await updateEventsSend(ids, { state: "sent", at: Date.now() });
|
||||
const response = await postIngestMatches([built.match]);
|
||||
const link = response.linkedMatches?.find(
|
||||
(linked) => 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<E extends DetectedEvent>(
|
||||
return built.filter((match) => !skipped.has(match));
|
||||
}
|
||||
|
||||
async function postIngestMatches(matches: ScannerMatch[]) {
|
||||
async function postIngestMatches(
|
||||
matches: ScannerMatch[],
|
||||
): Promise<IngestResponse> {
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user