"use client"; import Link from "next/link"; import { useCallback, useEffect, useLayoutEffect, useRef, useState, } from "react"; import { MatchLogColoredBody } from "@/components/match-log-colored-body"; import type { MatchmakerLogSource } from "@/lib/matchmaker-log-source"; type Props = { layout: "embedded" | "fullscreen"; source: MatchmakerLogSource; processedHref: string; rawHref: string; content: string; errorMessage: string | null; }; /** If the user is within this many px of the bottom, treat as "following" the tail. */ const TAIL_FOLLOW_PX = 80; const POLL_INTERVAL_MS = 5000; const REFRESH_BTN = "shrink-0 rounded border border-zinc-600 bg-zinc-800 px-2.5 py-1 font-mono text-xs text-zinc-200 transition hover:border-zinc-500 hover:bg-zinc-700 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"; function segClass(active: boolean) { return [ "rounded border px-2.5 py-1 font-mono text-xs transition", active ? "border-emerald-500/60 bg-emerald-950/40 text-emerald-200" : "border-zinc-600 bg-zinc-800 text-zinc-300 hover:border-zinc-500 hover:text-white", ].join(" "); } async function fetchLogApi(source: MatchmakerLogSource): Promise< | { ok: true; content: string } | { ok: false; message: string } > { try { const params = new URLSearchParams(); if (source === "raw") params.set("mklog", "raw"); params.set("_t", String(Date.now())); const res = await fetch(`/api/matchmaker-logs?${params.toString()}`, { credentials: "same-origin", cache: "no-store", headers: { Accept: "application/json" }, }); const rawText = await res.text(); let data: { content?: string; error?: string }; try { data = rawText ? (JSON.parse(rawText) as typeof data) : {}; } catch { return { ok: false, message: "Server returned non-JSON (lost session?). Refresh and sign in again.", }; } if (!res.ok) { return { ok: false, message: data.error ?? `Request failed (${res.status})`, }; } return { ok: true, content: data.content ?? "" }; } catch (e) { return { ok: false, message: e instanceof Error ? e.message : "Network error", }; } } export function MatchmakerLogTerminal({ layout, source, processedHref, rawHref, content, errorMessage, }: Props) { const filename = source === "processed" ? "history.log" : "matchmaker.log"; const scrollRef = useRef(null); const tailFollowRef = useRef(true); /** Avoid overlapping auto + manual fetches (helps dev Strict Mode / double clicks). */ const fetchLockRef = useRef(false); const [displayContent, setDisplayContent] = useState(content); const [displayError, setDisplayError] = useState(errorMessage); const [refreshing, setRefreshing] = useState(false); /** Bumped on every successful reload so identical file text still updates the UI (React skips re-render when setState equals previous string). */ const [reloadKey, setReloadKey] = useState(0); const [lastFetchedAtMs, setLastFetchedAtMs] = useState( null, ); const runLoad = useCallback(async (manual: boolean) => { if ( typeof document !== "undefined" && document.hidden && !manual ) { return; } if (fetchLockRef.current) return; fetchLockRef.current = true; if (manual) { tailFollowRef.current = true; setRefreshing(true); } try { const result = await fetchLogApi(source); if (!result.ok) { setDisplayError(result.message); return; } setDisplayError(null); setDisplayContent(result.content); if (manual) { setReloadKey((k) => k + 1); setLastFetchedAtMs(Date.now()); } } finally { fetchLockRef.current = false; if (manual) setRefreshing(false); } }, [source]); const refresh = useCallback(async () => { await runLoad(true); }, [runLoad]); const updateTailFollowFromScroll = useCallback(() => { const el = scrollRef.current; if (!el) return; const gap = el.scrollHeight - el.scrollTop - el.clientHeight; tailFollowRef.current = gap <= TAIL_FOLLOW_PX; }, []); useEffect(() => { setDisplayContent(content); setDisplayError(errorMessage); setReloadKey((k) => k + 1); }, [content, errorMessage]); useEffect(() => { tailFollowRef.current = true; }, [source]); useEffect(() => { let cancelled = false; async function tick() { if (cancelled) return; await runLoad(false); } queueMicrotask(tick); const intervalId = window.setInterval(tick, POLL_INTERVAL_MS); function onVisibilityChange() { if (!document.hidden && !cancelled) void tick(); } document.addEventListener("visibilitychange", onVisibilityChange); return () => { cancelled = true; window.clearInterval(intervalId); document.removeEventListener("visibilitychange", onVisibilityChange); }; }, [source, runLoad]); useLayoutEffect(() => { const el = scrollRef.current; if (!el || !tailFollowRef.current) return; el.scrollTop = el.scrollHeight; }, [displayContent, displayError, reloadKey]); const frameClass = layout === "fullscreen" ? "flex h-full min-h-0 flex-1 flex-col overflow-hidden rounded-lg border border-zinc-700/80 bg-[#0d1117] text-zinc-100 shadow-lg" : "flex h-[calc(100dvh-12rem)] max-h-[calc(100dvh-12rem)] min-h-[200px] shrink-0 flex-col overflow-hidden rounded-lg border border-zinc-700/80 bg-[#0d1117] text-zinc-100 shadow-lg"; return (

matchmaker {filename}

Log Processed Raw
{lastFetchedAtMs != null ? ( · manual{" "} {new Date(lastFetchedAtMs).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit", })} ) : null} · auto {POLL_INTERVAL_MS / 1000}s
{displayError ? (

error: {displayError}

) : displayContent === "" ? (

(empty file)

) : ( )}
); }