Improve responsive layouts and streamline page loading
All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 8m58s

This commit is contained in:
2026-10-21 11:15:07 +05:00
parent 690fe12c2c
commit 0330c4072f
16 changed files with 650 additions and 118 deletions

View File

@@ -83,6 +83,24 @@ html[data-theme="light"] :is(h1, h2, h3) {
html[data-theme="light"] .hero h1 span { html[data-theme="light"] .hero h1 span {
text-shadow: none; text-shadow: none;
} }
html[data-theme="light"] :is(.compass-art, .floating-tag) {
background: var(--panel);
color: var(--text);
border-color: var(--line);
box-shadow:
inset 0 1px #fff,
0 4px 12px #243a1814;
}
html[data-theme="light"] .floating-tag strong {
color: var(--text);
}
html[data-theme="light"] .floating-tag > span {
color: var(--muted);
}
html[data-theme="light"] .card-type {
background: var(--panel2);
color: var(--text);
}
html[data-theme="light"] :is(.site-header, .site-footer) { html[data-theme="light"] :is(.site-header, .site-footer) {
background: var(--bg); background: var(--bg);
border-color: var(--line); border-color: var(--line);
@@ -387,7 +405,12 @@ html[data-theme="light"] .site-cursor-dot span {
.header-inner { .header-inner {
max-width: 1600px; max-width: 1600px;
} }
@media (max-width: 1500px) { .nav-toggle,
.site-navigation .nav-support,
.account-symbol {
display: none;
}
@media (max-width: 1500px) and (min-width: 1201px) {
.site-header { .site-header {
height: auto; height: auto;
} }
@@ -407,76 +430,140 @@ html[data-theme="light"] .site-cursor-dot span {
padding: 12px 0 2px; padding: 12px 0 2px;
} }
} }
@media (max-width: 500px) { @media (max-width: 1200px) {
.header-inner { .site-header {
grid-template-columns: minmax(0, 1fr) auto; height: auto;
gap: 10px;
} }
.header-actions { .header-inner {
grid-row: 1; display: grid;
grid-column: 2; grid-template-columns: minmax(0, 1fr) auto auto;
justify-self: end; gap: 10px;
padding: 12px 24px;
} }
.header-inner > .brand { .header-inner > .brand {
grid-row: 1; grid-area: 1 / 1;
grid-column: 1; }
justify-self: start; .header-inner .header-actions {
grid-area: 1 / 2;
margin: 0;
justify-self: end;
gap: 8px;
}
.header-inner .nav-toggle {
grid-area: 1 / 3;
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border: 1px solid var(--line);
border-radius: var(--radius-control);
color: var(--text);
background: var(--panel);
}
.header-inner nav.site-navigation {
display: none;
grid-area: 2 / 1 / auto / -1;
width: 100%;
padding: 12px 0 0;
margin: 2px 0 0;
border-top: 1px solid var(--line);
gap: 4px;
font:
500 14px/1.5 Arial,
Helvetica,
sans-serif;
}
.header-inner nav.site-navigation[data-open="true"] {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.site-navigation a {
padding: 12px;
border-radius: 6px;
}
.site-navigation a[aria-current="page"] {
color: var(--green);
background: var(--panel2);
}
.site-navigation a[aria-current="page"]::after {
display: none;
}
.site-navigation .nav-support {
display: block;
}
.site-navigation a:hover,
.nav-toggle:hover {
background: var(--panel2);
}
}
@media (max-width: 600px) {
.header-inner {
padding: 10px 16px;
gap: 8px;
}
.header-inner .brand {
gap: 5px;
} }
.header-inner .brand .brand-wordmark { .header-inner .brand .brand-wordmark {
font-size: 21px; font-size: 21px;
} }
.header-inner .brand .brand-mark { .header-inner .brand .brand-mark {
width: 30px; width: 28px;
height: 36px; height: 34px;
} }
.header-inner .brand { .header-inner .brand sup {
gap: 5px; display: none;
} }
.header-actions { .header-inner .header-actions {
gap: 4px; gap: 6px;
}
.header-actions .header-report,
.header-account > span,
.header-account .account-arrow {
display: none;
}
.header-actions .header-account {
width: 40px;
height: 40px;
padding: 0;
justify-content: center;
border-radius: var(--radius-control);
}
.header-account .account-symbol {
display: block;
}
.header-actions .theme-toggle,
.header-inner .nav-toggle {
width: 40px;
height: 40px;
} }
.game-header .brand .brand-wordmark { .game-header .brand .brand-wordmark {
font-size: 17px; font-size: 17px;
} }
} }
@media (max-width: 600px) {
.header-actions {
grid-row: 2;
grid-column: 1 / -1;
justify-self: start;
margin-left: 0;
}
.header-inner nav {
grid-row: 3;
}
}
@media (max-width: 500px) {
html[lang="ru"] .header-actions {
grid-row: 2;
grid-column: 1 / -1;
justify-self: start;
margin-left: 0;
}
html[lang="ru"] .header-inner nav {
grid-row: 3;
}
}
.difficulty-options button.difficulty-void { .difficulty-options button.difficulty-void {
border-color: #a855f7; border-color: #a855f7;
background: linear-gradient(145deg, #21122f, #100c18); background: linear-gradient(145deg, #21122f, #100c18);
color: #e9d5ff; color: #e9d5ff;
box-shadow: 0 0 16px #a855f738, inset 0 0 20px #a855f710; box-shadow:
0 0 16px #a855f738,
inset 0 0 20px #a855f710;
} }
.difficulty-options button.difficulty-void strong { .difficulty-options button.difficulty-void strong {
color: #e9d5ff; color: #e9d5ff;
text-shadow: 0 0 10px #c084fc99, 0 0 22px #a855f780; text-shadow:
0 0 10px #c084fc99,
0 0 22px #a855f780;
} }
.difficulty-options button.difficulty-void.selected, .difficulty-options button.difficulty-void.selected,
html[data-theme="light"] .difficulty-options button.difficulty-void.selected { html[data-theme="light"] .difficulty-options button.difficulty-void.selected {
color: #f3e8ff; color: #f3e8ff;
background: linear-gradient(145deg, #482363, #21122f); background: linear-gradient(145deg, #482363, #21122f);
border-color: #d8b4fe; border-color: #d8b4fe;
box-shadow: 0 0 24px #a855f760, inset 0 0 20px #c084fc18; box-shadow:
0 0 24px #a855f760,
inset 0 0 20px #c084fc18;
} }
html[data-theme="light"] .difficulty-options button.difficulty-void { html[data-theme="light"] .difficulty-options button.difficulty-void {
background: #f3e8ff; background: #f3e8ff;
@@ -486,6 +573,9 @@ html[data-theme="light"] .difficulty-options button.difficulty-void {
html[data-theme="light"] .difficulty-options button.difficulty-void strong { html[data-theme="light"] .difficulty-options button.difficulty-void strong {
color: #7e22ce; color: #7e22ce;
} }
html[data-theme="light"] .difficulty-options button.difficulty-void.selected strong { html[data-theme="light"]
.difficulty-options
button.difficulty-void.selected
strong {
color: #f3e8ff; color: #f3e8ff;
} }

View File

@@ -26,6 +26,7 @@ import { SiteSettingsProvider } from "@/components/SiteSettingsProvider";
import { initialSiteSettings } from "@/lib/site-settings"; import { initialSiteSettings } from "@/lib/site-settings";
import { rawUser, accountState } from "@/lib/supabase"; import { rawUser, accountState } from "@/lib/supabase";
import { LegacyPasswordNotice } from "@/components/LegacyPasswordNotice"; import { LegacyPasswordNotice } from "@/components/LegacyPasswordNotice";
import { Suspense } from "react";
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const { t } = await getI18n(); const { t } = await getI18n();
return { return {
@@ -43,11 +44,10 @@ export default async function RootLayout({
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const [{ locale, preference, detectedLocale }, settings, account] = const [{ locale, preference, detectedLocale }, settings] = await Promise.all([
await Promise.all([getI18n(), initialSiteSettings(), rawUser()]); getI18n(),
const active = account initialSiteSettings(),
? (await accountState(account.id)) === "active" ]);
: false;
return ( return (
<html lang={locale} data-theme="dark" suppressHydrationWarning> <html lang={locale} data-theme="dark" suppressHydrationWarning>
<head> <head>
@@ -62,7 +62,9 @@ export default async function RootLayout({
<SiteSettingsProvider initialSettings={settings}> <SiteSettingsProvider initialSettings={settings}>
{children} {children}
<SiteCursor /> <SiteCursor />
<LegacyPasswordNotice enabled={active && !!account?.email} /> <Suspense fallback={null}>
<AccountPasswordNotice />
</Suspense>
</SiteSettingsProvider> </SiteSettingsProvider>
</I18nProvider> </I18nProvider>
<Analytics /> <Analytics />
@@ -70,3 +72,10 @@ export default async function RootLayout({
</html> </html>
); );
} }
async function AccountPasswordNotice() {
const account = await rawUser();
const active = account
? (await accountState(account.id)) === "active"
: false;
return <LegacyPasswordNotice enabled={active && !!account?.email} />;
}

View File

@@ -18,13 +18,12 @@ import { LeaderboardPreview } from "@/components/Leaderboard";
import desktopRelease from "@/shared/desktop-release.json"; import desktopRelease from "@/shared/desktop-release.json";
import { FirstVisitIntro } from "@/components/FirstVisitIntro"; import { FirstVisitIntro } from "@/components/FirstVisitIntro";
import { SiteTrailer } from "@/components/SiteTrailer"; import { SiteTrailer } from "@/components/SiteTrailer";
import { Suspense } from "react";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function Home() { export default async function Home() {
const { t, formatNumber } = await getI18n(); const { t, formatNumber } = await getI18n();
const [maps, rankings] = await Promise.all([ const rankings = leaderboard({ limit: 5 });
catalog(), const maps = await catalog();
leaderboard({ limit: 5 }),
]);
const servers = new Set(maps.map((map) => map.server_id)).size; const servers = new Set(maps.map((map) => map.server_id)).size;
const locations = maps.reduce((total, map) => total + map.panorama_count, 0); const locations = maps.reduce((total, map) => total + map.panorama_count, 0);
return ( return (
@@ -78,6 +77,7 @@ export default async function Home() {
width={1024} width={1024}
height={1024} height={1024}
loading="eager" loading="eager"
fetchPriority="high"
unoptimized unoptimized
/> />
<span className="square-pin pin-green" /> <span className="square-pin pin-green" />
@@ -142,7 +142,13 @@ export default async function Home() {
{maps.length ? ( {maps.length ? (
<div className="cards-grid"> <div className="cards-grid">
{maps.slice(0, 4).map((map, index) => ( {maps.slice(0, 4).map((map, index) => (
<MapCard map={map} index={index} key={map.id} minecraftPaper /> <MapCard
map={map}
index={index}
key={map.id}
minecraftPaper
loading="lazy"
/>
))} ))}
</div> </div>
) : ( ) : (
@@ -175,7 +181,19 @@ export default async function Home() {
)} )}
</section> </section>
<SiteTrailer /> <SiteTrailer />
<LeaderboardPreview data={rankings} /> <Suspense
fallback={
<section
className="section leaderboard-preview-placeholder"
aria-busy="true"
>
<h2>{t("THE EXPLORERS’ BOARD")}</h2>
<div className="loading-surface" />
</section>
}
>
<HomeLeaderboard data={rankings} />
</Suspense>
<section className="section home-panels"> <section className="section home-panels">
<article className="home-panel"> <article className="home-panel">
<span className="eyebrow">{t("HOW TO PLAY")}</span> <span className="eyebrow">{t("HOW TO PLAY")}</span>
@@ -217,7 +235,7 @@ export default async function Home() {
</article> </article>
<article className="home-panel"> <article className="home-panel">
<span className="eyebrow">{t("CHOOSE YOUR CHALLENGE")}</span> <span className="eyebrow">{t("CHOOSE YOUR CHALLENGE")}</span>
<h2>{t("One world. Three ways.")}</h2> <h2>{t("Find your challenge.")}</h2>
<div className="mode-list"> <div className="mode-list">
{[ {[
{ {
@@ -238,6 +256,12 @@ export default async function Home() {
detail: t("Think fast. No zoom allowed."), detail: t("Think fast. No zoom allowed."),
color: "red", color: "red",
}, },
{
name: t("Void"),
time: t("10 seconds"),
detail: t("The ultimate test. Fixed view, strict scoring."),
color: "void",
},
].map((mode) => ( ].map((mode) => (
<Link href="/servers" className="mode-row" key={mode.name}> <Link href="/servers" className="mode-row" key={mode.name}>
<i className={`stat-cube ${mode.color}-cube`} /> <i className={`stat-cube ${mode.color}-cube`} />
@@ -278,3 +302,10 @@ export default async function Home() {
</> </>
); );
} }
async function HomeLeaderboard({
data,
}: {
data: ReturnType<typeof leaderboard>;
}) {
return <LeaderboardPreview data={await data} />;
}

View File

@@ -28,6 +28,12 @@ select:focus-visible {
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
} }
/* Fallback for user-provided Cyrillic names on English pages. */
h1,
h2,
h3 {
font-family: MineGeoPixel, MineGeoPixelCyrillic, monospace;
}
html[lang="ru"] h1, html[lang="ru"] h1,
html[lang="ru"] h2, html[lang="ru"] h2,
html[lang="ru"] h3, html[lang="ru"] h3,
@@ -127,7 +133,7 @@ html[lang="ru"] .mode-row h3 span {
} }
/* Keep catalog cards readable instead of squeezing long map names. */ /* Keep catalog cards readable instead of squeezing long map names. */
.cards-grid { .cards-grid {
grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr)); grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr));
} }
.map-card .card-body .map-credit { .map-card .card-body .map-credit {
display: block; display: block;
@@ -251,6 +257,259 @@ html[lang="ru"] .mode-row h3 span {
gap: 20px; gap: 20px;
margin: 24px 0 32px; margin: 24px 0 32px;
} }
/* Shared layout repairs: readable content, predictable cards, and touch targets. */
:is(a, button, input, select, textarea, summary):focus-visible {
outline: 2px solid var(--green);
outline-offset: 4px;
}
.page-content > h1 {
text-wrap: balance;
overflow-wrap: anywhere;
}
.lead {
text-wrap: pretty;
}
.map-card {
display: flex;
flex-direction: column;
min-width: 0;
}
.map-card .card-body {
flex: 1;
display: flex;
flex-direction: column;
padding: 20px;
gap: 12px;
}
.map-card .card-body .eyebrow {
font: 11px/1.5 monospace;
margin: 0;
}
.map-card .card-body h3,
html[lang="ru"] .map-card .card-body h3 {
font:
400 18px/1.6 MineGeoPixelCyrillic,
monospace;
letter-spacing: -0.3px;
text-shadow: none;
min-height: 4.8em;
margin: 0;
gap: 12px;
align-items: flex-start;
}
.map-card .card-body h3 > span {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
min-width: 0;
overflow-wrap: anywhere;
}
.map-card .card-body h3 svg {
margin-top: 4px;
}
.map-card .card-body p {
font-size: 13px;
line-height: 1.65;
margin: 0;
min-height: 3.3em;
}
.map-card .card-body .map-credit {
margin-top: auto;
padding-top: 4px;
}
.map-card .card-meta {
font-size: 11px;
gap: 8px;
padding-top: 14px;
}
.catalog-count {
margin: 0 0 20px;
font-size: 13px;
font-variant-numeric: tabular-nums;
}
.catalog-empty {
padding: 36px 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.catalog-empty p {
margin: 0;
}
.catalog-controls .tabs button {
min-height: 44px;
}
.map-back-link {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--muted);
margin-bottom: 28px;
min-height: 32px;
}
.map-back-link:hover {
color: var(--green);
}
.map-detail-page .map-detail-title {
font-family: MineGeoPixelCyrillic, monospace;
font-size: clamp(24px, 3vw, 34px);
line-height: 1.5;
max-width: 30ch;
text-wrap: pretty;
}
.map-detail-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin: 16px 0;
}
.map-detail-actions .map-export {
margin: 0;
}
.map-detail-id {
font-size: 12px;
overflow-wrap: anywhere;
}
.detail-grid > * {
min-width: 0;
}
.start-panel .difficulty-options button {
min-width: 0;
overflow-wrap: anywhere;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
}
.detail-grid .card-meta span:last-child {
display: flex;
}
.stat-cube.void-cube {
background: #9e6bc4;
border-color: #cca6ef;
}
.mode-row:has(.void-cube) {
border-color: #9163b080;
}
.world-stats strong,
.card-meta {
font-variant-numeric: tabular-nums;
}
.loading-surface {
background: var(--panel2);
border: 1px solid var(--line);
border-radius: var(--radius-control);
}
.loading-title {
width: min(440px, 75%);
height: 48px;
margin: 24px 0 36px;
}
.loading-card {
height: 420px;
}
.route-loading {
min-height: 85dvh;
}
.leaderboard-preview-placeholder {
min-height: 420px;
}
.leaderboard-preview-placeholder .loading-surface {
height: 240px;
}
@media (max-width: 960px) {
.map-detail-page .detail-grid {
grid-template-columns: minmax(0, 1fr);
}
.detail-grid > div:first-child {
width: 100%;
max-width: 520px;
margin-inline: auto;
}
.start-panel .difficulty-options {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 701px) and (max-width: 960px) {
.home-page .hero {
grid-template-columns: minmax(0, 1fr);
gap: 32px;
}
.hero-copy {
max-width: 620px;
}
.home-page .hero h1 {
font-size: clamp(40px, 5.8vw, 54px);
line-height: 1.12;
text-wrap: balance;
}
.hero-art {
min-height: 350px;
width: 100%;
max-width: 440px;
margin-inline: auto;
}
.hero-art .compass-art {
right: calc(50% - 150px);
top: 0;
}
}
@media (max-width: 600px) {
.section.page-content {
padding: 32px 20px 48px;
}
.catalog-search {
width: 100%;
}
.catalog-search .search-field {
min-height: 46px;
padding-inline: 12px;
}
.catalog-search input {
font-size: 16px;
}
.catalog-controls {
gap: 16px;
}
.catalog-controls .tabs {
gap: 4px;
}
.catalog-controls .tabs button {
padding-inline: 10px;
}
.map-card .card-body {
padding: 18px;
}
.map-detail-page .map-detail-title {
font-size: 22px;
}
.map-back-link {
margin-bottom: 20px;
}
.detail-grid {
gap: 28px;
margin-top: 28px;
}
.detail-grid .start-panel {
padding: 20px;
}
.start-panel .difficulty-options {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.start-panel .difficulty-options span {
font-size: 12px;
}
.hero-actions {
align-items: stretch;
}
.hero-actions > .button {
width: 100%;
}
}
.moderator-setting-row { .moderator-setting-row {
display: flex; display: flex;
gap: 24px; gap: 24px;

View File

@@ -0,0 +1 @@
export { PageLoading as default } from "@/components/PageLoading";

View File

@@ -7,10 +7,11 @@ import { StartGame } from "@/components/StartGame";
import { getMap } from "@/lib/catalog"; import { getMap } from "@/lib/catalog";
import { ContributionInstructions } from "@/components/ContributionInstructions"; import { ContributionInstructions } from "@/components/ContributionInstructions";
import { ReportContent } from "@/components/ReportContent"; import { ReportContent } from "@/components/ReportContent";
import { user } from "@/lib/supabase"; import { user, admin, required } from "@/lib/supabase";
import { accountProfile } from "@/lib/profile";
import { ExportMap } from "@/components/MapTransfer"; import { ExportMap } from "@/components/MapTransfer";
import { MapCommunity } from "@/components/MapCommunity"; import { MapCommunity } from "@/components/MapCommunity";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
export default async function ServerPage({ export default async function ServerPage({
params, params,
}: { }: {
@@ -18,29 +19,42 @@ export default async function ServerPage({
}) { }) {
const { t } = await getI18n(); const { t } = await getI18n();
const { id } = await params; const { id } = await params;
const map = await getMap(id); const [map, account] = await Promise.all([getMap(id), user()]);
if (!map) notFound(); if (!map) notFound();
const account = await user(); const profileResult = account
const profile = account ? await accountProfile(account) : null; ? await admin()
.from("profiles")
.select("display_name")
.eq("id", account.id)
.maybeSingle()
: null;
if (profileResult?.error) required(profileResult);
const profile = profileResult?.data;
return ( return (
<> <>
<Header /> <Header />
<main className="section page-content"> <main className="section page-content map-detail-page">
<Link className="map-back-link" href="/servers">
<ArrowLeft size={16} aria-hidden="true" />
{t("All worlds")}
</Link>
<span className="eyebrow"> <span className="eyebrow">
{map.source === "plugin" {map.source === "plugin"
? t("SERVER EXPORT") ? t("SERVER EXPORT")
: t("PLAYER CONTRIBUTION")}{" "} : t("PLAYER CONTRIBUTION")}{" "}
{t("· MINECRAFT 1.21.11")} {t("· MINECRAFT 1.21.11")}
</span> </span>
<h1>{map.name}</h1> <h1 className="map-detail-title">{map.name}</h1>
<p className="lead">{map.description}</p> <p className="lead">{map.description}</p>
<p className="map-credit"> <p className="map-credit">
{t("Created by")}{" "} {t("Created by")}{" "}
<Username name={map.credit} appearance={map.creditAppearance} /> <Username name={map.credit} appearance={map.creditAppearance} />
</p> </p>
<div className="map-detail-actions">
<ReportContent datasetId={id} /> <ReportContent datasetId={id} />
{account?.id === map.owner_id && <ExportMap id={id} />} {account?.id === map.owner_id && <ExportMap id={id} />}
<p className="muted"> </div>
<p className="muted map-detail-id">
{t("Map ID:")} <code>{map.id}</code> {t("Map ID:")} <code>{map.id}</code>
</p> </p>
<div className="detail-grid"> <div className="detail-grid">
@@ -66,7 +80,10 @@ export default async function ServerPage({
</span> </span>
</div> </div>
</div> </div>
<StartGame datasetId={id} hostName={profile?.display_name ?? null} /> <StartGame
datasetId={id}
hostName={account ? (profile?.display_name ?? "Explorer") : null}
/>
</div> </div>
{map.allow_contributions && ( {map.allow_contributions && (
<ContributionInstructions <ContributionInstructions

1
app/servers/loading.tsx Normal file
View File

@@ -0,0 +1 @@
export { PageLoading as default } from "@/components/PageLoading";

View File

@@ -54,7 +54,7 @@ export function CatalogBrowser({
))} ))}
</div> </div>
</div> </div>
<p className="muted"> <p className="muted catalog-count" role="status">
{t("Worlds to explore: {count}", { count: shown.length })} {t("Worlds to explore: {count}", { count: shown.length })}
</p> </p>
<div className="cards-grid"> <div className="cards-grid">
@@ -63,7 +63,20 @@ export function CatalogBrowser({
))} ))}
</div> </div>
{!shown.length && ( {!shown.length && (
<p className="empty-state">{t("No worlds match that search.")}</p> <div className="empty-state catalog-empty">
<Search size={28} aria-hidden="true" />
<p>{t("No worlds match that search.")}</p>
<button
type="button"
className="button secondary"
onClick={() => {
setQuery("");
setSource("all");
}}
>
{t("Clear filters")}
</button>
</div>
)} )}
</> </>
); );

View File

@@ -21,10 +21,12 @@ export function MapCard({
map, map,
index = 0, index = 0,
minecraftPaper = false, minecraftPaper = false,
loading,
}: { }: {
map: CardMap; map: CardMap;
index?: number; index?: number;
minecraftPaper?: boolean; minecraftPaper?: boolean;
loading?: "eager" | "lazy";
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
return ( return (
@@ -35,7 +37,7 @@ export function MapCard({
src={map.image || "/images/example-map.webp"} src={map.image || "/images/example-map.webp"}
alt={t("Terrain of {name}", { name: map.name })} alt={t("Terrain of {name}", { name: map.name })}
fill fill
loading={index < 3 ? "eager" : "lazy"} loading={loading ?? (index < 3 ? "eager" : "lazy")}
unoptimized unoptimized
sizes="(max-width: 640px) 100vw, 33vw" sizes="(max-width: 640px) 100vw, 33vw"
/> />
@@ -48,8 +50,8 @@ export function MapCard({
<div className="card-body"> <div className="card-body">
<span className="eyebrow">{map.server?.name || t("Overworld")}</span> <span className="eyebrow">{map.server?.name || t("Overworld")}</span>
<h3> <h3>
{map.name} <span title={map.name}>{map.name}</span>
<ArrowUpRight size={20} /> <ArrowUpRight size={20} aria-hidden="true" />
</h3> </h3>
<p>{map.description}</p> <p>{map.description}</p>
{map.credit && ( {map.credit && (

View File

@@ -0,0 +1,22 @@
import { getI18n } from "@/lib/i18n";
export async function PageLoading() {
const { t } = await getI18n();
return (
<main
className="section page-content route-loading"
aria-busy="true"
aria-label={t("Loading page")}
>
<p role="status" className="muted">
{t("Loading page")}…
</p>
<div className="loading-title loading-surface" aria-hidden="true" />
<div className="cards-grid" aria-hidden="true">
{[0, 1, 2].map((index) => (
<div className="loading-card loading-surface" key={index} />
))}
</div>
</main>
);
}

View File

@@ -1,7 +1,7 @@
import { getI18n } from "@/lib/i18n"; import { getI18n } from "@/lib/i18n";
import "server-only"; import "server-only";
import Link from "next/link"; import Link from "next/link";
import { Compass, ArrowUpRight } from "lucide-react"; import { Compass, ArrowUpRight, UserRound } from "lucide-react";
import { LanguageSwitcher } from "./LanguageSwitcher"; import { LanguageSwitcher } from "./LanguageSwitcher";
import { rawUser, admin, required } from "@/lib/supabase"; import { rawUser, admin, required } from "@/lib/supabase";
import { canReviewMaps, isAdministrator } from "@/shared/permissions"; import { canReviewMaps, isAdministrator } from "@/shared/permissions";
@@ -25,8 +25,19 @@ export async function Header() {
<Link <Link
href={account ? "/dashboard" : "/log-in"} href={account ? "/dashboard" : "/log-in"}
className="header-account" className="header-account"
aria-label={account ? t("My account") : t("Log in")}
> >
{account ? t("My account") : t("Log in")} <ArrowUpRight size={15} /> <UserRound
size={18}
className="account-symbol"
aria-hidden="true"
/>
<span>{account ? t("My account") : t("Log in")}</span>{" "}
<ArrowUpRight
className="account-arrow"
size={15}
aria-hidden="true"
/>
</Link> </Link>
</div> </div>
</div> </div>

View File

@@ -3,6 +3,8 @@ import { useI18n } from "@/components/I18nProvider";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { Menu, X } from "lucide-react";
const links = [ const links = [
["/servers", "Play & maps"], ["/servers", "Play & maps"],
@@ -17,12 +19,62 @@ const links = [
export function SiteNav({ forumUrl }: { forumUrl?: string | null }) { export function SiteNav({ forumUrl }: { forumUrl?: string | null }) {
const { t } = useI18n(); const { t } = useI18n();
const pathname = usePathname(); const pathname = usePathname();
const [openPath, setOpenPath] = useState<string | null>(null);
const open = openPath === pathname;
const toggle = useRef<HTMLButtonElement>(null);
const navigation = useRef<HTMLElement>(null);
useEffect(() => {
if (!open) return;
function dismiss(event: PointerEvent) {
if (
event.target instanceof Node &&
!navigation.current?.contains(event.target) &&
!toggle.current?.contains(event.target)
)
setOpenPath(null);
}
function escape(event: KeyboardEvent) {
if (event.key === "Escape") {
setOpenPath(null);
toggle.current?.focus();
}
}
document.addEventListener("pointerdown", dismiss);
document.addEventListener("keydown", escape);
return () => {
document.removeEventListener("pointerdown", dismiss);
document.removeEventListener("keydown", escape);
};
}, [open]);
return ( return (
<nav aria-label={t("Main navigation")}> <>
<button
ref={toggle}
type="button"
className="nav-toggle"
aria-label={open ? t("Close navigation") : t("Open navigation")}
aria-expanded={open}
aria-controls="site-navigation"
onClick={() => setOpenPath(open ? null : pathname)}
>
{open ? (
<X size={20} aria-hidden="true" />
) : (
<Menu size={20} aria-hidden="true" />
)}
</button>
<nav
ref={navigation}
id="site-navigation"
className="site-navigation"
data-open={open}
aria-label={t("Main navigation")}
>
{links.map(([href, label]) => ( {links.map(([href, label]) => (
<Link <Link
key={href} key={href}
href={href} href={href}
onClick={() => setOpenPath(null)}
aria-current={ aria-current={
pathname === href || pathname.startsWith(`${href}/`) pathname === href || pathname.startsWith(`${href}/`)
? "page" ? "page"
@@ -37,6 +89,14 @@ export function SiteNav({ forumUrl }: { forumUrl?: string | null }) {
{t("Forum")} {t("Forum")}
</a> </a>
)} )}
<Link
className="nav-support"
href={`/support?from=${encodeURIComponent(pathname)}`}
onClick={() => setOpenPath(null)}
>
{t("Report a bug")}
</Link>
</nav> </nav>
</>
); );
} }

View File

@@ -32,6 +32,7 @@ export function SiteSettingsProvider({
const version = useRef(0); const version = useRef(0);
const pending = useRef(false); const pending = useRef(false);
const pathname = usePathname(); const pathname = usePathname();
const previousPathname = useRef(pathname);
const updateSettings = useCallback((next: SiteSettings) => { const updateSettings = useCallback((next: SiteSettings) => {
version.current++; version.current++;
setSettings(next); setSettings(next);
@@ -51,6 +52,9 @@ export function SiteSettingsProvider({
} }
}, []); }, []);
useEffect(() => { useEffect(() => {
// SSR already supplied fresh settings for the initial page.
if (previousPathname.current === pathname) return;
previousPathname.current = pathname;
void refresh(); void refresh();
}, [pathname, refresh]); }, [pathname, refresh]);
useEffect(() => { useEffect(() => {

View File

@@ -49,45 +49,50 @@ export async function mediaUrls(paths: (string | null)[], expires = 3600) {
} }
export async function catalog() { export async function catalog() {
const client = admin(); const client = admin();
const maps = required( const [mapResult, serverResult] = await Promise.all([
await client client
.from("datasets") .from("datasets")
.select("*") .select("*")
.eq("state", "published") .eq("state", "published")
.order("created_at", { ascending: false }), .order("created_at", { ascending: false }),
) as unknown as MapListing[]; client.from("catalog_servers").select("*"),
const servers = required( ]);
await client.from("catalog_servers").select("*"), const maps = required(mapResult) as unknown as MapListing[];
) as { id: string; name: string; address: string | null }[]; const servers = required(serverResult) as {
const owners = maps.length id: string;
? required( name: string;
await client address: string | null;
}[];
const ownerIds = [
...new Set(maps.flatMap((map) => (map.owner_id ? [map.owner_id] : []))),
];
const [ownersResult, signed] = await Promise.all([
ownerIds.length
? client
.from("profiles") .from("profiles")
.select( .select(
"id,display_name,avatar_path,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon", "id,display_name,avatar_path,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
) )
.in("id", [ .in("id", ownerIds)
...new Set( : Promise.resolve({ data: [], error: null }),
maps mediaUrls(
.map((m) => m.owner_id)
.filter((id): id is string => id !== null),
),
]),
)
: [];
const signed = await mediaUrls(
maps.flatMap((map) => [map.map_path || map.cover_path, map.map_path]), maps.flatMap((map) => [map.map_path || map.cover_path, map.map_path]),
),
]);
const owners = new Map(
required(ownersResult).map((owner) => [owner.id, owner]),
); );
const serverById = new Map(servers.map((server) => [server.id, server]));
return maps.map((m, index) => ({ return maps.map((m, index) => ({
...m, ...m,
credit: credit:
owners.find((p) => p.id === m.owner_id)?.display_name ?? owners.get(m.owner_id ?? "")?.display_name ??
(m.owner_id ? "Explorer" : "Deleted player"), (m.owner_id ? "Explorer" : "Deleted player"),
creditGlow: owners.find((p) => p.id === m.owner_id)?.name_glow ?? false, creditGlow: owners.get(m.owner_id ?? "")?.name_glow ?? false,
creditAppearance: owners.find((p) => p.id === m.owner_id), creditAppearance: owners.get(m.owner_id ?? ""),
image: signed[index * 2] ?? undefined, image: signed[index * 2] ?? undefined,
mapImage: signed[index * 2 + 1] ?? undefined, mapImage: signed[index * 2 + 1] ?? undefined,
server: servers.find((s) => s.id === m.server_id), server: serverById.get(m.server_id),
})); }));
} }
export async function getMap(id: string) { export async function getMap(id: string) {

View File

@@ -64,6 +64,6 @@ export async function proxy(request: NextRequest) {
} }
export const config = { export const config = {
matcher: [ matcher: [
"/((?!_next/static|_next/image|favicon.ico|\\.well-known/workflow/|tools/|desktop/|fonts/|images/|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|ttf|woff|woff2|jar)$).*)", "/((?!_next/static|_next/image|favicon.ico|\\.well-known/workflow/|tools/|desktop/|fonts/|images/|media/|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|ttf|woff|woff2|jar)$).*)",
], ],
}; };

View File

@@ -1,5 +1,12 @@
{ {
"MineGeo": "MineGeo", "MineGeo": "MineGeo",
"Open navigation": "Открыть меню",
"Close navigation": "Закрыть меню",
"Find your challenge.": "Найди свой вызов.",
"10 seconds": "10 секунд",
"The ultimate test. Fixed view, strict scoring.": "Главное испытание. Фиксированный вид, строгий подсчёт.",
"Clear filters": "Сбросить фильтры",
"Loading page": "Загрузка страницы",
"Find your place in a blocky world.": "Найди своё место в мире блоков.", "Find your place in a blocky world.": "Найди своё место в мире блоков.",
"Skip intro": "Пропустить вступление", "Skip intro": "Пропустить вступление",
"THE EXPEDITION AWAITS": "ЭКСПЕДИЦИЯ ЖДЁТ", "THE EXPEDITION AWAITS": "ЭКСПЕДИЦИЯ ЖДЁТ",