mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-30 15:17:43 -05:00
Split styles
This commit is contained in:
@@ -10,9 +10,8 @@ detected game per object, every field nullable — which feed `/ingest`
|
||||
emberz repo; see `MIGRATION.md` there.
|
||||
|
||||
Deliberate convention exceptions (dev tool, ported wholesale): the UI is
|
||||
English-only (no i18next) and styled by one global `components/styles.css`
|
||||
instead of per-component CSS modules; `tests/node-test-compat.ts` uses a
|
||||
default export to stay a `node:test` drop-in.
|
||||
English-only (no i18next) and `tests/node-test-compat.ts` uses a default
|
||||
export to stay a `node:test` drop-in.
|
||||
|
||||
## Commands
|
||||
|
||||
|
||||
13
app/features/scanner/components/AbilityGrid.module.css
Normal file
13
app/features/scanner/components/AbilityGrid.module.css
Normal file
@@ -0,0 +1,13 @@
|
||||
button.abilityTrigger {
|
||||
height: auto;
|
||||
padding: var(--s-0-5);
|
||||
background: var(--color-bg-higher);
|
||||
border: 1px solid var(--color-border-high);
|
||||
border-radius: var(--radius-selector);
|
||||
|
||||
& img {
|
||||
display: block;
|
||||
width: var(--field-size-icon);
|
||||
height: var(--field-size-icon);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import { Button } from "react-aria-components";
|
||||
import { Ability } from "~/components/Ability";
|
||||
import { SendouPopover } from "~/components/elements/Popover";
|
||||
import type { AbilityWithUnknown } from "~/modules/in-game-lists/types";
|
||||
import styles from "./AbilityGrid.module.css";
|
||||
import eventCardStyles from "./EventCard.module.css";
|
||||
|
||||
const ROW_LABELS = ["head", "clothes", "shoes"] as const;
|
||||
|
||||
@@ -16,7 +18,7 @@ export function AbilityGrid({
|
||||
abilities: AbilityWithUnknown[][];
|
||||
}) {
|
||||
return (
|
||||
<table className="players">
|
||||
<table className={eventCardStyles.players}>
|
||||
<tbody>
|
||||
{abilities.map((row, i) => (
|
||||
<tr key={i}>
|
||||
@@ -48,7 +50,7 @@ export function AbilityPopover({
|
||||
<SendouPopover
|
||||
trigger={
|
||||
<Button
|
||||
className="ability-trigger"
|
||||
className={styles.abilityTrigger}
|
||||
aria-label="Show abilities (from death events)"
|
||||
>
|
||||
<Ability ability={trigger} size="TINY" />
|
||||
|
||||
36
app/features/scanner/components/App.module.css
Normal file
36
app/features/scanner/components/App.module.css
Normal file
@@ -0,0 +1,36 @@
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-6);
|
||||
padding-bottom: var(--s-3);
|
||||
border-bottom: var(--border-width) solid var(--color-bg-high);
|
||||
margin-bottom: var(--s-4);
|
||||
|
||||
& nav {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
|
||||
& a {
|
||||
color: var(--color-text-high);
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-bold);
|
||||
text-decoration: none;
|
||||
padding: var(--s-1) var(--s-2-5);
|
||||
border-radius: var(--radius-field);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: var(--focus-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--color-text-accent);
|
||||
background: var(--color-bg-high);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,19 @@
|
||||
import clsx from "clsx";
|
||||
import { Link } from "react-router";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { useSearchParam } from "~/modules/search-params/hooks";
|
||||
import { SCANNER_PAGE } from "~/utils/urls";
|
||||
import scannerStyles from "../scanner.module.css";
|
||||
import {
|
||||
SCANNER_TABS,
|
||||
type ScannerTab,
|
||||
scannerSearchParams,
|
||||
} from "../scanner-search-params";
|
||||
import styles from "./App.module.css";
|
||||
import { LivePage } from "./LivePage";
|
||||
import { ScreenshotPage } from "./ScreenshotPage";
|
||||
import type { SendouUser } from "./sendou-ingest";
|
||||
import { VodPage } from "./VodPage";
|
||||
import "./styles.css";
|
||||
|
||||
const TAB_LABELS: Record<ScannerTab, string> = {
|
||||
live: "Live",
|
||||
@@ -36,14 +38,14 @@ export function App() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="scanner-app">
|
||||
<header className="topbar">
|
||||
<div className={scannerStyles.app}>
|
||||
<header className={styles.topbar}>
|
||||
<nav>
|
||||
{SCANNER_TABS.map((tabOption) => (
|
||||
<Link
|
||||
key={tabOption}
|
||||
to={scannerSearchParams.href(SCANNER_PAGE, { tab: tabOption })}
|
||||
className={tab === tabOption ? "active" : ""}
|
||||
className={clsx({ [styles.active]: tab === tabOption })}
|
||||
>
|
||||
{TAB_LABELS[tabOption]}
|
||||
</Link>
|
||||
|
||||
40
app/features/scanner/components/DeathCard.module.css
Normal file
40
app/features/scanner/components/DeathCard.module.css
Normal file
@@ -0,0 +1,40 @@
|
||||
.body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-3);
|
||||
padding-block: var(--s-0-5);
|
||||
}
|
||||
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-0-5);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: var(--font-xs);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.weapon {
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-semi);
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.abilities {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2-5);
|
||||
margin-inline-start: auto;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.gear {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-0-5);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import clsx from "clsx";
|
||||
import { Ability } from "~/components/Ability";
|
||||
import { WeaponImage } from "~/components/Image";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
@@ -5,6 +6,8 @@ import {
|
||||
DEATH_EVENT_TYPE,
|
||||
type DeathData,
|
||||
} from "../core/detectors/death/index";
|
||||
import styles from "./DeathCard.module.css";
|
||||
import eventCardStyles from "./EventCard.module.css";
|
||||
import { FrameThumb } from "./FrameThumb";
|
||||
import { useEventTimeFormatter } from "./format";
|
||||
import { weaponLabel } from "./labels";
|
||||
@@ -25,8 +28,8 @@ export function DeathCard(props: {
|
||||
const weaponName = weaponLabel(data.weaponType, data.weaponId);
|
||||
const formatDetectedAt = useEventTimeFormatter();
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="meta">
|
||||
<div className={eventCardStyles.card}>
|
||||
<div className={eventCardStyles.meta}>
|
||||
<MetaPills
|
||||
t={t}
|
||||
confidence={confidence}
|
||||
@@ -41,26 +44,26 @@ export function DeathCard(props: {
|
||||
fixture={{ data, type: "Death" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="teams death">
|
||||
<div className="team">
|
||||
<div className="death-body">
|
||||
<div className={clsx(eventCardStyles.teams, eventCardStyles.death)}>
|
||||
<div className={eventCardStyles.team}>
|
||||
<div className={styles.body}>
|
||||
{data.weaponId !== null && data.weaponType === "MAIN" ? (
|
||||
<WeaponImage
|
||||
weaponSplId={data.weaponId as MainWeaponId}
|
||||
variant="build"
|
||||
size={28}
|
||||
className="weapon-icon"
|
||||
className={eventCardStyles.weaponIcon}
|
||||
/>
|
||||
) : null}
|
||||
<div className="death-info">
|
||||
<span className="death-name">
|
||||
<div className={styles.info}>
|
||||
<span className={styles.name}>
|
||||
splatted by <b>{data.name ?? "?"}</b>
|
||||
</span>
|
||||
<span className="death-weapon">{weaponName ?? "?"}</span>
|
||||
<span className={styles.weapon}>{weaponName ?? "?"}</span>
|
||||
</div>
|
||||
<div className="death-abilities">
|
||||
<div className={styles.abilities}>
|
||||
{data.abilities.map((row, i) => (
|
||||
<div key={i} className="gear">
|
||||
<div key={i} className={styles.gear}>
|
||||
{row.map((id, j) => (
|
||||
<Ability
|
||||
key={j}
|
||||
|
||||
142
app/features/scanner/components/EventCard.module.css
Normal file
142
app/features/scanner/components/EventCard.module.css
Normal file
@@ -0,0 +1,142 @@
|
||||
/* card chrome shared by every detected-event card, plus the sendou.ink send
|
||||
status strip the feed pages hang under one */
|
||||
|
||||
.card {
|
||||
background: var(--color-bg-high);
|
||||
border-radius: var(--radius-box);
|
||||
/* MatchCard's nested event list tightens this */
|
||||
padding: var(--scanner-card-padding, var(--s-4));
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s-2) var(--s-4);
|
||||
color: var(--color-text-high);
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-semi);
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin-bottom: var(--s-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.teams {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
/* single hug-content box (own-results cards) */
|
||||
.solo {
|
||||
grid-template-columns: max-content;
|
||||
}
|
||||
|
||||
/* single full-width box (death cards) */
|
||||
.death {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* lighter than a solid slab: translucent fill + hairline so the boxes read
|
||||
as grouping, not chrome */
|
||||
.team {
|
||||
border-radius: var(--radius-field);
|
||||
padding: var(--s-2) var(--s-2-5);
|
||||
background: color-mix(in oklab, var(--color-bg) 45%, transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--color-border) 55%, transparent);
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
|
||||
& h3 {
|
||||
margin: 0 0 var(--s-1-5);
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-bold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
&.win h3 {
|
||||
color: var(--color-error-high);
|
||||
}
|
||||
|
||||
&.lose h3 {
|
||||
color: var(--color-info-high);
|
||||
}
|
||||
}
|
||||
|
||||
.players {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--font-xs);
|
||||
|
||||
& td {
|
||||
padding: var(--s-0-5) var(--s-1-5);
|
||||
white-space: nowrap;
|
||||
|
||||
&.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.solo .players {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.weaponCell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
/* round black pucks, same language as the match card's weapon row */
|
||||
.weaponIcon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
vertical-align: middle;
|
||||
background: var(--color-bg-badge);
|
||||
border-radius: var(--radius-full);
|
||||
padding: var(--s-0-5);
|
||||
}
|
||||
|
||||
/* sendou.ink send status strip under a feed card */
|
||||
.sendWrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-0-5);
|
||||
}
|
||||
|
||||
.sendStrip {
|
||||
display: flex;
|
||||
gap: var(--s-3);
|
||||
align-items: center;
|
||||
padding: var(--s-1) var(--s-4);
|
||||
border-radius: var(--radius-box);
|
||||
background: var(--color-bg-high);
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-semi);
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.sent {
|
||||
color: var(--color-success-high);
|
||||
background: var(--color-success-low);
|
||||
}
|
||||
|
||||
.failed {
|
||||
color: var(--color-error-high);
|
||||
background: var(--color-error-low);
|
||||
}
|
||||
|
||||
.queued,
|
||||
.sending {
|
||||
color: var(--color-info-high);
|
||||
background: var(--color-info-low);
|
||||
}
|
||||
|
||||
.sendError {
|
||||
color: inherit;
|
||||
font-weight: var(--weight-body);
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import { scannerSearchParams } from "../scanner-search-params";
|
||||
import type { SendStatus } from "../store/events";
|
||||
import { newInspectKey, putInspectFrame } from "../store/inspect";
|
||||
import { DeathCard } from "./DeathCard";
|
||||
import styles from "./EventCard.module.css";
|
||||
import type { FixtureData } from "./fixture-export";
|
||||
import { useEventTimeFormatter } from "./format";
|
||||
import { MapStartCard } from "./MapStartCard";
|
||||
@@ -89,7 +90,7 @@ export function EventCard(props: {
|
||||
const card = renderCard(type, data, shared, props.abilities);
|
||||
if (!props.send && !props.onSend) return card;
|
||||
return (
|
||||
<div className="send-wrap">
|
||||
<div className={styles.sendWrap}>
|
||||
{card}
|
||||
<SendStrip send={props.send} onSend={props.onSend} />
|
||||
</div>
|
||||
@@ -114,12 +115,14 @@ function SendStrip({
|
||||
const state = send?.state;
|
||||
const formatSentAt = useEventTimeFormatter();
|
||||
return (
|
||||
<div className={clsx("send-strip", state ?? "unsent")}>
|
||||
<div className={clsx(styles.sendStrip, state ? styles[state] : null)}>
|
||||
<span>
|
||||
sendou.ink: {state ? SEND_LABELS[state] : "not sent"}
|
||||
{state === "sent" && send ? ` ${formatSentAt(send.at)}` : null}
|
||||
</span>
|
||||
{send?.error ? <span className="error">{send.error}</span> : null}
|
||||
{send?.error ? (
|
||||
<span className={styles.sendError}>{send.error}</span>
|
||||
) : null}
|
||||
{onSend && state !== "sent" && state !== "sending" ? (
|
||||
<button type="button" onClick={onSend}>
|
||||
{state === "failed" ? "Retry" : "Send"}
|
||||
|
||||
@@ -43,5 +43,5 @@ export function EventTypeIcon({
|
||||
size?: number;
|
||||
}) {
|
||||
const Icon = EVENT_TYPE_ICONS[type] ?? CircleHelp;
|
||||
return <Icon size={size} aria-hidden className="event-type-icon" />;
|
||||
return <Icon size={size} aria-hidden />;
|
||||
}
|
||||
|
||||
37
app/features/scanner/components/EventsSummary.module.css
Normal file
37
app/features/scanner/components/EventsSummary.module.css
Normal file
@@ -0,0 +1,37 @@
|
||||
.summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2-5);
|
||||
flex-wrap: wrap;
|
||||
padding: var(--s-0-5) var(--s-1);
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-semi);
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.type {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-bg-higher);
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
button.toggle {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text-accent);
|
||||
height: auto;
|
||||
padding: 0;
|
||||
font-size: var(--font-2xs);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { SCOREBOARD_EVENT_TYPE } from "../core/detectors/scoreboard/index";
|
||||
import { SCOREBOARD_BATTLE_LOG_EVENT_TYPE } from "../core/detectors/scoreboard-battle-log/index";
|
||||
import { SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE } from "../core/detectors/scoreboard-battle-log-replay/index";
|
||||
import { SCOREBOARD_OWN_EVENT_TYPE } from "../core/detectors/scoreboard-own/index";
|
||||
import styles from "./EventsSummary.module.css";
|
||||
import { EventTypeIcon } from "./EventTypeIcon";
|
||||
|
||||
const EVENT_TYPE_LABELS: Record<string, string> = {
|
||||
@@ -40,23 +41,23 @@ export function EventsSummary({
|
||||
const sorted = Object.entries(counts).toSorted((a, b) => b[1] - a[1]);
|
||||
|
||||
return (
|
||||
<div className="events-summary">
|
||||
<div className={styles.summary}>
|
||||
{sorted.map(([type, count]) => {
|
||||
const label = EVENT_TYPE_LABELS[type] ?? type;
|
||||
return (
|
||||
<span
|
||||
key={type}
|
||||
className="events-summary-type"
|
||||
className={styles.type}
|
||||
title={`${count} ${label}${count === 1 ? "" : "s"}`}
|
||||
>
|
||||
<span className="events-summary-icon">
|
||||
<span className={styles.icon}>
|
||||
<EventTypeIcon type={type} />
|
||||
</span>
|
||||
×{count}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<button type="button" className="events-toggle" onClick={onToggle}>
|
||||
<button type="button" className={styles.toggle} onClick={onToggle}>
|
||||
{open ? "Hide events" : "Show events"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
39
app/features/scanner/components/FrameThumb.module.css
Normal file
39
app/features/scanner/components/FrameThumb.module.css
Normal file
@@ -0,0 +1,39 @@
|
||||
/* an unstyled button so the image itself is the target */
|
||||
button.thumbButton {
|
||||
height: auto;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
margin-inline-start: auto;
|
||||
border-radius: var(--radius-field);
|
||||
|
||||
&:hover {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
.thumb {
|
||||
display: block;
|
||||
height: 40px;
|
||||
width: auto;
|
||||
border-radius: var(--radius-field);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
width: max-content;
|
||||
max-width: min(96vw, 1400px);
|
||||
}
|
||||
|
||||
.frameFull {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: calc(80dvh - 9rem);
|
||||
border-radius: var(--radius-field);
|
||||
}
|
||||
|
||||
.frameActions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: var(--s-3);
|
||||
margin-block-start: var(--s-3);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { ExternalLink, FlaskConical } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import styles from "./FrameThumb.module.css";
|
||||
import { type FixtureData, saveFixtureFromEvent } from "./fixture-export";
|
||||
|
||||
export function FrameThumb({
|
||||
@@ -60,26 +61,26 @@ export function FrameThumb({
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="thumb-button"
|
||||
className={styles.thumbButton}
|
||||
title="View frame"
|
||||
onClick={show}
|
||||
>
|
||||
<img className="thumb" src={thumbnail} alt="analyzed frame" />
|
||||
<img className={styles.thumb} src={thumbnail} alt="analyzed frame" />
|
||||
</button>
|
||||
{open ? (
|
||||
<SendouDialog
|
||||
isDismissable
|
||||
aria-label="Analyzed frame"
|
||||
className="scanner-frame-dialog"
|
||||
className={styles.dialog}
|
||||
onClose={() => setOpen(false)}
|
||||
>
|
||||
<img
|
||||
className="frame-full"
|
||||
className={styles.frameFull}
|
||||
src={frameUrl ?? thumbnail}
|
||||
alt="analyzed frame"
|
||||
/>
|
||||
{onInspect || onSaveFixture ? (
|
||||
<div className="frame-actions">
|
||||
<div className={styles.frameActions}>
|
||||
{onInspect ? (
|
||||
<SendouButton
|
||||
size="small"
|
||||
|
||||
34
app/features/scanner/components/LivePage.module.css
Normal file
34
app/features/scanner/components/LivePage.module.css
Normal file
@@ -0,0 +1,34 @@
|
||||
/* the global select is full width, which would break the controls row */
|
||||
.deviceSelect {
|
||||
appearance: none;
|
||||
width: auto;
|
||||
max-width: 20rem;
|
||||
border: var(--border-style);
|
||||
border-radius: var(--radius-field);
|
||||
background-color: var(--color-bg);
|
||||
background-image: var(--field-icon-chevron);
|
||||
background-position: center right var(--field-padding);
|
||||
background-size: var(--field-size-icon) auto;
|
||||
background-repeat: no-repeat;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: var(--font-xs);
|
||||
height: var(--field-size-sm);
|
||||
padding: 0 calc(var(--field-size-icon) + var(--field-padding) + var(--s-2)) 0
|
||||
var(--field-padding);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
outline: none;
|
||||
|
||||
&:focus-within {
|
||||
outline: var(--focus-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
invalidObjectiveEvents,
|
||||
} from "../core/match-builder";
|
||||
import { TimelineBuilder } from "../core/timeline/index";
|
||||
import scannerStyles from "../scanner.module.css";
|
||||
import {
|
||||
clearEvents,
|
||||
deleteEvents,
|
||||
@@ -43,6 +44,7 @@ import { EventCard } from "./EventCard";
|
||||
import { EventsSummary } from "./EventsSummary";
|
||||
import { downloadEventsCsv } from "./events-csv";
|
||||
import { type FixtureData, saveFixture } from "./fixture-export";
|
||||
import styles from "./LivePage.module.css";
|
||||
import { MatchCard } from "./MatchCard";
|
||||
import { MatchLobbyTabs } from "./MatchLobbyTabs";
|
||||
import { objectiveDomain, playerStatusTeams } from "./player-status-view";
|
||||
@@ -305,7 +307,7 @@ export function LivePage({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="controls">
|
||||
<div className={scannerStyles.controls}>
|
||||
{!running ? (
|
||||
<>
|
||||
<button
|
||||
@@ -319,7 +321,7 @@ export function LivePage({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="outlined"
|
||||
className={scannerStyles.outlined}
|
||||
onClick={() => void start(false)}
|
||||
>
|
||||
Start capture (no sending)
|
||||
@@ -332,7 +334,7 @@ export function LivePage({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="outlined"
|
||||
className={scannerStyles.outlined}
|
||||
disabled={!sendouUser}
|
||||
title={sendouUser ? undefined : "Log in on sendou.ink first"}
|
||||
onClick={() => {
|
||||
@@ -345,7 +347,11 @@ export function LivePage({
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<select value={deviceId} onChange={(e) => setDeviceId(e.target.value)}>
|
||||
<select
|
||||
className={styles.deviceSelect}
|
||||
value={deviceId}
|
||||
onChange={(e) => setDeviceId(e.target.value)}
|
||||
>
|
||||
<option value="">Default camera (OBS Virtual Camera)</option>
|
||||
{devices.map((d) => (
|
||||
<option key={d.deviceId} value={d.deviceId}>
|
||||
@@ -354,17 +360,20 @@ export function LivePage({
|
||||
))}
|
||||
</select>
|
||||
<span
|
||||
className={clsx("status", {
|
||||
detected: status === "detected",
|
||||
watching: status === "watching",
|
||||
idle: status !== "detected" && status !== "watching",
|
||||
className={clsx(scannerStyles.status, {
|
||||
[scannerStyles.detected]: status === "detected",
|
||||
[scannerStyles.watching]: status === "watching",
|
||||
[scannerStyles.idle]:
|
||||
status !== "detected" && status !== "watching",
|
||||
})}
|
||||
>
|
||||
{status}
|
||||
{gateScore !== null ? ` · gate ${gateScore.toFixed(2)}` : null}
|
||||
</span>
|
||||
{liveSend ? (
|
||||
<span className="status watching">sending matches live</span>
|
||||
<span className={clsx(scannerStyles.status, scannerStyles.watching)}>
|
||||
sending matches live
|
||||
</span>
|
||||
) : null}
|
||||
<LiveMenu
|
||||
canSaveFixture={running}
|
||||
@@ -390,13 +399,20 @@ export function LivePage({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{error ? <p className="error">{error}</p> : null}
|
||||
{sendouError ? <p className="error">{sendouError}</p> : null}
|
||||
<div className="live-layout">
|
||||
<video ref={videoRef} className="preview" muted playsInline />
|
||||
<div className="feed">
|
||||
{error ? <p className={scannerStyles.error}>{error}</p> : null}
|
||||
{sendouError ? (
|
||||
<p className={scannerStyles.error}>{sendouError}</p>
|
||||
) : null}
|
||||
<div className={scannerStyles.liveLayout}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
className={scannerStyles.preview}
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
<div className={scannerStyles.feed}>
|
||||
{feed.length === 0 ? (
|
||||
<p className="score">No detections yet.</p>
|
||||
<p className={scannerStyles.score}>No detections yet.</p>
|
||||
) : null}
|
||||
<MatchLobbyTabs
|
||||
matches={builtMatches}
|
||||
@@ -533,7 +549,7 @@ function LiveMenu({
|
||||
trigger={
|
||||
<SendouButton
|
||||
icon={<Ellipsis />}
|
||||
className="icon-menu"
|
||||
className={scannerStyles.iconMenu}
|
||||
aria-label="More actions"
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
MAP_START_EVENT_TYPE,
|
||||
type MapStartData,
|
||||
} from "../core/detectors/map-start/index";
|
||||
import styles from "./EventCard.module.css";
|
||||
import { FrameThumb } from "./FrameThumb";
|
||||
import { useEventTimeFormatter } from "./format";
|
||||
import { modeLabel, stageLabel } from "./labels";
|
||||
@@ -21,8 +22,8 @@ export function MapStartCard(props: {
|
||||
props;
|
||||
const formatDetectedAt = useEventTimeFormatter();
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="meta">
|
||||
<div className={styles.card}>
|
||||
<div className={styles.meta}>
|
||||
<MetaPills
|
||||
t={t}
|
||||
confidence={confidence}
|
||||
|
||||
391
app/features/scanner/components/MatchCard.module.css
Normal file
391
app/features/scanner/components/MatchCard.module.css
Normal file
@@ -0,0 +1,391 @@
|
||||
.group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.matchCard {
|
||||
container: match-card / inline-size;
|
||||
position: sticky;
|
||||
top: var(--layout-sticky-top);
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-box);
|
||||
background-color: var(--color-bg-high);
|
||||
|
||||
/* only cards the scan just formed animate in — see MatchCard's `enter` */
|
||||
&.enter {
|
||||
animation: scanner-card-in 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
/* one overlay element all send-state effects draw on */
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&.sending {
|
||||
box-shadow: inset 0 0 0 1px var(--color-info-low);
|
||||
|
||||
&::after {
|
||||
background-image: linear-gradient(
|
||||
105deg,
|
||||
transparent 40%,
|
||||
rgb(255 255 255 / 0.09) 50%,
|
||||
transparent 60%
|
||||
);
|
||||
background-size: 250% 100%;
|
||||
animation: scanner-sheen 1.4s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
&.flashSent::after {
|
||||
animation: scanner-sent-pop 0.8s ease-out both;
|
||||
}
|
||||
|
||||
&.live {
|
||||
box-shadow: inset 0 0 0 1px var(--color-info-low);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scanner-card-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px) scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scanner-sheen {
|
||||
from {
|
||||
background-position: 130% 0;
|
||||
}
|
||||
to {
|
||||
background-position: -130% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scanner-sent-pop {
|
||||
0% {
|
||||
opacity: 1;
|
||||
box-shadow:
|
||||
inset 0 0 0 2px var(--color-success),
|
||||
inset 0 0 32px var(--color-success-low);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
box-shadow:
|
||||
inset 0 0 0 2px var(--color-success),
|
||||
inset 0 0 32px var(--color-success-low);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scanner-shake {
|
||||
20% {
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
80% {
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
.main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s-2) var(--s-3);
|
||||
padding: var(--s-3) var(--s-4);
|
||||
min-height: 66px;
|
||||
}
|
||||
|
||||
.matchCard.flashFailed .main {
|
||||
animation: scanner-shake 0.35s ease;
|
||||
}
|
||||
|
||||
.mode {
|
||||
flex-shrink: 0;
|
||||
filter: drop-shadow(0 1px 2px rgb(0 0 0 / 0.5));
|
||||
}
|
||||
|
||||
.headline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1);
|
||||
/* claims row space so the score/chips can never crush the stage name
|
||||
out of view in a narrow feed column; wraps instead */
|
||||
flex: 1 1 0;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s-1) var(--s-2);
|
||||
}
|
||||
|
||||
.stage {
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-extra);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-semi);
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.weapons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s-0-5);
|
||||
|
||||
& .vs {
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-semi);
|
||||
color: var(--color-text-high);
|
||||
margin: 0 5px;
|
||||
}
|
||||
}
|
||||
|
||||
/* a team is one wrapping unit: the eight never break up mid-team */
|
||||
.weaponRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-0-5);
|
||||
}
|
||||
|
||||
.weapon {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
vertical-align: middle;
|
||||
background: var(--color-bg-badge);
|
||||
border-radius: var(--radius-full);
|
||||
padding: var(--s-0-5);
|
||||
|
||||
&.pov {
|
||||
outline: var(--border-style-high);
|
||||
background-color: var(--color-bg-high);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: var(--s-1-5);
|
||||
margin-inline-start: auto;
|
||||
align-self: stretch;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.matchScore {
|
||||
font-weight: var(--weight-extra);
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
padding: 5px var(--s-3);
|
||||
border-radius: var(--radius-full);
|
||||
background: color-mix(in oklab, var(--color-bg) 72%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
|
||||
& .win {
|
||||
color: var(--color-error-high);
|
||||
}
|
||||
|
||||
& .lose {
|
||||
color: var(--color-info-high);
|
||||
}
|
||||
}
|
||||
|
||||
/* undo the scanner-wide solid button look for the banner icon button */
|
||||
button.expand {
|
||||
flex-shrink: 0;
|
||||
margin-block-start: auto;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--radius-full);
|
||||
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);
|
||||
height: var(--field-size-icon);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--color-text);
|
||||
background: color-mix(in oklab, var(--color-bg) 78%, transparent);
|
||||
}
|
||||
|
||||
&.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
&.live {
|
||||
color: var(--color-error);
|
||||
border-color: var(--color-error-low);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
|
||||
& .dot {
|
||||
animation: scanner-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
&.inProgress .dot {
|
||||
animation: scanner-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&.queued,
|
||||
&.sending {
|
||||
color: var(--color-info-high);
|
||||
border-color: var(--color-info-low);
|
||||
background-color: var(--color-info-low);
|
||||
|
||||
& .dot {
|
||||
animation: scanner-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
&.sent {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.unlinked {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scanner-pulse {
|
||||
50% {
|
||||
opacity: 0.25;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 0 var(--s-4) var(--s-2-5);
|
||||
font-size: var(--font-2xs);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
/* source event cards, nested under their match card via an indent rail */
|
||||
.events {
|
||||
--scanner-card-padding: var(--s-2-5) 14px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
margin-inline-start: var(--s-2-5);
|
||||
padding-inline-start: var(--s-3);
|
||||
border-inline-start: var(--border-width) solid var(--color-border);
|
||||
animation: scanner-detail-in 0.25s ease both;
|
||||
}
|
||||
|
||||
@keyframes scanner-detail-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
|
||||
.setDivider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2-5);
|
||||
margin-top: var(--s-1-5);
|
||||
color: var(--color-text-high);
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-semi);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: "";
|
||||
flex: 1;
|
||||
height: var(--border-width);
|
||||
background-color: var(--color-border);
|
||||
}
|
||||
}
|
||||
|
||||
/* the two teams' weapons no longer fit beside each other: one row each */
|
||||
@container match-card (width < 460px) {
|
||||
.weapons {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
row-gap: var(--s-1);
|
||||
|
||||
& .vs {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.matchCard,
|
||||
.matchCard.sending::after,
|
||||
.matchCard.flashSent::after,
|
||||
.matchCard.flashFailed .main,
|
||||
.events,
|
||||
.chip.live .dot,
|
||||
.chip.inProgress .dot,
|
||||
.chip.queued .dot,
|
||||
.chip.sending .dot {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import type { ScannerMatch } from "../core/scanner-match";
|
||||
import type { SendStatus } from "../store/events";
|
||||
import { formatTime, useEventTimeFormatter } from "./format";
|
||||
import { lobbyLabel, modeLabel, stageLabel } from "./labels";
|
||||
import styles from "./MatchCard.module.css";
|
||||
|
||||
/** the game score a knockout wins at */
|
||||
const KO_MATCH_SCORE = 100;
|
||||
@@ -89,24 +90,24 @@ export function MatchCard({
|
||||
|
||||
const inner = (
|
||||
<>
|
||||
<div className="match-card-main">
|
||||
<div className={styles.main}>
|
||||
{match.mode !== null ? (
|
||||
<ModeImage mode={match.mode} size={30} className="match-mode" />
|
||||
<ModeImage mode={match.mode} size={30} className={styles.mode} />
|
||||
) : null}
|
||||
<div className="match-headline">
|
||||
<div className="match-title">
|
||||
<div className="match-stage">
|
||||
<div className={styles.headline}>
|
||||
<div className={styles.title}>
|
||||
<div className={styles.stage}>
|
||||
{stageLabel(match.stage) ?? "Unknown stage"}
|
||||
</div>
|
||||
<StatusChip send={send} skipReason={skipReason} live={live} />
|
||||
</div>
|
||||
{meta ? <div className="match-meta">{meta}</div> : null}
|
||||
{meta ? <div className={styles.meta}>{meta}</div> : null}
|
||||
<TeamWeapons match={match} />
|
||||
</div>
|
||||
<div className="match-side">
|
||||
<div className={styles.side}>
|
||||
{live ? (
|
||||
<span className="match-chip live">
|
||||
<span className="dot" />
|
||||
<span className={clsx(styles.chip, styles.live)}>
|
||||
<span className={styles.dot} />
|
||||
live
|
||||
</span>
|
||||
) : (
|
||||
@@ -125,7 +126,7 @@ export function MatchCard({
|
||||
size="small"
|
||||
shape="circle"
|
||||
icon={<ChevronDown />}
|
||||
className={clsx("match-expand", { expanded })}
|
||||
className={clsx(styles.expand, { [styles.expanded]: expanded })}
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? "Hide events" : "Show events"}
|
||||
onPress={() => setExpanded(!expanded)}
|
||||
@@ -134,17 +135,21 @@ export function MatchCard({
|
||||
</div>
|
||||
</div>
|
||||
{send?.state === "failed" && send.error ? (
|
||||
<div className="match-error">{send.error}</div>
|
||||
<div className={styles.error}>{send.error}</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
const className = clsx("match-card", send?.state, {
|
||||
enter,
|
||||
live,
|
||||
"flash-sent": flash === "sent",
|
||||
"flash-failed": flash === "failed",
|
||||
});
|
||||
const className = clsx(
|
||||
styles.matchCard,
|
||||
send?.state ? styles[send.state] : null,
|
||||
{
|
||||
[styles.enter]: enter,
|
||||
[styles.live]: live,
|
||||
[styles.flashSent]: flash === "sent",
|
||||
[styles.flashFailed]: flash === "failed",
|
||||
},
|
||||
);
|
||||
|
||||
const card =
|
||||
match.stage !== null ? (
|
||||
@@ -157,16 +162,16 @@ export function MatchCard({
|
||||
|
||||
if (!children) return card;
|
||||
return (
|
||||
<div className="match-card-group">
|
||||
<div className={styles.group}>
|
||||
{card}
|
||||
{expanded ? <div className="match-events">{children}</div> : null}
|
||||
{expanded ? <div className={styles.events}>{children}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Labeled rule above the newest card of each set in the feed. */
|
||||
export function SetDivider({ number }: { number: number }) {
|
||||
return <div className="set-divider">Set {number}</div>;
|
||||
return <div className={styles.setDivider}>Set {number}</div>;
|
||||
}
|
||||
|
||||
function timeRangeLabel(match: ScannerMatch): string {
|
||||
@@ -186,8 +191,8 @@ function Score({
|
||||
if (match.matchScores === null) {
|
||||
if (!inProgress) return null;
|
||||
return (
|
||||
<span className="match-chip in-progress">
|
||||
<span className="dot" />
|
||||
<span className={clsx(styles.chip, styles.inProgress)}>
|
||||
<span className={styles.dot} />
|
||||
in progress
|
||||
</span>
|
||||
);
|
||||
@@ -200,12 +205,12 @@ function Score({
|
||||
// scoreboard-sourced matches list the winners first
|
||||
const winnerKnown = match.winner !== null;
|
||||
return (
|
||||
<div className="match-score">
|
||||
<span className={winnerKnown ? "win" : undefined}>
|
||||
<div className={styles.matchScore}>
|
||||
<span className={winnerKnown ? styles.win : undefined}>
|
||||
{scoreLabel(alpha, objectiveScores[0])}
|
||||
</span>
|
||||
<span className="sep"> – </span>
|
||||
<span className={winnerKnown ? "lose" : undefined}>
|
||||
<span> – </span>
|
||||
<span className={winnerKnown ? styles.lose : undefined}>
|
||||
{scoreLabel(bravo, objectiveScores[1])}
|
||||
</span>
|
||||
</div>
|
||||
@@ -251,10 +256,10 @@ function TeamWeapons({ match }: { match: ScannerMatch }) {
|
||||
if (alpha.length + bravo.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="match-weapons">
|
||||
<div className={styles.weapons}>
|
||||
{alpha.length > 0 ? <WeaponRow weapons={alpha} /> : null}
|
||||
{alpha.length > 0 && bravo.length > 0 ? (
|
||||
<span className="vs">vs</span>
|
||||
<span className={styles.vs}>vs</span>
|
||||
) : null}
|
||||
{bravo.length > 0 ? <WeaponRow weapons={bravo} /> : null}
|
||||
</div>
|
||||
@@ -264,14 +269,14 @@ function TeamWeapons({ match }: { match: ScannerMatch }) {
|
||||
/** One team's weapons, kept together when the card is too narrow for both. */
|
||||
function WeaponRow({ weapons }: { weapons: TeamWeapon[] }) {
|
||||
return (
|
||||
<div className="weapon-row">
|
||||
<div className={styles.weaponRow}>
|
||||
{weapons.map((weapon, i) => (
|
||||
<WeaponImage
|
||||
key={i}
|
||||
weaponSplId={weapon.weaponId}
|
||||
variant="build"
|
||||
size={22}
|
||||
className={clsx("weapon-icon", { pov: weapon.pov })}
|
||||
className={clsx(styles.weapon, { [styles.pov]: weapon.pov })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -290,7 +295,7 @@ function StatusChip({
|
||||
const formatSentAt = useEventTimeFormatter();
|
||||
if (skipReason) {
|
||||
return (
|
||||
<span className="match-chip">
|
||||
<span className={styles.chip}>
|
||||
{skipReason === "disconnect" ? "disconnect" : "not ingested"}
|
||||
</span>
|
||||
);
|
||||
@@ -298,7 +303,7 @@ function StatusChip({
|
||||
if (send?.state === "sent") {
|
||||
return (
|
||||
<span
|
||||
className="match-chip sent"
|
||||
className={clsx(styles.chip, styles.sent)}
|
||||
title={`ingested ${formatSentAt(send.at)}`}
|
||||
>
|
||||
✓
|
||||
@@ -316,16 +321,19 @@ function StatusChip({
|
||||
}
|
||||
if (send) {
|
||||
return (
|
||||
<span className={clsx("match-chip", send.state)} title={send.error}>
|
||||
<span
|
||||
className={clsx(styles.chip, styles[send.state])}
|
||||
title={send.error}
|
||||
>
|
||||
{send.state === "queued" || send.state === "sending" ? (
|
||||
<span className="dot" />
|
||||
<span className={styles.dot} />
|
||||
) : null}
|
||||
{SEND_CHIP_LABELS[send.state]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (live) return null;
|
||||
return <span className="match-chip">not sent</span>;
|
||||
return <span className={styles.chip}>not sent</span>;
|
||||
}
|
||||
|
||||
function ingestedMatchUrl(link: IngestedMatchLink): string {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
.matchList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-3);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import type { BuiltMatch } from "../core/match-builder";
|
||||
import { assignMatchSets } from "../core/match-sets";
|
||||
import type { ScannerLobby } from "../scanner-types";
|
||||
import { SetDivider } from "./MatchCard";
|
||||
import styles from "./MatchLobbyTabs.module.css";
|
||||
|
||||
type LobbyGroup = "private" | "x" | "other";
|
||||
|
||||
@@ -62,7 +63,7 @@ export function MatchLobbyTabs<E extends DetectedEvent>({
|
||||
))}
|
||||
</SendouTabList>
|
||||
{groups.map(({ group, matches: groupMatches }) => (
|
||||
<SendouTabPanel key={group} id={group} className="match-list">
|
||||
<SendouTabPanel key={group} id={group} className={styles.matchList}>
|
||||
<MatchList
|
||||
matches={groupMatches}
|
||||
sets={group === "private"}
|
||||
|
||||
27
app/features/scanner/components/MetaChips.module.css
Normal file
27
app/features/scanner/components/MetaChips.module.css
Normal file
@@ -0,0 +1,27 @@
|
||||
/* time + confidence + event type read as one unit, the detail text after it
|
||||
sits further away */
|
||||
.pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
/* neutral chip, the icon carries the accent and documents the value — full
|
||||
label on hover via title */
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: var(--s-0-5) var(--s-2-5);
|
||||
border-radius: var(--radius-full);
|
||||
border: var(--border-style);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-bold);
|
||||
|
||||
& svg {
|
||||
color: var(--color-text-accent);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
import { Clock, Gauge } from "lucide-react";
|
||||
import { EventTypeIcon } from "./EventTypeIcon";
|
||||
import { formatTime } from "./format";
|
||||
import styles from "./MetaChips.module.css";
|
||||
|
||||
export function MetaPills({
|
||||
t,
|
||||
@@ -20,16 +21,16 @@ export function MetaPills({
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="meta-pills">
|
||||
<span className="meta-chip" title="Video timestamp">
|
||||
<Clock size={12} aria-hidden className="meta-chip-icon" />
|
||||
<div className={styles.pills}>
|
||||
<span className={styles.chip} title="Video timestamp">
|
||||
<Clock size={12} aria-hidden />
|
||||
{formatTime(t)}
|
||||
</span>
|
||||
<span className="meta-chip" title="Detection confidence">
|
||||
<Gauge size={12} aria-hidden className="meta-chip-icon" />
|
||||
<span className={styles.chip} title="Detection confidence">
|
||||
<Gauge size={12} aria-hidden />
|
||||
{(confidence * 100).toFixed(0)}%
|
||||
</span>
|
||||
<span className="status detected">
|
||||
<span className={styles.chip}>
|
||||
<EventTypeIcon type={type} />
|
||||
{label}
|
||||
</span>
|
||||
|
||||
96
app/features/scanner/components/MinimapCard.module.css
Normal file
96
app/features/scanner/components/MinimapCard.module.css
Normal file
@@ -0,0 +1,96 @@
|
||||
.player {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
padding-block: 3px;
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
|
||||
.weaponMissing {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
/* the minimap cards are tighter than the scanner's default weapon puck */
|
||||
.weapon {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
vertical-align: middle;
|
||||
background: var(--color-bg-badge);
|
||||
border-radius: var(--radius-full);
|
||||
padding: var(--s-0-5);
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.abilities {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-0-5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.statusChip {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-bold);
|
||||
line-height: 1;
|
||||
|
||||
&.dead {
|
||||
background: var(--color-error-low);
|
||||
color: var(--color-error-high);
|
||||
}
|
||||
|
||||
&.special {
|
||||
background: var(--color-warning-low);
|
||||
color: var(--color-warning-high);
|
||||
}
|
||||
}
|
||||
|
||||
/* d-pad chevron / face-button pucks, same round-solid language as weapon icons */
|
||||
.slotMarker {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-bg-badge);
|
||||
color: #fff;
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-bold);
|
||||
line-height: 1;
|
||||
|
||||
& svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
&.right svg {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
&.down svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
&.left svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import clsx from "clsx";
|
||||
import { ChevronUp } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Ability } from "~/components/Ability";
|
||||
@@ -10,10 +11,12 @@ import {
|
||||
type MinimapTeammate,
|
||||
} from "../core/detectors/minimap/index";
|
||||
import type { CardSlot } from "../core/detectors/minimap/rois";
|
||||
import eventCardStyles from "./EventCard.module.css";
|
||||
import { FrameThumb } from "./FrameThumb";
|
||||
import { useEventTimeFormatter } from "./format";
|
||||
import { stageLabel } from "./labels";
|
||||
import { MetaPills } from "./MetaChips";
|
||||
import styles from "./MinimapCard.module.css";
|
||||
|
||||
const ENEMY_SLOT_LETTERS = ["A", "B", "X", "Y"] as const;
|
||||
|
||||
@@ -39,10 +42,10 @@ function AbilityRow({
|
||||
|
||||
function TeammateMarker({ slot }: { slot: CardSlot }) {
|
||||
if (slot === "self") {
|
||||
return <span className="slot-marker">●</span>;
|
||||
return <span className={styles.slotMarker}>●</span>;
|
||||
}
|
||||
return (
|
||||
<span className={`slot-marker ${slot}`}>
|
||||
<span className={clsx(styles.slotMarker, styles[slot])}>
|
||||
<ChevronUp strokeWidth={3.5} aria-label={slot} role="img" />
|
||||
</span>
|
||||
);
|
||||
@@ -56,30 +59,36 @@ function PlayerRow({
|
||||
player: MinimapTeammate | MinimapEnemy;
|
||||
}) {
|
||||
return (
|
||||
<div className="minimap-player">
|
||||
<div className={styles.player}>
|
||||
{marker}
|
||||
{player.weaponId !== null ? (
|
||||
<WeaponImage
|
||||
weaponSplId={player.weaponId}
|
||||
variant="build"
|
||||
size={24}
|
||||
className="weapon-icon"
|
||||
className={styles.weapon}
|
||||
/>
|
||||
) : (
|
||||
<span className="weapon-missing">?</span>
|
||||
<span className={styles.weaponMissing}>?</span>
|
||||
)}
|
||||
<span className="name">{player.name ?? ""}</span>
|
||||
<span className={styles.name}>{player.name ?? ""}</span>
|
||||
{player.dead ? (
|
||||
<span className="status-chip dead" title="respawning (struck out)">
|
||||
<span
|
||||
className={clsx(styles.statusChip, styles.dead)}
|
||||
title="respawning (struck out)"
|
||||
>
|
||||
✕
|
||||
</span>
|
||||
) : null}
|
||||
{player.specialReady ? (
|
||||
<span className="status-chip special" title="special ready (camo)">
|
||||
<span
|
||||
className={clsx(styles.statusChip, styles.special)}
|
||||
title="special ready (camo)"
|
||||
>
|
||||
★
|
||||
</span>
|
||||
) : null}
|
||||
<span className="abilities">
|
||||
<span className={styles.abilities}>
|
||||
<AbilityRow abilities={player.abilities} />
|
||||
</span>
|
||||
</div>
|
||||
@@ -100,8 +109,8 @@ export function MinimapCard(props: {
|
||||
props;
|
||||
const formatDetectedAt = useEventTimeFormatter();
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="meta">
|
||||
<div className={eventCardStyles.card}>
|
||||
<div className={eventCardStyles.meta}>
|
||||
<MetaPills
|
||||
t={t}
|
||||
confidence={confidence}
|
||||
@@ -117,8 +126,8 @@ export function MinimapCard(props: {
|
||||
fixture={{ data, type: "Minimap" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="teams">
|
||||
<div className="team">
|
||||
<div className={eventCardStyles.teams}>
|
||||
<div className={eventCardStyles.team}>
|
||||
<h3>Team</h3>
|
||||
{data.teammates.map((p) => (
|
||||
<PlayerRow
|
||||
@@ -129,13 +138,13 @@ export function MinimapCard(props: {
|
||||
))}
|
||||
</div>
|
||||
{data.enemies.length > 0 ? (
|
||||
<div className="team">
|
||||
<div className={eventCardStyles.team}>
|
||||
<h3>Enemies</h3>
|
||||
{data.enemies.map((p, i) => (
|
||||
<PlayerRow
|
||||
key={i}
|
||||
marker={
|
||||
<span className="slot-marker">
|
||||
<span className={styles.slotMarker}>
|
||||
{ENEMY_SLOT_LETTERS[i] ?? i + 1}
|
||||
</span>
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
OBJECTIVE_EVENT_TYPE,
|
||||
type ObjectiveData,
|
||||
} from "../core/detectors/objective/index";
|
||||
import styles from "./EventCard.module.css";
|
||||
import { FrameThumb } from "./FrameThumb";
|
||||
import { formatClock, useEventTimeFormatter } from "./format";
|
||||
import { MetaPills } from "./MetaChips";
|
||||
@@ -27,8 +28,8 @@ export function ObjectiveCard(props: {
|
||||
const holder = data.control.findIndex(Boolean);
|
||||
const formatDetectedAt = useEventTimeFormatter();
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="meta">
|
||||
<div className={styles.card}>
|
||||
<div className={styles.meta}>
|
||||
<MetaPills
|
||||
t={t}
|
||||
confidence={confidence}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
PLAYER_STATUS_EVENT_TYPE,
|
||||
type PlayerStatusData,
|
||||
} from "../core/detectors/objective/player-status";
|
||||
import styles from "./EventCard.module.css";
|
||||
import { FrameThumb } from "./FrameThumb";
|
||||
import { formatClock, useEventTimeFormatter } from "./format";
|
||||
import { MetaPills } from "./MetaChips";
|
||||
@@ -24,8 +25,8 @@ export function PlayerStatusCard(props: {
|
||||
.join("");
|
||||
const formatDetectedAt = useEventTimeFormatter();
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="meta">
|
||||
<div className={styles.card}>
|
||||
<div className={styles.meta}>
|
||||
<MetaPills
|
||||
t={t}
|
||||
confidence={confidence}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import clsx from "clsx";
|
||||
import { WeaponImage } from "~/components/Image";
|
||||
import type { PlayerAbilityMap } from "../core/ability-harvest";
|
||||
import type {
|
||||
@@ -6,7 +7,9 @@ import type {
|
||||
} from "../core/detectors/scoreboard/index";
|
||||
import { SCOREBOARD_BATTLE_LOG_EVENT_TYPE } from "../core/detectors/scoreboard-battle-log/index";
|
||||
import { SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE } from "../core/detectors/scoreboard-battle-log-replay/index";
|
||||
import scannerStyles from "../scanner.module.css";
|
||||
import { AbilityPopover } from "./AbilityGrid";
|
||||
import styles from "./EventCard.module.css";
|
||||
import { FrameThumb } from "./FrameThumb";
|
||||
import type { CardData } from "./fixture-export";
|
||||
import { useEventTimeFormatter } from "./format";
|
||||
@@ -24,18 +27,18 @@ function PlayerRows({
|
||||
abilities?: PlayerAbilityMap;
|
||||
}) {
|
||||
return (
|
||||
<table className="players">
|
||||
<table className={styles.players}>
|
||||
<tbody>
|
||||
{players.map((p, i) => (
|
||||
<tr key={i}>
|
||||
<td>
|
||||
<span className="weapon-cell">
|
||||
<span className={styles.weaponCell}>
|
||||
{p.weaponId !== null ? (
|
||||
<WeaponImage
|
||||
weaponSplId={p.weaponId}
|
||||
variant="build"
|
||||
size={28}
|
||||
className="weapon-icon"
|
||||
className={styles.weaponIcon}
|
||||
/>
|
||||
) : null}
|
||||
{abilities?.has(offset + i) ? (
|
||||
@@ -44,8 +47,8 @@ function PlayerRows({
|
||||
</span>
|
||||
</td>
|
||||
<td>{p.name || "?"}</td>
|
||||
<td className="num">{p.paint ?? "?"}p</td>
|
||||
<td className="num">
|
||||
<td className={styles.num}>{p.paint ?? "?"}p</td>
|
||||
<td className={styles.num}>
|
||||
{p.ka ?? "?"}/{p.d ?? "?"}/{p.s ?? "?"}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -82,8 +85,8 @@ export function ScoreboardCard(props: {
|
||||
const isScoreboardBattleLog = eventType === SCOREBOARD_BATTLE_LOG_EVENT_TYPE;
|
||||
const formatDetectedAt = useEventTimeFormatter();
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="meta">
|
||||
<div className={styles.card}>
|
||||
<div className={styles.meta}>
|
||||
<MetaPills
|
||||
t={t}
|
||||
confidence={confidence}
|
||||
@@ -109,7 +112,7 @@ export function ScoreboardCard(props: {
|
||||
) : null}
|
||||
{data.timestamp ? <span>{data.timestamp}</span> : null}
|
||||
{data.replayCode ? (
|
||||
<span className="score">{data.replayCode}</span>
|
||||
<span className={scannerStyles.score}>{data.replayCode}</span>
|
||||
) : null}
|
||||
{detectedAt ? <span>{formatDetectedAt(detectedAt)}</span> : null}
|
||||
<FrameThumb
|
||||
@@ -119,8 +122,8 @@ export function ScoreboardCard(props: {
|
||||
fixture={{ data, type: eventType }}
|
||||
/>
|
||||
</div>
|
||||
<div className="teams">
|
||||
<div className="team win">
|
||||
<div className={styles.teams}>
|
||||
<div className={clsx(styles.team, styles.win)}>
|
||||
<h3>{teamHeading("Victory", data, 0)}</h3>
|
||||
<PlayerRows
|
||||
players={data.players.slice(0, 4)}
|
||||
@@ -128,7 +131,7 @@ export function ScoreboardCard(props: {
|
||||
abilities={props.abilities}
|
||||
/>
|
||||
</div>
|
||||
<div className="team lose">
|
||||
<div className={clsx(styles.team, styles.lose)}>
|
||||
<h3>{teamHeading("Defeat", data, 1)}</h3>
|
||||
<PlayerRows
|
||||
players={data.players.slice(4, 8)}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import clsx from "clsx";
|
||||
import { WeaponImage } from "~/components/Image";
|
||||
import {
|
||||
SCOREBOARD_OWN_EVENT_TYPE,
|
||||
type ScoreboardOwnData,
|
||||
} from "../core/detectors/scoreboard-own/index";
|
||||
import { AbilityGrid } from "./AbilityGrid";
|
||||
import styles from "./EventCard.module.css";
|
||||
import { FrameThumb } from "./FrameThumb";
|
||||
import { useEventTimeFormatter } from "./format";
|
||||
import { lobbyLabel, mainWeaponLabel, modeLabel, stageLabel } from "./labels";
|
||||
@@ -23,8 +25,8 @@ export function ScoreboardOwnCard(props: {
|
||||
props;
|
||||
const formatDetectedAt = useEventTimeFormatter();
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="meta">
|
||||
<div className={styles.card}>
|
||||
<div className={styles.meta}>
|
||||
<MetaPills
|
||||
t={t}
|
||||
confidence={confidence}
|
||||
@@ -51,14 +53,14 @@ export function ScoreboardOwnCard(props: {
|
||||
fixture={{ data, type: "ScoreboardOwn" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="teams solo">
|
||||
<div className="team">
|
||||
<div className={clsx(styles.teams, styles.solo)}>
|
||||
<div className={styles.team}>
|
||||
{data.weaponId !== null ? (
|
||||
<WeaponImage
|
||||
weaponSplId={data.weaponId}
|
||||
variant="build"
|
||||
size={28}
|
||||
className="weapon-icon"
|
||||
className={styles.weaponIcon}
|
||||
/>
|
||||
) : null}
|
||||
<AbilityGrid abilities={data.abilities} />
|
||||
|
||||
233
app/features/scanner/components/ScreenshotPage.module.css
Normal file
233
app/features/scanner/components/ScreenshotPage.module.css
Normal file
@@ -0,0 +1,233 @@
|
||||
.frame {
|
||||
position: relative;
|
||||
margin-bottom: var(--s-4);
|
||||
|
||||
& canvas {
|
||||
width: 100%;
|
||||
border-radius: var(--radius-box);
|
||||
border: var(--border-width) solid var(--color-bg-high);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- per-detector gate results ---- */
|
||||
|
||||
.gateList {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, max-content) 1fr;
|
||||
column-gap: var(--s-4);
|
||||
row-gap: var(--s-1-5);
|
||||
align-items: baseline;
|
||||
font-size: var(--font-xs);
|
||||
margin-block: var(--s-4);
|
||||
}
|
||||
|
||||
.gateRow {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.gateBadge {
|
||||
justify-self: start;
|
||||
align-self: center;
|
||||
padding: 0 var(--s-1-5);
|
||||
border-radius: var(--radius-selector);
|
||||
background: var(--color-bg-high);
|
||||
color: var(--color-text-high);
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-bold);
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gateRow:not(.fired) .gateName {
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.gateRow.fired {
|
||||
& .gateBadge {
|
||||
background: var(--color-success-low);
|
||||
color: var(--color-success-high);
|
||||
}
|
||||
|
||||
& .gateName {
|
||||
font-weight: var(--weight-bold);
|
||||
}
|
||||
}
|
||||
|
||||
.gateScore {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text-high);
|
||||
font-size: var(--font-2xs);
|
||||
}
|
||||
|
||||
.gateNote {
|
||||
color: var(--color-text-high);
|
||||
font-size: var(--font-2xs);
|
||||
}
|
||||
|
||||
/* ---- fired detector's parse detail ---- */
|
||||
|
||||
.detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-3);
|
||||
margin-block: var(--s-4);
|
||||
}
|
||||
|
||||
.detailStats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s-1-5);
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: var(--s-1-5);
|
||||
padding: var(--s-1) var(--s-2);
|
||||
border-radius: var(--radius-field);
|
||||
background: var(--color-bg-high);
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
color: var(--color-text-high);
|
||||
font-size: var(--font-2xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-weight: var(--weight-bold);
|
||||
}
|
||||
|
||||
.statRaw {
|
||||
color: var(--color-text-high);
|
||||
font-size: var(--font-2xs);
|
||||
}
|
||||
|
||||
.detailCrops {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s-3);
|
||||
align-items: end;
|
||||
|
||||
& figure {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
& canvas {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
background: #000;
|
||||
border-radius: var(--radius-selector);
|
||||
border: var(--border-width) solid var(--color-bg-high);
|
||||
}
|
||||
|
||||
& figcaption {
|
||||
color: var(--color-text-high);
|
||||
font-size: var(--font-2xs);
|
||||
}
|
||||
}
|
||||
|
||||
/* one pill per player slot */
|
||||
.statusSlots {
|
||||
display: inline-flex;
|
||||
gap: var(--s-2);
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.statusSide {
|
||||
display: inline-flex;
|
||||
gap: var(--s-0-5);
|
||||
}
|
||||
|
||||
.statusSlot {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: 1.25rem;
|
||||
block-size: 1.25rem;
|
||||
border-radius: var(--radius-selector);
|
||||
background: var(--color-bg-higher);
|
||||
color: var(--color-text-high);
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-bold);
|
||||
|
||||
&.dead {
|
||||
background: var(--color-error-low);
|
||||
color: var(--color-error-high);
|
||||
}
|
||||
|
||||
&.special {
|
||||
background: var(--color-warning-low);
|
||||
color: var(--color-warning-high);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- per-row parse inspector ---- */
|
||||
|
||||
.inspector {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--font-xs);
|
||||
|
||||
& th {
|
||||
text-align: left;
|
||||
color: var(--color-text-high);
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-bold);
|
||||
padding: var(--s-1-5);
|
||||
border-bottom: var(--border-style);
|
||||
}
|
||||
|
||||
& td {
|
||||
padding: var(--s-1-5);
|
||||
border-bottom: 1px solid var(--color-bg-high);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
& canvas {
|
||||
display: block;
|
||||
background: #000;
|
||||
border-radius: var(--radius-selector);
|
||||
}
|
||||
}
|
||||
|
||||
.candidates {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.candidate {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--s-0-5);
|
||||
font-size: var(--font-2xs);
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.weaponCandidates .candidate:first-child {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.weaponIcon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
vertical-align: middle;
|
||||
background: var(--color-bg-badge);
|
||||
border-radius: var(--radius-full);
|
||||
padding: var(--s-0-5);
|
||||
}
|
||||
|
||||
/* the ROI crops set the width; scroll them instead of the whole page */
|
||||
@container scanner (width < 700px) {
|
||||
.inspector {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import * as replay from "../core/detectors/scoreboard-battle-log-replay/rois";
|
||||
import type { ScoreboardOwnData } from "../core/detectors/scoreboard-own/index";
|
||||
import * as own from "../core/detectors/scoreboard-own/rois";
|
||||
import type { DetectedEvent } from "../core/detectors/types";
|
||||
import scannerStyles from "../scanner.module.css";
|
||||
import { scannerSearchParams } from "../scanner-search-params";
|
||||
import { claimInspectFrame } from "../store/inspect";
|
||||
import { AnalyzerClient } from "../worker/client";
|
||||
@@ -43,6 +44,7 @@ import {
|
||||
stageLabel,
|
||||
weaponLabel,
|
||||
} from "./labels";
|
||||
import styles from "./ScreenshotPage.module.css";
|
||||
|
||||
type Result = Extract<WorkerResponse, { kind: "result" }>;
|
||||
|
||||
@@ -77,11 +79,11 @@ function RoiCrop(props: {
|
||||
|
||||
function Stat(props: { label: string; raw?: unknown; children: ReactNode }) {
|
||||
return (
|
||||
<span className="stat">
|
||||
<span className="stat-label">{props.label}</span>
|
||||
<span className="stat-value">{props.children}</span>
|
||||
<span className={styles.stat}>
|
||||
<span className={styles.statLabel}>{props.label}</span>
|
||||
<span className={styles.statValue}>{props.children}</span>
|
||||
{props.raw != null && props.raw !== "" ? (
|
||||
<span className="stat-raw">raw: {String(props.raw)}</span>
|
||||
<span className={styles.statRaw}>raw: {String(props.raw)}</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
@@ -104,15 +106,18 @@ function LabeledCrop(props: {
|
||||
/** One pill per player slot: number = alive, ★ = special held, ✗ = splatted. */
|
||||
function StatusSlots(props: { data: PlayerStatusData }) {
|
||||
return (
|
||||
<span className="status-slots">
|
||||
<span className={styles.statusSlots}>
|
||||
{([0, 1] as const).map((side) => (
|
||||
<span key={side} className="status-side">
|
||||
<span key={side} className={styles.statusSide}>
|
||||
{props.data.dead[side].map((dead, slot) => {
|
||||
const special = !dead && props.data.special[side][slot];
|
||||
return (
|
||||
<span
|
||||
key={slot}
|
||||
className={clsx("status-slot", { dead, special })}
|
||||
className={clsx(styles.statusSlot, {
|
||||
[styles.dead]: dead,
|
||||
[styles.special]: special,
|
||||
})}
|
||||
>
|
||||
{dead ? "✗" : special ? "★" : slot + 1}
|
||||
</span>
|
||||
@@ -511,7 +516,9 @@ export function ScreenshotPage() {
|
||||
<div>
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop target; the file input inside is the accessible path */}
|
||||
<div
|
||||
className={clsx("dropzone", { over })}
|
||||
className={clsx(scannerStyles.dropzone, {
|
||||
[scannerStyles.over]: over,
|
||||
})}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setOver(true);
|
||||
@@ -540,10 +547,10 @@ export function ScreenshotPage() {
|
||||
</label>
|
||||
{busy ? " — analyzing…" : null}
|
||||
</div>
|
||||
{error ? <p className="error">{error}</p> : null}
|
||||
{error ? <p className={scannerStyles.error}>{error}</p> : null}
|
||||
|
||||
<div
|
||||
className="screenshot-frame"
|
||||
className={styles.frame}
|
||||
style={{ display: frame ? "block" : "none" }}
|
||||
>
|
||||
<canvas ref={displayRef} />
|
||||
@@ -575,26 +582,30 @@ export function ScreenshotPage() {
|
||||
) : null}
|
||||
|
||||
{Object.keys(results).length > 0 ? (
|
||||
<div className="gate-list">
|
||||
<div className={styles.gateList}>
|
||||
{Object.values(results).map((result) => (
|
||||
<div
|
||||
key={result.detector}
|
||||
className={clsx("gate-row", { fired: result.gate.pass })}
|
||||
className={clsx(styles.gateRow, {
|
||||
[styles.fired]: result.gate.pass,
|
||||
})}
|
||||
>
|
||||
<span className="gate-badge">
|
||||
<span className={styles.gateBadge}>
|
||||
{result.gate.pass ? "fired" : "no fire"}
|
||||
</span>
|
||||
<span className="gate-name">{result.detector}</span>
|
||||
<span className="gate-score">{result.gate.score.toFixed(3)}</span>
|
||||
<span className="gate-note">{gateSummary(result)}</span>
|
||||
<span className={styles.gateName}>{result.detector}</span>
|
||||
<span className={styles.gateScore}>
|
||||
{result.gate.score.toFixed(3)}
|
||||
</span>
|
||||
<span className={styles.gateNote}>{gateSummary(result)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{frame && event && isReplay ? (
|
||||
<div className="detail">
|
||||
<div className="detail-stats">
|
||||
<div className={styles.detail}>
|
||||
<div className={styles.detailStats}>
|
||||
<Stat label="timestamp">{event.data.timestamp ?? "?"}</Stat>
|
||||
<Stat label="code" raw={event.debug?.codeRaw}>
|
||||
{event.data.replayCode ?? "?"}
|
||||
@@ -604,7 +615,7 @@ export function ScreenshotPage() {
|
||||
</Stat>
|
||||
<Stat label="winner panel">{winnerSide}</Stat>
|
||||
</div>
|
||||
<div className="detail-crops">
|
||||
<div className={styles.detailCrops}>
|
||||
<LabeledCrop
|
||||
label="header"
|
||||
frame={frame}
|
||||
@@ -620,15 +631,15 @@ export function ScreenshotPage() {
|
||||
) : null}
|
||||
|
||||
{frame && event && isScoreboardBattleLog ? (
|
||||
<div className="detail">
|
||||
<div className="detail-stats">
|
||||
<div className={styles.detail}>
|
||||
<div className={styles.detailStats}>
|
||||
<Stat label="timestamp">{event.data.timestamp ?? "?"}</Stat>
|
||||
<Stat label="match scores">
|
||||
{JSON.stringify(event.data.matchScores)}
|
||||
</Stat>
|
||||
<Stat label="winner panel">{winnerSide}</Stat>
|
||||
</div>
|
||||
<div className="detail-crops">
|
||||
<div className={styles.detailCrops}>
|
||||
<LabeledCrop
|
||||
label="header top"
|
||||
frame={frame}
|
||||
@@ -644,12 +655,12 @@ export function ScreenshotPage() {
|
||||
) : null}
|
||||
|
||||
{frame && event && isDeath ? (
|
||||
<div className="detail">
|
||||
<div className={styles.detail}>
|
||||
{(() => {
|
||||
const data = event.data as unknown as DeathData;
|
||||
return (
|
||||
<>
|
||||
<div className="detail-stats">
|
||||
<div className={styles.detailStats}>
|
||||
<Stat label="weapon" raw={event.debug?.weaponRaw}>
|
||||
{weaponLabel(data.weaponType, data.weaponId) ?? "?"}
|
||||
</Stat>
|
||||
@@ -660,7 +671,7 @@ export function ScreenshotPage() {
|
||||
{data.abilities.map((row) => row.join(" ")).join(" | ")}
|
||||
</Stat>
|
||||
</div>
|
||||
<div className="detail-crops">
|
||||
<div className={styles.detailCrops}>
|
||||
<LabeledCrop
|
||||
label="weapon line"
|
||||
frame={frame}
|
||||
@@ -679,12 +690,12 @@ export function ScreenshotPage() {
|
||||
) : null}
|
||||
|
||||
{frame && event && isMapStart ? (
|
||||
<div className="detail">
|
||||
<div className={styles.detail}>
|
||||
{(() => {
|
||||
const data = event.data as unknown as MapStartData;
|
||||
return (
|
||||
<>
|
||||
<div className="detail-stats">
|
||||
<div className={styles.detailStats}>
|
||||
<Stat label="mode" raw={event.debug?.modeReading}>
|
||||
{modeLabel(data.mode) ?? "?"}
|
||||
</Stat>
|
||||
@@ -692,7 +703,7 @@ export function ScreenshotPage() {
|
||||
{stageLabel(data.stage) ?? "?"}
|
||||
</Stat>
|
||||
</div>
|
||||
<div className="detail-crops">
|
||||
<div className={styles.detailCrops}>
|
||||
<LabeledCrop
|
||||
label="mode block"
|
||||
frame={frame}
|
||||
@@ -712,12 +723,12 @@ export function ScreenshotPage() {
|
||||
) : null}
|
||||
|
||||
{frame && event && isOwn ? (
|
||||
<div className="detail">
|
||||
<div className={styles.detail}>
|
||||
{(() => {
|
||||
const data = event.data as unknown as ScoreboardOwnData;
|
||||
return (
|
||||
<>
|
||||
<div className="detail-stats">
|
||||
<div className={styles.detailStats}>
|
||||
<Stat label="weapon" raw={event.debug?.weaponReading}>
|
||||
{mainWeaponLabel(data.weaponId) ?? "?"}
|
||||
</Stat>
|
||||
@@ -725,7 +736,7 @@ export function ScreenshotPage() {
|
||||
{data.abilities.map((row) => row.join(" ")).join(" | ")}
|
||||
</Stat>
|
||||
</div>
|
||||
<div className="detail-crops">
|
||||
<div className={styles.detailCrops}>
|
||||
<LabeledCrop
|
||||
label="title band"
|
||||
frame={frame}
|
||||
@@ -752,12 +763,12 @@ export function ScreenshotPage() {
|
||||
) : null}
|
||||
|
||||
{frame && event && isMinimap ? (
|
||||
<div className="detail">
|
||||
<div className={styles.detail}>
|
||||
{(() => {
|
||||
const data = event.data as unknown as MinimapData;
|
||||
return (
|
||||
<>
|
||||
<div className="detail-stats">
|
||||
<div className={styles.detailStats}>
|
||||
<Stat label="stage">{stageLabel(data.stage) ?? "?"}</Stat>
|
||||
<Stat label="view">
|
||||
{data.spectator ? "spectator map" : "POV overlay"}
|
||||
@@ -782,7 +793,7 @@ export function ScreenshotPage() {
|
||||
) : null}
|
||||
</div>
|
||||
{!data.spectator ? (
|
||||
<div className="detail-crops">
|
||||
<div className={styles.detailCrops}>
|
||||
{minimap.CARD_LAYOUTS.map((card) => (
|
||||
<LabeledCrop
|
||||
key={card.slot}
|
||||
@@ -800,7 +811,7 @@ export function ScreenshotPage() {
|
||||
) : null}
|
||||
|
||||
{frame && event && isObjective ? (
|
||||
<div className="detail">
|
||||
<div className={styles.detail}>
|
||||
{(() => {
|
||||
const data = event.data as unknown as ObjectiveData;
|
||||
const status = active?.events.find(
|
||||
@@ -808,7 +819,7 @@ export function ScreenshotPage() {
|
||||
) as DetectedEvent<PlayerStatusData> | undefined;
|
||||
return (
|
||||
<>
|
||||
<div className="detail-stats">
|
||||
<div className={styles.detailStats}>
|
||||
<Stat label="timer">{formatTimer(data.time)}</Stat>
|
||||
<Stat label="score">
|
||||
{data.score[0] ?? "?"}–{data.score[1] ?? "?"}
|
||||
@@ -832,7 +843,7 @@ export function ScreenshotPage() {
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="detail-crops">
|
||||
<div className={styles.detailCrops}>
|
||||
<LabeledCrop
|
||||
label="left count"
|
||||
frame={frame}
|
||||
@@ -873,7 +884,7 @@ export function ScreenshotPage() {
|
||||
!isOwn &&
|
||||
!isMinimap &&
|
||||
!isObjective ? (
|
||||
<table className="inspector">
|
||||
<table className={styles.inspector}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>row</th>
|
||||
@@ -895,16 +906,23 @@ export function ScreenshotPage() {
|
||||
<RoiCrop frame={frame} roi={roi.weapon} />
|
||||
</td>
|
||||
<td>
|
||||
<div className="candidates weapon-candidates">
|
||||
<div
|
||||
className={clsx(
|
||||
styles.candidates,
|
||||
styles.weaponCandidates,
|
||||
)}
|
||||
>
|
||||
{dbg?.weapon?.top.map((c) => (
|
||||
<span className="cand" key={c.id}>
|
||||
<span className={styles.candidate} key={c.id}>
|
||||
<img
|
||||
className="weapon-icon"
|
||||
className={styles.weaponIcon}
|
||||
src={`${mainWeaponImageUrl(Number(c.id) as MainWeaponId)}.avif`}
|
||||
alt={c.id}
|
||||
/>
|
||||
{c.id}
|
||||
<span className="score">{c.score.toFixed(3)}</span>
|
||||
<span className={scannerStyles.score}>
|
||||
{c.score.toFixed(3)}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -912,20 +930,24 @@ export function ScreenshotPage() {
|
||||
<td>
|
||||
<RoiCrop frame={frame} roi={roi.name} />
|
||||
<b>{player?.name || "—"}</b>{" "}
|
||||
<span className="score">{dbg?.nameScore.toFixed(3)}</span>
|
||||
<span className={scannerStyles.score}>
|
||||
{dbg?.nameScore.toFixed(3)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<RoiCrop frame={frame} roi={roi.paint} />
|
||||
<b>{player?.paint ?? "—"}</b>{" "}
|
||||
<span className="score">{dbg?.paintScore.toFixed(3)}</span>
|
||||
<span className={scannerStyles.score}>
|
||||
{dbg?.paintScore.toFixed(3)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="candidates">
|
||||
<div className={styles.candidates}>
|
||||
{([0, 1, 2] as const).map((s) => (
|
||||
<span className="cand" key={s}>
|
||||
<span className={styles.candidate} key={s}>
|
||||
<RoiCrop frame={frame} roi={roi.stats[s]} scale={2} />
|
||||
<b>{[player?.ka, player?.d, player?.s][s] ?? "—"}</b>
|
||||
<span className="score">
|
||||
<span className={scannerStyles.score}>
|
||||
{dbg?.statScores[s].toFixed(2)}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
99
app/features/scanner/components/VodPage.module.css
Normal file
99
app/features/scanner/components/VodPage.module.css
Normal file
@@ -0,0 +1,99 @@
|
||||
/* internal navigation styled like the sibling action buttons */
|
||||
.linkButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--s-1-5);
|
||||
padding: 0 var(--field-padding);
|
||||
height: var(--field-size-sm);
|
||||
border: var(--border-style-accent);
|
||||
border-radius: var(--radius-field);
|
||||
background: var(--color-text-accent);
|
||||
color: var(--color-text-inverse);
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-bold);
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
|
||||
& > svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: var(--focus-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.vodList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.vodItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-3);
|
||||
background: var(--color-bg-high);
|
||||
border-radius: var(--radius-box);
|
||||
padding: var(--s-3) var(--s-4);
|
||||
}
|
||||
|
||||
.vodName {
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-bold);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vodMeta {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* the shared destructive variant loses to the scanner's own button look */
|
||||
button.vodDelete {
|
||||
width: var(--field-size-sm);
|
||||
padding: 0;
|
||||
border-color: var(--color-error);
|
||||
background: var(--color-error);
|
||||
outline-color: var(--color-error);
|
||||
}
|
||||
|
||||
.telemetry {
|
||||
margin: var(--s-2) 0;
|
||||
font-size: var(--font-2xs);
|
||||
color: var(--color-text-high);
|
||||
|
||||
& summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
& table {
|
||||
margin-top: var(--s-1-5);
|
||||
border-collapse: collapse;
|
||||
|
||||
& th,
|
||||
& td {
|
||||
padding: var(--s-0-5) var(--s-2-5) var(--s-0-5) 0;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
&:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@container scanner (width < 700px) {
|
||||
.vodItem {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
invalidObjectiveEvents,
|
||||
} from "../core/match-builder";
|
||||
import { TimelineBuilder } from "../core/timeline/index";
|
||||
import scannerStyles from "../scanner.module.css";
|
||||
import { scannerSearchParams } from "../scanner-search-params";
|
||||
import type { SendStatus } from "../store/events";
|
||||
import {
|
||||
@@ -72,6 +73,7 @@ import {
|
||||
} from "./sendou-ingest";
|
||||
import { sendouUpload } from "./sendou-upload";
|
||||
import { thumbnailFromBlob } from "./thumbnail";
|
||||
import styles from "./VodPage.module.css";
|
||||
|
||||
/** The scan knows the on-screen sides only, not who is playing. */
|
||||
const SCANNER_TEAM_LABELS = ["Alpha", "Bravo"] as const;
|
||||
@@ -551,7 +553,9 @@ export function VodPage({
|
||||
<div>
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop target; the file input inside is the accessible path */}
|
||||
<div
|
||||
className={clsx("dropzone", { over })}
|
||||
className={clsx(scannerStyles.dropzone, {
|
||||
[scannerStyles.over]: over,
|
||||
})}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setOver(true);
|
||||
@@ -579,17 +583,18 @@ export function VodPage({
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="controls">
|
||||
<div className={scannerStyles.controls}>
|
||||
{showVodView ? (
|
||||
<>
|
||||
<button type="button" onClick={backToList}>
|
||||
← All VoDs
|
||||
</button>
|
||||
<span
|
||||
className={clsx("status", {
|
||||
watching: status === "scanning",
|
||||
detected: status === "done",
|
||||
idle: status !== "scanning" && status !== "done",
|
||||
className={clsx(scannerStyles.status, {
|
||||
[scannerStyles.watching]: status === "scanning",
|
||||
[scannerStyles.detected]: status === "done",
|
||||
[scannerStyles.idle]:
|
||||
status !== "scanning" && status !== "done",
|
||||
})}
|
||||
>
|
||||
{source === "stored" ? "saved" : status}
|
||||
@@ -600,7 +605,7 @@ export function VodPage({
|
||||
: null}
|
||||
</span>
|
||||
{progress ? (
|
||||
<span className="score">
|
||||
<span className={scannerStyles.score}>
|
||||
{formatTime(progress.t)} / {formatTime(progress.duration)}
|
||||
{progress.duration > 0
|
||||
? ` (${Math.round((progress.t / progress.duration) * 100)}%)`
|
||||
@@ -611,13 +616,13 @@ export function VodPage({
|
||||
</span>
|
||||
) : null}
|
||||
{upload?.url ? (
|
||||
<Link to={upload.url} className="link-button">
|
||||
<Link to={upload.url} className={styles.linkButton}>
|
||||
<Video aria-hidden />
|
||||
Add VoD
|
||||
</Link>
|
||||
) : null}
|
||||
{upload?.problem ? (
|
||||
<span className="score">
|
||||
<span className={scannerStyles.score}>
|
||||
upload unavailable: {upload.problem}
|
||||
</span>
|
||||
) : null}
|
||||
@@ -634,8 +639,8 @@ export function VodPage({
|
||||
) : null}
|
||||
{resultsSend ? (
|
||||
<span
|
||||
className={clsx("score", {
|
||||
error:
|
||||
className={clsx(scannerStyles.score, {
|
||||
[scannerStyles.error]:
|
||||
resultsSend.state === "done" && Boolean(resultsSend.error),
|
||||
})}
|
||||
>
|
||||
@@ -655,21 +660,21 @@ export function VodPage({
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{error ? <p className="error">{error}</p> : null}
|
||||
{error ? <p className={scannerStyles.error}>{error}</p> : null}
|
||||
{showVodView && telemetry ? (
|
||||
<TelemetryPanel telemetry={telemetry} />
|
||||
) : null}
|
||||
{!showVodView ? (
|
||||
<div className="vod-list">
|
||||
<div className={styles.vodList}>
|
||||
{vods.length === 0 ? (
|
||||
<p className="score">
|
||||
<p className={scannerStyles.score}>
|
||||
No saved VoDs yet — scan one and it will show up here.
|
||||
</p>
|
||||
) : null}
|
||||
{vods.map((vod) => (
|
||||
<div key={vod.name} className="vod-item">
|
||||
<span className="name">{vod.name}</span>
|
||||
<span className="score">
|
||||
<div key={vod.name} className={styles.vodItem}>
|
||||
<span className={styles.vodName}>{vod.name}</span>
|
||||
<span className={clsx(scannerStyles.score, styles.vodMeta)}>
|
||||
{vod.eventCount} event{vod.eventCount === 1 ? "" : "s"} ·{" "}
|
||||
{formatTime(vod.duration)} · {formatSavedAt(vod.savedAt)}
|
||||
</span>
|
||||
@@ -684,7 +689,7 @@ export function VodPage({
|
||||
variant="destructive"
|
||||
size="small"
|
||||
shape="square"
|
||||
className="vod-delete"
|
||||
className={styles.vodDelete}
|
||||
icon={<Trash2 />}
|
||||
aria-label="Delete"
|
||||
/>
|
||||
@@ -694,7 +699,7 @@ export function VodPage({
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className="live-layout"
|
||||
className={scannerStyles.liveLayout}
|
||||
style={{
|
||||
display: showVodView ? undefined : "none",
|
||||
// a reopened saved VoD has no video to review — give the feed the full width
|
||||
@@ -704,12 +709,12 @@ export function VodPage({
|
||||
<div style={{ display: source === "scan" ? undefined : "none" }}>
|
||||
<canvas
|
||||
ref={previewRef}
|
||||
className="preview"
|
||||
className={scannerStyles.preview}
|
||||
style={{ display: status === "scanning" ? "block" : "none" }}
|
||||
/>
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="preview"
|
||||
className={scannerStyles.preview}
|
||||
muted
|
||||
playsInline
|
||||
controls
|
||||
@@ -718,9 +723,9 @@ export function VodPage({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="feed">
|
||||
<div className={scannerStyles.feed}>
|
||||
{matches.length === 0 ? (
|
||||
<p className="score">
|
||||
<p className={scannerStyles.score}>
|
||||
{status === "scanning"
|
||||
? "Scanning — matches appear here as scoreboards are detected."
|
||||
: "No matches found in this VoD."}
|
||||
@@ -840,7 +845,7 @@ function ExportMenu({
|
||||
trigger={
|
||||
<SendouButton
|
||||
icon={<Download />}
|
||||
className="icon-menu"
|
||||
className={scannerStyles.iconMenu}
|
||||
aria-label="Export"
|
||||
/>
|
||||
}
|
||||
@@ -884,7 +889,7 @@ function TelemetryPanel({ telemetry }: { telemetry: ScanTelemetry }) {
|
||||
);
|
||||
const coveredS = telemetry.activeVideoS + telemetry.skimVideoS;
|
||||
return (
|
||||
<details className="telemetry">
|
||||
<details className={styles.telemetry}>
|
||||
<summary>
|
||||
telemetry · analyzed {telemetry.analyzedFrames}/
|
||||
{telemetry.decodedFrames} decoded frames
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
216
app/features/scanner/scanner.module.css
Normal file
216
app/features/scanner/scanner.module.css
Normal file
@@ -0,0 +1,216 @@
|
||||
/*
|
||||
Chrome shared by the scanner's pages and cards. Everything is scoped under
|
||||
`.app`, whose container name the pages' container queries key off; design
|
||||
tokens come from sendou.ink's app/styles/vars.css.
|
||||
*/
|
||||
|
||||
.app {
|
||||
container: scanner / inline-size;
|
||||
|
||||
/* the app's own buttons, kept at :where() specificity so any component
|
||||
class overrides it without having to win an ordering race */
|
||||
& :where(button) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--s-1-5);
|
||||
border: var(--border-style-accent);
|
||||
border-radius: var(--radius-field);
|
||||
appearance: none;
|
||||
background: var(--color-text-accent);
|
||||
color: var(--color-text-inverse);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-bold);
|
||||
padding: 0 var(--field-padding);
|
||||
height: var(--field-size-sm);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
|
||||
& > svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: var(--focus-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
transform: initial;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
button.outlined {
|
||||
background: transparent;
|
||||
color: var(--color-text-accent);
|
||||
}
|
||||
|
||||
/* secondary actions live behind this icon-only trigger, pushed to the row's end.
|
||||
Sized explicitly: it is a SendouButton, which brings a competing base look */
|
||||
button.iconMenu {
|
||||
margin-inline-start: auto;
|
||||
width: var(--field-size-sm);
|
||||
height: var(--field-size-sm);
|
||||
border-radius: var(--radius-field);
|
||||
padding: 0;
|
||||
border: var(--border-style);
|
||||
background: var(--color-bg-higher);
|
||||
color: var(--color-text);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-text-accent);
|
||||
}
|
||||
|
||||
& > svg {
|
||||
width: var(--field-size-icon);
|
||||
height: var(--field-size-icon);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
align-items: center;
|
||||
margin-bottom: var(--s-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: var(--s-0-5) var(--s-3);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-bold);
|
||||
border: var(--border-style);
|
||||
}
|
||||
|
||||
.idle {
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.watching {
|
||||
color: var(--color-info-high);
|
||||
border-color: var(--color-info-low);
|
||||
background: var(--color-info-low);
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: var(--radius-full);
|
||||
background: currentColor;
|
||||
margin-inline-end: var(--s-1-5);
|
||||
animation: scanner-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.detected {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--color-success-high);
|
||||
border-color: var(--color-success-low);
|
||||
background: var(--color-success-low);
|
||||
}
|
||||
|
||||
@keyframes scanner-pulse {
|
||||
50% {
|
||||
opacity: 0.25;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
.liveLayout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 640px) minmax(0, 1fr);
|
||||
gap: var(--s-4);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.preview {
|
||||
width: 100%;
|
||||
background: #000;
|
||||
border-radius: var(--radius-box);
|
||||
border: var(--border-width) solid var(--color-bg-high);
|
||||
}
|
||||
|
||||
.feed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-3);
|
||||
}
|
||||
|
||||
.score {
|
||||
color: var(--color-text-high);
|
||||
font-size: var(--font-2xs);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.dropzone {
|
||||
border: var(--border-width) dashed var(--color-border-high);
|
||||
border-radius: var(--radius-box);
|
||||
padding: var(--s-8);
|
||||
text-align: center;
|
||||
color: var(--color-text-high);
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-semi);
|
||||
margin-bottom: var(--s-4);
|
||||
|
||||
& label {
|
||||
display: inline;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
margin-block-end: 0;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.over {
|
||||
border-color: var(--color-text-accent);
|
||||
color: var(--color-text-accent);
|
||||
}
|
||||
|
||||
/* a 640px wide preview would starve the feed next to it: split evenly instead */
|
||||
@container scanner (width < 1000px) {
|
||||
.liveLayout {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
/* the controls wrap here, where a trigger pushed to the end reads as stray */
|
||||
button.iconMenu {
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@container scanner (width < 700px) {
|
||||
.liveLayout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
/* stacked, the preview would otherwise push the feed off the screen */
|
||||
.preview {
|
||||
max-height: 45vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.watching::before {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user