diff --git a/app/api/integrations/discord/dispatch/route.ts b/app/api/integrations/discord/dispatch/route.ts index 8721da7..6fd1693 100644 --- a/app/api/integrations/discord/dispatch/route.ts +++ b/app/api/integrations/discord/dispatch/route.ts @@ -1,6 +1,8 @@ import { timingSafeEqual } from "node:crypto"; import { NextRequest, NextResponse } from "next/server"; import { dispatchDiscordAnnouncements } from "@/lib/discord-announcements"; +import { dispatchTournamentAnnouncements } from "@/lib/tournament-discord"; +import { admin, required } from "@/lib/supabase"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export const maxDuration = 300; @@ -15,6 +17,8 @@ export async function GET(request: NextRequest) { ) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); try { + required(await admin().rpc("tick_tournaments")); + await dispatchTournamentAnnouncements(admin()); await dispatchDiscordAnnouncements(); return NextResponse.json( { ok: true }, diff --git a/app/cinematic-intro.css b/app/cinematic-intro.css index 6b4b422..afc21af 100644 --- a/app/cinematic-intro.css +++ b/app/cinematic-intro.css @@ -1,3 +1,11 @@ +/* Shared motion curves: entrances/exits and on-screen movement respectively. */ +:root { + --ease-out: cubic-bezier(0.23, 1, 0.32, 1); + --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); +} + +/* Four-second first-visit story: locate, place a pin, read, return to the site. + Every keyframe changes only transform/opacity; no per-frame React work. */ .first-visit-intro { position: fixed; inset: 0; @@ -6,27 +14,31 @@ max-width: none; max-height: none; margin: 0; - padding: 0; + padding: 24px 24px 88px; border: 0; overflow: hidden; - color: #f5f0da; + color: #f4eedb; background: radial-gradient( - ellipse at 50% 58%, - #264c38, - #0a1914 48%, - #050c09 100% + ellipse at 50% 38%, + #1d392b, + #0a1812 58%, + #07100c ); } .first-visit-intro[open] { display: grid; place-items: center; - animation: intro-reveal-site 0.55s 4.2s both; + animation: + intro-opacity-in 250ms var(--ease-out) both, + intro-opacity-out 250ms var(--ease-out) 3750ms forwards; } .first-visit-intro::backdrop { - background: #050c09; - animation: intro-reveal-site 0.55s 4.2s both; + background: #07100c; + animation: + intro-opacity-in 250ms var(--ease-out) both, + intro-opacity-out 250ms var(--ease-out) 3750ms forwards; } -/* A modal lives above the custom cursor overlay, so restore a native cursor. */ +/* The native modal is above the custom cursor layer. */ html.has-site-cursor dialog.first-visit-intro, html.has-site-cursor dialog.first-visit-intro * { cursor: auto !important; @@ -34,195 +46,313 @@ html.has-site-cursor dialog.first-visit-intro * { html.has-site-cursor dialog.first-visit-intro button { cursor: pointer !important; } -.intro-landscape { +.intro-cartography { position: absolute; inset: 0; - overflow: hidden; pointer-events: none; -} -.intro-horizon { - position: absolute; - inset: 45% -40% -65%; background: - repeating-linear-gradient(90deg, #b6d99210 0 1px, transparent 1px 90px), - repeating-linear-gradient(0deg, #b6d99210 0 1px, transparent 1px 90px); - transform: perspective(450px) rotateX(56deg); - mask-image: linear-gradient(transparent, #000 28%); + repeating-linear-gradient(90deg, #b4ceaa0b 0 1px, transparent 1px 64px), + repeating-linear-gradient(0deg, #b4ceaa0b 0 1px, transparent 1px 64px); + mask-image: radial-gradient(ellipse at center, black 10%, transparent 70%); + animation: + intro-surface-in 500ms var(--ease-out) both, + intro-surface-out 250ms var(--ease-out) 3500ms forwards; } -.intro-terrain { - position: absolute; - inset: 10% -5%; - transform: rotate(-12deg); +.intro-content { + position: relative; + width: min(100%, 700px); + display: grid; + justify-items: center; + gap: 20px; } -.intro-terrain i { +.intro-map-stage { + position: relative; + width: 300px; + height: 250px; + animation: intro-surface-out 250ms var(--ease-out) 3500ms forwards; +} +.intro-map-enter { position: absolute; - width: 90px; - height: 90px; - background: #274b35; - border: 1px solid #74935e40; + inset: 48px 50px 2px; + animation: intro-surface-in 500ms var(--ease-out) 80ms both; +} +.intro-map-surface { + position: relative; + width: 200px; + height: 200px; + border: 1px solid #c4dd9b50; + background: + repeating-linear-gradient(90deg, transparent 0 24px, #d4eac714 24px 25px), + repeating-linear-gradient(0deg, transparent 0 24px, #d4eac714 24px 25px), + #365a39; box-shadow: - 18px 18px 0 #112d22, - 19px 19px 0 #74935e40; - transform: rotateX(52deg) rotateZ(45deg); - animation: intro-terrain-rise 4.75s both; + -1px 1px 0 #29452b, + -3px 3px 0 #29452b, + -5px 5px 0 #1d3524, + -7px 7px 0 #1d3524, + -9px 9px 0 #15291d, + -11px 11px 0 #15291d; + transform: perspective(600px) rotateX(56deg) rotateZ(-35deg); + overflow: hidden; } -.intro-terrain i:nth-child(3n) { - width: 140px; - height: 140px; - background: #33573c; - animation-delay: 0.15s; -} -.intro-terrain i:nth-child(5n) { - width: 55px; - height: 55px; - background: #51714a; - animation-delay: 0.3s; -} -.intro-terrain i:nth-child(1) { - left: 8%; - top: 14%; -} -.intro-terrain i:nth-child(2) { - left: 20%; - top: 67%; -} -.intro-terrain i:nth-child(3) { - left: 77%; - top: 65%; -} -.intro-terrain i:nth-child(4) { - left: 89%; - top: 14%; -} -.intro-terrain i:nth-child(5) { - left: 63%; - top: 15%; -} -.intro-terrain i:nth-child(6) { - left: 0; - top: 75%; -} -.intro-terrain i:nth-child(7) { - left: 31%; - top: 82%; -} -.intro-terrain i:nth-child(8) { - left: 70%; - top: 86%; -} -.intro-terrain i:nth-child(9) { - left: 93%; - top: 60%; -} -.intro-terrain i:nth-child(10) { - left: 27%; - top: 13%; -} -.intro-terrain i:nth-child(11) { - left: 4%; - top: 45%; -} -.intro-terrain i:nth-child(12) { - left: 43%; - top: -15%; -} -.intro-terrain i:nth-child(13) { - left: 56%; - top: 95%; -} -.intro-terrain i:nth-child(14) { - left: 85%; - top: 43%; -} -.intro-terrain i:nth-child(15) { - left: 16%; - top: 40%; -} -.intro-compass { +.intro-map-water { position: absolute; - left: 50%; - top: 38%; - width: 230px; - height: 230px; - transform: translate(-50%, -50%); - color: #e9c67d; - animation: intro-compass-arrive 3.8s both; + inset: 0 0 0 150px; + background: #497b78; + border-left: 12px solid #668c64; } -.intro-compass > span { +.intro-map-path { position: absolute; - inset: -18px; - border: 1px solid #e9c67d60; + left: 20px; + top: 100px; + width: 80px; + height: 72px; + border-right: 4px solid #d6c18c; + border-bottom: 4px solid #d6c18c; +} +.intro-map-block { + position: absolute; + width: 24px; + height: 24px; + background: #8ea86b; + border: 1px solid #d2ddb578; + box-shadow: -4px 4px 0 #203c29; +} +.intro-map-block-one { + left: 24px; + top: 28px; +} +.intro-map-block-two { + left: 52px; + top: 28px; +} +.intro-map-block-three { + left: 24px; + top: 56px; +} +.intro-map-target { + position: absolute; + left: 82px; + top: 82px; + width: 34px; + height: 34px; + border: 1px solid #efcf8b; border-radius: 50%; + animation: intro-target-align 500ms var(--ease-in-out) 600ms both; } -.intro-compass > span:nth-child(2) { - inset: -40px; - border-style: dashed; - opacity: 0.45; - animation: intro-ring-turn 15s linear infinite; -} -.intro-compass > svg:first-of-type { - width: 100%; - height: 100%; - opacity: 0.35; -} -.intro-compass .intro-pin { +.intro-pin-anchor { position: absolute; - inset: 65px; - width: 100px; - height: 100px; - filter: drop-shadow(0 0 24px #cdb57766); + left: 118px; + top: 83px; + width: 64px; + height: 72px; +} +.intro-pin { + width: 64px; + height: 72px; + color: #f0c876; + fill: #624d25; + animation: intro-pin-place 500ms var(--ease-out) 540ms both; +} +.intro-coordinate, +.intro-bearing { + position: absolute; + color: #a9c49d99; + font: 10px/1.5 monospace; + letter-spacing: 2px; + animation: intro-opacity-in 300ms var(--ease-out) 300ms both; } .intro-coordinate { - position: absolute; - top: 44%; - color: #e9c67d60; - font: 11px monospace; - letter-spacing: 3px; + top: 190px; } .intro-coordinate-left { - left: 14%; + left: 0; } .intro-coordinate-right { - right: 14%; + right: 0; +} +.intro-bearing { + top: 20px; + left: 146px; + color: #ebce92; } .intro-title { - position: relative; + width: 100%; text-align: center; - padding: 220px 24px 0; - animation: intro-title-sequence 4.4s both; + animation: intro-surface-out 250ms var(--ease-out) 3300ms forwards; } .intro-title .eyebrow { - color: #e9c67d; - letter-spacing: 5px; + color: #d1ba83; font-size: 10px; + letter-spacing: 3px; + margin: 0 0 16px; + animation: intro-surface-in 300ms var(--ease-out) 720ms both; } .intro-title h2 { + margin: 0 0 16px; font: - clamp(50px, 9vw, 100px) / 1.2 MineGeoPixel, + clamp(42px, 8vw, 78px) / 1.15 MineGeoPixel, monospace; - letter-spacing: -4px; - margin: 0 0 12px; - text-shadow: - 0 5px #11291c, - 0 0 45px #9ac57725; + letter-spacing: -2px; + white-space: nowrap; } -.intro-title > p:last-child { - color: #c6d4c5; - font-size: clamp(13px, 2vw, 18px); - letter-spacing: 2px; +.intro-title h2 span { + display: inline-block; + animation: intro-surface-in 400ms var(--ease-out) 780ms both; +} +.intro-title h2 span + span { + color: #cbe19b; + animation-delay: 840ms; +} +.intro-subtitle { + max-width: 420px; + margin: 0 auto; + color: #bdcbb8; + font-size: clamp(13px, 2.4vw, 16px); + line-height: 1.6; + animation: intro-surface-in 300ms var(--ease-out) 900ms both; } .intro-skip.button { position: absolute; - bottom: 30px; - right: 32px; - border: 1px solid #c7d5ba50; - background: #0d211bd9; - color: #e6eddd; + right: 28px; + bottom: max(24px, env(safe-area-inset-bottom)); gap: 20px; + min-height: 44px; + border: 1px solid #c7d5ba50; + background: #0d211b; + color: #e6eddd; } .intro-skip span { - color: #e9c67d; + color: #baa779; + font: 11px monospace; } +@keyframes intro-opacity-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +@keyframes intro-opacity-out { + from { + opacity: 1; + } + to { + opacity: 0; + } +} +@keyframes intro-surface-in { + from { + opacity: 0; + transform: translateY(12px) scale(0.96); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} +@keyframes intro-surface-out { + from { + opacity: 1; + transform: translateY(0) scale(1); + } + to { + opacity: 0; + transform: translateY(12px) scale(0.96); + } +} +@keyframes intro-pin-place { + from { + opacity: 0; + transform: translateY(-48%) scale(0.96); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} +@keyframes intro-target-align { + from { + opacity: 0; + transform: rotate(-45deg) scale(0.96); + } + to { + opacity: 1; + transform: rotate(0) scale(1); + } +} +@media (max-width: 380px) { + .first-visit-intro { + padding-inline: 16px; + } + .intro-title h2 { + font-size: 40px; + } + .intro-title .eyebrow { + font-size: 9px; + letter-spacing: 2px; + } +} +@media (max-height: 540px) and (min-width: 560px) { + .first-visit-intro { + padding: 20px 28px 72px; + } + .intro-content { + grid-template-columns: 240px 1fr; + gap: 24px; + width: min(100%, 750px); + } + .intro-map-stage { + width: 240px; + } + .intro-map-enter { + left: 20px; + right: 20px; + } + .intro-pin-anchor { + left: 88px; + } + .intro-bearing { + left: 116px; + } + .intro-title { + text-align: left; + } + .intro-title h2 { + font-size: clamp(30px, 6vw, 55px); + } + .intro-title .eyebrow { + letter-spacing: 1.5px; + } +} +@media (prefers-reduced-motion: reduce) { + /* Defensive CSS as well as the browser preference check before showModal. */ + .first-visit-intro, + .first-visit-intro *, + .first-visit-intro::backdrop { + animation: none !important; + } +} + +@media (max-height: 380px) and (min-width: 560px) { + .intro-map-stage { + height: 200px; + } + .intro-map-enter { + top: 8px; + } + .intro-pin-anchor { + top: 43px; + } + .intro-coordinate { + top: 150px; + } + .intro-bearing { + top: 0; + } +} + .site-trailer { padding-top: 28px; } @@ -262,100 +392,8 @@ html.has-site-cursor dialog.first-visit-intro button { white-space: nowrap; font-size: 12px; } -@keyframes intro-terrain-rise { - 0% { - opacity: 0; - translate: 0 95px; - } - 25%, - 70% { - opacity: 0.8; - } - 100% { - opacity: 0.15; - translate: 0 -30px; - } -} -@keyframes intro-compass-arrive { - 0% { - opacity: 0; - scale: 0.5; - rotate: -32deg; - } - 30%, - 80% { - opacity: 1; - scale: 1; - rotate: 0deg; - } - 100% { - opacity: 0.4; - scale: 1.08; - rotate: 6deg; - } -} -@keyframes intro-title-sequence { - 0%, - 17% { - opacity: 0; - translate: 0 22px; - filter: blur(8px); - } - 38%, - 78% { - opacity: 1; - translate: 0 0; - filter: blur(0); - } - 100% { - opacity: 0; - translate: 0 -12px; - filter: blur(4px); - } -} -@keyframes intro-reveal-site { - from { - opacity: 1; - } - to { - opacity: 0; - } -} -@keyframes intro-ring-turn { - to { - rotate: 360deg; - } -} + @media (max-width: 640px) { - .intro-compass { - width: 160px; - height: 160px; - top: 35%; - } - .intro-compass .intro-pin { - inset: 45px; - width: 70px; - height: 70px; - } - .intro-title { - padding-top: 150px; - } - .intro-title h2 { - letter-spacing: -2px; - } - .intro-title .eyebrow { - letter-spacing: 3px; - } - .intro-coordinate { - display: none; - } - .intro-skip.button { - bottom: 22px; - right: 20px; - } - .intro-terrain { - opacity: 0.6; - } .trailer-caption { flex-direction: column; gap: 12px; @@ -366,12 +404,3 @@ html.has-site-cursor dialog.first-visit-intro button { flex-direction: column; } } -@media (prefers-reduced-motion: reduce) { - .first-visit-intro[open] { - display: none; - } - .first-visit-intro *, - .first-visit-intro::backdrop { - animation: none !important; - } -} diff --git a/app/layout.tsx b/app/layout.tsx index ea9019f..b5936c9 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -20,6 +20,7 @@ import "./appearance.css"; import "./community.css"; import "./community-social.css"; import "./cinematic-intro.css"; +import "./tournaments.css"; import { themeInitScript } from "@/shared/theme"; import { SiteSettingsProvider } from "@/components/SiteSettingsProvider"; import { initialSiteSettings } from "@/lib/site-settings"; diff --git a/app/leaderboard/page.tsx b/app/leaderboard/page.tsx index 06d425e..3fbfb36 100644 --- a/app/leaderboard/page.tsx +++ b/app/leaderboard/page.tsx @@ -79,6 +79,13 @@ export default async function LeaderboardPage({ {data ? ( <> + {difficulty === "all" && ( +

+ {t( + "The latest tournament champion holds first place on the global leaderboard for seven days. Solo scores remain unchanged.", + )} +

+ )} {data.leaders.length > 0 && !search && data.page === 1 && ( )} diff --git a/app/tournaments.css b/app/tournaments.css new file mode 100644 index 0000000..f9ba830 --- /dev/null +++ b/app/tournaments.css @@ -0,0 +1,188 @@ +.tournament-page { + max-width: 1180px; + min-height: 60vh; +} +.tournament-page .eyebrow, +.tournament-server-clock, +.tournament-card p, +.tournament-facts, +.tournament-game-heading { + display: flex; + align-items: center; + gap: 10px; +} +.tournament-page .section-heading { + align-items: flex-start; + gap: 24px; +} +.tournament-page h1 { + max-width: 820px; +} +.tournament-page .muted { + color: var(--muted); + line-height: 1.7; +} +.tournament-server-clock { + font-size: 12px; + color: var(--muted); + margin: 20px 0 30px; +} +.tournament-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr)); + gap: 20px; + margin-top: 28px; +} +.tournament-card { + padding: 24px; + color: inherit; + text-decoration: none; + border: 1px solid var(--line); + border-radius: 16px; + background: var(--panel); +} +.tournament-card-top { + display: flex; + justify-content: space-between; + align-items: center; + color: var(--green); +} +.tournament-card h2 { + font-size: 19px; + line-height: 1.6; + overflow-wrap: anywhere; +} +.tournament-card p { + color: var(--muted); + font-size: 12px; + line-height: 1.7; +} +.tournament-status { + font: 11px monospace; + text-transform: uppercase; + color: var(--muted); +} +.tournament-status.running { + color: var(--green); +} +.tournament-form { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 20px; + padding: 28px; + margin: 25px 0; + border: 1px solid var(--line); + border-radius: 16px; +} +.tournament-form label { + display: grid; + gap: 9px; + font-size: 13px; + min-width: 0; +} +.tournament-form input:not([type="checkbox"]), +.tournament-form select { + width: 100%; + min-height: 46px; + padding: 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--bg); + color: var(--text); + font: inherit; +} +.tournament-form h2, +.tournament-form > p, +.tournament-form .tournament-checkbox { + grid-column: 1/-1; +} +.tournament-form .tournament-checkbox { + display: flex; + align-items: center; + gap: 12px; + min-height: 44px; +} +.tournament-checkbox input { + width: 18px; + height: 18px; +} +.tournament-facts, +.tournament-actions { + display: flex; + gap: 22px; + flex-wrap: wrap; + margin: 22px 0; +} +.tournament-facts { + color: var(--muted); + font-size: 13px; +} +.tournament-facts > span { + display: flex; + align-items: center; + gap: 8px; +} +.tournament-actions { + align-items: center; +} +.tournament-winner { + display: flex; + gap: 24px; + padding: 28px; + margin: 32px 0; + border: 1px solid #d0b76a; + border-radius: 16px; +} +.tournament-winner > svg { + color: #d0b76a; + flex-shrink: 0; +} +.tournament-winner h2 { + margin: 12px 0; +} +.tournament-game-heading { + padding: 12px 22px; + background: #102116; + color: #d7e3c7; + font-size: 12px; +} +.tournament-play .multiplayer-game { + position: relative; +} +.tournament-page td, +.tournament-page th { + overflow-wrap: anywhere; +} +.champion-badge { + display: block; + color: #d0b76a; + font: 10px/1.6 monospace; + margin-top: 5px; +} +.tournament-champion-note { + font-size: 13px; + line-height: 1.7; + color: var(--muted); +} +@media (max-width: 640px) { + .tournament-form { + grid-template-columns: 1fr; + padding: 18px; + } + .tournament-page .section-heading { + flex-direction: column; + } + .tournament-facts { + gap: 14px; + } + .tournament-winner { + padding: 20px; + gap: 14px; + } + .tournament-server-clock { + flex-wrap: wrap; + } + .tournament-page h1 { + font-size: 28px; + } +} diff --git a/app/tournaments/[id]/page.tsx b/app/tournaments/[id]/page.tsx new file mode 100644 index 0000000..bdc449d --- /dev/null +++ b/app/tournaments/[id]/page.tsx @@ -0,0 +1,24 @@ +import { Header, Footer } from "@/components/Shell"; +import { TournamentLobby } from "@/components/TournamentLobby"; +import { user } from "@/lib/supabase"; +import { notFound } from "next/navigation"; +import { z } from "zod"; + +export default async function TournamentPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + if (!z.string().uuid().safeParse(id).success) notFound(); + const account = await user(); + return ( + <> +
+
+ +
+