diff --git a/components/DuelStoryFrame.tsx b/components/DuelStoryFrame.tsx new file mode 100644 index 0000000..aee2954 --- /dev/null +++ b/components/DuelStoryFrame.tsx @@ -0,0 +1,180 @@ +"use client"; + +import { forwardRef, type CSSProperties } from "react"; +import type { Card } from "@/lib/scoring/types"; +import PlayerCard from "./PlayerCard"; +import VsBurst from "./VsBurst"; +import { duelThemes } from "./finishTheme"; +import { tallyRows, type Duel } from "@/lib/duel"; + +const STORY_W = 1080; +const STORY_H = 1920; + +const SAFE_TOP = 250; +const SAFE_BOTTOM = 250; +const SAFE_H = STORY_H - SAFE_TOP - SAFE_BOTTOM; + +const CARD_W = 440; +const CARD_H = Math.round(CARD_W * (820 / 540)); // ≈ 668 + +const BRAND = "#39d353"; + +function rgbaGlow(glow: string, alpha: number): string { + const m = glow.match(/rgba?\(([^)]+)\)/); + if (!m) return glow; + const [r, g, b] = m[1].split(",").map((s) => s.trim()); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + +const FONT_DISPLAY = "var(--font-bebas), 'Saira Condensed', sans-serif"; +const FONT_COND = "var(--font-din-cond), 'Saira Condensed', sans-serif"; + +const DuelStoryFrame = forwardRef(function DuelStoryFrame( + { duel }, + ref, +) { + const { challenger, opponent, rows, onPenalties, winner } = duel; + const { home: aTheme, away: bTheme } = duelThemes(challenger, opponent); + + // Use ALL rows since the exported story represents the final settled duel + const { a: scoreA, b: scoreB } = tallyRows(rows); + + // Optical centring + const cardTop = SAFE_TOP + 240; + + const abs = (top: number): CSSProperties => ({ + position: "absolute", + left: 0, + right: 0, + top, + display: "flex", + flexDirection: "column", + alignItems: "center", + }); + + return ( +
+ {/* Background split wash */} +
+
+ + {/* top — brand wordmark + concept line */} +
+
+ GITFUT +
+
+ SCOUT DUEL +
+
+ + {/* centre — the cards and VS burst */} +
+
+
+
+ +
+
+ +
+ +
+ +
+
+
+ +
+
+
+ + {/* Scoreline */} +
+
+ {scoreA} + + {scoreB} +
+ {onPenalties && ( +
AFTER PENALTIES
+ )} +
+ + {/* bottom — the CTA */} +
+
+ START A DUEL ON GITFUT.COM + +
+
+
+ ); +}); + +export default DuelStoryFrame; diff --git a/components/DuelView.tsx b/components/DuelView.tsx index 78a5887..8c158b6 100644 --- a/components/DuelView.tsx +++ b/components/DuelView.tsx @@ -1,8 +1,11 @@ "use client"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import Link from "next/link"; -import { ArrowLeft, Check, Link2, Repeat, Share2 } from "lucide-react"; +import { ArrowLeft, Check, ChevronDown, Copy, Download, ImageDown, Link2, Repeat, Share2 } from "lucide-react"; +import { toPng, toBlob } from "html-to-image"; +import { renderCardImage } from "@/lib/capture"; +import DuelStoryFrame from "./DuelStoryFrame"; import { dominanceShare, tallyRows, type Duel, type DuelSide } from "@/lib/duel"; import type { Card } from "@/lib/scoring/types"; import PlayerCard from "./PlayerCard"; @@ -20,17 +23,23 @@ import { useDuelReveal } from "@/hooks/useReveal"; import { useShareActions } from "@/hooks/useShareActions"; import { resolvedRows } from "@/lib/reveal"; import { formatCount } from "@/lib/format"; -import { duelIntentUrl, duelSharePayload, duelUrl } from "@/lib/share"; +import { duelIntentUrl, duelSharePayload, duelUrl, nativeSharePayload } from "@/lib/share"; import { AWARD_META, groupAwards, type AwardGroup } from "@/lib/awards"; import AwardModal from "./AwardModal"; import TrophySprite, { AWARD_SPRITES } from "./TrophySprite"; +type ActionId = "download" | "copy" | "story" | "link"; +const ACTION_COPY: Record = { + download: { name: "Download", busy: "Saving…", done: "Saved" }, + story: { name: "Story", busy: "Rendering…", done: "Done" }, + copy: { name: "Copy image", busy: "Copying…", done: "Copied" }, + link: { name: "Copy link", busy: "…", done: "Link copied" }, +}; + +const MENU_ITEM = "flex w-full items-center gap-[9px] rounded-lg px-[11px] py-[9px] text-left text-[12.5px] font-semibold text-ink-soft transition-colors hover:bg-white/[0.06] hover:text-white"; + const CARD_WIDTH = "clamp(150px, min(24vw, 34vh), 292px)"; -// One shootout stat as a butterfly bar: the two bars grow out from the centre -// label toward each value (the FIFA head-to-head graphic, not a table row). -// Until the sequence reaches it the values are masked and the bars empty — -// layout stays stable, only the reveal moves. function StatBar({ row, resolved, @@ -42,8 +51,6 @@ function StatBar({ aAccent: string; bAccent: string; }) { - // The winner's dot is a non-color cue too: same-tier duels (gold vs gold) - // and color-blind viewers still read who took the row by dot presence. const value = (side: DuelSide, accent: string) => { const won = row.winner === side; const lost = row.winner !== null && !won; @@ -132,7 +139,6 @@ const Masked = () => ( ); -// A scoreboard digit that pops on change (a goal going in). function ScoreDigit({ value, accent }: { value: number; accent: string }) { return ( (null); - // Kit clash (see finishTheme): ONLY a toty/totw vs silver pairing recolors — - // the toty side wears its saturated tier blue so the sides stay readable. + const [done, setDone] = useState(null); + const [busy, setBusy] = useState(null); + const [menuOpen, setMenuOpen] = useState(false); + const menuRef = useRef(null); + const caretRef = useRef(null); + const openedByKey = useRef(false); + + const targetRef = useRef(null); + const storyRef = useRef(null); const { home: aTheme, away: bTheme } = duelThemes(challenger, opponent); const { phase, skip } = useDuelReveal(); const settled = phase.kind === "settled"; @@ -167,15 +180,9 @@ export default function DuelView({ const shown = resolvedRows(phase); const shared = new Set(duel.sharedPlaystyles); - // Scoreline as currently visible: only rows the shootout has resolved count, - // so the scoreboard and the stadium light always agree with what's on screen. const visible = rows.slice(0, shown); const { a: scoreA, b: scoreB } = tallyRows(visible); - - // Dominance: margin-weighted, resolved rows only (see lib/duel) — it ticks - // live with the shootout and never runs ahead of the page. const pctA = dominanceShare(visible); - const focus: DuelSide | null = stamped ? winner : null; const winnerCard: Card | null = @@ -190,21 +197,197 @@ export default function DuelView({ const resultAccent = winnerCard ? winnerTheme.ink : "var(--color-ink-faint)"; const headlineHex = winnerCard ? winnerTheme.ink : "#8b949e"; - // Share-row gestures — shared with CardActions via the same hook (score-free - // duel payload/intent from lib/share). - const { canNativeShare, nativeShare, copyLink, linkCopied } = useShareActions({ - getSharePayload: () => - duelSharePayload(challenger.login, opponent.login), + const splitHalf = { + style: { color: resultAccent, borderColor: `${resultAccent}66`, background: `${resultAccent}1f` }, + onMouseEnter: (e: React.MouseEvent) => { + e.currentTarget.style.background = `${resultAccent}33`; + }, + onMouseLeave: (e: React.MouseEvent) => { + e.currentTarget.style.background = `${resultAccent}1f`; + }, + }; + + const { canNativeShare, nativeShare, copyLink } = useShareActions({ + getSharePayload: async () => { + const node = targetRef.current; + const payload = duelSharePayload(challenger.login, opponent.login); + if (node && "canShare" in navigator) { + const blob = await renderCardImage(node, async (n) => { + n.style.minHeight = "0"; + n.style.paddingTop = "40px"; + n.style.paddingBottom = "40px"; + const bg = n.querySelector("[data-capture-bg]"); + if (bg) bg.style.position = "absolute"; + n.querySelectorAll("[data-hide-capture]").forEach((el) => { + el.style.display = "none"; + }); + return toBlob(n, { pixelRatio: 2, cacheBust: true }); + }); + if (blob) { + const file = new File([blob], `${challenger.login}-vs-${opponent.login}-gitfut.png`, { + type: "image/png", + }); + if (navigator.canShare?.({ files: [file] })) { + return { ...payload, files: [file] }; + } + } + } + return payload; + }, getIntentUrl: () => duelIntentUrl(challenger.login, opponent.login), getCopyUrl: () => duelUrl(challenger.login, opponent.login), }); - const status = - !stamped && shown === 0 ? "KICK-OFF" : !stamped ? "LIVE" : "FULL TIME"; + useEffect(() => { + if (!menuOpen) return; + if (openedByKey.current) { + menuRef.current?.querySelector("[role='menuitem']")?.focus(); + } + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + setMenuOpen(false); + caretRef.current?.focus(); + } + }; + const onPointerDown = (e: PointerEvent) => { + if (!menuRef.current?.contains(e.target as Node)) setMenuOpen(false); + }; + window.addEventListener("keydown", onKey); + document.addEventListener("pointerdown", onPointerDown); + return () => { + window.removeEventListener("keydown", onKey); + document.removeEventListener("pointerdown", onPointerDown); + }; + }, [menuOpen]); + + const onMenuKeyDown = (e: React.KeyboardEvent) => { + if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return; + e.preventDefault(); + const items = Array.from( + menuRef.current?.querySelectorAll("[role='menuitem']") ?? [], + ); + if (!items.length) return; + const i = items.indexOf(document.activeElement as HTMLButtonElement); + items[e.key === "ArrowDown" ? (i + 1) % items.length : i <= 0 ? items.length - 1 : i - 1]?.focus(); + }; + + const finish = (id: ActionId) => { + setDone(id); + setTimeout(() => setDone((d) => (d === id ? null : d)), 1500); + }; + + const track = async (id: ActionId, run: () => Promise) => { + if (busy) return; + setBusy(id); + setMenuOpen(false); + try { + const completedAs = await run(); + finish(completedAs ?? id); + } catch (e) { + console.error(`[gitfut] duel ${id} failed:`, e); + } finally { + setBusy(null); + } + }; + + const shareStory = () => + track("story", async () => { + const node = storyRef.current; + if (!node) return; + const blob = await renderCardImage(node, async (n) => { + const b = await toBlob(n, { pixelRatio: 1, cacheBust: true }); + if (!b) throw new Error("render returned no image"); + return b; + }); + const file = new File([blob], `${challenger.login}-vs-${opponent.login}-gitfut-story.png`, { + type: "image/png", + }); + if (navigator.canShare?.({ files: [file] })) { + await navigator.share({ + files: [file], + }); + } else { + // Fallback for desktop: download it + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.download = file.name; + a.href = url; + a.click(); + URL.revokeObjectURL(url); + } + }); + + const downloadDuelPng = async () => { + const node = targetRef.current; + if (!node) return; + const url = await renderCardImage(node, (n) => { + n.style.minHeight = "0"; + n.style.paddingTop = "40px"; + n.style.paddingBottom = "40px"; + const bg = n.querySelector("[data-capture-bg]"); + if (bg) bg.style.position = "absolute"; + n.querySelectorAll("[data-hide-capture]").forEach((el) => { + el.style.display = "none"; + }); + return toPng(n, { pixelRatio: 2, cacheBust: true }); + }); + const a = document.createElement("a"); + a.download = `${challenger.login}-vs-${opponent.login}-gitfut.png`; + a.href = url; + a.click(); + }; + + const downloadPng = () => track("download", downloadDuelPng); + + const copyImage = () => + track("copy", async () => { + const node = targetRef.current; + if (!node) return; + + const canCopyImage = + typeof ClipboardItem !== "undefined" && + typeof navigator.clipboard?.write === "function"; + + if (canCopyImage) { + try { + await navigator.clipboard.write([ + new ClipboardItem({ + "image/png": renderCardImage( + node, + async (n) => { + n.style.minHeight = "0"; + n.style.paddingTop = "40px"; + n.style.paddingBottom = "40px"; + const bg = n.querySelector("[data-capture-bg]"); + if (bg) bg.style.position = "absolute"; + n.querySelectorAll("[data-hide-capture]").forEach((el) => { + el.style.display = "none"; + }); + const blob = await toBlob(n, { pixelRatio: 2, cacheBust: true }); + if (!blob) throw new Error("render returned no image"); + return blob; + }, + { transparent: false }, + ), + }), + ]); + return; + } catch (error) { + console.warn("[gitfut] image clipboard unavailable; using fallback:", error); + } + } + + if (await copyLink()) return "link"; + + await downloadDuelPng(); + return "download"; + }); + + const copyCardLink = () => + track("link", async () => { + if (!(await copyLink())) throw new Error("clipboard unavailable"); + }); - // Both header names share one size — the longer login sets it (a fixture - // reads as one pair, not two weights) — shrinking smoothly for long handles - // so neither side can shove the VS burst off the page's centre axis. const nameLen = Math.max(challenger.login.length, opponent.login.length); const nameSize = `clamp(16px, ${Math.min(4.2, 42 / nameLen)}vw, ${Math.min(44, 460 / nameLen)}px)`; @@ -344,9 +527,16 @@ export default function DuelView({ ); }; + const mainLabel = busy + ? ACTION_COPY[busy].busy + : done + ? ACTION_COPY[done].done + : "Download"; + return ( <>
{/* top bar — mirrors the scout report's frame */} -
+
SHARE THE DUEL )} -
+
- - - Swap corners + +
+ + + {menuOpen && ( +
+ + + +
+ )} +
@@ -715,7 +962,7 @@ export default function DuelView({ {corner(opponent, bTheme, "opponent")}
-
+
@@ -730,6 +977,11 @@ export default function DuelView({ onClose={() => setActiveAward(null)} /> )} + + {/* 1080×1920 Story capture frame (off-screen, rendered at native size) */} +
+ +
); }