diff --git a/src/components/admin-match-analysis.tsx b/src/components/admin-match-analysis.tsx index edb7d1f..411c85c 100644 --- a/src/components/admin-match-analysis.tsx +++ b/src/components/admin-match-analysis.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { useMemo } from "react"; import { ClickableUserId } from "@/components/clickable-user-id"; +import { AnalysisMatchAlertBox } from "@/components/analysis-match-alert-box"; import { MatchAnalysisEmoteChart } from "@/components/match-analysis-emote-chart"; import { MatchAnalysisForceChart } from "@/components/match-analysis-force-chart"; import { buildDashboardHref } from "@/lib/dashboard-search-url"; @@ -269,41 +270,19 @@ export function AdminMatchAnalysis({ - {analysis.mirrorExceptionDisconnectMatchIds.length > 0 ? ( -
-

- Mirror exception disconnect ( - {analysis.mirrorExceptionDisconnectMatchIds.length.toLocaleString( - "en-US", - )}{" "} - {analysis.mirrorExceptionDisconnectMatchIds.length === 1 - ? "match" - : "matches"} - ) -

-

+ These matches include a{" "} [Mirror/Error] line where a player was disconnected because handling a command caused an exception. -

- -
- ) : null} + + } + />
{analysis.notEndedMatchIds.length > 0 ? ( -
-

- Not ended ( - {analysis.notEndedMatchIds.length.toLocaleString("en-US")}{" "} - {analysis.notEndedMatchIds.length === 1 ? "match" : "matches"}) -

-

- Log file present but no winner PATCH line — the match did not - finish normally. -

-
    - {analysis.notEndedMatchIds.map((matchId) => ( -
  • - - Match {matchId} - -
  • - ))} -
-
+ ) : (

Every match with a log file includes a winner PATCH line. diff --git a/src/components/analysis-match-alert-box.tsx b/src/components/analysis-match-alert-box.tsx new file mode 100644 index 0000000..254abad --- /dev/null +++ b/src/components/analysis-match-alert-box.tsx @@ -0,0 +1,178 @@ +"use client"; + +import Link from "next/link"; +import { + useCallback, + useEffect, + useMemo, + useState, + type MouseEvent, + type ReactNode, +} from "react"; +import { + EMPTY_HIDDEN_MIRROR, + EMPTY_HIDDEN_NOT_ENDED, + getHiddenAnalysisMatchIds, + hideAnalysisMatchIds, + subscribeAnalysisHiddenAlerts, + type AnalysisAlertCategory, +} from "@/lib/analysis-alert-hide-cookies"; + +function CloseIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +const STYLES: Record< + AnalysisAlertCategory, + { + box: string; + body: string; + chip: string; + dismiss: string; + headerDismiss: string; + } +> = { + mirror: { + box: "rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-950 dark:border-red-800 dark:bg-red-950/50 dark:text-red-100", + body: "text-red-800 dark:text-red-200/90", + chip: "inline-flex items-center gap-0.5 overflow-hidden rounded-md border border-red-400/80 bg-white shadow-sm dark:border-red-700 dark:bg-red-950/80", + dismiss: + "shrink-0 p-1 text-red-700 hover:bg-red-100 dark:text-red-200 dark:hover:bg-red-900/80", + headerDismiss: + "shrink-0 rounded-md p-1.5 text-red-800 hover:bg-red-100 dark:text-red-100 dark:hover:bg-red-900/80", + }, + notEnded: { + box: "rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-100", + body: "text-amber-900/90 dark:text-amber-200/90", + chip: "inline-flex items-center gap-0.5 overflow-hidden rounded-md border border-amber-400/80 bg-white shadow-sm dark:border-amber-700 dark:bg-amber-950/80", + dismiss: + "shrink-0 p-1 text-amber-800 hover:bg-amber-100 dark:text-amber-100 dark:hover:bg-amber-900/80", + headerDismiss: + "shrink-0 rounded-md p-1.5 text-amber-900 hover:bg-amber-100 dark:text-amber-50 dark:hover:bg-amber-900/80", + }, +}; + +function emptyHiddenFor(category: AnalysisAlertCategory): number[] { + return category === "mirror" ? EMPTY_HIDDEN_MIRROR : EMPTY_HIDDEN_NOT_ENDED; +} + +function useHiddenMatchIds(category: AnalysisAlertCategory): number[] { + const [hiddenIds, setHiddenIds] = useState(() => + emptyHiddenFor(category), + ); + + useEffect(() => { + setHiddenIds(getHiddenAnalysisMatchIds(category)); + return subscribeAnalysisHiddenAlerts(() => { + setHiddenIds(getHiddenAnalysisMatchIds(category)); + }); + }, [category]); + + return hiddenIds; +} + +type Props = { + category: AnalysisAlertCategory; + matchIds: number[]; + title: string; + description: ReactNode; +}; + +export function AnalysisMatchAlertBox({ + category, + matchIds, + title, + description, +}: Props) { + const hiddenIds = useHiddenMatchIds(category); + const hiddenSet = useMemo(() => new Set(hiddenIds), [hiddenIds]); + + const visibleIds = useMemo( + () => matchIds.filter((id) => !hiddenSet.has(id)), + [matchIds, hiddenSet], + ); + + const hideOne = useCallback( + (e: MouseEvent, matchId: number) => { + e.preventDefault(); + e.stopPropagation(); + hideAnalysisMatchIds(category, [matchId]); + }, + [category], + ); + + const hideAll = useCallback( + (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + hideAnalysisMatchIds(category, visibleIds); + }, + [category, visibleIds], + ); + + if (matchIds.length === 0 || visibleIds.length === 0) return null; + + const s = STYLES[category]; + + return ( +

+
+
+

+ {title} ({visibleIds.length.toLocaleString("en-US")}{" "} + {visibleIds.length === 1 ? "match" : "matches"}) +

+

{description}

+
+ +
+
    + {visibleIds.map((matchId) => ( +
  • + + + Match {matchId} + + + +
  • + ))} +
+
+ ); +} diff --git a/src/lib/analysis-alert-hide-cookies.ts b/src/lib/analysis-alert-hide-cookies.ts new file mode 100644 index 0000000..dbbd8a8 --- /dev/null +++ b/src/lib/analysis-alert-hide-cookies.ts @@ -0,0 +1,129 @@ +/** Client-side cookie storing per-admin dismissed analysis alert match ids. */ +export const ANALYSIS_HIDDEN_ALERTS_COOKIE = "kk_analysis_hidden_alerts"; + +export type AnalysisAlertCategory = "mirror" | "notEnded"; + +type StoredHiddenAlerts = { + mirror: number[]; + notEnded: number[]; +}; + +const EMPTY_MIRROR: number[] = []; +const EMPTY_NOT_ENDED: number[] = []; + +const MAX_IDS_PER_CATEGORY = 400; + +const COOKIE_MAX_AGE_SEC = 60 * 60 * 24 * 365; + +export const ANALYSIS_HIDDEN_ALERTS_EVENT = "kk-analysis-hidden-alerts-change"; + +/** Stable empty snapshots for useSyncExternalStore / initial state. */ +export const EMPTY_HIDDEN_MIRROR = EMPTY_MIRROR; +export const EMPTY_HIDDEN_NOT_ENDED = EMPTY_NOT_ENDED; + +let storeCache: { + cookieValue: string; + mirror: number[]; + notEnded: number[]; +} | null = null; + +function notifyHiddenAlertsChanged(): void { + if (typeof window !== "undefined") { + window.dispatchEvent(new Event(ANALYSIS_HIDDEN_ALERTS_EVENT)); + } +} + +export function subscribeAnalysisHiddenAlerts( + onStoreChange: () => void, +): () => void { + if (typeof window === "undefined") return () => {}; + window.addEventListener(ANALYSIS_HIDDEN_ALERTS_EVENT, onStoreChange); + return () => + window.removeEventListener(ANALYSIS_HIDDEN_ALERTS_EVENT, onStoreChange); +} + +function parseIdList(raw: unknown): number[] { + if (!Array.isArray(raw)) return []; + const out: number[] = []; + for (const v of raw) { + const n = Number(v); + if (Number.isInteger(n) && n >= 1) out.push(n); + } + return [...new Set(out)].sort((a, b) => a - b); +} + +function invalidateStoreCache(): void { + storeCache = null; +} + +function readStored(): StoredHiddenAlerts { + if (typeof document === "undefined") { + return { mirror: EMPTY_MIRROR, notEnded: EMPTY_NOT_ENDED }; + } + const match = document.cookie.match( + new RegExp(`(?:^|; )${ANALYSIS_HIDDEN_ALERTS_COOKIE}=([^;]*)`), + ); + const cookieValue = match?.[1] ?? ""; + if (storeCache?.cookieValue === cookieValue) { + return storeCache; + } + + let mirror = EMPTY_MIRROR; + let notEnded = EMPTY_NOT_ENDED; + + if (cookieValue) { + try { + const parsed = JSON.parse(decodeURIComponent(cookieValue)) as unknown; + if (parsed && typeof parsed === "object") { + const o = parsed as Record; + const m = parseIdList(o.mirror); + const n = parseIdList(o.notEnded); + if (m.length > 0) mirror = m; + if (n.length > 0) notEnded = n; + } + } catch { + // ignore corrupt cookie + } + } + + storeCache = { cookieValue, mirror, notEnded }; + return storeCache; +} + +function writeStored(data: StoredHiddenAlerts): void { + if (typeof document === "undefined") return; + const value = encodeURIComponent(JSON.stringify(data)); + document.cookie = `${ANALYSIS_HIDDEN_ALERTS_COOKIE}=${value}; path=/; max-age=${COOKIE_MAX_AGE_SEC}; SameSite=Lax`; + invalidateStoreCache(); +} + +function trimIds(ids: number[]): number[] { + if (ids.length <= MAX_IDS_PER_CATEGORY) return ids; + return ids.slice(ids.length - MAX_IDS_PER_CATEGORY); +} + +/** Match ids the admin dismissed for this alert category (stable array reference). */ +export function getHiddenAnalysisMatchIds( + category: AnalysisAlertCategory, +): number[] { + const stored = readStored(); + return category === "mirror" ? stored.mirror : stored.notEnded; +} + +/** Add match ids to the hide list for a category (persists in cookie). */ +export function hideAnalysisMatchIds( + category: AnalysisAlertCategory, + matchIds: number[], +): void { + const stored = readStored(); + const set = new Set(stored[category]); + for (const id of matchIds) { + if (Number.isInteger(id) && id >= 1) set.add(id); + } + const next = trimIds([...set].sort((a, b) => a - b)); + writeStored({ + mirror: category === "mirror" ? next : stored.mirror, + notEnded: category === "notEnded" ? next : stored.notEnded, + }); + notifyHiddenAlertsChanged(); +}