Improve responsive layouts and streamline page loading
All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 8m58s
All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 8m58s
This commit is contained in:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />;
|
||||||
|
}
|
||||||
|
|||||||
45
app/page.tsx
45
app/page.tsx
@@ -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} />;
|
||||||
|
}
|
||||||
|
|||||||
261
app/polish.css
261
app/polish.css
@@ -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;
|
||||||
|
|||||||
1
app/servers/[id]/loading.tsx
Normal file
1
app/servers/[id]/loading.tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { PageLoading as default } from "@/components/PageLoading";
|
||||||
@@ -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
1
app/servers/loading.tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { PageLoading as default } from "@/components/PageLoading";
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
22
components/PageLoading.tsx
Normal file
22
components/PageLoading.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
2
proxy.ts
2
proxy.ts
@@ -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)$).*)",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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": "ЭКСПЕДИЦИЯ ЖДЁТ",
|
||||||
|
|||||||
Reference in New Issue
Block a user