Compare commits

..

53 Commits

Author SHA1 Message Date
05854818a0 Map admins: owners invite friends to co-manage maps
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 6m2s
Owners can invite players by name from the map workshop; invitees accept
from their dashboard and then manage the map (details, locations,
submission, refresh, contributions, appeals) through owner-delegated
checks. Adds the map_admins table, invite/admin API endpoints, dashboard
invite box, workshop admin management, and Russian translations.
2026-10-31 17:28:05 +05:00
0d62f08e77 Fix login return routes and recovery feedback
All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 6m29s
2026-10-31 13:56:01 +05:00
48d4058e06 Float glass header and document moderator applications
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 6m16s
2026-10-31 12:06:22 +05:00
50b27cf498 Add moderator bots and editable MineGeo usernames
All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 3m27s
2026-10-29 16:32:55 +05:00
658c8974ac Release v0.1.4 with terrain rescanning and owner-published map updates
All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 6m26s
2026-10-28 19:40:53 +05:00
5898ef35a5 Move the field guide into the docs section
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 10m22s
2026-10-28 11:08:36 +05:00
ed4d264801 Verify and restore the complete Minecraft asset cache
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 8m10s
2026-10-27 21:07:56 +05:00
1fc59d4ceb Fix party slow mode across account sessions
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 8m22s
2026-10-27 17:05:56 +05:00
9c314ecb4e Add tournament and multiplayer community chat moderation
All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 12m17s
2026-10-27 12:54:15 +05:00
b8a430d4c2 Add liquid glass filter definitions to site layout
All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 10m3s
2026-10-25 18:57:25 +05:00
927923dc95 Improve accessibility and polish page styling
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 11m31s
2026-10-24 14:11:17 +05:00
060e8f27ef Update status link and moderation page spacing
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 8m16s
2026-10-21 16:20:02 +05:00
0330c4072f Improve responsive layouts and streamline page loading
All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 8m58s
2026-10-21 11:15:07 +05:00
690fe12c2c Fix restricted map controls and release verification
All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 5m17s
2026-10-20 16:46:36 +05:00
de0e891b62 Add Void difficulty and publish changelogs
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 3m40s
2026-10-20 14:05:01 +05:00
3ba398a838 Set MineGeo community forum URL
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 9m20s
2026-10-19 18:51:53 +05:00
425b0e0805 Test and document shared tournament deadlines
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 2m20s
2026-10-19 10:16:23 +05:00
da1a50a6ad Add map-specific tournament creation link
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 3m31s
2026-10-18 13:32:56 +05:00
a7085e4e80 Improve MineGeo UI and application flows
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 2m37s
2026-10-18 11:44:04 +05:00
ad73132f15 Add cinematic banner, real-site trailer, and first-visit intro
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 3m14s
2026-10-17 19:54:23 +05:00
128caff2a2 Fix live map community schema and refresh API cache
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 3m16s
2026-10-17 14:17:08 +05:00
8dc8ee3bd7 Add detailed diagnostics to Dokploy deployment workflow
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 4m2s
2026-10-17 13:38:05 +05:00
2cb34fff86 Pin Dokploy deployment URL in workflow
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 5m50s
2026-10-17 13:21:24 +05:00
e1a6ab5730 Add map community reactions, comments, and moderation
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 3m22s
2026-10-17 12:45:18 +05:00
0bd05d6e0b Validate and deduplicate custom difficulty presets
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 6m4s
2026-10-16 13:29:32 +05:00
1a34eb71c2 Require distinct panorama locations for rounds and captures
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 6m30s
2026-10-16 10:09:36 +05:00
c9fc9496eb Improve worker error logging and update Dokploy deployment docs
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Has been cancelled
2026-10-16 09:37:57 +05:00
1f254b29a2 Update MineGeo domain and harden process supervision
All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 5m57s
2026-10-15 20:35:39 +05:00
0e4412c99b Revert profile cosmetics and GeoCoin currency
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 6m16s
2026-10-15 18:32:18 +05:00
c541cc09b4 Add public profiles and cosmetic shop
All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 7m10s
2026-10-15 17:30:10 +05:00
44e62cff2f Update Minecraft tools to use minegeo.site
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 5m49s
2026-10-15 15:48:04 +05:00
930e00a736 Secure profile photo uploads and trust configured production origin
All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 8m53s
2026-10-15 14:00:46 +05:00
07cd0993a0 Reduce Docker build memory use on small VPS
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 7m39s
2026-10-14 17:04:26 +05:00
3c3d27b35c Clarify Dokploy build-time Supabase configuration
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 9m12s
2026-10-14 16:37:47 +05:00
ci
1637ad922e ci: redeploy after VPN restored
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 3m47s
2026-10-14 13:40:40 +05:00
ci
a9515d96fc ci: allow the pinned Dokploy HTTP origin until a TLS domain is configured
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 3m24s
2026-10-14 13:02:47 +05:00
ci
d2108a1f99 ci: verify runner checkout with host.docker.internal
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 2m16s
2026-10-14 12:39:56 +05:00
663061d03a Exclude build artifacts and document Dokploy deployment
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 3m39s
2026-10-14 11:07:41 +05:00
de812453ec Add profile photo uploads and custom difficulty support 2026-10-13 20:50:54 +05:00
8c3de302e7 Add live account appeal updates and restricted status settings 2026-10-13 14:22:12 +05:00
cfe5820802 Add account restrictions, appeals, and admin moderation tools 2026-10-13 12:32:37 +05:00
018be5049e Add developer API, Prometheus metrics, and community links 2026-10-12 19:59:37 +05:00
3cc3136762 Add docs navigation and tighten API request security 2026-10-11 18:53:46 +05:00
a54a3e7705 Add portable Windows desktop downloads and release uploads 2026-10-11 11:44:27 +05:00
fc6da6756d Add account based multiplayer hosting and bug report retention 2026-10-10 16:24:34 +05:00
9af71c85c2 Add MineGeo gameplay systems and interface 2026-10-10 10:27:47 +05:00
bf01d9801d Add theme toggle and update MineGeo branding 2026-10-08 20:37:50 +05:00
0b08672f4a Add player bug reports and profile identity features 2026-10-08 18:27:20 +05:00
36e8d11eb4 Add private chat to multiplayer rooms 2026-10-08 10:22:19 +05:00
1307436e65 Add map import and export with durable refresh progress 2026-10-07 19:44:49 +05:00
dac8650ac1 Add Minecraft 26.x support and version Java artifacts 2026-10-07 13:18:31 +05:00
48b34db6f1 Add multiplayer rooms and Vercel analytics 2026-10-07 12:28:45 +05:00
ce26e6fdbc Improve authentication recovery and mobile gameplay 2026-10-07 11:10:29 +05:00
432 changed files with 50227 additions and 903 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

23
.dockerignore Normal file
View File

@@ -0,0 +1,23 @@
.git
**/.git
node_modules
**/node_modules
.next
**/.next
.env*
**/.env*
.cache
**/.cache
.data
artifacts
coverage
playwright-report
test-results
*.log
*.hprof
**/*.hprof
**/.gradle
**/build
minecraft/
desktop/dist
public/generated

View File

@@ -4,6 +4,31 @@ MINEGEO_URL=http://localhost:3000
MINEGEO_HOST=127.0.0.1
# Optional comma-separated exact public origins for a reverse proxy or aliases.
MINEGEO_ALLOWED_ORIGINS=
# Optional public community links. Leave either blank to hide its footer button.
# Discord: https://discord.gg/<invite> or https://discord.com/invite/<invite>
# Telegram: https://t.me/<channel-or-group> (private group invite links also work).
MINEGEO_DISCORD_URL=
MINEGEO_TELEGRAM_URL=
# Optional HTTPS override for the MineGeo forum linked in the header/footer.
MINEGEO_FORUM_URL=
# Optional server-only Discord bot integration. All published parties use one channel.
# Create a bot at https://discord.com/developers/applications and invite it to your server.
# Channel IDs are copied with Discord Developer Mode enabled. Never use NEXT_PUBLIC_ here.
DISCORD_BOT_TOKEN=
# Separate MineGeo Telegram bot. Run one bot process on the VPS.
TELEGRAM_BOT_TOKEN=
# Enable exactly one bot process on the VPS.
MINEGEO_RUN_BOTS=false
# Optional immutable Discord user ID for the initial admin binding.
DISCORD_MODERATOR_ADMIN_ID=
DISCORD_MAPS_CHANNEL_ID=
DISCORD_PARTIES_CHANNEL_ID=
# Optional overrides for the read-only changelog (defaults to MineGeo news and release webhook).
# The existing bot requires View Channel and Read Message History there.
DISCORD_NEWS_CHANNEL_ID=
DISCORD_RELEASE_WEBHOOK_ID=
# Optional bearer secret for scheduled GET /api/integrations/discord/dispatch retries.
CRON_SECRET=
# Development testing only; production rejects this bypass of CSRF protection.
MINEGEO_ALLOW_ANY_ORIGIN=false
NEXT_PUBLIC_SUPABASE_URL=
@@ -20,3 +45,10 @@ MINEGEO_RENDER_IDLE_SECONDS=600
# Allow Chromium to use the GPU; software forces SwiftShader for headless hosts.
MINEGEO_RENDER_BACKEND=auto
MINEGEO_MAX_UPLOAD_BYTES=268435456
# Player bug reports. Keep secrets server-only; configure Production on Vercel.
LINEAR_API_KEY=
LINEAR_WEBHOOK_SECRET=
LINEAR_TEAM_ID=
# Optional UUID or project identifier (MineGeo: P-DUN-3).
LINEAR_PROJECT_ID=

View File

@@ -0,0 +1,154 @@
name: Deploy MineGeo to Dokploy
on:
push:
branches:
- master
jobs:
deploy:
# This label must map to a Docker job image in the Gitea act_runner config.
runs-on: ubuntu-latest
steps:
- name: Check out the pushed commit
uses: actions/checkout@v4
- name: Upload source and wait for Dokploy build
shell: bash
env:
DOKPLOY_API_KEY: ${{ secrets.DOKPLOY_API_KEY }}
DOKPLOY_APPLICATION_ID: ${{ secrets.DOKPLOY_APPLICATION_ID }}
run: |
set -eu
umask 077
# Pin the deployment origin; runner variables and .env cannot override it.
readonly DOKPLOY_URL='https://dokploy.minegeo.site'
: "${DOKPLOY_API_KEY:?Add the DOKPLOY_API_KEY Actions secret.}"
: "${DOKPLOY_APPLICATION_ID:?Add the DOKPLOY_APPLICATION_ID Actions secret.}"
temp_root="${RUNNER_TEMP:-/tmp}"
mkdir -p "$temp_root"
auth_file="$(mktemp "$temp_root/minegeo-dokploy-auth.XXXXXX")"
archive_file="$(mktemp "$temp_root/minegeo-dokploy-source.XXXXXX")"
response_file="$(mktemp "$temp_root/minegeo-dokploy-response.XXXXXX")"
deployment_file="$(mktemp "$temp_root/minegeo-dokploy-status.XXXXXX")"
metrics_file="$(mktemp "$temp_root/minegeo-dokploy-metrics.XXXXXX")"
error_file="$(mktemp "$temp_root/minegeo-dokploy-errors.XXXXXX")"
headers_file="$(mktemp "$temp_root/minegeo-dokploy-headers.XXXXXX")"
cleanup() {
rm -f "$auth_file" "$archive_file" "$response_file" "$deployment_file" "$metrics_file" "$error_file" "$headers_file"
}
trap cleanup EXIT
# Keep the API key out of curl's command-line arguments and workflow logs.
printf 'x-api-key: %s\n' "$DOKPLOY_API_KEY" > "$auth_file"
unset DOKPLOY_API_KEY
echo "[Dokploy] Workflow revision: $(git rev-parse HEAD)"
curl --version | head -n 2
node scripts/dokploy-diagnostics.mjs environment "$DOKPLOY_URL"
dokploy_request() {
label="$1"
body_file="$2"
shift 2
: > "$body_file"
: > "$headers_file"
: > "$metrics_file"
: > "$error_file"
echo "[Dokploy] Starting $label at $(date -u '+%Y-%m-%dT%H:%M:%SZ')" >&2
if curl --silent --show-error \
--header "@$auth_file" \
--dump-header "$headers_file" \
--output "$body_file" \
--write-out '%{http_code}\nremote_ip=%{remote_ip} remote_port=%{remote_port} local_ip=%{local_ip}\ndns=%{time_namelookup}s connect=%{time_connect}s tls=%{time_appconnect}s first_byte=%{time_starttransfer}s total=%{time_total}s\nssl_verify_result=%{ssl_verify_result} redirects=%{num_redirects} uploaded=%{size_upload}B downloaded=%{size_download}B\n' \
"$@" > "$metrics_file" 2> "$error_file"; then
curl_exit=0
else
curl_exit=$?
fi
node scripts/dokploy-diagnostics.mjs request "$label" "$curl_exit" \
"$metrics_file" "$error_file" "$headers_file" "$body_file" "$auth_file" >&2
http_code="$(head -n 1 "$metrics_file")"
if [ "$curl_exit" -ne 0 ]; then
echo "[Dokploy] $label failed: curl exit $curl_exit, HTTP ${http_code:-000}." >&2
return 1
fi
case "$http_code" in
2??) ;;
*) echo "[Dokploy] $label failed: HTTP ${http_code:-000}." >&2; return 1 ;;
esac
}
# Only committed files from this revision are uploaded. This excludes
# .git metadata, local .env files, caches, and untracked workspace data.
git archive --format=zip --output="$archive_file" HEAD
echo "[Dokploy] Source archive size: $(wc -c < "$archive_file") bytes"
dokploy_request "API preflight GET /api/deployment.all" "$deployment_file" --get \
--data-urlencode "applicationId=$DOKPLOY_APPLICATION_ID" \
--connect-timeout 10 --max-time 30 \
"$DOKPLOY_URL/api/deployment.all"
previous_ids="$(node scripts/dokploy-deployment-state.mjs snapshot "$deployment_file")" || {
node scripts/dokploy-diagnostics.mjs request "API preflight response parsing" parse-error \
"$metrics_file" "$error_file" "$headers_file" "$deployment_file" "$auth_file" >&2
echo "Dokploy returned an unexpected deployment list; source was not uploaded." >&2
exit 1
}
dokploy_request "Source upload POST /api/application.dropDeployment" "$response_file" \
--form-string "applicationId=$DOKPLOY_APPLICATION_ID" \
--form "zip=@$archive_file;type=application/zip" \
--connect-timeout 15 --max-time 1800 \
"$DOKPLOY_URL/api/application.dropDeployment"
echo "Dokploy accepted the source archive; waiting for the VPS build and deployment."
attempt=0
poll_failures=0
while [ "$attempt" -lt 360 ]; do
attempt=$((attempt + 1))
if dokploy_request "Deployment status GET /api/deployment.all (attempt $attempt/360)" "$deployment_file" --get \
--data-urlencode "applicationId=$DOKPLOY_APPLICATION_ID" \
--connect-timeout 10 --max-time 30 \
"$DOKPLOY_URL/api/deployment.all"; then
poll_failures=0
else
poll_failures=$((poll_failures + 1))
if node scripts/dokploy-deployment-state.mjs retry "$curl_exit" "$http_code" "$poll_failures"; then
echo "Transient status lookup failure ($poll_failures/6); the accepted build may still be running. Retrying only the GET."
sleep 10
continue
fi
exit 1
fi
status="$(DOKPLOY_PREVIOUS_DEPLOYMENT_IDS="$previous_ids" node scripts/dokploy-deployment-state.mjs status "$deployment_file")" || {
node scripts/dokploy-diagnostics.mjs request "Deployment status response parsing" parse-error \
"$metrics_file" "$error_file" "$headers_file" "$deployment_file" "$auth_file" >&2
echo "Dokploy returned an unexpected deployment status; inspect the response diagnostics above." >&2
exit 1
}
case "$status" in
done)
echo "Dokploy build and deployment completed successfully."
exit 0
;;
error|cancelled|canceled)
echo "Dokploy reported a failed deployment; inspect that deployment's logs in Dokploy." >&2
exit 1
;;
*)
if [ $((attempt % 3)) -eq 0 ]; then
echo "Dokploy build status: $status."
fi
;;
esac
sleep 10
done
echo "Timed out waiting for Dokploy to finish; inspect the deployment in Dokploy." >&2
exit 1

2
.gitignore vendored
View File

@@ -1,4 +1,5 @@
node_modules/
desktop/dist/
.next/
.env*
!.env.example
@@ -18,3 +19,4 @@ public/downloads/*.jar
# Generated by Next.js
next-env.d.ts
/.swc

54
Dockerfile Normal file
View File

@@ -0,0 +1,54 @@
# syntax=docker/dockerfile:1
FROM node:22-bookworm-slim AS dependencies
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
FROM dependencies AS builder
COPY . .
# Next.js embeds NEXT_PUBLIC_* values into browser bundles; the server secret is runtime-only.
ARG NEXT_PUBLIC_SUPABASE_URL
ARG NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
NODE_OPTIONS=--max-old-space-size=640 \
NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL} \
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=${NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY}
# The Webpack compiler cache is build-only and can be hundreds of megabytes.
RUN if [ -z "$NEXT_PUBLIC_SUPABASE_URL" ] || [ -z "$NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY" ]; then \
echo "In Dokploy, set NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY under Application > Environment > Build Time Arguments." >&2; \
exit 1; \
fi && npm run build:docker \
&& rm -rf /app/.next/cache/webpack
# Reuse the installation, and prune only after the build has completed.
# A second independent npm ci stage could overlap with compilation in BuildKit.
FROM builder AS production-dependencies
RUN npm prune --omit=dev --no-audit --no-fund
FROM node:22-bookworm-slim AS runner
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
NODE_OPTIONS=--max-old-space-size=256 \
PORT=3000 \
HOSTNAME=0.0.0.0 \
MINEGEO_HOST=0.0.0.0 \
MINEGEO_WORKER_HEAP_MB=512 \
UV_THREADPOOL_SIZE=2
COPY --from=production-dependencies --chown=node:node /app/node_modules ./node_modules
COPY --from=builder --chown=node:node /app/.next ./.next
COPY --from=builder --chown=node:node /app/public ./public
COPY --chown=node:node package.json tsconfig.json next.config.ts ./
COPY --chown=node:node scripts/supervise.mjs scripts/runtime-config.mjs scripts/assets.mjs scripts/moderator-bots.mjs ./scripts/
COPY --chown=node:node worker ./worker
COPY --chown=node:node shared ./shared
COPY --chown=node:node lib ./lib
COPY --chown=node:node renderer ./renderer
RUN mkdir -p /app/.cache && chown node:node /app/.cache
USER node
EXPOSE 3000
CMD ["node", "scripts/supervise.mjs", "production"]

View File

@@ -0,0 +1,37 @@
# MineGeo community improvement report
28 October 2026 • Review of the live homepage and Discord server
Make it easy for new players to start a game, find support and discover community maps. The server already has English and Russian channels, a shared support selection, announcements and a server guide.
## Changes made
Updated the Discord server description to explain the game, community maps and bilingual support. Combined support selection into the existing language answers and removed the duplicate support question. Clarified the language and notification questions in English and Russian. Created the map-showcase forum with bilingual posting guidance and an optional onboarding answer. The English and Russian support channels now direct players to the bug button on the site. Sending messages and thread replies is denied to @everyone in both channels; administrator privileges and explicit role overrides can still affect access.
## Recommended next steps
- Make the first journey obvious: link the server guide to Play and maps, the field guide, official links and the site support button. Test it using a normal member account.
- Encourage members to use the new map-showcase forum for one post per map, with a link, screenshots and spoiler warnings. Review its activity before adding more channels.
- Run a small recurring community challenge: select one map, publish the deadline and rules, and invite players to share scores without revealing locations until the event ends.
- Give players an optional map creator or event notification choice in onboarding, linked to a real channel or role. Keep notifications opt-in and use clear bilingual labels.
- Continue applying English and Russian labels consistently across onboarding and the server guide.
- Replace the image-only rules with accessible text in both languages and clarify how to report harassment or unsafe content. Keep the existing rules until the replacement is agreed.
- Review the duplicate general and voice channels as membership grows; retain them for now and consolidate only after checking activity and existing content.
- On the homepage, distinguish production maps from test content. The live page showed two published maps, including one labelled TEST, and two ranked players at review time.
- Add a short first-game walkthrough and a prominent way to discover community maps. Explain browser and desktop options and link creators to the capture and publishing guide.
- Test the support journey end to end in both languages, including mobile navigation, the bug button, submission confirmation and how users follow up. This review did not submit a real report.
## Scope and limitations
The site observations are from the live homepage, not a full functional or accessibility audit. Discord permissions were reviewed in the owner account; test with a regular member to confirm effective permissions. Existing channel history was preserved.
Sources: https://minegeo.site/ • https://forum.minegeo.site/ • MineGeo Discord server

Binary file not shown.

View File

@@ -0,0 +1,37 @@
MineGeo community improvement report
28 October 2026 • Review of the live homepage and Discord server
Make it easy for new players to start a game, find support and discover community maps. The server already has English and Russian channels, a shared support selection, announcements and a server guide.
Changes made
Updated the Discord server description to explain the game, community maps and bilingual support. Combined support selection into the existing language answers and removed the duplicate support question. Clarified the language and notification questions in English and Russian. Created the map-showcase forum with bilingual posting guidance and an optional onboarding answer. The English and Russian support channels now direct players to the bug button on the site. Sending messages and thread replies is denied to @everyone in both channels; administrator privileges and explicit role overrides can still affect access.
Recommended next steps
Make the first journey obvious: link the server guide to Play and maps, the field guide, official links and the site support button. Test it using a normal member account.
Encourage members to use the new map-showcase forum for one post per map, with a link, screenshots and spoiler warnings. Review its activity before adding more channels.
Run a small recurring community challenge: select one map, publish the deadline and rules, and invite players to share scores without revealing locations until the event ends.
Give players an optional map creator or event notification choice in onboarding, linked to a real channel or role. Keep notifications opt-in and use clear bilingual labels.
Continue applying English and Russian labels consistently across onboarding and the server guide.
Replace the image-only rules with accessible text in both languages and clarify how to report harassment or unsafe content. Keep the existing rules until the replacement is agreed.
Review the duplicate general and voice channels as membership grows; retain them for now and consolidate only after checking activity and existing content.
On the homepage, distinguish production maps from test content. The live page showed two published maps, including one labelled TEST, and two ranked players at review time.
Add a short first-game walkthrough and a prominent way to discover community maps. Explain browser and desktop options and link creators to the capture and publishing guide.
Test the support journey end to end in both languages, including mobile navigation, the bug button, submission confirmation and how users follow up. This review did not submit a real report.
Scope and limitations
The site observations are from the live homepage, not a full functional or accessibility audit. Discord permissions were reviewed in the owner account; test with a regular member to confirm effective permissions. Existing channel history was preserved.
Sources: https://minegeo.site/ • https://forum.minegeo.site/ • MineGeo Discord server

Binary file not shown.

View File

@@ -1,52 +1,5 @@
import { getI18n } from "@/lib/i18n";
import { Header, Footer } from "@/components/Shell";
import Link from "next/link";
export default async function About() {
const { t } = await getI18n();
return (
<>
<Header />
<main className="section page-content prose">
<span className="eyebrow">{t("THE EXPLORER’S FIELD GUIDE")}</span>
<h1>
{t("You know this place.")}
<br />
<span className="green">{t("Probably.")}</span>
</h1>
<p className="lead">
{t(
"MineGeo drops you into a panorama from a community Minecraft world. Find out where you are before the clock runs out.",
)}
</p>
<h2>{t("Look for the little things")}</h2>
<p>
{t(
"Drag to look around. Mountains, shorelines, roofs, and landmarks all help. You stay in one place throughout a round.",
)}
</p>
<h2>{t("Make your guess")}</h2>
<p>
{t(
"Expand the map in the bottom-right corner and click to place a pin. North is up, X increases east, and Z increases south. Move your pin until you’re ready, then submit.",
)}
</p>
<h2>{t("Five chances to find your way")}</h2>
<p>
{t(
"Easy gives you 180 seconds, Normal 90, and Hard 30. Hard disables zoom. A timed-out round scores zero; you can still finish the expedition.",
)}
</p>
<h2>{t("Closer means better")}</h2>
<p>
{t(
"Each round awards up to 5,000 points based on horizontal distance. A guess within one block earns a perfect score. Compare all five guesses at the end.",
)}
</p>
<Link href="/servers" className="button primary">
{t("Let’s find a world →")}
</Link>
</main>
<Footer />
</>
);
import { permanentRedirect } from "next/navigation";
export default function About() {
permanentRedirect("/docs?page=guide");
}

View File

@@ -1,4 +1,15 @@
/* Shared account and authentication surfaces, layered after the Minecraft theme. */
.account-status-card {
margin-bottom: 32px;
gap: 18px;
}
.account-status-card h2 {
margin: 0;
}
.account-status-reason {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.auth-page {
padding-top: 48px;
padding-bottom: 72px;
@@ -166,6 +177,29 @@
box-shadow: 0 12px 30px #0002;
min-width: 0;
}
.account-avatar-control {
display: flex;
align-items: center;
gap: 18px;
margin: 24px 0;
}
.account-avatar-preview {
width: 84px;
height: 84px;
object-fit: cover;
box-shadow: 3px 3px #03070b;
}
.account-avatar-input {
flex: 1;
min-width: 0;
}
.account-avatar-input .field {
margin: 0 0 12px;
}
.account-avatar-input .field input {
min-height: 40px;
padding: 8px;
}
.settings-heading {
display: flex;
align-items: flex-start;
@@ -284,3 +318,34 @@
gap: 8px;
}
}
.legacy-password-dialog {
width: min(540px, calc(100vw - 24px));
}
.legacy-password-dialog > svg {
color: var(--green);
}
.legacy-password-dialog h2 {
margin-top: 16px;
}
.legacy-password-dialog .field input {
width: 100%;
font: 14px monospace;
padding: 12px;
background: var(--bg);
color: var(--text);
border: 1px solid var(--line);
}
.legacy-password-dialog > .text-link {
margin-top: 18px;
}
.password-saved {
display: flex;
gap: 10px;
align-items: flex-start;
margin: 20px 0;
}
.password-saved input {
flex-shrink: 0;
margin-top: 5px;
accent-color: var(--green);
}

30
app/admin/page.tsx Normal file
View File

@@ -0,0 +1,30 @@
import Link from "next/link";
import { Header, Footer } from "@/components/Shell";
import { Moderation } from "@/components/Moderation";
import { getI18n } from "@/lib/i18n";
import { staffPage } from "@/lib/staff-page";
export const metadata = { title: "Admin Tools" };
export default async function AdminPage() {
const [{ t }] = await Promise.all([getI18n(), staffPage(true)]);
return (
<>
<Header />
<main className="section page-content moderation-page" id="main-content" tabIndex={-1}>
<span className="eyebrow">MINEGEO ADMIN</span>
<h1>{t("Admin Tools.")}</h1>
<p className="lead">
{t(
"Manage accounts, site settings, integrations and community reports.",
)}
</p>
<div className="staff-page-switch">
<Link className="button" href="/moderation">
{t("Open Moderation Tools")}
</Link>
</div>
<Moderation mode="admin" />
</main>
<Footer />
</>
);
}

View File

@@ -0,0 +1,8 @@
import { developerKeys } from "@/lib/developer-key-management";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const GET = developerKeys;
export const POST = developerKeys;
export const DELETE = developerKeys;
export const PUT = developerKeys;
export const PATCH = developerKeys;

View File

@@ -0,0 +1,10 @@
import { developerOpenApi } from "@/shared/developer-openapi";
export const dynamic = "force-static";
export function GET() {
return Response.json(developerOpenApi, {
headers: {
"Cache-Control": "public, max-age=3600",
"X-Content-Type-Options": "nosniff",
},
});
}

3
app/api/dev/route.ts Normal file
View File

@@ -0,0 +1,3 @@
export function GET(request: Request) {
return Response.redirect(new URL("/dev/api", request.url), 307);
}

View File

@@ -0,0 +1,17 @@
import { developerApi } from "@/lib/developer-api";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const maxDuration = 30;
async function handle(
request: Request,
{ params }: { params: Promise<{ path: string[] }> },
) {
return developerApi(request, (await params).path);
}
export {
handle as GET,
handle as POST,
handle as PATCH,
handle as PUT,
handle as DELETE,
};

View File

@@ -0,0 +1,33 @@
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;
export async function GET(request: NextRequest) {
const secret = process.env.CRON_SECRET;
const actual = Buffer.from(request.headers.get("authorization") ?? "");
const expected = Buffer.from(`Bearer ${secret ?? ""}`);
if (
!secret ||
actual.length !== expected.length ||
!timingSafeEqual(actual, expected)
)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
try {
required(await admin().rpc("tick_tournaments"));
await dispatchTournamentAnnouncements(admin());
await dispatchDiscordAnnouncements();
return NextResponse.json(
{ ok: true },
{ headers: { "Cache-Control": "no-store" } },
);
} catch {
return NextResponse.json(
{ error: "Discord dispatch will retry" },
{ status: 503 },
);
}
}

16
app/api/metrics/route.ts Normal file
View File

@@ -0,0 +1,16 @@
import { admin, required } from "@/lib/supabase";
import { metricsResponse } from "@/lib/prometheus-response";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const maxDuration = 30;
export function GET(request: Request) {
return metricsResponse(request, async (tokenHash) => {
const result = await admin().rpc("read_prometheus_metrics", {
p_token_hash: tokenHash,
});
// NULL is an invalid or disabled credential, not a missing database record.
if (result.error) required(result);
return result.data;
});
}

View File

@@ -0,0 +1,112 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { admin, required } from "@/lib/supabase";
import { verifySupportWebhook } from "@/lib/support-signature";
import { supportCommand } from "@/shared/bug-reports";
import { reconcileBugStatus, scheduleBugCleanup } from "@/lib/bug-lifecycle";
export const runtime = "nodejs";
export async function POST(request: NextRequest) {
const secret = process.env.LINEAR_WEBHOOK_SECRET;
if (!secret)
return NextResponse.json(
{ error: "Support integration is not configured" },
{ status: 503 },
);
const reader = request.body?.getReader();
if (!reader) return new NextResponse(null, { status: 400 });
const chunks: Uint8Array[] = [];
let size = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
size += value.length;
if (size > 256 * 1024) {
await reader.cancel();
return new NextResponse(null, { status: 413 });
}
chunks.push(value);
}
const raw = Buffer.concat(chunks).toString("utf8");
if (
!verifySupportWebhook(raw, request.headers.get("linear-signature"), secret)
)
return new NextResponse(null, { status: 401 });
let decoded: unknown;
try {
decoded = JSON.parse(raw);
} catch {
return new NextResponse(null, { status: 400 });
}
const payload = z
.object({
action: z.string(),
type: z.string(),
actor: z
.object({
id: z.string(),
type: z.string(),
name: z.string().optional(),
})
.nullable()
.optional(),
data: z.object({
id: z.string().uuid(),
issueId: z.string().uuid().optional(),
body: z.string().optional(),
userId: z.string().optional(),
}),
})
.safeParse(decoded);
if (!payload.success) return new NextResponse(null, { status: 400 });
const event = payload.data;
if (event.type === "Issue" && ["create", "update"].includes(event.action)) {
try {
await reconcileBugStatus(event.data.id, true);
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json(
{ error: "Support status delivery failed" },
{ status: 503 },
);
}
}
// Only explicit new staff comments are public. Internal comments and user echoes stay private.
if (
event.action !== "create" ||
event.type !== "Comment" ||
event.actor?.type.toLowerCase() !== "user" ||
event.data.userId !== event.actor.id ||
!event.data.issueId
)
return NextResponse.json({ ok: true });
const command = supportCommand(event.data.body ?? "");
if (!command) return NextResponse.json({ ok: true });
try {
if (command.kind === "close") {
required(
await admin().rpc("receive_bug_close", {
p_report: event.data.issueId,
p_external: event.data.id,
}),
);
await scheduleBugCleanup(event.data.issueId);
return NextResponse.json({ ok: true });
}
required(
await admin().rpc("receive_bug_reply", {
p_report: event.data.issueId,
p_external: event.data.id,
p_author: event.actor.name ?? "Support",
p_body: command.kind === "reply" ? command.text : "",
p_glow: command.kind === "glow" ? command.enabled : null,
}),
);
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json(
{ error: "Support delivery failed" },
{ status: 503 },
);
}
}

View File

@@ -2,6 +2,7 @@ import { NextRequest } from "next/server";
import { handle } from "@/lib/api";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const maxDuration = 300;
async function route(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },

581
app/appearance.css Normal file
View File

@@ -0,0 +1,581 @@
/* Site chrome only: map artwork, parchment, tiles and panoramas keep their colors. */
.brand {
gap: 9px;
}
.brand .brand-mark {
display: block;
width: 42px;
height: 46px;
object-fit: contain;
flex-shrink: 0;
}
.brand > span.brand-wordmark {
color: var(--text);
font:
850 25px/1 Arial,
Helvetica,
sans-serif;
letter-spacing: -1.3px;
}
.brand-wordmark > span {
color: var(--green);
}
.brand sup {
margin-left: 0;
}
.site-footer .brand .brand-mark {
width: 36px;
height: 40px;
}
.game-header .brand .brand-wordmark {
font-size: 22px;
}
.game-header .brand .brand-mark {
width: 32px;
height: 36px;
}
.theme-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 44px;
height: 44px;
border: 1px solid var(--line);
border-radius: var(--radius-control);
color: var(--text);
background: var(--panel);
}
.theme-toggle:hover {
background: var(--panel2);
border-color: var(--green);
}
.theme-moon,
html[data-theme="light"] .theme-sun {
display: none;
}
html[data-theme="light"] .theme-moon {
display: block;
}
html[data-theme="light"] {
--bg: #f4f6f1;
--panel: #ffffff;
--panel2: #e9eee5;
--line: #cad4c6;
--green: #397325;
--muted: #586654;
--text: #202c20;
--board-stone: #ffffff;
--board-edge: #cad4c6;
--board-gold: #866318;
color-scheme: light;
}
html[data-theme="light"] body {
background-image: radial-gradient(
ellipse at 80% 12%,
#dce8d180,
transparent 55%
);
}
html[data-theme="light"] :is(h1, h2, h3) {
text-shadow: none;
}
html[data-theme="light"] .hero h1 span {
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) {
background: var(--bg);
border-color: var(--line);
}
html[data-theme="light"] :is(nav, .brand sup) {
color: var(--muted);
border-color: var(--line);
}
html[data-theme="light"] .header-account {
background: #e0ecd7;
color: #2b4b21;
border-color: #a8bf9c;
border-bottom-color: #739565;
}
html[data-theme="light"] .header-account:hover {
background: #d1e3c5;
}
html[data-theme="light"]
:is(.button, .button.secondary, .icon-button):not(.map-actions .button) {
background: var(--panel2);
color: var(--text);
border-color: #aab9a3;
text-shadow: none;
box-shadow:
inset 0 1px #fff,
0 3px 8px #243a1812;
}
html[data-theme="light"]
:is(.button, .icon-button):not(.map-actions .button):hover {
background: #dce7d4;
}
html[data-theme="light"] .button.primary:not(.map-actions .button) {
background: linear-gradient(#a6df80, #83c05d);
color: #18310d;
border-color: #74a554;
}
html[data-theme="light"] .button.primary:not(.map-actions .button):hover {
background: linear-gradient(#b7e994, #98ce75);
}
html[data-theme="light"]
:is(
.world-stats span:not(.live-dot),
.hero p,
.hero-mode-note,
.hero-notes,
.contribute-banner p,
.play-steps li > span,
.round-result p,
.round-result .score small,
.results-total small,
.card-body p,
.card-meta,
.first-map-panel p,
.play-steps p,
.mode-row p,
.mode-row h3 span,
.mode-footnote,
.site-footer p,
.site-footer > div:nth-child(2),
.leaderboard-preview-copy p,
.board-heading > span:last-child,
.board-footnote,
.leaderboard-table thead th,
.rank-number,
.score-unit,
.leaderboard-table .games-column,
.leaderboard-table .average-column,
.leaderboard-period,
.podium-label,
.podium-player p span,
.rankings-heading p,
.leaderboard-pagination,
.leaderboard-pagination nav,
.leaderboard-rules p,
.leaderboard-empty p,
.field
) {
color: var(--muted);
}
html[data-theme="light"]
:is(
.eyebrow,
.status-label,
.text-link,
.leaderboard-preview-copy .text-link,
.leaderboard-rules .text-link,
.history-table th
) {
color: var(--green);
}
html[data-theme="light"]
:is(
.world-stats,
.map-card,
.home-page .map-card,
.card-body,
.home-page .card-body,
.home-panel,
.home-panel + .home-panel,
.first-map-panel,
.empty-state,
.auth-layout .auth-card,
.settings-card,
.leaderboard-preview-board,
.rankings-board,
.tool-card,
.start-panel,
.auth-card,
.editor-panel,
.report-dialog,
.result-summary,
.round-result,
.draft-row,
.token-box
) {
background: var(--panel);
border-color: var(--line);
box-shadow: 0 5px 18px #243a1810;
}
html[data-theme="light"]
:is(
.world-stats > div,
.card-meta,
.card-body,
.home-leaderboard,
.leaderboard-toolbar,
.leaderboard-rules,
.board-footnote,
.leaderboard-table td,
.leaderboard-table th,
.leaderboard-pagination,
.search-summary,
.history-table td,
.history-table th
) {
border-color: var(--line);
}
html[data-theme="light"]
:is(.world-stats strong, .score-column strong, .podium-player p) {
color: var(--text);
}
html[data-theme="light"]
:is(.mode-row, .account-email, .auth-tabs, .leaderboard-table thead) {
background: var(--panel2);
border-color: var(--line);
}
html[data-theme="light"] .mode-row:hover {
background: #dce7d4;
border-color: #99b389;
}
html[data-theme="light"] :is(.contribute-banner, .badge) {
background: #e9f2e1;
color: #36552a;
border-color: #aac49b;
}
html[data-theme="light"]
:is(
.field input,
.field textarea,
.field select,
.search-field,
.leaderboard-search,
.preview-toolbar select,
.language-switcher select
) {
background: var(--panel);
color: var(--text);
border-color: #b4c3ac;
}
html[data-theme="light"] :is(input, textarea)::placeholder {
color: #64735e;
opacity: 1;
}
html[data-theme="light"]
:is(
.tabs button.active,
.auth-tabs button[aria-pressed="true"],
.difficulty-options button.selected,
.leaderboard-filters a[aria-current="page"]
) {
background: #dcebd1;
color: #2b5420;
border-color: #88aa75;
}
html[data-theme="light"]
:is(.difficulty-options button, .leaderboard-search button) {
background: var(--panel2);
color: var(--text);
border-color: var(--line);
}
html[data-theme="light"]
:is(.leaderboard-filters a:hover, .leaderboard-table tbody tr:hover) {
background: #eef3e9;
color: var(--text);
}
html[data-theme="light"] .your-row {
background: #e9f2e1;
}
html[data-theme="light"]
:is(.you-label, .leaderboard-skeleton, .leaderboard-skeleton > div) {
border-color: var(--line);
}
html[data-theme="light"] .leaderboard-skeleton span {
background: var(--panel2);
}
html[data-theme="light"] .page-number {
color: var(--text);
}
html[data-theme="light"] .rank-number.place-2 {
color: #52646f;
}
html[data-theme="light"] .rank-number.place-3 {
color: #866145;
}
html[data-theme="light"]
:is(.podium-block, .podium-1 .podium-block, .podium-3 .podium-block) {
background-color: var(--panel2);
border-color: #a8b99f;
box-shadow:
inset 5px 0 #ffffff80,
inset -5px 0 #243a1808,
0 4px #cad4c6;
}
html[data-theme="light"]
:is(.podium-block > span, .podium-1 .podium-block > span) {
color: #506446;
text-shadow: none;
}
html[data-theme="light"] :is(pre, .token-box code) {
background: #e9eee5;
color: #33502a;
border-color: var(--line);
}
html[data-theme="light"] .error-message {
background: #fbeee8;
color: #953c25;
}
html[data-theme="light"] .success-message {
background: #e9f2e1;
}
html[data-theme="light"] .username-moderator {
color: #246494;
}
html[data-theme="light"] .username-admin {
color: #8c491e;
}
html[data-theme="light"] .username-bug-finder {
color: #866318;
}
html[data-theme="light"] .username-custom-prefix {
color: #7043a4;
}
html[data-theme="light"] .prefix-player-list button[aria-pressed="true"] {
border-color: #246494;
}
/* Keep freely chosen glow colors visible against a dark chip in light mode. */
html[data-theme="light"] .username-glow {
background: #202c20;
border-radius: 5px;
padding-inline: 5px;
}
html[data-theme="light"]
:is(
.game-info,
.multiplayer-scoreboard,
.round-result,
.panorama-controls button,
.preview-viewport .panorama-controls button,
.preview-status,
.game-loading,
.look-help
) {
background: #f4f6f1f2;
color: var(--text);
border-color: var(--line);
}
html[data-theme="light"] .game-info small {
color: var(--muted);
}
html[data-theme="light"] .game-info strong {
color: var(--text);
}
html[data-theme="light"] .game-info .urgent {
color: #a23924;
}
html[data-theme="light"] .game-header .brand {
background: #f4f6f1ed;
padding: 4px 9px;
border-radius: 9px;
filter: none;
}
html[data-theme="light"] .room-chat-mine strong {
color: var(--green);
}
html[data-theme="light"] .site-cursor-orbit {
border-color: #34532aa6;
}
html[data-theme="light"] .site-cursor-dot span {
background: #273b21;
box-shadow: 0 0 1px #fff;
}
.header-inner {
max-width: 1600px;
}
.nav-toggle,
.site-navigation .nav-support,
.account-symbol {
display: none;
}
@media (max-width: 1500px) and (min-width: 1201px) {
.site-header {
height: auto;
}
.header-inner {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
padding-block: 14px;
gap: 14px;
}
.header-inner nav {
grid-column: 1 / -1;
grid-row: 2;
display: flex;
flex-wrap: wrap;
width: 100%;
border-top: 1px solid var(--line);
padding: 12px 0 2px;
}
}
@media (max-width: 1200px) {
.site-header {
height: auto;
}
.header-inner {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 10px;
padding: 12px 24px;
}
.header-inner > .brand {
grid-area: 1 / 1;
}
.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 {
font-size: 21px;
}
.header-inner .brand .brand-mark {
width: 28px;
height: 34px;
}
.header-inner .brand sup {
display: none;
}
.header-inner .header-actions {
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 {
font-size: 17px;
}
}
.difficulty-options button.difficulty-void {
border-color: #a855f7;
background: linear-gradient(145deg, #21122f, #100c18);
color: #e9d5ff;
box-shadow:
0 0 16px #a855f738,
inset 0 0 20px #a855f710;
}
.difficulty-options button.difficulty-void strong {
color: #e9d5ff;
text-shadow:
0 0 10px #c084fc99,
0 0 22px #a855f780;
}
.difficulty-options button.difficulty-void.selected,
html[data-theme="light"] .difficulty-options button.difficulty-void.selected {
color: #f3e8ff;
background: linear-gradient(145deg, #482363, #21122f);
border-color: #d8b4fe;
box-shadow:
0 0 24px #a855f760,
inset 0 0 20px #c084fc18;
}
html[data-theme="light"] .difficulty-options button.difficulty-void {
background: #f3e8ff;
border-color: #a855f7;
color: #581c87;
}
html[data-theme="light"] .difficulty-options button.difficulty-void strong {
color: #7e22ce;
}
html[data-theme="light"]
.difficulty-options
button.difficulty-void.selected
strong {
color: #f3e8ff;
}

View File

@@ -11,11 +11,13 @@ export async function GET(request: NextRequest) {
const legacyToken = request.nextUrl.searchParams.get("legacy_recovery_token");
const type = request.nextUrl.searchParams.get("type");
const next = authDestination(request.nextUrl.searchParams.get("next"));
if (type === "magiclink")
return NextResponse.redirect(new URL("/log-in?auth=failed", origin));
if (
code ||
legacyToken ||
(tokenHash &&
(type === "email" || type === "recovery" || type === "magiclink"))
(type === "email" || type === "signup" || type === "recovery"))
) {
const client = await authClient();
let { data, error } = code
@@ -24,7 +26,7 @@ export async function GET(request: NextRequest) {
? await client.auth.exchangeCodeForSession(legacyToken)
: await client.auth.verifyOtp({
token_hash: tokenHash!,
type: type as "email" | "recovery" | "magiclink",
type: type as "email" | "signup" | "recovery",
});
// Older email templates could place a recovery token hash in /code=...
// instead of a PKCE code. Only Supabase can decide whether it is valid.

View File

@@ -3,7 +3,7 @@ import { cookies } from "next/headers";
import Link from "next/link";
import { Header, Footer } from "@/components/Shell";
import { PasswordForm } from "@/components/PasswordForm";
import { authClient, user } from "@/lib/supabase";
import { authClient, rawUser } from "@/lib/supabase";
import { recoveryCookie, verifyRecoveryGrant } from "@/lib/password-recovery";
export async function generateMetadata() {
const { t } = await getI18n();
@@ -12,7 +12,7 @@ export async function generateMetadata() {
export default async function ResetPassword() {
const { t } = await getI18n();
const [account, jar, client] = await Promise.all([
user(),
rawUser(),
cookies(),
authClient(),
]);
@@ -30,7 +30,7 @@ export default async function ResetPassword() {
return (
<>
<Header />
<main className="section page-content">
<main className="section page-content" id="main-content" tabIndex={-1}>
<section className="auth-card reset-card">
<span className="eyebrow">{t("ACCOUNT RECOVERY")}</span>
<h1>{t("A fresh start.")}</h1>

View File

@@ -0,0 +1,74 @@
.heading {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.heading h2,
.heading h3 {
margin: 0;
}
.heading {
margin-bottom: 18px;
}
.search {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 16px;
margin: 24px 0;
}
.search label {
flex: 1 1 240px;
min-width: 0;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 20px;
margin-top: 24px;
}
.refreshActions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 20px;
margin: 24px 0 20px;
}
.list {
display: grid;
gap: 24px;
margin-top: 24px;
min-width: 0;
}
.record {
border: 1px solid var(--line);
border-radius: 12px;
padding: clamp(16px, 3vw, 24px);
min-width: 0;
}
.record code,
.record h3,
.record p {
overflow-wrap: anywhere;
}
.record h3 {
font-size: 22px;
}
.reason {
white-space: pre-wrap;
}
.confirm {
border-top: 1px solid var(--line);
padding-top: 24px;
margin-top: 24px;
}
.confirm label {
margin-top: 20px;
}
.page {
max-width: 900px;
}
.page p {
line-height: 1.8;
}

44
app/ban/appeal/page.tsx Normal file
View File

@@ -0,0 +1,44 @@
import Link from "next/link";
import { Header, Footer } from "@/components/Shell";
import { AccountAppeal } from "@/components/AccountAppeal";
import { rawUser } from "@/lib/supabase";
import { getI18n } from "@/lib/i18n";
import styles from "./page.module.css";
export const metadata = { title: "Account access & appeals" };
export const dynamic = "force-dynamic";
export default async function AppealPage() {
const [account, { locale }] = await Promise.all([rawUser(), getI18n()]);
const ru = locale === "ru";
return (
<>
<Header />
<main className={`section page-content ${styles.page}`} id="main-content" tabIndex={-1}>
<span className="eyebrow">MINEGEO</span>
<h1>{ru ? "Доступ и апелляции." : "Account access & appeals."}</h1>
{account ? (
<AccountAppeal />
) : (
<>
<p className="lead">
{ru
? "Войдите в заблокированный аккаунт, чтобы прочитать причину и подать апелляцию."
: "Log in to the affected account to see the reason and submit a ban appeal."}
</p>
<Link
className="button primary"
href="/log-in?next=%2Fban%2Fappeal"
>
{ru ? "Войти" : "Log in"}
</Link>
<p>
<Link href="/docs?page=account-bans">
{ru ? "Как работают апелляции" : "How appeals work"}
</Link>
</p>
</>
)}
</main>
<Footer />
</>
);
}

77
app/changelogs/page.tsx Normal file
View File

@@ -0,0 +1,77 @@
import Link from "next/link";
import type { Metadata } from "next";
import { Header, Footer } from "@/components/Shell";
import { ReleaseNotes } from "@/components/ReleaseNotes";
import { ChangelogRefresh } from "@/components/ChangelogRefresh";
import { getChangelog } from "@/lib/changelog";
import { getI18n } from "@/lib/i18n";
export const metadata: Metadata = {
title: "Changelogs",
description: "The latest MineGeo releases, improvements, and bug fixes.",
};
export default async function ChangelogsPage({
searchParams,
}: {
searchParams: Promise<{ before?: string }>;
}) {
const params = await searchParams;
const before =
typeof params.before === "string" && /^\d{17,20}$/.test(params.before)
? params.before
: undefined;
const [{ t, formatDate }, feed] = await Promise.all([
getI18n(),
getChangelog(before),
]);
return (
<>
<Header />
<main className="section page-content changelog-page" id="main-content" tabIndex={-1}>
{!before && <ChangelogRefresh />}
<span className="eyebrow">{t("WHAT'S NEW")}</span>
<h1>{t("Changelogs")}</h1>
<p className="lead">
{t("New releases, improvements, and fixes from MineGeo.")}
</p>
{before && <Link href="/changelogs">{t("Latest releases")}</Link>}
{feed.unavailable ? (
<div className="empty-state">
<h2>{t("Release notes are temporarily unavailable")}</h2>
<p>{t("Please check back soon.")}</p>
</div>
) : feed.entries.length === 0 ? (
<p className="muted">{t("No releases on this page yet.")}</p>
) : (
<div className="changelog-list">
{feed.entries.map((entry) => (
<article
className="changelog-entry"
key={entry.id}
id={`release-${entry.id}`}
>
<div className="changelog-meta">
<span className="tag">{t(entry.kind)}</span>
<time dateTime={entry.publishedAt}>
{formatDate(entry.publishedAt)}
</time>
</div>
<h2>{entry.title}</h2>
<div className="release-notes">
<ReleaseNotes body={entry.body} />
</div>
</article>
))}
</div>
)}
{!feed.unavailable && "before" in feed && feed.before && (
<Link className="button" href={`/changelogs?before=${feed.before}`}>
{t("Older posts")}
</Link>
)}
</main>
<Footer />
</>
);
}

406
app/cinematic-intro.css Normal file
View File

@@ -0,0 +1,406 @@
/* 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;
width: 100%;
height: 100%;
max-width: none;
max-height: none;
margin: 0;
padding: 24px 24px 88px;
border: 0;
overflow: hidden;
color: #f4eedb;
background: radial-gradient(
ellipse at 50% 38%,
#1d392b,
#0a1812 58%,
#07100c
);
}
.first-visit-intro[open] {
display: grid;
place-items: center;
animation:
intro-opacity-in 250ms var(--ease-out) both,
intro-opacity-out 250ms var(--ease-out) 3750ms forwards;
}
.first-visit-intro::backdrop {
background: #07100c;
animation:
intro-opacity-in 250ms var(--ease-out) both,
intro-opacity-out 250ms var(--ease-out) 3750ms forwards;
}
/* 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;
}
html.has-site-cursor dialog.first-visit-intro button {
cursor: pointer !important;
}
.intro-cartography {
position: absolute;
inset: 0;
pointer-events: none;
background:
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-content {
position: relative;
width: min(100%, 700px);
display: grid;
justify-items: center;
gap: 20px;
}
.intro-map-stage {
position: relative;
width: 300px;
height: 250px;
animation: intro-surface-out 250ms var(--ease-out) 3500ms forwards;
}
.intro-map-enter {
position: absolute;
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:
-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-map-water {
position: absolute;
inset: 0 0 0 150px;
background: #497b78;
border-left: 12px solid #668c64;
}
.intro-map-path {
position: absolute;
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-pin-anchor {
position: absolute;
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 {
top: 190px;
}
.intro-coordinate-left {
left: 0;
}
.intro-coordinate-right {
right: 0;
}
.intro-bearing {
top: 20px;
left: 146px;
color: #ebce92;
}
.intro-title {
width: 100%;
text-align: center;
animation: intro-surface-out 250ms var(--ease-out) 3300ms forwards;
}
.intro-title .eyebrow {
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(42px, 8vw, 78px) / 1.15 MineGeoPixel,
monospace;
letter-spacing: -2px;
white-space: nowrap;
}
.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;
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: #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;
}
.site-trailer .eyebrow {
display: flex;
align-items: center;
gap: 9px;
}
.trailer-duration {
color: var(--muted);
font: 12px monospace;
white-space: nowrap;
}
.site-trailer-player {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
background: #08130e;
border: 1px solid var(--line);
border-radius: 15px;
object-fit: contain;
}
.trailer-caption {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 30px;
padding-top: 18px;
}
.trailer-caption p {
max-width: 710px;
margin: 0;
color: var(--muted);
font-size: 13px;
}
.trailer-caption .text-link {
white-space: nowrap;
font-size: 12px;
}
@media (max-width: 640px) {
.trailer-caption {
flex-direction: column;
gap: 12px;
}
.site-trailer .section-heading {
align-items: flex-start;
gap: 15px;
flex-direction: column;
}
}

37
app/community-social.css Normal file
View File

@@ -0,0 +1,37 @@
.map-community {
margin-top: 36px;
}
.map-community .button,
.map-comment .button {
display: inline-flex;
align-items: center;
gap: 8px;
}
.map-community .button[aria-pressed="true"] {
border-color: var(--green);
background: var(--panel2);
color: var(--green);
}
.map-comments {
margin-block: 24px;
}
.map-comment {
border-top: 1px solid var(--line);
padding-block: 20px;
}
.map-comment-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 16px;
}
.map-comment-meta time {
font-size: 12px;
}
.community-body {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.map-community textarea {
min-height: 100px;
}

40
app/community.css Normal file
View File

@@ -0,0 +1,40 @@
.community-links {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin-top: 18px;
}
.community-link {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 44px;
max-width: 100%;
padding: 10px 13px;
border: 1px solid var(--line);
border-radius: var(--radius-control);
background: var(--panel);
color: var(--text);
font:
600 12px/1.5 Arial,
Helvetica,
sans-serif;
}
.community-link:hover {
border-color: var(--green);
background: var(--panel2);
}
.community-discord > svg:first-child {
color: #bab3ff;
}
.community-telegram > svg:first-child {
color: #83d3ff;
}
html[data-theme="light"] .community-discord > svg:first-child {
color: #6250b4;
}
html[data-theme="light"] .community-telegram > svg:first-child {
color: #20709d;
}

View File

@@ -2,6 +2,7 @@ import { getI18n } from "@/lib/i18n";
import { Header, Footer } from "@/components/Shell";
import { DraftEditor } from "@/components/DraftEditor";
import { user } from "@/lib/supabase";
import { accountProfile } from "@/lib/profile";
import { redirect } from "next/navigation";
export default async function Draft({
params,
@@ -10,15 +11,17 @@ export default async function Draft({
}) {
const { t } = await getI18n();
const { id } = await params;
if (!(await user()))
const account = await user();
if (!account)
redirect(`/log-in?next=${encodeURIComponent("/dashboard/" + id)}`);
const profile = await accountProfile(account);
return (
<>
<Header />
<main className="section page-content">
<main className="section page-content" id="main-content" tabIndex={-1}>
<span className="eyebrow">{t("YOUR WORLD · YOUR LOCATIONS")}</span>
<h1>{t("Map workshop")}</h1>
<DraftEditor id={id} />
<DraftEditor id={id} hostName={profile.display_name} />
</main>
<Footer />
</>

View File

@@ -5,6 +5,7 @@ import { Dashboard } from "@/components/Dashboard";
import { admin, user, required } from "@/lib/supabase";
import Link from "next/link";
import { accountProfile } from "@/lib/profile";
import { Username } from "@/components/Username";
export const dynamic = "force-dynamic";
export default async function DashboardPage() {
const { t, formatDate } = await getI18n();
@@ -17,7 +18,7 @@ export default async function DashboardPage() {
? required(
await admin()
.from("game_sessions")
.select("id,difficulty,created_at,current_round")
.select("id,difficulty,difficulty_settings,created_at,current_round")
.eq("owner_id", account.id)
.gte("created_at", historyCutoff)
.order("created_at", { ascending: false })
@@ -27,13 +28,18 @@ export default async function DashboardPage() {
return (
<>
<Header />
<main className="section page-content">
<main className="section page-content" id="main-content" tabIndex={-1}>
{account ? (
<>
<span className="eyebrow">{t("THE CARTOGRAPHER’S DESK")}</span>
<h1>{t("My expedition.")}</h1>
<p className="lead">
{t("Welcome back,")} {profile?.display_name}.
{t("Welcome back,")}{" "}
<Username
name={profile?.display_name ?? "Explorer"}
appearance={profile ?? undefined}
/>
.
</p>
<Dashboard />
{history.length > 0 && (
@@ -46,7 +52,17 @@ export default async function DashboardPage() {
<div className="draft-row" key={s.id}>
<span>
{formatDate(s.created_at)} ·{" "}
{t(s.difficulty[0].toUpperCase() + s.difficulty.slice(1))}{" "}
{s.difficulty_settings &&
typeof s.difficulty_settings === "object" &&
!Array.isArray(s.difficulty_settings) &&
typeof s.difficulty_settings.name === "string"
? ["easy", "normal", "hard", "void"].includes(s.difficulty)
? t(s.difficulty_settings.name)
: s.difficulty_settings.name
: t(
s.difficulty[0].toUpperCase() +
s.difficulty.slice(1),
)}{" "}
· {s.current_round}
{t("/5 rounds")}
</span>

225
app/dev/api/page.module.css Normal file
View File

@@ -0,0 +1,225 @@
.intro {
max-width: 780px;
}
.tabs {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin: 24px 0 36px;
}
.stack {
display: grid;
gap: 32px;
}
.panel {
padding: clamp(20px, 4vw, 32px);
border: 1px solid var(--line);
border-radius: 18px;
background: var(--panel);
min-width: 0;
}
.panel h2 {
font-size: clamp(24px, 3vw, 32px);
}
.heading {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 18px;
}
.heading h2 {
margin: 0;
}
.fields {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(140px, 200px);
gap: 20px;
}
.fieldset {
border: 0;
padding: 0;
margin: 24px 0;
min-width: 0;
}
.fieldset legend {
font-weight: 700;
margin-bottom: 12px;
}
.scopes {
display: grid;
gap: 12px;
}
.scope {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px;
border: 1px solid var(--line);
border-radius: 10px;
}
.scope input {
margin-top: 5px;
width: 18px;
height: 18px;
flex: 0 0 18px;
accent-color: var(--green);
}
.scope strong,
.scope small {
display: block;
}
.scope small {
color: var(--muted);
line-height: 1.6;
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin: 20px 0 0;
}
.token {
margin-bottom: 24px;
padding: 20px;
border: 1px solid var(--green);
border-radius: 12px;
background: var(--panel2);
}
.token input {
width: 100%;
font-family: monospace;
font-size: 13px;
}
.keyList {
display: grid;
gap: 16px;
padding: 0;
list-style: none;
margin: 24px 0 0;
}
.key {
border: 1px solid var(--line);
border-radius: 12px;
padding: 20px;
min-width: 0;
}
.key h3 {
overflow-wrap: anywhere;
margin: 0;
font-size: 20px;
}
.key dl {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 6px 18px;
margin: 16px 0;
}
.key dt {
color: var(--muted);
}
.key dd {
margin: 0;
overflow-wrap: anywhere;
}
.key code {
overflow-wrap: anywhere;
}
.notice {
padding: 14px 18px;
border-left: 3px solid var(--green);
background: var(--panel2);
margin: 20px 0;
}
.error {
color: var(--text);
border-left-color: #ed7878;
}
.docs {
scroll-margin-top: 120px;
}
.docs h3 {
margin-top: 32px;
}
.docs p,
.docs li {
line-height: 1.8;
}
.docs ul,
.docs ol {
padding-left: 24px;
}
.docs ul {
list-style: disc;
}
.docs ol {
list-style: decimal;
}
.docs a {
color: var(--green);
text-decoration: underline;
text-underline-offset: 3px;
}
.docs code {
overflow-wrap: anywhere;
font-family: monospace;
}
.code {
overflow-x: auto;
padding: 20px;
border-radius: 10px;
border: 1px solid var(--line);
background: var(--bg);
font-family: monospace;
font-size: 13px;
line-height: 1.7;
max-width: 100%;
}
.code code {
overflow-wrap: normal;
}
.tableWrap {
overflow-x: auto;
max-width: 100%;
}
.table {
width: 100%;
border-collapse: collapse;
text-align: left;
}
.table th,
.table td {
padding: 14px 16px;
border-bottom: 1px solid var(--line);
vertical-align: top;
}
.table th {
color: var(--muted);
}
.table td:first-child {
min-width: 150px;
}
.table td:last-child {
min-width: 200px;
}
@media (max-width: 600px) {
.fields {
grid-template-columns: minmax(0, 1fr);
}
.key dl {
grid-template-columns: minmax(0, 1fr);
gap: 2px;
}
.key dd {
margin-bottom: 10px;
}
.code {
padding: 14px;
font-size: 12px;
}
.table th,
.table td {
padding: 12px;
}
}

23
app/dev/api/page.tsx Normal file
View File

@@ -0,0 +1,23 @@
import type { Metadata } from "next";
import { Header, Footer } from "@/components/Shell";
import { DeveloperPanel } from "@/components/DeveloperPanel";
import { user } from "@/lib/supabase";
export const metadata: Metadata = {
title: "Developer API",
description: "Create and manage scoped MineGeo API keys.",
};
export const dynamic = "force-dynamic";
export default async function DeveloperApiPage() {
const account = await user();
return (
<>
<Header />
<main className="section page-content" id="main-content" tabIndex={-1}>
<DeveloperPanel signedIn={Boolean(account)} />
</main>
<Footer />
</>
);
}

66
app/docs/page.module.css Normal file
View File

@@ -0,0 +1,66 @@
.layout {
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
gap: 48px;
}
.menu {
display: flex;
flex-direction: column;
gap: 12px;
}
.menu a {
line-height: 1.5;
}
.menu a[aria-current="page"] {
color: var(--green);
font-weight: 700;
}
.article {
min-width: 0;
max-width: 780px;
}
.article h1 {
font-size: clamp(30px, 4vw, 48px);
}
.article a {
color: var(--green);
text-decoration: underline;
text-underline-offset: 3px;
}
.article li {
margin: 8px 0;
line-height: 1.7;
}
.article ol { list-style: decimal; padding-left: 24px; }
.article ul { list-style: disc; padding-left: 24px; }
.article blockquote {
margin: 24px 0;
padding: 12px 20px;
border-left: 3px solid var(--green);
}
.article figure {
margin: 24px 0;
}
.article img {
max-width: 100%;
height: auto;
border-radius: 12px;
}
.paging {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 24px;
margin-top: 48px;
}
@media (max-width: 800px) {
.layout {
grid-template-columns: minmax(0, 1fr);
gap: 32px;
}
.menu {
flex-direction: row;
flex-wrap: wrap;
gap: 10px 20px;
}
}

147
app/docs/page.tsx Normal file
View File

@@ -0,0 +1,147 @@
import Link from "next/link";
import { DeveloperDocumentation } from "@/components/DeveloperDocumentation";
import { notFound } from "next/navigation";
import { Header, Footer } from "@/components/Shell";
import guides from "@/shared/player-docs.json";
import styles from "./page.module.css";
import { getI18n } from "@/lib/i18n";
import { PlayerGuide } from "@/components/PlayerGuide";
export const metadata = { title: "Player docs" };
function inline(text: string) {
return text
.split(/(\*\*[^*]+\*\*|\[[^\]]+\]\(https:\/\/[^)]+\))/g)
.map((part, index) => {
if (part.startsWith("**"))
return <strong key={index}>{part.slice(2, -2)}</strong>;
const link = part.match(/^\[([^\]]+)\]\((https:\/\/[^)]+)\)$/);
return link ? (
<a key={index} href={link[2]}>
{link[1]}
</a>
) : (
part
);
});
}
function content(markdown: string) {
return markdown.split(/\n\s*\n/).map((block, index) => {
const heading = block.match(/^(#{1,3}) (.+)$/);
if (heading) {
const text = inline(heading[2]);
return heading[1].length === 1 ? (
<h1 key={index}>{text}</h1>
) : heading[1].length === 2 ? (
<h2 key={index}>{text}</h2>
) : (
<h3 key={index}>{text}</h3>
);
}
const image = block.match(/^!\[([^\]]*)\]\((https:\/\/[^)]+)\)$/);
if (image)
return (
<figure key={index}>
<img src={image[2]} alt={image[1]} loading="lazy" width="420" />
</figure>
);
if (block.startsWith("> "))
return (
<blockquote key={index}>
{inline(block.replace(/^> /gm, ""))}
</blockquote>
);
const lines = block.split("\n");
if (lines.every((line) => /^[-*] /.test(line)))
return (
<ul key={index}>
{lines.map((line, i) => (
<li key={i}>{inline(line.slice(2))}</li>
))}
</ul>
);
if (lines.every((line) => /^\d+\. /.test(line)))
return (
<ol key={index}>
{lines.map((line, i) => (
<li key={i}>{inline(line.replace(/^\d+\. /, ""))}</li>
))}
</ol>
);
return <p key={index}>{inline(block)}</p>;
});
}
export default async function Docs({
searchParams,
}: {
searchParams: Promise<{ page?: string }>;
}) {
const { page = "welcome" } = await searchParams;
const { locale } = await getI18n();
const pages = [
guides[0],
{
slug: "guide",
title: locale === "ru" ? "Руководство" : "Field guide",
markdown: "",
},
...guides.slice(1),
{
slug: "developer-api",
title: locale === "ru" ? "API для разработчиков" : "Developer API",
markdown: "",
},
];
const guide = pages.find((item) => item.slug === page);
if (!guide) notFound();
const index = pages.indexOf(guide);
return (
<>
<Header />
<main
className={`section page-content ${styles.layout}`}
id="main-content"
tabIndex={-1}
>
<aside>
<h2>Player docs</h2>
<nav aria-label="Documentation pages" className={styles.menu}>
{pages.map((item) => (
<Link
key={item.slug}
href={`/docs?page=${item.slug}`}
aria-current={page === item.slug ? "page" : undefined}
>
{item.title}
</Link>
))}
</nav>
</aside>
<article className={`prose ${styles.article}`}>
{page === "guide" ? (
<PlayerGuide />
) : page === "developer-api" ? (
<DeveloperDocumentation locale={locale} />
) : (
content(guide.markdown)
)}
<div className={styles.paging}>
{index > 0 && (
<Link href={`/docs?page=${pages[index - 1].slug}`}>
← {pages[index - 1].title}
</Link>
)}
{index < pages.length - 1 && (
<Link href={`/docs?page=${pages[index + 1].slug}`}>
{pages[index + 1].title} →
</Link>
)}
</div>
</article>
</main>
<Footer />
</>
);
}

View File

@@ -3,15 +3,46 @@ import { Header, Footer } from "@/components/Shell";
import { Blocks, Server, Download, ExternalLink } from "lucide-react";
import { existsSync } from "node:fs";
import Link from "next/link";
import desktopRelease from "@/shared/desktop-release.json";
const release = "0.1.4";
const versions = ["1.21.11", "26.1", "26.2"];
export const dynamic = "force-dynamic";
export default async function Downloads() {
const { t } = await getI18n();
const fabric = existsSync("public/tools/mod/minegeo-fabric-1.21.11.jar"),
plugin = existsSync("public/tools/plugin/minegeo-plugin-1.21.11.jar");
const pluginPath = `/tools/plugin/minegeo-plugin-${release}.jar`;
const plugin = existsSync(`public${pluginPath}`);
return (
<>
<Header />
<main className="section page-content">
<main className="section page-content" id="main-content" tabIndex={-1}>
<article className="tool-card desktop-download-card">
<span className="eyebrow">{t("MINEGEO DESKTOP")}</span>
<h2>{t("Your next expedition, on your desktop.")}</h2>
<p className="muted">
{t(
"Download the portable app, run the EXE, and start exploring. No installation needed.",
)}
</p>
<a className="button primary" href={desktopRelease.file} download>
<Download size={16} /> {t("Download for Windows")}
</a>
<p className="muted desktop-download-version">
{t(
"Windows 10/11 · 64-bit · Version {version} · Internet required",
{ version: desktopRelease.version },
)}
</p>
<p className="muted">
{t(
"The app connects to minegeo.site and keeps you signed in on this device.",
)}
</p>
<p className="muted">
{t(
"This release is unsigned. Windows may show a publisher warning when you open it.",
)}
</p>
</article>
<span className="eyebrow">
{t("MADE FOR THE PEOPLE WHO KNOW THEIR WORLDS")}
</span>
@@ -33,7 +64,9 @@ export default async function Downloads() {
</p>
<ul>
<li>
{t("Minecraft Java 1.21.11 + Fabric Loader + Fabric API")}
{t(
"Minecraft Java 1.21.11, 26.1 & 26.2 + Fabric Loader + Fabric API",
)}
</li>
<li>{t("Open the capture panel with G")}</li>
<li>{t("Link your account through a browser pairing code")}</li>
@@ -48,24 +81,34 @@ export default async function Downloads() {
</li>
<li>{t("Contributions check the recorded server address")}</li>
</ul>
{fabric ? (
<a
className="button primary"
href="/tools/mod/minegeo-fabric-1.21.11.jar"
download
>
<Download size={16} /> {t("Download Fabric mod")}
</a>
) : (
<p className="muted">
{t("The Fabric build is not available yet.")}
</p>
{versions.map((version) =>
existsSync(
`public/tools/mod/minegeo-fabric-${version}-${release}.jar`,
) ? (
<a
key={version}
className="button primary download-build-link"
href={`/tools/mod/minegeo-fabric-${version}-${release}.jar`}
download
>
<Download size={16} /> {t("Download Fabric mod")} · {version}
</a>
) : (
<p className="muted" key={version}>
{version}: {t("The Fabric build is not available yet.")}
</p>
),
)}
<p className="muted" style={{ fontSize: 11, marginTop: 20 }}>
<p className="muted tool-card-note tool-card-note-spaced">
{t(
"Install the JAR in your Minecraft mods folder alongside Fabric API.",
)}
</p>
<p className="muted">
{t(
"Java 21 for 1.21.11; Java 25 for 26.1 and 26.2. Both tools connect to minegeo.site automatically.",
)}
</p>
</article>
<article className="tool-card">
<Server size={34} />
@@ -77,7 +120,7 @@ export default async function Downloads() {
)}
</p>
<ul>
<li>{t("Bukkit/Paper 1.21.11 · Java 21")}</li>
<li>{t("Bukkit/Paper 1.21.11, 26.1 & 26.2")}</li>
<li>{t("Select a bounded area and export existing chunks")}</li>
<li>{t("Choose up to 24 random, safe surface viewpoints")}</li>
<li>
@@ -90,11 +133,7 @@ export default async function Downloads() {
<li>{t("Vanilla blocks/fluids, simplified lighting")}</li>
</ul>
{plugin ? (
<a
className="button primary"
href="/tools/plugin/minegeo-plugin-1.21.11.jar"
download
>
<a className="button primary" href={pluginPath} download>
<Download size={16} /> {t("Download server plugin")}
</a>
) : (
@@ -104,7 +143,7 @@ export default async function Downloads() {
)}
<pre>
{
"/minegeo website https://your-minegeo-site.example\n/minegeo server play.your-server.example\n/minegeo link <dashboard token>\n/minegeo area world -64 -64 64 64\n/minegeo export My Overworld\n/minegeo status\n# Wait for Export complete, then:\n/minegeo upload"
"/minegeo server play.your-server.example\n/minegeo link <dashboard token>\n/minegeo area world -64 -64 64 64\n/minegeo export My Overworld\n/minegeo status\n# Wait for Export complete, then:\n/minegeo upload"
}
</pre>
<p className="muted">
@@ -116,11 +155,10 @@ export default async function Downloads() {
". The manifest appears when export finishes. Upload sends the latest completed export; it does not capture the world.",
)}
</p>
<p className="muted" style={{ fontSize: 11 }}>
Put the JAR in plugins/ and restart. Set website, server-name and
server-address in plugins/MineGeo/config.yml, then run /minegeo
reload. HTTP LAN addresses are supported for testing. Permission:
minegeo.admin.
<p className="muted tool-card-note">
{t(
"Put the JAR in plugins/ and restart. Set server-name and server-address in plugins/MineGeo/config.yml, then run /minegeo reload. Permission: minegeo.admin. The same plugin JAR supports all three versions.",
)}
</p>
</article>
</div>

View File

@@ -4,7 +4,7 @@ import { useI18n } from "@/components/I18nProvider";
export default function ErrorPage({ retry }: { retry: () => void }) {
const { t } = useI18n();
return (
<main className="section page-content">
<main className="section page-content" id="main-content" tabIndex={-1}>
<h1>{t("Lost connection.")}</h1>
<p>
{t(

View File

@@ -1,6 +1,13 @@
@import "tailwindcss" source(none);
@source ".";
@source "../components";
.metrics-steps {
list-style: decimal;
padding-left: 24px;
margin: 20px 0;
}
.metrics-steps li { margin: 12px 0; }
.metrics-config { font-family: monospace; font-size: 12px; overflow-wrap: normal; white-space: pre; }
:root {
--bg: #0b100f;
--panel: #121b17;
@@ -19,7 +26,12 @@ body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: Arial, Helvetica, sans-serif;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
font-size: 14px;
line-height: 1.6;
}
@@ -43,7 +55,7 @@ button {
cursor: pointer;
}
button:disabled {
cursor: wait;
cursor: not-allowed;
opacity: 0.6;
}
::selection {
@@ -263,6 +275,7 @@ nav a:hover,
}
.hero-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 26px;
margin-top: 30px;
@@ -706,10 +719,32 @@ nav a:hover,
}
.catalog-controls {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 20px;
justify-content: space-between;
margin: 35px 0;
}
.map-import-dialog {
margin: auto;
background: var(--panel);
color: var(--text);
border: 1px solid var(--line);
border-radius: 12px;
padding: 24px;
width: min(540px, calc(100vw - 32px));
max-height: calc(100dvh - 32px);
overflow: auto;
}
.map-import-dialog::backdrop { background: rgb(0 0 0 / 65%); }
.map-import-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.map-import-heading h2 { margin: 0; }
.map-import-dialog .field input { max-width: 100%; }
.map-export { margin: 16px 0; }
.catalog-search { display: flex; align-items: center; gap: 12px; flex: 1; min-width: 0; }
.catalog-search .search-field { flex: 1; min-width: 0; }
.catalog-search .search-field input { min-width: 0; }
.catalog-search > .button { flex-shrink: 0; }
.search-field {
border: 1px solid var(--line);
background: var(--panel);
@@ -771,7 +806,7 @@ nav a:hover,
}
.difficulty-options {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
margin: 25px 0;
}
@@ -799,6 +834,51 @@ nav a:hover,
font-size: 10px;
margin-top: 5px;
}
.difficulty-library {
margin: -12px 0 24px;
}
.difficulty-builder {
display: grid;
gap: 10px;
max-width: 430px;
margin-top: 12px;
padding: 18px;
border: 1px solid var(--line);
background: #0d160f;
}
.difficulty-builder label {
color: var(--muted);
font-size: 12px;
}
.difficulty-builder > input,
.difficulty-builder-time input {
min-height: 42px;
padding: 9px 11px;
border: 1px solid #34452e;
background: var(--panel);
color: var(--text);
font: inherit;
}
.difficulty-builder-time {
display: flex;
align-items: center;
gap: 9px;
}
.difficulty-builder-time input {
width: 120px;
}
.difficulty-builder-time span {
color: var(--muted);
font-size: 12px;
}
.difficulty-builder .difficulty-builder-zoom {
display: flex;
align-items: center;
gap: 9px;
}
.difficulty-builder-zoom input {
accent-color: var(--green);
}
.prose {
max-width: 850px;
}
@@ -1399,6 +1479,7 @@ pre {
}
.catalog-controls {
flex-direction: column;
align-items: stretch;
}
.difficulty-options {
gap: 8px;
@@ -1669,3 +1750,13 @@ pre {
display: block;
position: relative;
}
.changelog-page { max-width: 960px; }
.changelog-list { margin: 36px 0; }
.changelog-entry { padding: 32px 0; border-top: 1px solid var(--line); scroll-margin-top: 100px; }
.changelog-entry h2 { margin: 14px 0 20px; font-size: clamp(1.4rem, 3vw, 2rem); }
.changelog-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 16px; color: var(--muted); font-size: .85rem; }
.release-notes { line-height: 1.8; overflow-wrap: anywhere; }
.release-notes ul { padding-left: 24px; }
.release-notes li + li { margin-top: 12px; }
.release-notes p { white-space: normal; }

BIN
app/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { Analytics } from "@vercel/analytics/next";
import "./globals.css";
import "./block-theme.css";
import "./account.css";
@@ -12,8 +13,22 @@ import { SiteCursor } from "@/components/SiteCursor";
import { I18nProvider } from "@/components/I18nProvider";
import { getI18n } from "@/lib/i18n";
import "./polish.css";
import "./mobile-game.css";
import "./multiplayer.css";
import "./support.css";
import "./appearance.css";
import "./community.css";
import "./community-social.css";
import "./cinematic-intro.css";
import "./tournaments.css";
import "./site-glass.css";
import { themeInitScript } from "@/shared/theme";
import { SiteSettingsProvider } from "@/components/SiteSettingsProvider";
import { initialSiteSettings } from "@/lib/site-settings";
import { rawUser, accountState } from "@/lib/supabase";
import { LegacyPasswordNotice } from "@/components/LegacyPasswordNotice";
import { BackNavigationTransition } from "@/components/BackNavigationTransition";
import { Suspense } from "react";
export async function generateMetadata(): Promise<Metadata> {
const { t } = await getI18n();
return {
@@ -31,24 +46,100 @@ export default async function RootLayout({
}: {
children: React.ReactNode;
}) {
const [{ locale, preference, detectedLocale }, settings] = await Promise.all([
getI18n(),
initialSiteSettings(),
]);
const [{ locale, preference, detectedLocale, t }, settings] =
await Promise.all([getI18n(), initialSiteSettings()]);
return (
<html lang={locale}>
<html lang={locale} data-theme="dark" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
</head>
<body>
<svg
aria-hidden="true"
className="site-filter-definitions"
focusable="false"
>
<defs>
<filter
id="liquid-glass-distortion"
x="-10%"
y="-20%"
width="120%"
height="140%"
colorInterpolationFilters="sRGB"
>
<feTurbulence
type="fractalNoise"
baseFrequency="0.012 0.055"
numOctaves={2}
seed={7}
result="glass-noise"
/>
<feGaussianBlur
in="glass-noise"
stdDeviation="2.5"
result="soft-glass-noise"
/>
<feDisplacementMap
in="SourceGraphic"
in2="soft-glass-noise"
scale={18}
xChannelSelector="R"
yChannelSelector="G"
result="refracted-backdrop"
/>
<feSpecularLighting
in="soft-glass-noise"
surfaceScale={3}
specularConstant={0.55}
specularExponent={32}
lightingColor="#fff"
result="glass-highlight"
>
<feDistantLight azimuth={315} elevation={55} />
</feSpecularLighting>
<feComposite
in="glass-highlight"
in2="SourceAlpha"
operator="in"
result="bounded-highlight"
/>
<feBlend
in="refracted-backdrop"
in2="bounded-highlight"
mode="screen"
/>
</filter>
</defs>
</svg>
<a className="skip-link" href="#main-content">
{t("Skip to content")}
</a>
<I18nProvider
locale={locale}
preference={preference}
detectedLocale={detectedLocale}
>
<SiteSettingsProvider initialSettings={settings}>
<Suspense fallback={null}>
<BackNavigationTransition />
</Suspense>
{children}
<SiteCursor />
<Suspense fallback={null}>
<AccountPasswordNotice />
</Suspense>
</SiteSettingsProvider>
</I18nProvider>
<Analytics />
</body>
</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

@@ -238,6 +238,8 @@ input:focus-visible {
width: 36px;
height: 36px;
flex-shrink: 0;
display: block;
object-fit: cover;
background: #314034;
box-shadow: 2px 2px #03070b;
}

View File

@@ -6,6 +6,8 @@ export default async function LoadingLeaderboard() {
className="section page-content leaderboard-page"
aria-busy="true"
aria-label={t("Loading leaderboard")}
id="main-content"
tabIndex={-1}
>
<span className="eyebrow">{t("THE EXPLORERS’ BOARD")}</span>
<h1>{t("Every block counts.")}</h1>

View File

@@ -23,7 +23,7 @@ export async function generateMetadata(): Promise<Metadata> {
return {
title: t("Leaderboard"),
description: t(
"Meet MineGeo’s top explorers. Compare the best five-round scores across easy, normal, and hard Minecraft expeditions.",
"Meet MineGeo’s top explorers. Compare the best five-round scores across Easy, Normal, Hard, and Void Minecraft expeditions.",
),
};
}
@@ -42,7 +42,7 @@ export default async function LeaderboardPage({
return (
<>
<Header />
<main className="section page-content leaderboard-page" id="main-content">
<main className="section page-content leaderboard-page" id="main-content" tabIndex={-1}>
<Link href="/" className="text-link leaderboard-back">
<ArrowLeft size={14} /> {t("Back to home")}
</Link>
@@ -79,6 +79,13 @@ export default async function LeaderboardPage({
</div>
{data ? (
<>
{difficulty === "all" && (
<p className="tournament-champion-note">
{t(
"The latest tournament champion holds first place on the global leaderboard for seven days. Solo scores remain unchanged.",
)}
</p>
)}
{data.leaders.length > 0 && !search && data.page === 1 && (
<WinnersPodium leaders={data.leaders} />
)}

View File

@@ -2,7 +2,7 @@ import { getI18n } from "@/lib/i18n";
import { redirect } from "next/navigation";
import { Header, Footer } from "@/components/Shell";
import { SignIn } from "@/components/SignIn";
import { user } from "@/lib/supabase";
import { rawUser, accountState } from "@/lib/supabase";
import { authDestination } from "@/shared/account";
export async function generateMetadata() {
const { t } = await getI18n();
@@ -15,12 +15,14 @@ export default async function LogIn({
}) {
const query = await searchParams;
const next = authDestination(query.next);
if (await user())
redirect(next === "/auth/reset-password" ? "/settings" : next);
const account = await rawUser();
if (account && (await accountState(account.id)) !== "active")
redirect("/ban/appeal");
if (account) redirect(next === "/auth/reset-password" ? "/settings" : next);
return (
<>
<Header />
<main className="section page-content auth-page">
<main className="section page-content auth-page" id="main-content" tabIndex={-1}>
<SignIn
next={next}
initialError={

192
app/mobile-game.css Normal file
View File

@@ -0,0 +1,192 @@
.mobile-map-toggle,
.report-icon {
display: none;
}
@media (max-width: 700px) {
.game-page {
min-height: 0;
}
.game-header {
display: grid;
grid-template-columns: minmax(0, 1fr) 44px;
gap: 8px;
padding: max(10px, env(safe-area-inset-top))
max(10px, env(safe-area-inset-right)) 12px
max(10px, env(safe-area-inset-left));
}
.game-header .brand {
font-size: 17px;
white-space: nowrap;
gap: 6px;
}
.game-header .brand-cube {
flex-shrink: 0;
}
.game-header .report-trigger {
width: 44px;
height: 44px;
padding: 0;
}
.game-header .report-trigger > span {
display: none;
}
.game-header .report-icon {
display: block;
}
.game-info {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
width: 100%;
gap: 8px;
padding: 8px;
border-radius: 10px;
}
.game-info > div {
min-width: 0;
}
.game-info small {
white-space: nowrap;
font-size: 8px;
letter-spacing: 0;
}
.game-info strong {
white-space: nowrap;
font-size: 17px;
line-height: 1.4;
}
.panorama-controls {
top: calc(max(10px, env(safe-area-inset-top)) + 112px);
left: max(10px, env(safe-area-inset-left));
gap: 6px;
}
.panorama-controls button {
width: 44px;
height: 44px;
padding: 0;
}
.game-page .game-map,
.game-page .game-map.expanded {
right: max(8px, env(safe-area-inset-right));
left: max(8px, env(safe-area-inset-left));
bottom: max(10px, env(safe-area-inset-bottom));
width: auto;
max-width: none;
box-shadow: 0 8px 24px #0006;
}
.game-map:not(.expanded) .game-map-content {
position: absolute;
bottom: 100%;
left: 0;
right: 0;
visibility: hidden;
pointer-events: none;
}
.mobile-map-toggle {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
min-height: 56px;
padding: 12px;
border: 0;
color: #fff1d0;
background: #241e18;
font: 14px var(--font-pixel, monospace);
}
.mobile-map-toggle small {
font: 11px monospace;
}
.game-map.expanded .mobile-map-toggle {
display: none;
}
.game-map .map-topline {
min-height: 44px;
padding: 0 8px 0 12px;
}
.game-map .map-topline button {
display: grid;
place-items: center;
width: 44px;
height: 44px;
}
.game-page .game-map .map-surface {
height: min(46dvh, 360px);
border-width: 12px;
border-image-width: 12px;
}
.game-map .map-actions {
padding: 8px 10px 10px;
}
.game-map .map-actions .button {
min-height: 48px;
padding: 10px 12px;
font-size: 13px;
white-space: nowrap;
}
.game-map .map-actions small {
margin-bottom: 8px;
}
.game-map .leaflet-bar a {
width: 40px;
height: 40px;
line-height: 40px;
}
.look-help {
display: none;
}
.round-result {
max-width: calc(100% - 24px);
max-height: calc(100dvh - 140px);
overflow-y: auto;
padding: 20px;
}
html[lang] .round-result h2 {
font-size: 21px;
}
.round-result .score {
font-size: 42px;
}
}
@media (max-height: 500px) and (max-width: 950px) {
.game-page {
min-height: 0;
}
.game-header {
display: flex;
padding: 8px 12px;
gap: 10px;
}
.game-header .brand {
font-size: 16px;
}
.game-header .report-trigger {
order: 3;
}
.game-info {
width: auto;
flex: 1;
max-width: 300px;
margin-left: auto;
gap: 16px;
padding: 6px 10px;
}
.panorama-controls {
top: 72px;
left: 12px;
}
.game-page .game-map,
.game-page .game-map.expanded {
left: auto;
width: min(440px, calc(100vw - 80px));
right: 8px;
bottom: max(8px, env(safe-area-inset-bottom));
}
.game-page .game-map .map-surface {
height: max(100px, calc(100dvh - 190px));
}
.round-result {
max-height: calc(100dvh - 80px);
}
}

View File

@@ -1,19 +1,28 @@
import { getI18n } from "@/lib/i18n";
import { Header, Footer } from "@/components/Shell";
import { Moderation } from "@/components/Moderation";
import Link from "next/link";
import { staffPage } from "@/lib/staff-page";
import { isAdministrator } from "@/shared/permissions";
export const metadata = { title: "Moderation Tools" };
export default async function Review() {
const { t } = await getI18n();
const [{ t }, staff] = await Promise.all([getI18n(), staffPage()]);
return (
<>
<Header />
<main className="section page-content">
<main className="section page-content moderation-page" id="main-content" tabIndex={-1}>
<span className="eyebrow">{t("THE COMMUNITY’S COMPASS")}</span>
<h1>{t("Map moderation.")}</h1>
<h1>{t("Moderation Tools.")}</h1>
<p className="lead">
{t(
"Review map quality and coverage before a world enters the public catalog.",
)}
{t("Review pending maps, map updates and account ban appeals.")}
</p>
{isAdministrator(staff.role) && (
<div className="staff-page-switch">
<Link className="button" href="/admin">
{t("Open Admin Tools")}
</Link>
</div>
)}
<Moderation />
</main>
<Footer />

View File

@@ -0,0 +1,32 @@
import { createHash } from "node:crypto";
import { createClient } from "@supabase/supabase-js";
import { redirect } from "next/navigation";
import { user } from "@/lib/supabase";
import { Header, Footer } from "@/components/Shell";
export const metadata = { robots: { index: false, follow: false }, referrer: "no-referrer" as const };
function db() {
return createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SECRET_KEY!, { auth: { persistSession: false } });
}
async function confirm(form: FormData) {
"use server";
const token = String(form.get("token") ?? "");
if (!/^[a-f0-9]{64}$/.test(token)) throw new Error("Invalid application link");
const account = await user();
if (!account) redirect(`/log-in?next=${encodeURIComponent(`/moderator-application/${token}`)}`);
const database = db();
const { data: profile, error: profileError } = await database.from("profiles").select("username,account_status").eq("id", account.id).single();
if (profileError || !profile || profile.account_status !== "active" || !profile.username) redirect(`/moderator-application/${token}?error=username`);
const hash = createHash("sha256").update(token).digest("hex");
const { data, error } = await database.from("moderator_applications").update({ status: "pending", profile_id: account.id }).eq("confirmation_hash", hash).eq("requested_username", profile.username).eq("status", "confirming").gt("expires_at", new Date().toISOString()).select("id").maybeSingle();
if (error || !data) redirect(`/moderator-application/${token}?error=link`);
redirect(`/moderator-application/${token}?submitted=1`);
}
export default async function ApplicationPage({ params, searchParams }: { params: Promise<{ token: string }>; searchParams: Promise<{ submitted?: string; error?: string }> }) {
const { token } = await params;
if (!/^[a-f0-9]{64}$/.test(token)) return <main>Invalid application link.</main>;
const account = await user();
if (!account) redirect(`/log-in?next=${encodeURIComponent(`/moderator-application/${token}`)}`);
const { submitted, error } = await searchParams;
return <><Header /><main className="section page-content"><h1>Moderator application</h1>{submitted ? <p>Application sent for review.</p> : <><p>Confirm that this MineGeo account belongs to you.</p>{error && <p role="alert">{error === "username" ? "Set your username in account settings first." : "This link expired, was already used, or belongs to a different username. Start again in the bot."}</p>}<form action={confirm}><input type="hidden" name="token" value={token} /><button className="button primary">Confirm application</button></form></>}</main><Footer /></>;
}

363
app/multiplayer.css Normal file
View File

@@ -0,0 +1,363 @@
.multiplayer-settings,
.multiplayer-join {
display: grid;
gap: 12px;
margin: 18px 0;
}
.multiplayer-settings label,
.multiplayer-join label,
.multiplayer-lobby > label {
font-weight: 650;
}
.multiplayer-settings input,
.multiplayer-settings select,
.multiplayer-join input,
.multiplayer-invite input {
width: 100%;
min-height: 44px;
border: 1px solid var(--line, #35404c);
border-radius: 10px;
padding: 10px 12px;
color: inherit;
background: var(--panel, #1a222c);
font: inherit;
}
.multiplayer-settings select {
max-width: 160px;
}
.multiplayer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24px 5%;
gap: 20px;
border-bottom: 1px solid var(--line, #35404c);
}
.multiplayer-lobby {
max-width: 1000px;
min-height: 70vh;
}
.multiplayer-lobby .eyebrow,
.multiplayer-lobby .lead {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.multiplayer-join {
max-width: 480px;
}
.multiplayer-invite {
display: flex;
gap: 10px;
margin: 12px 0 28px;
}
.multiplayer-invite input {
min-width: 0;
}
.multiplayer-invite .button {
white-space: nowrap;
flex-shrink: 0;
}
.multiplayer-roster {
list-style: none;
padding: 0;
margin: 16px 0 24px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.multiplayer-roster li {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border: 1px solid var(--line, #35404c);
border-radius: 12px;
gap: 12px;
overflow-wrap: anywhere;
}
.multiplayer-roster li > span {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.multiplayer-avatar {
width: 28px;
height: 28px;
}
.multiplayer-table-wrap {
overflow-x: auto;
margin: 28px 0;
}
.multiplayer-final {
min-width: 650px;
width: 100%;
}
.multiplayer-final caption {
text-align: left;
font-weight: 650;
margin-bottom: 16px;
}
.multiplayer-final .multiplayer-you {
background: #8dce6614;
}
.multiplayer-final-avatar {
width: 24px;
height: 24px;
display: inline-block;
vertical-align: middle;
margin-right: 6px;
}
.multiplayer-scoreboard {
position: absolute;
z-index: 20;
left: 80px;
top: 100px;
width: 245px;
padding: 12px 16px;
border-radius: 12px;
background: rgba(26, 34, 44, 0.96);
color: var(--text);
box-shadow: 0 6px 24px #0002;
max-height: calc(100dvh - 190px);
overflow: auto;
}
.multiplayer-scoreboard summary {
cursor: pointer;
font-weight: 650;
}
.multiplayer-scoreboard summary svg {
display: inline-block;
vertical-align: middle;
margin-right: 4px;
}
.multiplayer-scoreboard ul {
list-style: none;
margin: 12px 0;
padding: 0;
}
.multiplayer-scoreboard li {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 8px 0;
border-bottom: 1px solid var(--line);
overflow-wrap: anywhere;
}
.multiplayer-scoreboard li > span {
flex: 1;
min-width: 0;
}
.multiplayer-scoreboard .multiplayer-avatar {
width: 26px;
height: 26px;
}
.multiplayer-scoreboard li small {
display: block;
font-size: 11px;
opacity: 0.7;
}
.multiplayer-network,
.multiplayer-waiting {
position: fixed;
z-index: 50;
bottom: 18px;
left: 50%;
transform: translateX(-50%);
width: max-content;
max-width: calc(100% - 32px);
border-radius: 10px;
background: var(--panel);
color: var(--text);
box-shadow: 0 6px 24px #0003;
padding: 12px 18px;
text-align: center;
}
.multiplayer-network {
z-index: 100;
top: 80px;
bottom: auto;
border: 1px solid #aa5040;
}
.multiplayer-round-result {
max-height: calc(100dvh - 100px);
overflow-y: auto;
}
.multiplayer-round-scores {
padding: 0;
margin: 12px 0 20px;
list-style: none;
text-align: left;
}
.multiplayer-round-scores li {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 7px 0;
border-bottom: 1px solid var(--line);
overflow-wrap: anywhere;
}
@media (max-width: 640px) {
.multiplayer-header {
padding: 18px 5%;
}
.multiplayer-roster {
grid-template-columns: 1fr;
}
.multiplayer-invite {
flex-wrap: wrap;
}
.multiplayer-scoreboard {
top: 130px;
left: 64px;
width: min(220px, calc(100% - 80px));
}
.multiplayer-waiting {
bottom: 96px;
}
.multiplayer-game .game-header > .report-content {
display: none;
}
.multiplayer-round-result {
max-height: calc(100dvh - 48px);
}
}
.start-panel .tabs button {
display: inline-flex;
align-items: center;
gap: 8px;
}
.room-chat {
position: fixed;
bottom: max(20px, env(safe-area-inset-bottom));
left: max(20px, env(safe-area-inset-left));
z-index: 70;
width: min(350px, calc(100vw - 40px));
color: var(--text);
}
.room-chat-toggle {
background: var(--panel, #1a222c);
box-shadow: 0 4px 18px #0005;
min-height: 44px;
}
.room-chat-unread {
border-radius: 50%;
min-width: 22px;
padding: 2px 5px;
background: var(--accent, #8dce66);
color: #172210;
font: 12px sans-serif;
}
.room-chat section {
position: absolute;
bottom: calc(100% + 8px);
width: 100%;
border: 1px solid var(--line, #35404c);
border-radius: 12px;
background: var(--panel, #1a222c);
box-shadow: 0 8px 32px #0006;
overflow: hidden;
}
.room-chat:not(.room-chat-playing) {
position: static;
width: 90%;
max-width: 900px;
margin: 0 auto 40px;
}
.room-chat:not(.room-chat-playing) section {
position: static;
margin-top: 8px;
}
.room-chat-heading {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
border-bottom: 1px solid var(--line);
}
.room-chat-heading button {
display: grid;
place-items: center;
width: 44px;
height: 44px;
background: transparent;
border: 0;
color: inherit;
cursor: pointer;
}
.room-chat-log {
height: min(260px, 30dvh);
overflow-y: auto;
overscroll-behavior: contain;
padding: 12px;
}
.room-chat-log p {
margin: 0 0 14px;
overflow-wrap: anywhere;
font-size: 14px;
}
.room-chat-log strong,
.room-chat-log p > span {
display: block;
}
.room-chat-log strong {
font-size: 12px;
margin-bottom: 3px;
}
.room-chat-mine strong {
color: var(--accent, #8dce66);
}
.room-chat form {
display: flex;
gap: 8px;
padding: 10px;
border-top: 1px solid var(--line);
}
.room-chat input {
flex: 1;
min-width: 0;
min-height: 44px;
padding: 10px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--bg, #131b24);
color: inherit;
font: inherit;
font-size: 16px;
}
.room-chat form .button {
padding: 10px 12px;
}
.room-chat .sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.room-chat .error-message {
margin: 0;
padding: 0 12px 10px;
font-size: 13px;
}
@media (max-width: 700px) {
.room-chat {
left: max(10px, env(safe-area-inset-left));
width: min(350px, calc(100vw - 20px));
}
.room-chat-playing {
bottom: calc(max(10px, env(safe-area-inset-bottom)) + 68px);
}
.room-chat-playing section {
max-height: calc(100dvh - 220px);
overflow-y: auto;
}
}

View File

@@ -0,0 +1,13 @@
import { MultiplayerRoom } from "@/components/MultiplayerRoom";
import { notFound } from "next/navigation";
import { z } from "zod";
export default async function MultiplayerPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
if (!z.string().uuid().safeParse(id).success) notFound();
return <MultiplayerRoom id={id} />;
}

View File

@@ -3,7 +3,7 @@ import Link from "next/link";
export default async function NotFound() {
const { t } = await getI18n();
return (
<main className="section page-content">
<main className="section page-content" id="main-content" tabIndex={-1}>
<span className="eyebrow">{t("UNKNOWN COORDINATES")}</span>
<h1>{t("Uncharted territory.")}</h1>
<p>{t("This map or page isn’t available.")}</p>

View File

@@ -8,28 +8,32 @@ import {
Timer,
Blocks,
Upload,
Download,
} from "lucide-react";
import { Header, Footer } from "@/components/Shell";
import { MapCard } from "@/components/MapCard";
import { catalog } from "@/lib/catalog";
import { leaderboard } from "@/lib/leaderboard";
import { LeaderboardPreview } from "@/components/Leaderboard";
import desktopRelease from "@/shared/desktop-release.json";
import { FirstVisitIntro } from "@/components/FirstVisitIntro";
import { SiteTrailer } from "@/components/SiteTrailer";
import { Suspense } from "react";
export const dynamic = "force-dynamic";
export default async function Home() {
const { t, formatNumber } = await getI18n();
const [maps, rankings] = await Promise.all([
catalog(),
leaderboard({ limit: 5 }),
]);
const rankings = leaderboard({ limit: 5 });
const maps = await catalog();
const servers = new Set(maps.map((map) => map.server_id)).size;
const locations = maps.reduce((total, map) => total + map.panorama_count, 0);
return (
<>
<FirstVisitIntro />
<Header />
<main className="home-page" id="main-content">
<main className="home-page" id="main-content" tabIndex={-1}>
<section className="hero">
<div className="hero-copy">
<div className="status-label">{t("A MINECRAFT GUESSING GAME")}</div>
<div className="status-label">{t("A Minecraft guessing game")}</div>
<h1>
{t("Guess where you are")}
<br />
@@ -45,6 +49,9 @@ export default async function Home() {
<Link href="/servers" className="button primary">
{t("Start exploring")} <ArrowRight size={18} />
</Link>
<a href={desktopRelease.file} className="button" download>
<Download size={18} /> {t("Download for Windows")}
</a>
<Link href="/leaderboard" className="text-link">
{t("Meet the top explorers")} <ArrowRight size={14} />
</Link>
@@ -70,6 +77,7 @@ export default async function Home() {
width={1024}
height={1024}
loading="eager"
fetchPriority="high"
unoptimized
/>
<span className="square-pin pin-green" />
@@ -123,8 +131,8 @@ export default async function Home() {
<section className="section catalog-section">
<div className="section-heading">
<div>
<span className="eyebrow">{t("COMMUNITY MAPS")}</span>
<h2>{t("Where will you go today?")}</h2>
<span className="eyebrow">{t("Community maps")}</span>
<h2>{t("Explore community worlds.")}</h2>
</div>
<Link className="text-link" href="/servers">
{t("All maps ({count})", { count: maps.length })}{" "}
@@ -134,7 +142,13 @@ export default async function Home() {
{maps.length ? (
<div className="cards-grid">
{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>
) : (
@@ -166,10 +180,23 @@ export default async function Home() {
</div>
)}
</section>
<LeaderboardPreview data={rankings} />
<SiteTrailer />
<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">
<article className="home-panel">
<span className="eyebrow">{t("HOW TO PLAY")}</span>
<span className="eyebrow">{t("How to play")}</span>
<h2>{t("Know your blocks?")}</h2>
<ol className="play-steps">
<li>
@@ -202,13 +229,13 @@ export default async function Home() {
</div>
</li>
</ol>
<Link className="text-link" href="/about">
<Link className="text-link" href="/docs?page=guide">
{t("Read the field guide")} <ArrowRight size={14} />
</Link>
</article>
<article className="home-panel">
<span className="eyebrow">{t("CHOOSE YOUR CHALLENGE")}</span>
<h2>{t("One world. Three ways.")}</h2>
<span className="eyebrow">{t("Choose a difficulty")}</span>
<h2>{t("Pick a difficulty.")}</h2>
<div className="mode-list">
{[
{
@@ -229,6 +256,12 @@ export default async function Home() {
detail: t("Think fast. No zoom allowed."),
color: "red",
},
{
name: t("Void"),
time: t("10 seconds"),
detail: t("The ultimate test. Fixed view, strict scoring."),
color: "void",
},
].map((mode) => (
<Link href="/servers" className="mode-row" key={mode.name}>
<i className={`stat-cube ${mode.color}-cube`} />
@@ -254,8 +287,8 @@ export default async function Home() {
<section className="section contribute-banner">
<Blocks size={38} />
<div>
<span className="eyebrow">{t("BUILT BY THE COMMUNITY")}</span>
<h2>{t("Your world deserves to be explored.")}</h2>
<span className="eyebrow">{t("Made by MineGeo players")}</span>
<h2>{t("Put your world on the map.")}</h2>
<p>
{t("Share your builds and your favorite corners of Minecraft.")}
</p>
@@ -269,3 +302,10 @@ export default async function Home() {
</>
);
}
async function HomeLeaderboard({
data,
}: {
data: ReturnType<typeof leaderboard>;
}) {
return <LeaderboardPreview data={await data} />;
}

View File

@@ -14,7 +14,7 @@ export default async function Pair({
return (
<>
<Header />
<main className="section page-content">
<main className="section page-content" id="main-content" tabIndex={-1}>
{!account ? (
<>
<p className="muted">

View File

@@ -1,4 +1,21 @@
/* Responsive corrections shared by both languages. */
.skip-link {
position: fixed;
inset-block-start: 12px;
inset-inline-start: 12px;
z-index: 1000;
padding: 10px 14px;
transform: translateY(calc(-100% - 16px));
border: 1px solid var(--line);
border-radius: var(--radius-control);
color: var(--text);
background: var(--panel);
box-shadow: 0 4px 14px #03060a55;
white-space: nowrap;
}
.skip-link:focus-visible {
transform: translateY(0);
}
.language-switcher {
display: inline-flex;
align-items: center;
@@ -28,6 +45,12 @@ select:focus-visible {
font-style: normal;
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"] h2,
html[lang="ru"] h3,
@@ -101,6 +124,22 @@ html[lang="ru"] .mode-row h3 span {
display: flex;
flex-direction: column;
}
.desktop-download-card {
margin-bottom: 40px;
}
.desktop-download-version {
margin-top: 16px;
}
.download-build-link {
margin-inline-end: 8px;
margin-block-end: 8px;
}
.tool-card-note {
font-size: 11px;
}
.tool-card-note-spaced {
margin-top: 20px;
}
.tool-card pre,
.token-box code {
max-width: 100%;
@@ -125,6 +164,29 @@ html[lang="ru"] .mode-row h3 span {
.auth-tabs button {
border-radius: var(--radius-control);
}
/* Keep catalog cards readable instead of squeezing long map names. */
.cards-grid {
grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr));
}
.map-card .card-body .map-credit {
display: block;
overflow: visible;
min-height: 0;
-webkit-line-clamp: unset;
}
.map-card .card-body .eyebrow {
overflow-wrap: anywhere;
}
.search-field:focus-within {
outline: 2px solid var(--green);
outline-offset: 4px;
}
.dashboard-toolbar {
flex-wrap: wrap;
}
.dashboard-toolbar > * {
min-width: 0;
}
@media (max-width: 850px) {
.site-header {
height: auto;
@@ -209,12 +271,300 @@ html[lang="ru"] .mode-row h3 span {
.home-panel + .home-panel {
min-width: 0;
}
.map-card .card-body p {
display: -webkit-box;
}
.map-card .card-meta span:last-child {
display: flex;
}
}
.moderator-settings {
margin-bottom: 28px;
}
.moderation-page :is(.tool-card, .draft-row) {
margin-bottom: 32px;
}
.moderation-page > .token-box {
margin-bottom: 32px;
}
.staff-page-switch {
display: flex;
flex-wrap: wrap;
gap: 20px;
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;
}
}
.moderator-settings { margin-bottom: 28px; }
.moderator-setting-row { display: flex; gap: 24px; justify-content: space-between; align-items: center; margin-top: 24px; }
.moderator-setting-row h3 { margin: 0 0 8px; }
.moderator-setting-row p { margin: 6px 0; }
.moderator-setting-row .button { flex-shrink: 0; }
@media (max-width: 600px) {
.moderator-setting-row { align-items: stretch; flex-direction: column; }
.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 {
display: flex;
gap: 24px;
justify-content: space-between;
align-items: center;
margin-top: 24px;
}
.moderator-setting-row h3 {
margin: 0 0 8px;
}
.moderator-setting-row p {
margin: 6px 0;
}
.moderator-setting-row .button {
flex-shrink: 0;
}
@media (max-width: 600px) {
.moderator-setting-row {
align-items: stretch;
flex-direction: column;
}
}

View File

@@ -8,7 +8,7 @@ export default async function ResultPage({
return (
<>
<Header />
<main className="section page-content">
<main className="section page-content" id="main-content" tabIndex={-1}>
<Results id={(await params).id} />
</main>
<Footer />

View File

@@ -9,7 +9,7 @@
--text: #eef1f4;
--board-stone: #1a222c;
--board-edge: #35404c;
--radius-panel: 16px;
--radius-panel: 12px;
--radius-control: 9px;
}
body {

View File

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

View File

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

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

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

View File

@@ -1,15 +1,16 @@
import { getI18n } from "@/lib/i18n";
import { Header, Footer, EmptyState } from "@/components/Shell";
import { Header, Footer } from "@/components/Shell";
import { catalog } from "@/lib/catalog";
import { CatalogBrowser } from "@/components/CatalogBrowser";
import { user } from "@/lib/supabase";
export const dynamic = "force-dynamic";
export default async function Servers() {
const { t } = await getI18n();
const maps = await catalog();
const [maps, account] = await Promise.all([catalog(), user()]);
return (
<>
<Header />
<main className="section page-content">
<main className="section page-content" id="main-content" tabIndex={-1}>
<span className="eyebrow">
{t("THE COMMUNITY AT YOUR COORDINATES")}
</span>
@@ -19,15 +20,7 @@ export default async function Servers() {
"From familiar spawn towns to places nobody’s quite sure about. Pick your next expedition.",
)}
</p>
{maps.length ? (
<CatalogBrowser maps={maps} />
) : (
<EmptyState title={t("Fresh chunks, fresh start")}>
{t(
"No maps have been approved yet. Your server could be the first.",
)}
</EmptyState>
)}
<CatalogBrowser maps={maps} signedIn={!!account} />
</main>
<Footer />
</>

View File

@@ -3,8 +3,10 @@ import Link from "next/link";
import { redirect } from "next/navigation";
import { Header, Footer } from "@/components/Shell";
import { AccountSettings } from "@/components/AccountSettings";
import { user } from "@/lib/supabase";
import { rawUser } from "@/lib/supabase";
import { accountProfile } from "@/lib/profile";
import { Username } from "@/components/Username";
import { ShieldCheck } from "lucide-react";
export async function generateMetadata() {
const { t } = await getI18n();
return { title: t("Account settings") };
@@ -15,31 +17,80 @@ export default async function Settings({
searchParams: Promise<{ password?: string }>;
}) {
const { t } = await getI18n();
const account = await user();
const account = await rawUser();
if (!account) redirect("/log-in?next=%2Fsettings");
const [profile, query] = await Promise.all([
accountProfile(account),
searchParams,
]);
const active = profile.account_status === "active";
const statusLabel =
profile.account_status === "banned"
? "Banned"
: profile.account_status === "deactivated"
? "Deactivated"
: profile.account_status === "deleting"
? "Deletion pending"
: "Active";
return (
<>
<Header />
<main className="section page-content">
<Link className="text-link" href="/dashboard">
{t("← My account")}
<main className="section page-content" id="main-content" tabIndex={-1}>
<Link
className="text-link"
href={active ? "/dashboard" : "/ban/appeal"}
>
{t(active ? "← My account" : "← Account access & appeals")}
</Link>
<div className="account-page-heading">
<span className="eyebrow">{t("YOUR CARTOGRAPHER ACCOUNT")}</span>
<h1>{t("Account settings.")}</h1>
<p className="lead">
<Username name={profile.display_name} appearance={profile} /> ·{" "}
{t("Your name, your sign-in, your next expedition.")}
</p>
</div>
<AccountSettings
email={account.email ?? ""}
displayName={profile.display_name}
passwordUpdated={query.password === "updated"}
/>
<section
className="tool-card account-status-card"
aria-labelledby="account-status-title"
>
<ShieldCheck size={24} aria-hidden="true" />
<h2 id="account-status-title">{t("Account status")}</h2>
<p>
<span
className="badge"
data-account-status={profile.account_status}
>
{t(statusLabel)}
</span>
</p>
<p>
{t(
active
? "Your account is active. You can use MineGeo normally."
: "Account changes are disabled while access is restricted.",
)}
</p>
{!active && profile.account_reason && (
<p className="account-status-reason">{profile.account_reason}</p>
)}
{!active && (
<Link className="button" href="/ban/appeal">
{t("View account status & appeal")}
</Link>
)}
</section>
{active && (
<AccountSettings
accountId={account.id}
email={account.email ?? ""}
displayName={profile.display_name}
username={profile.username}
avatarPath={profile.avatar_path}
appearance={profile}
passwordUpdated={query.password === "updated"}
/>
)}
</main>
<Footer />
</>

191
app/site-glass.css Normal file
View File

@@ -0,0 +1,191 @@
/* A restrained frosted-glass treatment for the shared site navigation. */
.site-header {
--site-header-glass: rgb(8 11 16 / 0.68);
position: sticky;
inset-block-start: 16px;
z-index: 40;
height: 72px;
margin: 16px clamp(12px, 2vw, 28px) 0;
overflow: visible;
background: var(--bg);
border: 1px solid rgb(235 242 250 / 0.16);
border-radius: 22px;
box-shadow:
inset 0 1px rgb(255 255 255 / 0.12),
inset 0 -1px rgb(255 255 255 / 0.025),
0 12px 36px rgb(3 6 10 / 0.24),
0 2px 8px rgb(3 6 10 / 0.16);
transition:
background-color 160ms ease,
border-color 160ms ease;
}
@supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
.site-header {
background: var(--site-header-glass);
backdrop-filter: blur(18px) saturate(160%);
-webkit-backdrop-filter: blur(18px) saturate(160%);
}
}
@supports (backdrop-filter: url("#liquid-glass-distortion")) {
.site-header {
backdrop-filter: url("#liquid-glass-distortion") blur(5px) saturate(135%);
}
}
@supports (-webkit-backdrop-filter: url("#liquid-glass-distortion")) {
.site-header {
-webkit-backdrop-filter: url("#liquid-glass-distortion") blur(5px)
saturate(135%);
}
}
html[data-theme="light"] .site-header {
--site-header-glass: rgb(246 249 242 / 0.72);
border-color: rgb(52 76 43 / 0.18);
box-shadow:
inset 0 1px rgb(255 255 255 / 0.82),
inset 0 -1px rgb(255 255 255 / 0.28),
0 12px 36px rgb(39 58 33 / 0.12),
0 2px 8px rgb(39 58 33 / 0.06);
}
@media (max-width: 700px) {
.site-header {
inset-block-start: 10px;
height: 60px;
margin: 10px 10px 0;
border-radius: 18px;
}
}
@media (prefers-reduced-transparency: reduce) {
.site-header {
background: var(--bg);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.site-header::before {
display: none;
}
}
.site-header::before {
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
content: "";
background:
linear-gradient(
112deg,
rgb(255 255 255 / 0.09),
transparent 24%,
transparent 76%,
rgb(255 255 255 / 0.035)
),
linear-gradient(180deg, rgb(255 255 255 / 0.045), transparent 70%);
}
.site-header .header-inner {
position: relative;
z-index: 1;
}
html[data-theme="light"] .site-header::before {
background:
linear-gradient(
112deg,
rgb(255 255 255 / 0.55),
transparent 24%,
transparent 76%,
rgb(255 255 255 / 0.18)
),
linear-gradient(180deg, rgb(255 255 255 / 0.34), transparent 70%);
}
.site-filter-definitions {
position: absolute;
width: 0;
height: 0;
overflow: hidden;
pointer-events: none;
}
/* Keep the secondary support label quiet until someone points to or focuses it. */
.header-report {
position: relative;
}
.header-report > span {
display: block;
position: absolute;
inset-block-start: calc(100% + 9px);
inset-inline-start: 50%;
z-index: 2;
max-width: min(14rem, calc(100vw - 24px));
padding: 6px 9px;
border: 1px solid var(--line);
border-radius: 5px;
background: var(--panel);
box-shadow: 0 5px 16px rgb(3 6 10 / 0.2);
color: var(--text);
font-size: 11px;
line-height: 1.35;
opacity: 0;
visibility: hidden;
pointer-events: none;
transform: translate(-50%, -3px);
transition:
opacity 140ms ease,
transform 140ms ease,
visibility 140ms ease;
}
.header-report:is(:hover, :focus-visible) > span {
opacity: 1;
visibility: visible;
transform: translate(-50%, 0);
}
/* The header icon carries this action above phone width, so avoid a duplicate menu row. */
@media (max-width: 1200px) and (min-width: 601px) {
.site-navigation .nav-support {
display: none;
}
}
/* Touch screens cannot reveal hover labels; keep the action clear there. */
@media (hover: none) {
.header-report > span {
position: static;
max-width: none;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
color: inherit;
font-size: inherit;
line-height: inherit;
opacity: 1;
visibility: visible;
transform: none;
}
}
@media (hover: none) and (max-width: 1000px) {
.header-report {
width: auto;
padding-inline: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.site-header,
.header-report > span {
transition: none;
}
}

267
app/support.css Normal file
View File

@@ -0,0 +1,267 @@
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.header-report {
display: inline-flex;
gap: 7px;
align-items: center;
justify-content: center;
min-height: 44px;
color: var(--text);
font-size: 12px;
white-space: nowrap;
}
.support-layout {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(220px, 1fr);
gap: 50px;
}
.support-layout h1,
.support-conversation h1 {
margin-top: 14px;
overflow-wrap: anywhere;
}
.support-form form,
.support-conversation form {
display: grid;
gap: 12px;
}
.support-form label,
.support-conversation label {
font-weight: 650;
display: flex;
align-items: center;
gap: 8px;
}
.support-form input,
.support-form textarea,
.support-conversation textarea {
width: 100%;
min-width: 0;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--panel);
color: var(--text);
padding: 12px;
font: 16px var(--font-body, sans-serif);
}
.support-form textarea,
.support-conversation textarea {
resize: vertical;
}
.support-form small {
color: var(--muted);
}
.support-form form .button,
.support-conversation form .button {
justify-self: start;
}
.support-inbox {
border-left: 1px solid var(--line);
padding-left: 28px;
}
.support-inbox ul {
list-style: none;
margin: 0;
padding: 0;
}
.support-inbox li {
padding: 16px 0;
border-bottom: 1px solid var(--line);
overflow-wrap: anywhere;
}
.support-inbox strong,
.support-inbox small {
display: block;
}
.support-inbox small {
margin-top: 6px;
color: var(--muted);
}
.support-conversation {
max-width: 850px;
margin: 0 auto;
}
.support-conversation > .eyebrow {
display: block;
margin-top: 28px;
}
.support-screenshot {
display: block;
max-width: 100%;
max-height: 400px;
object-fit: contain;
border: 1px solid var(--line);
border-radius: 12px;
margin: 24px 0;
}
.support-messages {
display: grid;
gap: 16px;
margin: 24px 0;
}
.support-message {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 12px;
padding: 18px;
overflow-wrap: anywhere;
}
.support-message-staff {
border-color: var(--accent, #8dce66);
}
.support-message-system {
border-style: dashed;
}
.support-message header {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
}
.support-message small {
color: var(--muted);
}
.support-message p {
white-space: pre-wrap;
margin: 12px 0;
}
.support-reward {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
padding: 16px;
border: 1px solid #dac880;
border-radius: 12px;
}
.support-reward small {
flex-basis: 100%;
}
.username-glow {
color: #ffe6a1;
text-shadow:
0 0 8px #ffca6090,
0 0 18px #ffca6060;
}
.username {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 5px;
max-width: 100%;
vertical-align: middle;
overflow-wrap: anywhere;
white-space: normal;
}
.username > span {
min-width: 0;
max-width: 100%;
}
.username-prefix {
display: inline-flex;
align-items: center;
gap: 4px;
font:
600 11px/1.4 Arial,
sans-serif;
padding: 2px 6px;
border: 1px solid currentColor;
border-radius: 5px;
text-shadow: none;
}
.username-prefix svg {
flex-shrink: 0;
}
.username-moderator {
color: #91d3ff;
}
.username-admin {
color: #ffc18a;
}
.username-bug-finder {
color: #ffe6a1;
}
.username-custom-prefix {
color: #cdb8ff;
}
.username-preview {
padding: 18px;
border: 1px solid var(--line);
border-radius: 10px;
margin: 16px 0;
}
.glow-settings input[type="color"] {
width: 90px;
height: 48px;
cursor: pointer;
}
.prefix-search {
display: flex;
align-items: end;
flex-wrap: wrap;
gap: 12px;
}
.prefix-search .field {
flex: 1;
min-width: 200px;
}
.prefix-player-list {
list-style: none;
padding: 0;
display: grid;
gap: 8px;
max-height: 300px;
overflow-y: auto;
}
.prefix-player-list button {
width: 100%;
text-align: left;
background: var(--panel);
color: var(--text);
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px;
}
.prefix-player-list button[aria-pressed="true"] {
border-color: #91d3ff;
}
.prefix-player-list small {
display: block;
color: var(--muted);
margin-top: 5px;
overflow-wrap: anywhere;
}
.prefix-editor {
display: grid;
gap: 12px;
margin-top: 20px;
}
@media (max-width: 1000px) {
.header-report span {
display: none;
}
.header-report {
width: 44px;
}
.header-actions {
gap: 6px;
}
}
@media (max-width: 700px) {
.support-layout {
grid-template-columns: 1fr;
gap: 32px;
}
.support-inbox {
border-left: 0;
padding-left: 0;
border-top: 1px solid var(--line);
padding-top: 20px;
}
.header-actions {
margin-left: auto;
}
}

22
app/support/[id]/page.tsx Normal file
View File

@@ -0,0 +1,22 @@
import { notFound } from "next/navigation";
import { z } from "zod";
import { Header, Footer } from "@/components/Shell";
import { SupportConversation } from "@/components/Support";
export const metadata = { robots: { index: false, follow: false } };
export default async function Report({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
if (!z.string().uuid().safeParse(id).success) notFound();
return (
<>
<Header />
<main className="section page-content" id="main-content" tabIndex={-1}>
<SupportConversation key={id} id={id} />
</main>
<Footer />
</>
);
}

27
app/support/page.tsx Normal file
View File

@@ -0,0 +1,27 @@
import { Header, Footer } from "@/components/Shell";
import { SupportInbox } from "@/components/Support";
import { getI18n } from "@/lib/i18n";
export async function generateMetadata() {
const { t } = await getI18n();
return { title: t("Report a bug") };
}
export default async function Support({
searchParams,
}: {
searchParams: Promise<{ from?: string }>;
}) {
const query = await searchParams;
const pagePath =
query.from && /^\/(?!\/)[a-zA-Z0-9/_-]{0,299}$/.test(query.from)
? query.from
: "/";
return (
<>
<Header />
<main className="section page-content" id="main-content" tabIndex={-1}>
<SupportInbox pagePath={pagePath} />
</main>
<Footer />
</>
);
}

226
app/tournaments.css Normal file
View File

@@ -0,0 +1,226 @@
.tournament-page {
max-width: 1180px;
min-height: 60vh;
}
.start-panel .tournament-map-action {
margin-top: 12px;
border-color: var(--green);
color: var(--green);
text-align: center;
text-decoration: none;
}
.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;
}
.community-moderation > ul,
.community-moderation > h3 {
grid-column: 1 / -1;
}
.community-moderation .multiplayer-roster li {
flex-wrap: wrap;
gap: 12px;
}
.community-moderation form,
.community-moderation .inline-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: end;
}
.community-moderation-floating {
position: fixed;
z-index: 71;
right: max(20px, env(safe-area-inset-right));
top: max(70px, env(safe-area-inset-top));
}
.community-moderation-floating > summary {
cursor: pointer;
}
.community-moderation-floating .community-moderation {
width: min(640px, calc(100vw - 40px));
max-height: min(70vh, 700px);
overflow: auto;
background: var(--bg);
box-shadow: 0 8px 32px #0008;
}
.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;
}
}

View File

@@ -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 (
<>
<Header />
<main id="main-content" tabIndex={-1}>
<TournamentLobby id={id} signedIn={!!account} />
</main>
<Footer />
</>
);
}

23
app/tournaments/page.tsx Normal file
View File

@@ -0,0 +1,23 @@
import { Header, Footer } from "@/components/Shell";
import { TournamentIndex } from "@/components/TournamentLobby";
import { user } from "@/lib/supabase";
export default async function TournamentsPage({
searchParams,
}: {
searchParams: Promise<{ map?: string | string[] }>;
}) {
const account = await user();
const { map } = await searchParams;
const initialMapId =
typeof map === "string" && /^[0-9a-f-]{36}$/i.test(map) ? map : null;
return (
<>
<Header />
<main id="main-content" tabIndex={-1}>
<TournamentIndex signedIn={!!account} initialMapId={initialMapId} />
</main>
<Footer />
</>
);
}

View File

@@ -11,12 +11,12 @@
"repeated upload is idempotent",
"cancellation preserves completed export",
"24 random positions change on the next export",
"contribution command caps at 12 and retries idempotently",
"shared upload client caps contributions at 12 and retries idempotently",
"wrong server blocked before upload, including cached retry",
"owner override/default port and cleared address supported"
],
"blocks": 10112,
"chunks": 16,
"viewpoints": 24,
"directory": ".cache/plugin-regression-1791207258927"
}
"directory": ".cache/plugin-regression-1791360575134"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 819 KiB

274
cinematics/composition.mjs Normal file
View File

@@ -0,0 +1,274 @@
import { enter, shots } from "./motion.mjs";
const cream = "#f0efda",
green = "#b7df8a",
gold = "#dcc28a";
function text(ctx, value, x, y, size, color = cream) {
ctx.font = `${size}px Pixel`;
ctx.textAlign = "left";
ctx.fillStyle = color;
ctx.fillText(value, x, y);
}
function label(ctx, value, x, y, size = 11, color = "#a8bda3") {
ctx.font = `${size}px Arial`;
ctx.fillStyle = color;
ctx.textAlign = "left";
ctx.letterSpacing = "2px";
ctx.fillText(value, x, y);
ctx.letterSpacing = "0px";
}
function cross(ctx, x, y) {
ctx.beginPath();
ctx.moveTo(x - 5, y);
ctx.lineTo(x + 5, y);
ctx.moveTo(x, y - 5);
ctx.lineTo(x, y + 5);
ctx.stroke();
}
function word(ctx, value, x, y, size, time, start, end, color = cream) {
// 50ms stagger: the word reads as one gesture, followed by a long quiet hold.
let offset = 0;
ctx.font = `${size}px Pixel`;
for (let i = 0; i < value.length; i++) {
const a = enter(time, start + i * 0.05);
const out = enter(time, end - 0.25);
ctx.save();
ctx.globalAlpha *= a * (1 - out);
text(ctx, value[i], x + offset, y + (1 - a) * 12 + out * 12, size, color);
ctx.restore();
offset += ctx.measureText(value[i]).width;
}
}
function mark(ctx, x, y, size, time) {
ctx.save();
ctx.translate(x, y);
ctx.strokeStyle = "rgba(209,217,174,.5)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(0, 0, size, 0, Math.PI * 2);
ctx.stroke();
for (let i = 0; i < 32; i++) {
const a = (i / 32) * Math.PI * 2,
r = size - (i % 8 === 0 ? 9 : 3);
ctx.beginPath();
ctx.moveTo(Math.sin(a) * r, Math.cos(a) * r);
ctx.lineTo(Math.sin(a) * size, Math.cos(a) * size);
ctx.stroke();
}
ctx.rotate(Math.sin(time) * 0.09 - 0.35);
ctx.fillStyle = gold;
ctx.beginPath();
ctx.moveTo(0, -size * 0.6);
ctx.lineTo(size * 0.13, 0);
ctx.lineTo(0, size * 0.1);
ctx.lineTo(-size * 0.13, 0);
ctx.closePath();
ctx.fill();
ctx.fillStyle = "#57725b";
ctx.beginPath();
ctx.moveTo(0, size * 0.6);
ctx.lineTo(size * 0.13, 0);
ctx.lineTo(0, -size * 0.1);
ctx.lineTo(-size * 0.13, 0);
ctx.closePath();
ctx.fill();
ctx.restore();
}
function screenshot(ctx, image, x, y, w, h) {
ctx.save();
ctx.fillStyle = "#07130e";
ctx.beginPath();
ctx.roundRect(x - 1, y - 1, w + 2, h + 2, 8);
ctx.fill();
ctx.strokeStyle = "rgba(181,205,154,.5)";
ctx.stroke();
ctx.beginPath();
ctx.roundRect(x, y, w, h, 7);
ctx.clip();
ctx.drawImage(
image,
0,
0,
image.naturalWidth,
image.naturalHeight,
x,
y,
w,
h,
);
ctx.restore();
}
function screenshotShot(ctx, shot, image, t) {
const a = enter(t, shot.start - 0.125),
exit = enter(t, shot.end - 0.125);
ctx.save();
ctx.globalAlpha = a * (1 - exit);
label(ctx, shot.chapter, 64, 30, 10, gold);
text(ctx, shot.title, 62, 76, 34, cream);
ctx.font = "15px Arial";
ctx.fillStyle = "#aebfa6";
ctx.fillText(shot.subtitle, 65, 100);
// Readable data stays still after a 250ms settle; no continuous pan or zoom.
const settle = enter(t, shot.start + 0.05),
leave = enter(t, shot.end - 0.125);
const w = 1040,
h = 585,
x = 120,
y = 121;
ctx.save();
ctx.globalAlpha *= settle;
ctx.translate(640, y + h / 2 + 8 * (1 - settle) + 8 * leave);
const scale = 0.96 + 0.04 * settle;
ctx.scale(scale, scale);
ctx.translate(-640, -y - h / 2);
screenshot(ctx, image, x, y, w, h);
if (shot.image === "game") {
const focus =
enter(t, shot.start + 2, 0.25) * (1 - enter(t, shot.end - 0.4, 0.25));
ctx.globalAlpha *= focus;
ctx.strokeStyle = gold;
ctx.lineWidth = 2;
ctx.strokeRect(x + w * 0.771, y + h * 0.496, w * 0.222, h * 0.482);
ctx.fillStyle = "#0b1b13";
ctx.fillRect(x + 24, y + h - 66, 310, 38);
label(ctx, "LOOK AROUND. PLACE YOUR PIN.", x + 37, y + h - 42, 10, gold);
}
ctx.restore();
ctx.restore();
}
function brandShot(ctx, shot, t, closing) {
const a = enter(t, shot.start),
exit = enter(t, shot.end - 0.25);
ctx.save();
ctx.globalAlpha = a * (1 - exit);
label(ctx, shot.chapter, 66, 215, 11, gold);
if (closing) {
word(ctx, "MINEGEO", 60, 322, 80, t, shot.start + 0.05, shot.end, green);
ctx.save();
ctx.globalAlpha *= enter(t, shot.start + 0.45);
ctx.font = "20px Arial";
ctx.fillStyle = "#becdb3";
ctx.fillText(shot.subtitle, 66, 369);
ctx.restore();
const cta = enter(t, shot.start + 0.7);
ctx.save();
ctx.globalAlpha *= cta;
ctx.translate(0, 8 * (1 - cta));
ctx.fillStyle = green;
ctx.fillRect(65, 409, 330, 57);
text(ctx, "MINEGEO.SITE", 88, 448, 25, "#112215");
label(ctx, "FREE TO EXPLORE / FIVE ROUNDS", 66, 500, 10);
ctx.restore();
} else {
word(ctx, "GET LOST.", 60, 313, 68, t, shot.start + 0.05, shot.end);
word(
ctx,
"FIND YOUR WORLD.",
60,
392,
48,
t,
shot.start + 0.16,
shot.end,
green,
);
ctx.save();
ctx.globalAlpha *= enter(t, shot.start + 0.7);
ctx.font = "17px Arial";
ctx.fillStyle = "#becdb3";
ctx.fillText(shot.subtitle, 66, 445);
ctx.restore();
}
ctx.restore();
}
function drawBanner(ctx, t, pin) {
label(ctx, "YOUR NEXT EXPEDITION", 66, 108, 12, gold);
// Always legible in Discord: only the scene and targeting sweep loop.
text(ctx, "MINEGEO", 60, 233, 82, green);
ctx.font = "18px Arial";
ctx.fillStyle = "#ccd7bf";
ctx.fillText("Get lost. Find your world.", 66, 280);
ctx.strokeStyle = "rgba(184,207,160,.35)";
ctx.beginPath();
ctx.moveTo(66, 320);
ctx.lineTo(430, 320);
ctx.stroke();
label(ctx, "MINEGEO.SITE", 66, 350, 13, cream);
label(ctx, "EXPLORE / GUESS / DISCOVER", 66, 388, 10);
mark(ctx, 1126, 68, 25, (t / 3) * Math.PI * 2);
if (pin) {
ctx.save();
ctx.translate(pin.x, pin.y);
const phase = (t / 3) * Math.PI * 2,
pulse = 0.5 - 0.5 * Math.cos(phase);
ctx.strokeStyle = `rgba(234,210,144,${0.25 + 0.25 * (1 - pulse)})`;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(0, 0, 20 + pulse * 13, 0, Math.PI * 2);
ctx.stroke();
ctx.rotate(phase);
ctx.strokeStyle = "rgba(234,210,144,.65)";
for (let i = 0; i < 4; i++) {
ctx.rotate(Math.PI / 2);
ctx.beginPath();
ctx.moveTo(28, -7);
ctx.lineTo(28, 7);
ctx.stroke();
}
ctx.restore();
}
}
export function composeFrame(
ctx,
source,
{ width, height, banner, time: t, screenshots, pin },
) {
ctx.clearRect(0, 0, width, height);
ctx.drawImage(source, 0, 0);
const scale = width / (banner ? 1200 : 1280),
w = width / scale,
h = height / scale;
ctx.save();
ctx.scale(scale, scale);
const gradient = ctx.createLinearGradient(0, 0, w, 0);
gradient.addColorStop(0, "rgba(5,15,10,.97)");
gradient.addColorStop(0.38, "rgba(5,15,10,.86)");
gradient.addColorStop(0.8, "rgba(5,15,10,0)");
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, w, h);
const wash = ctx.createLinearGradient(0, 0, 0, h);
wash.addColorStop(0, "rgba(5,15,10,.2)");
wash.addColorStop(0.65, "rgba(5,15,10,0)");
wash.addColorStop(1, "rgba(5,15,10,.55)");
ctx.fillStyle = wash;
ctx.fillRect(0, 0, w, h);
ctx.strokeStyle = "rgba(171,196,144,.25)";
ctx.lineWidth = 1;
cross(ctx, 28, 28);
cross(ctx, w - 28, 28);
cross(ctx, 28, h - 28);
cross(ctx, w - 28, h - 28);
if (banner)
drawBanner(ctx, t, pin ? { x: pin.x / scale, y: pin.y / scale } : null);
else {
for (const shot of shots) {
if (t < shot.start - 0.125 || t > shot.end + 0.125) continue;
if (shot.image) screenshotShot(ctx, shot, screenshots[shot.image], t);
else brandShot(ctx, shot, t, shot.start === 23);
}
if (t < 3 || t >= 23) {
mark(ctx, 1188, 81, 29, t * 0.12);
label(ctx, "A MINECRAFT GUESSING GAME", 66, 65, 10);
}
const current = shots.findIndex((s) => t >= s.start && t < s.end);
for (let i = 0; i < shots.length; i++) {
ctx.fillStyle = i <= current ? "#bfd49b" : "rgba(165,186,143,.3)";
ctx.fillRect(28 + i * 16, h - 20, 10, 2);
}
ctx.fillStyle = `rgba(5,15,10,${1 - enter(t, 0)})`;
ctx.fillRect(0, 0, w, h);
ctx.fillStyle = `rgba(5,15,10,${enter(t, 29.75)})`;
ctx.fillRect(0, 0, w, h);
}
ctx.restore();
}

43
cinematics/index.html Normal file
View File

@@ -0,0 +1,43 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>MineGeo cinematic studio</title>
<style>
@font-face {
font-family: Pixel;
src: url("/fonts/Monocraft.ttf");
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
background: #08140f;
overflow: hidden;
}
canvas {
display: block;
width: 100vw;
height: 100vh;
object-fit: contain;
}
#status {
position: fixed;
color: #dcf3d6;
font: 14px monospace;
bottom: 12px;
left: 14px;
}
</style>
<script type="importmap">
{ "imports": { "three": "/three/three.module.js" } }
</script>
</head>
<body>
<div id="status">Building your world…</div>
<script type="module" src="/cinematics/scene.mjs"></script>
</body>
</html>

72
cinematics/motion.mjs Normal file
View File

@@ -0,0 +1,72 @@
// Animate skill's canonical curves, shared by the deterministic media timeline.
export const clamp01 = (value) => Math.max(0, Math.min(1, value));
export function cubicBezier(x1, y1, x2, y2) {
const sample = (t, a, b) =>
3 * (1 - t) ** 2 * t * a + 3 * (1 - t) * t * t * b + t ** 3;
return (value) => {
const x = clamp01(value);
if (x === 0 || x === 1) return x;
let lo = 0,
hi = 1;
for (let step = 0; step < 24; step++) {
const t = (lo + hi) / 2;
if (sample(t, x1, x2) < x) lo = t;
else hi = t;
}
return sample((lo + hi) / 2, y1, y2);
};
}
export const easeOut = cubicBezier(0.23, 1, 0.32, 1);
export const easeInOut = cubicBezier(0.77, 0, 0.175, 1);
export const enter = (time, start, duration = 0.25) =>
easeOut((time - start) / duration);
export const visibility = (time, start, end, duration = 0.25) =>
enter(time, start, duration) * (1 - enter(time, end - duration, duration));
export const shots = [
{
start: 0,
end: 3,
title: "GET LOST.",
subtitle: "Find a familiar world from a different perspective.",
chapter: "THE OVERWORLD IS CALLING",
},
{
start: 3,
end: 8,
title: "YOUR NEXT EXPEDITION.",
subtitle: "A Minecraft guessing game. A new way to explore.",
chapter: "01 / DISCOVER MINEGEO",
image: "home",
},
{
start: 8,
end: 13,
title: "PICK YOUR WORLD.",
subtitle: "Explore maps made by the community.",
chapter: "02 / CHOOSE A MAP",
image: "catalog",
},
{
start: 13,
end: 18,
title: "LOOK. GUESS. DISCOVER.",
subtitle: "Read the landscape. Place your pin.",
chapter: "03 / REAL GAMEPLAY",
image: "game",
},
{
start: 18,
end: 23,
title: "PLAY YOUR WAY.",
subtitle: "Your map. Your difficulty. Solo or together.",
chapter: "04 / MAKE IT YOURS",
image: "map",
},
{
start: 23,
end: 30,
title: "MINEGEO",
subtitle: "Get lost. Find your world.",
chapter: "YOUR NEXT ADVENTURE STARTS HERE",
},
];

332
cinematics/scene.mjs Normal file
View File

@@ -0,0 +1,332 @@
import * as THREE from "three";
import { composeFrame } from "./composition.mjs";
import { easeInOut, enter } from "./motion.mjs";
const params = new URLSearchParams(location.search);
const banner = params.get("mode") === "banner";
const width = Number(params.get("width")) || (banner ? 1200 : 1280);
const height = Number(params.get("height")) || (banner ? 480 : 720);
const renderer = new THREE.WebGLRenderer({
antialias: true,
preserveDrawingBuffer: true,
});
renderer.setSize(width, height);
renderer.setPixelRatio(1);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.35;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const scene = new THREE.Scene();
scene.background = new THREE.Color("#0c211c");
scene.fog = new THREE.FogExp2("#0c211c", 0.008);
const camera = new THREE.PerspectiveCamera(38, width / height, 0.1, 350);
scene.add(new THREE.HemisphereLight(0xc5e4d4, 0x263522, 2.1));
const sun = new THREE.DirectionalLight(0xffd8a2, 4.2);
sun.position.set(-24, 45, 22);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.left = -42;
sun.shadow.camera.right = 42;
sun.shadow.camera.top = 42;
sun.shadow.camera.bottom = -42;
sun.shadow.normalBias = 0.08;
scene.add(sun);
const rim = new THREE.DirectionalLight(0x74d6d3, 2.5);
rim.position.set(20, 8, -30);
scene.add(rim);
const world = new THREE.Group();
scene.add(world);
const palettes = {
rock: [0x4b655d, 0x546e62, 0x3c514c, 0x668177],
dirt: [0x66563c, 0x786548, 0x8a7150],
grass: [0x77a653, 0x90b962, 0x648f45, 0xa0bd6d],
sand: [0xceb98a, 0xe1cd9e, 0xb9ad80],
snow: [0xcddbd1, 0xe4eee1],
leaves: [0x3c7449, 0x4d925b, 0x79a456, 0x9fac61],
wood: [0x69513a, 0x84654a],
water: [0x3b9190, 0x56aeaa],
};
const instances = Object.fromEntries(
Object.keys(palettes).map((key) => [key, []]),
);
const hash = (x, z, salt = 0) => {
const n = Math.sin(x * 127.1 + z * 311.7 + salt * 74.7) * 43758.5453;
return n - Math.floor(n);
};
function block(kind, x, y, z, sx = 1, sy = 1, sz = 1, variation = 0) {
instances[kind].push({
x,
y,
z,
sx,
sy,
sz,
color:
palettes[kind][Math.floor(hash(x, z, variation) * palettes[kind].length)],
});
}
function groundHeight(x, z) {
const hill = Math.exp(-((x - 6) ** 2 + (z + 5) ** 2) / 49) * 10;
const hill2 = Math.exp(-((x + 8) ** 2 + (z - 5) ** 2) / 48) * 3;
return Math.floor(
2 + hill + hill2 + Math.sin(x * 0.32) * 0.8 + Math.cos(z * 0.38) * 0.7,
);
}
for (let x = -23; x <= 23; x++) {
for (let z = -19; z <= 19; z++) {
const edge = (x / 23) ** 2 + (z / 19) ** 2;
if (edge > 0.92 + Math.sin(x * 0.7 + z * 0.3) * 0.07) continue;
const river = Math.abs(z - (Math.sin(x * 0.16) * 3 + 3)) < 1.7;
const h = river ? 1 : groundHeight(x, z);
const depth = Math.max(1, Math.floor((1 - edge) * 8 + hash(x, z) * 2));
block("rock", x, -depth / 2, z, 0.99, depth, 0.99);
block("dirt", x, h / 2, z, 1, h, 1);
const kind = river
? "water"
: h > 9
? "snow"
: edge > 0.73
? "sand"
: "grass";
block(kind, x, h + 0.08, z, 1, 0.32, 1);
if (
!river &&
edge < 0.65 &&
h < 7 &&
hash(x, z, 7) > 0.958 &&
(x % 3 === 0 || z % 4 === 0)
) {
const tall = 3 + Math.floor(hash(x, z, 9) * 2);
block("wood", x, h + tall / 2, z, 0.55, tall, 0.55);
block("leaves", x, h + tall, z, 2.8, 1.8, 2.8);
block("leaves", x, h + tall + 1.1, z, 1.65, 1.2, 1.65, 2);
}
if (edge > 0.7 && edge < 0.91 && hash(x, z, 12) > 0.94)
block("leaves", x, -depth + 0.2, z, 0.24, 2 + hash(x, z, 10) * 3, 0.24);
}
}
// A warm-lit expedition hut and an old stone arch make the terrain recognizable.
const hutX = -8,
hutZ = -5,
hutY = groundHeight(hutX, hutZ) + 0.2;
block("wood", hutX, hutY + 1.15, hutZ, 3.2, 2.4, 3);
for (let i = 0; i < 3; i++)
block("rock", hutX, hutY + 2.5 + i * 0.4, hutZ, 4 - i, 0.5, 3.8);
for (const x of [9, 13]) for (let y = 0; y < 5; y++) block("rock", x, 4 + y, 8);
for (let x = 9; x <= 13; x++) block("rock", x, 9, 8);
const box = new THREE.BoxGeometry(1, 1, 1);
const dummy = new THREE.Object3D();
for (const [kind, cells] of Object.entries(instances)) {
const material = new THREE.MeshStandardMaterial({
color: 0xffffff,
roughness: kind === "water" ? 0.24 : 0.92,
metalness: kind === "water" ? 0.25 : 0.02,
});
const mesh = new THREE.InstancedMesh(box, material, cells.length);
cells.forEach((b, i) => {
dummy.position.set(b.x, b.y, b.z);
dummy.scale.set(b.sx, b.sy, b.sz);
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
mesh.setColorAt(i, new THREE.Color(b.color));
});
mesh.instanceMatrix.needsUpdate = true;
mesh.instanceColor.needsUpdate = true;
mesh.castShadow = kind !== "water";
mesh.receiveShadow = true;
world.add(mesh);
}
const windowLight = new THREE.Mesh(
new THREE.BoxGeometry(0.12, 0.7, 0.7),
new THREE.MeshBasicMaterial({ color: 0xffce77 }),
);
windowLight.position.set(hutX - 1.64, hutY + 1.25, hutZ);
world.add(windowLight);
// Thin cartographic rings float beneath the cutaway, like a world waiting to be found.
const ringGroup = new THREE.Group();
ringGroup.position.y = -8;
for (const radius of [25, 29, 33]) {
const points = Array.from(
{ length: 161 },
(_, i) =>
new THREE.Vector3(
Math.cos((i / 160) * Math.PI * 2) * radius,
0,
Math.sin((i / 160) * Math.PI * 2) * radius,
),
);
const line = new THREE.LineLoop(
new THREE.BufferGeometry().setFromPoints(points),
new THREE.LineBasicMaterial({
color: 0x759b7b,
transparent: true,
opacity: 0.25,
}),
);
ringGroup.add(line);
}
world.add(ringGroup);
const sweepPoints = Array.from(
{ length: 49 },
(_, i) =>
new THREE.Vector3(
Math.cos((i / 48) * 0.7) * 29,
0.04,
Math.sin((i / 48) * 0.7) * 29,
),
);
ringGroup.add(
new THREE.Line(
new THREE.BufferGeometry().setFromPoints(sweepPoints),
new THREE.LineBasicMaterial({
color: 0xd6bb76,
transparent: true,
opacity: 0.8,
}),
),
);
function pin(color, x, z) {
const group = new THREE.Group();
const mat = new THREE.MeshStandardMaterial({
color,
emissive: color,
emissiveIntensity: 0.25,
metalness: 0.35,
roughness: 0.3,
});
const head = new THREE.Mesh(new THREE.OctahedronGeometry(1.4), mat);
head.position.y = 3;
const stem = new THREE.Mesh(new THREE.ConeGeometry(0.35, 2.2, 4), mat);
stem.rotation.z = Math.PI;
stem.position.y = 1.25;
group.add(head, stem);
const halo = new THREE.Mesh(
new THREE.TorusGeometry(2, 0.055, 6, 64),
new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.75 }),
);
halo.rotation.x = -Math.PI / 2;
halo.position.y = 0.15;
group.add(halo);
group.position.set(x, groundHeight(x, z) + 0.5, z);
world.add(group);
return { group, halo, base: group.position.y };
}
const pins = [
pin(0xffcf70, 5, -5),
pin(0xa3e271, -12, 9),
pin(0xef7967, 12, 5),
];
const moteGeo = new THREE.BufferGeometry();
const motePositions = [];
for (let i = 0; i < 150; i++)
motePositions.push(
(hash(i, 4) - 0.5) * 130,
hash(i, 5) * 58 - 12,
(hash(i, 6) - 0.5) * 100,
);
moteGeo.setAttribute(
"position",
new THREE.Float32BufferAttribute(motePositions, 3),
);
const motes = new THREE.Points(
moteGeo,
new THREE.PointsMaterial({
color: 0xdce8b9,
size: 0.13,
transparent: true,
opacity: 0.6,
}),
);
scene.add(motes);
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
document.body.prepend(canvas);
const ctx = canvas.getContext("2d");
await document.fonts.load("24px Pixel");
await document.fonts.ready;
const screenshots = banner
? {}
: Object.fromEntries(
await Promise.all(
["home", "catalog", "map", "game"].map(async (name) => {
const image = new Image();
image.src = `/shots/${name}.jpg`;
await image.decode();
return [name, image];
}),
),
);
// Terrain and lighting are static: cache their shadow map, not a moving world.
renderer.shadowMap.autoUpdate = false;
renderer.shadowMap.needsUpdate = true;
const projectedPin = new THREE.Vector3();
function renderAt(time) {
const t = banner ? ((time % 3) + 3) % 3 : Math.max(0, Math.min(30, time));
const loop = (t / 3) * Math.PI * 2;
const travel = banner
? (1 - Math.cos(loop)) / 2
: easeInOut(Math.min(t, 3) / 3);
const azimuth = banner ? 0.66 + 0.045 * Math.sin(loop) : 0.56 + 0.13 * travel;
const distance = banner ? 79 + Math.sin(loop) * 1.6 : 100 - 13 * travel;
camera.position.set(
Math.sin(azimuth) * distance,
banner ? 38 : 43,
Math.cos(azimuth) * distance,
);
camera.lookAt(0, 0, 0);
camera.setViewOffset(
width,
height,
-width * (banner ? 0.23 : 0.17),
0,
width,
height,
);
world.position.y = 0;
pins.forEach((p, i) => {
p.group.position.y =
p.base +
(banner
? Math.sin(loop) * 0.1
: (1 - enter(t, 23 + i * 0.08, 0.25)) * 1.5);
p.group.rotation.y = banner ? Math.sin(loop) * 0.12 : 0.15;
p.halo.scale.setScalar(1 + (banner ? (1 - Math.cos(loop)) * 0.035 : 0));
p.group.visible = i === 0 && (banner || t < 3 || t >= 23);
});
motes.rotation.y = banner ? Math.sin(loop) * 0.006 : 0;
ringGroup.rotation.y = banner ? loop : t * 0.12;
renderer.render(scene, camera);
projectedPin.copy(pins[0].group.position);
projectedPin.y += 3;
projectedPin.project(camera);
composeFrame(ctx, renderer.domElement, {
width,
height,
banner,
time: t,
screenshots,
pin: {
x: (projectedPin.x * 0.5 + 0.5) * width,
y: (-projectedPin.y * 0.5 + 0.5) * height,
},
});
}
window.renderCinematic = renderAt;
window.cinematicCanvas = canvas;
document.querySelector("#status").remove();
renderAt(Number(params.get("time")) || 0);
window.cinematicReady = true;
if (!params.has("still")) {
const start = performance.now();
function loopFrame(now) {
renderAt(((now - start) / 1000) % (banner ? 3 : 30));
requestAnimationFrame(loopFrame);
}
requestAnimationFrame(loopFrame);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

View File

@@ -0,0 +1,281 @@
"use client";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/client-api";
import { useI18n } from "./I18nProvider";
import type {
ManagedAccount,
AccountAppealRecord,
} from "@/shared/account-controls";
import styles from "@/app/ban/appeal/page.module.css";
import { useAppealRealtime } from "./useAppealRealtime";
export function AccountAppeal() {
const { locale } = useI18n();
const l = (en: string, ru: string) => (locale === "ru" ? ru : en);
const [data, setData] = useState<{
account: ManagedAccount;
appeals: AccountAppealRecord[];
} | null>(null);
const [error, setError] = useState("");
const [message, setMessage] = useState("");
const [loading, setLoading] = useState(true);
const [reason, setReason] = useState("");
const [busy, setBusy] = useState(false);
const lock = useRef(false);
const statusReads = useRef(0);
const realtime = useAppealRealtime({
enabled: Boolean(data?.account.id),
ownerId: data?.account.id,
refresh: async () => {
const version = ++statusReads.current;
const result = await api<{
account: ManagedAccount;
appeals: AccountAppealRecord[];
}>("ban/appeal");
if (version === statusReads.current) setData(result);
},
onError: (error) => setError((error as Error).message),
});
useEffect(() => {
let active = true;
const version = ++statusReads.current;
api<{ account: ManagedAccount; appeals: AccountAppealRecord[] }>(
"ban/appeal",
)
.then((result) => {
if (active && version === statusReads.current) setData(result);
})
.catch((e) => {
if (active) setError((e as Error).message);
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, []);
async function run(operation: () => Promise<void>) {
if (lock.current) return;
lock.current = true;
setBusy(true);
setError("");
try {
await operation();
} catch (e) {
setError((e as Error).message);
} finally {
lock.current = false;
setBusy(false);
}
}
async function reload() {
const version = ++statusReads.current;
const result = await api<{
account: ManagedAccount;
appeals: AccountAppealRecord[];
}>("ban/appeal");
if (version === statusReads.current) {
setData(result);
setLoading(false);
}
}
const current = data?.appeals.find(
(a) => a.status_version === data.account.status_version,
);
return (
<section
aria-label={l(
"Account access and appeals",
"Доступ к аккаунту и апелляции",
)}
>
{error && (
<p className="error-message" role="alert">
{error}
</p>
)}
{message && (
<p className="muted" role="status">
{message}
</p>
)}
{loading && !error && (
<p role="status">
{l("Loading account status…", "Загрузка состояния аккаунта…")}
</p>
)}
<div className={styles.actions}>
<button
className="button"
disabled={busy}
onClick={() => void run(reload)}
>
{l("Refresh status", "Обновить статус")}
</button>
<button
className="button"
disabled={busy}
onClick={() =>
void run(async () => {
await api("auth/logout", {});
window.location.assign("/log-in");
})
}
>
{l("Log out", "Выйти")}
</button>
<Link className="button" href="/docs?page=account-bans">
{l("Read the guide", "Открыть руководство")}
</Link>
</div>
{data && (
<p className="muted" style={{ marginTop: 20 }}>
{realtime === "live"
? l(
"Live updates connected",
"Обновления в реальном времени подключены",
)
: realtime === "offline"
? l(
"Reconnecting… Refresh remains available.",
"Переподключение… Можно обновить вручную.",
)
: l("Connecting live updates…", "Подключение обновлений…")}
</p>
)}
{data && (
<>
<div className={styles.record} style={{ marginTop: 32 }}>
<h2>
{data.account.account_status === "active"
? l("Your account is active", "Ваш аккаунт активен")
: data.account.account_status === "banned"
? l("Your account is banned", "Ваш аккаунт заблокирован")
: data.account.account_status === "deleting"
? l(
"Account deletion is pending",
"Ожидается удаление аккаунта",
)
: l(
"Your account is deactivated",
"Ваш аккаунт деактивирован",
)}
</h2>
{data.account.account_reason && (
<>
<strong>{l("Reason", "Причина")}</strong>
<p className={styles.reason}>{data.account.account_reason}</p>
</>
)}
{data.account.account_status === "active" ? (
<Link className="button primary" href="/servers">
{l("Continue playing", "Продолжить играть")}
</Link>
) : (
<p>
{l(
"Normal account access, uploads and API keys are blocked. You can still read the docs, view your status in Settings, use this page and sign out.",
"Обычный доступ к аккаунту, загрузки и ключи API заблокированы. Вы можете читать документацию, видеть статус в настройках, открывать эту страницу и выходить из аккаунта.",
)}
</p>
)}
{data.account.account_status === "deactivated" && (
<p>
{l(
"An Admin must restore a deactivated account. Ban appeals are available only for banned accounts. Contact the team through the community links below.",
"Деактивированный аккаунт может восстановить только администратор. Апелляции доступны только при блокировке. Свяжитесь с командой через ссылки сообщества внизу.",
)}
</p>
)}
{data.account.account_status === "deleting" && (
<p>
{l(
"An Admin must finish this action. A deleted account cannot be appealed or recovered here.",
"Администратор должен завершить это действие. Удалённый аккаунт нельзя восстановить или обжаловать здесь.",
)}
</p>
)}
</div>
{data.account.account_status === "banned" && !current && (
<form
className={styles.confirm}
onSubmit={(e) => {
e.preventDefault();
void run(async () => {
await api("ban/appeal", { reason });
setReason("");
setMessage(
l(
"Appeal sent. A Moderator or Admin will review it.",
"Апелляция отправлена. Её рассмотрит модератор или администратор.",
),
);
await reload();
});
}}
>
<h2>{l("Appeal this ban", "Обжаловать блокировку")}</h2>
<p>
{l(
"Explain what happened and why the ban should be reconsidered. You can submit one appeal for each ban. Staff will leave their decision here.",
"Расскажите, что произошло и почему блокировку стоит пересмотреть. Для каждой блокировки можно подать одну апелляцию. Решение команды появится здесь.",
)}
</p>
<label className="field">
{l(
"Your explanation (10–2000 characters)",
"Ваше объяснение (10–2000 символов)",
)}
<textarea
required
minLength={10}
maxLength={2000}
rows={6}
disabled={busy}
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
</label>
<button
className="button primary"
disabled={busy || reason.trim().length < 10}
>
{busy
? l("Sending…", "Отправка…")
: l("Send appeal", "Отправить апелляцию")}
</button>
</form>
)}
{data.appeals.length > 0 && (
<div className={styles.list}>
<h2>{l("Your appeals", "Ваши апелляции")}</h2>
{data.appeals.map((appeal) => (
<article className={styles.record} key={appeal.id}>
<span className="badge">
{appeal.state === "open"
? l("Awaiting review", "Ожидает рассмотрения")
: appeal.state === "approved"
? l("Approved", "Одобрена")
: l("Rejected", "Отклонена")}
</span>
<p className="muted">
{new Date(appeal.created_at).toLocaleString(locale)}
</p>
<p className={styles.reason}>{appeal.reason}</p>
{appeal.review_reason && (
<>
<strong>{l("Staff decision", "Решение команды")}</strong>
<p className={styles.reason}>{appeal.review_reason}</p>
</>
)}
</article>
))}
</div>
)}
</>
)}
</section>
);
}

View File

@@ -0,0 +1,452 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { ShieldCheck, Users, RefreshCw } from "lucide-react";
import { api } from "@/lib/client-api";
import { useI18n } from "./I18nProvider";
import type {
ManagedAccount,
AccountAppealRecord,
} from "@/shared/account-controls";
import styles from "@/app/ban/appeal/page.module.css";
import { useAppealRealtime } from "./useAppealRealtime";
export function AccountControls({
administrator,
showAppeals = true,
}: {
administrator: boolean;
showAppeals?: boolean;
}) {
const { locale } = useI18n();
const l = (en: string, ru: string) => (locale === "ru" ? ru : en);
const [query, setQuery] = useState("");
const [users, setUsers] = useState<ManagedAccount[]>([]);
const [viewer, setViewer] = useState("");
const [searched, setSearched] = useState(false);
const [appeals, setAppeals] = useState<AccountAppealRecord[]>([]);
const [loading, setLoading] = useState(showAppeals);
const [error, setError] = useState("");
const [message, setMessage] = useState("");
const [busy, setBusy] = useState(false);
const lock = useRef(false);
const appealReads = useRef(0);
const [selected, setSelected] = useState<{
account: ManagedAccount;
action: "deactivate" | "ban" | "restore" | "delete";
} | null>(null);
const [reason, setReason] = useState("");
const [confirmation, setConfirmation] = useState("");
const [reviewReasons, setReviewReasons] = useState<Record<string, string>>(
{},
);
const realtime = useAppealRealtime({
enabled: showAppeals,
refresh: async () => {
const version = ++appealReads.current;
const result = await api<{ appeals: AccountAppealRecord[] }>(
"moderation/account-appeals",
);
if (version === appealReads.current) {
setAppeals(result.appeals);
setLoading(false);
}
},
onError: (error) => {
setError((error as Error).message);
setLoading(false);
},
});
useEffect(() => {
if (!showAppeals) return;
let active = true;
const version = ++appealReads.current;
api<{ appeals: AccountAppealRecord[] }>("moderation/account-appeals")
.then((data) => {
if (active && version === appealReads.current) setAppeals(data.appeals);
})
.catch((e) => {
if (active && version === appealReads.current)
setError((e as Error).message);
})
.finally(() => {
if (active && version === appealReads.current) setLoading(false);
});
return () => {
active = false;
};
}, [showAppeals]);
async function run(operation: () => Promise<void>) {
if (lock.current) return;
lock.current = true;
setBusy(true);
setError("");
setMessage("");
try {
await operation();
} catch (e) {
setError((e as Error).message);
} finally {
lock.current = false;
setBusy(false);
}
}
async function find() {
const result = await api<{ users: ManagedAccount[]; viewerId: string }>(
"moderation/accounts?q=" + encodeURIComponent(query),
);
setUsers(result.users);
setViewer(result.viewerId);
setSearched(true);
}
async function refreshAppeals() {
if (!showAppeals) return;
setLoading(true);
const version = ++appealReads.current;
try {
const result = await api<{ appeals: AccountAppealRecord[] }>(
"moderation/account-appeals",
);
if (version === appealReads.current) setAppeals(result.appeals);
} finally {
if (version === appealReads.current) setLoading(false);
}
}
async function save(event: React.FormEvent) {
event.preventDefault();
if (!selected) return;
const chosen = selected;
await run(async () => {
await api("moderation/accounts/" + chosen.account.id, {
action: chosen.action,
reason,
...(chosen.action === "delete" ? { confirmation } : {}),
});
setSelected(null);
setReason("");
setConfirmation("");
await find();
await refreshAppeals();
setMessage(
l("Account action completed.", "Действие с аккаунтом выполнено."),
);
});
}
const stateLabel = (status: string) =>
({
active: l("Active", "Активен"),
deactivated: l("Deactivated", "Деактивирован"),
banned: l("Banned", "Заблокирован"),
deleting: l("Deletion pending", "Ожидает удаления"),
})[status] ?? status;
const actionLabel = (action: string) =>
({
deactivate: l("Deactivate", "Деактивировать"),
ban: l("Ban", "Заблокировать"),
restore: l("Restore access", "Восстановить доступ"),
delete: l("Delete permanently", "Удалить навсегда"),
})[action] ?? action;
return (
<>
{error && (
<p className="error-message" role="alert">
{error}
</p>
)}
{message && (
<p className="muted" role="status">
{message}
</p>
)}
{administrator && (
<section className="tool-card" aria-labelledby="account-controls-title">
<div className={styles.heading}>
<Users size={24} aria-hidden="true" />
<h2 id="account-controls-title">
{l("Account management", "Управление аккаунтами")}
</h2>
</div>
<p>
{l(
"Deactivate access temporarily, ban with a reason, restore access, or permanently delete an account. Admin accounts are protected.",
"Временно отключайте доступ, блокируйте с указанием причины, восстанавливайте доступ или удаляйте аккаунты навсегда. Аккаунты администраторов защищены.",
)}
</p>
<form
className={styles.search}
onSubmit={(e) => {
e.preventDefault();
void run(find);
}}
>
<label className="field">
{l("Player name or account ID", "Имя игрока или ID аккаунта")}
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
maxLength={80}
disabled={busy}
/>
</label>
<button className="button" disabled={busy}>
{l("Find accounts", "Найти аккаунты")}
</button>
</form>
{searched && users.length === 0 && (
<p>{l("No accounts found.", "Аккаунты не найдены.")}</p>
)}
<div className={styles.list}>
{users.map((account) => (
<article className={styles.record} key={account.id}>
<div className={styles.heading}>
<h3>{account.display_name}</h3>
<span className="badge">
{stateLabel(account.account_status)}
</span>
</div>
<p>
<code>{account.id}</code> · {account.role}
</p>
{account.account_reason && <p>{account.account_reason}</p>}
{account.role === "admin" || account.id === viewer ? (
<p className="muted">
{l("This account is protected.", "Этот аккаунт защищён.")}
</p>
) : (
<div className={styles.actions}>
{(["deactivate", "ban", "restore", "delete"] as const).map(
(action) => (
<button
className="button"
type="button"
key={action}
disabled={
busy ||
(action === "restore" &&
["active", "deleting"].includes(
account.account_status,
)) ||
(action === "deactivate" &&
account.account_status !== "active") ||
(action === "ban" &&
["banned", "deleting"].includes(
account.account_status,
))
}
onClick={() => {
setSelected({ account, action });
setReason("");
setConfirmation("");
setMessage("");
}}
>
{actionLabel(action)}
</button>
),
)}
</div>
)}
{selected?.account.id === account.id && (
<form
className={styles.confirm}
onSubmit={(e) => void save(e)}
>
<h3>
{actionLabel(selected.action)}: {account.display_name}
</h3>
<p>
{selected.action === "delete"
? l(
"Permanent deletion removes sign-in, profile, credentials and account appeals. Shared maps and community history remain without an account owner. This cannot be undone.",
"Удаление навсегда убирает вход, профиль, ключи и апелляции аккаунта. Общие карты и история сообщества сохраняются без владельца аккаунта. Отменить удаление нельзя.",
)
: l(
"The player will see your reason. Restricting access revokes existing keys and ends existing games and multiplayer participation.",
"Игрок увидит указанную причину. Ограничение доступа отзывает ключи и завершает текущие игры и участие в мультиплеере.",
)}
</p>
<label className="field">
{l(
"Reason (10–2000 characters)",
"Причина (10–2000 символов)",
)}
<textarea
required
minLength={10}
maxLength={2000}
rows={3}
value={reason}
onChange={(e) => setReason(e.target.value)}
disabled={busy}
/>
</label>
{selected.action === "delete" && (
<label className="field">
{l(
"Type the account ID to confirm",
"Введите ID аккаунта для подтверждения",
)}
<input
required
value={confirmation}
onChange={(e) => setConfirmation(e.target.value)}
autoComplete="off"
disabled={busy}
/>
</label>
)}
<div className={styles.actions}>
<button
className="button primary"
disabled={
busy ||
reason.trim().length < 10 ||
(selected.action === "delete" &&
confirmation !== account.id)
}
>
{busy
? l("Working…", "Подождите…")
: l("Confirm action", "Подтвердить действие")}
</button>
<button
className="button"
type="button"
disabled={busy}
onClick={() => setSelected(null)}
>
{l("Cancel", "Отмена")}
</button>
</div>
</form>
)}
</article>
))}
</div>
</section>
)}
{showAppeals && (
<section className="tool-card" aria-labelledby="account-appeals-title">
<div className={styles.heading}>
<ShieldCheck size={24} aria-hidden="true" />
<h2 id="account-appeals-title">
{l("Account ban appeals", "Апелляции блокировок аккаунтов")}
</h2>
</div>
<p>
{l(
"Moderators and Admins can review appeals. Approving an appeal restores access for that ban. Deactivation and deletion remain Admin controls.",
"Модераторы и администраторы могут рассматривать апелляции. Одобрение восстанавливает доступ после этой блокировки. Деактивация и удаление доступны только администраторам.",
)}
</p>
<div className={styles.refreshActions}>
<button
className="button"
disabled={busy || loading}
onClick={() => void run(refreshAppeals)}
>
<RefreshCw size={16} aria-hidden="true" />
{l("Refresh appeals", "Обновить апелляции")}
</button>
<span className="muted">
{realtime === "live"
? l(
"Live updates connected",
"Обновления в реальном времени подключены",
)
: realtime === "offline"
? l(
"Reconnecting… Refresh remains available.",
"Переподключение… Можно обновить вручную.",
)
: l("Connecting live updates…", "Подключение обновлений…")}
</span>
</div>
{loading && (
<p role="status">{l("Loading appeals…", "Загрузка апелляций…")}</p>
)}
{!loading && !error && appeals.length === 0 && (
<p>
{l(
"No appeals awaiting review.",
"Нет апелляций, ожидающих рассмотрения.",
)}
</p>
)}
<div className={styles.list}>
{appeals.map((appeal) => (
<article className={styles.record} key={appeal.id}>
<h3>{appeal.display_name}</h3>
<p className="muted">
<code>{appeal.owner_id}</code> ·{" "}
{new Date(appeal.created_at).toLocaleString(locale)}
</p>
{appeal.ban_reason && (
<p>
<strong>{l("Ban reason: ", "Причина блокировки: ")}</strong>
{appeal.ban_reason}
</p>
)}
<p className={styles.reason}>{appeal.reason}</p>
<label className="field">
{l(
"Decision reason (10–2000 characters)",
"Причина решения (10–2000 символов)",
)}
<textarea
minLength={10}
maxLength={2000}
rows={3}
value={reviewReasons[appeal.id] ?? ""}
onChange={(e) =>
setReviewReasons((current) => ({
...current,
[appeal.id]: e.target.value,
}))
}
disabled={busy}
/>
</label>
<div className={styles.actions}>
{(["approve", "reject"] as const).map((decision) => (
<button
className="button"
key={decision}
disabled={
busy ||
(reviewReasons[appeal.id] ?? "").trim().length < 10
}
onClick={() =>
void run(async () => {
await api("moderation/account-appeals/" + appeal.id, {
decision,
reason: reviewReasons[appeal.id],
});
await refreshAppeals();
if (administrator && searched) await find();
setMessage(
l("Appeal reviewed.", "Апелляция рассмотрена."),
);
})
}
>
{decision === "approve"
? l(
"Approve · restore access",
"Одобрить · восстановить доступ",
)
: l(
"Reject · keep ban",
"Отклонить · оставить блокировку",
)}
</button>
))}
</div>
</article>
))}
</div>
</section>
)}
</>
);
}

View File

@@ -7,13 +7,29 @@ import { UserRound, LockKeyhole, Mail } from "lucide-react";
import { api } from "./StartGame";
import { PasswordForm } from "./PasswordForm";
import { displayNameSchema } from "@/shared/account";
import {
defaultGlowColor,
profileAvatarMaxBytes,
profileAvatarMimeTypes,
type ProfileAppearance,
} from "@/shared/profile-appearance";
import { Username } from "./Username";
import { PlayerAvatar } from "./PixelArt";
export function AccountSettings({
accountId,
email,
displayName,
username,
avatarPath,
appearance,
passwordUpdated = false,
}: {
accountId: string;
email: string;
displayName: string;
username: string | null;
avatarPath: string | null;
appearance: ProfileAppearance;
passwordUpdated?: boolean;
}) {
const { t } = useI18n();
@@ -25,6 +41,95 @@ export function AccountSettings({
[recoveryError, setRecoveryError] = useState(""),
[sending, setSending] = useState(false);
const router = useRouter();
const [handle, setHandle] = useState(username ?? "");
const [handleMessage, setHandleMessage] = useState("");
const [handleBusy, setHandleBusy] = useState(false);
async function saveHandle(event: React.FormEvent) {
event.preventDefault();
setHandleBusy(true);
setHandleMessage("");
try {
const result = await api<{ profile: { username: string } }>("profile", { username: handle }, "PATCH");
setHandle(result.profile.username);
setHandleMessage("Username saved.");
router.refresh();
} catch (e) { setHandleMessage((e as Error).message); }
finally { setHandleBusy(false); }
}
const [color, setColor] = useState(appearance.glow_color ?? defaultGlowColor);
const [colorBusy, setColorBusy] = useState(false),
[colorError, setColorError] = useState(""),
[colorSaved, setColorSaved] = useState(false);
const [photoPath, setPhotoPath] = useState(avatarPath),
[photoBusy, setPhotoBusy] = useState(false),
[photoError, setPhotoError] = useState(""),
[photoSaved, setPhotoSaved] = useState(false);
async function uploadAvatar(event: React.ChangeEvent<HTMLInputElement>) {
const input = event.currentTarget;
const file = input.files?.[0];
input.value = "";
setPhotoError("");
setPhotoSaved(false);
if (!file) return;
if (file.size > profileAvatarMaxBytes) {
setPhotoError("Images must be 5 MB or smaller.");
return;
}
if (
!profileAvatarMimeTypes.includes(
file.type as (typeof profileAvatarMimeTypes)[number],
)
) {
setPhotoError("Choose a JPG, PNG or WebP image.");
return;
}
setPhotoBusy(true);
try {
const form = new FormData();
form.append("photo", file);
const result = await api<{ profile: { avatar_path: string } }>(
"profile/avatar",
form,
);
setPhotoPath(result.profile.avatar_path);
setPhotoSaved(true);
router.refresh();
} catch (error) {
setPhotoError((error as Error).message);
} finally {
setPhotoBusy(false);
}
}
async function removeAvatar() {
setPhotoBusy(true);
setPhotoError("");
setPhotoSaved(false);
try {
await api("profile", { avatarPath: null }, "PATCH");
setPhotoPath(null);
setPhotoSaved(true);
router.refresh();
} catch (error) {
setPhotoError((error as Error).message);
} finally {
setPhotoBusy(false);
}
}
async function saveColor(event: React.FormEvent) {
event.preventDefault();
setColorBusy(true);
setColorError("");
setColorSaved(false);
try {
await api("profile", { glowColor: color }, "PATCH");
setColorSaved(true);
router.refresh();
} catch (error) {
setColorError((error as Error).message);
} finally {
setColorBusy(false);
}
}
async function save(event: React.FormEvent) {
event.preventDefault();
setError("");
@@ -53,6 +158,7 @@ export function AccountSettings({
async function recover() {
setSending(true);
setRecoveryError("");
setRecoveryMessage("");
try {
const data = await api<{ message: string }>("auth", {
action: "recover",
@@ -78,6 +184,45 @@ export function AccountSettings({
</div>
</div>
<form onSubmit={save}>
<div className="account-avatar-control">
<PlayerAvatar
id={accountId}
avatarPath={photoPath}
className="account-avatar-preview"
/>
<div className="account-avatar-input">
<label className="field">
{t("Profile photo")}
<input
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={uploadAvatar}
disabled={photoBusy}
/>
<small>{t("JPG, PNG or WebP · Maximum size: 5 MB.")}</small>
</label>
{photoPath && (
<button
type="button"
className="button"
onClick={removeAvatar}
disabled={photoBusy}
>
{t("Remove photo")}
</button>
)}
</div>
</div>
{photoError && (
<p role="alert" className="error-message">
{t(photoError)}
</p>
)}
{photoSaved && (
<p role="status" className="success-message">
{t("Profile photo saved.")}
</p>
)}
<label className="field">
{t("Display name")}
<input
@@ -119,6 +264,61 @@ export function AccountSettings({
</button>
</form>
</section>
<section className="settings-card">
<h2>{t("MineGeo username")}</h2>
<p className="muted">{t("Your unique username for applications. You can change it anytime.")}</p>
<form onSubmit={saveHandle}>
<label className="field">{t("Username")}
<input value={handle} onChange={(e) => setHandle(e.target.value)} minLength={3} maxLength={32} pattern="[A-Za-z0-9_]{3,32}" required disabled={handleBusy} />
<small>{t("3–32 letters, numbers or underscores.")}</small>
</label>
{handleMessage && <p role="status">{t(handleMessage)}</p>}
<button className="button primary" disabled={handleBusy}>{t("Save username")}</button>
</form>
</section>
<section className="settings-card glow-settings">
<h2>{t("Username glow")}</h2>
<p className="muted">
{appearance.name_glow
? t("Choose the color of your glow.")
: t("Support can award a glow for a confirmed bug report.")}
</p>
<div className="username-preview">
<Username
name={name}
appearance={{ ...appearance, glow_color: color }}
/>
</div>
{appearance.name_glow && (
<form onSubmit={saveColor}>
<label className="field">
{t("Glow color")}
<input
type="color"
value={color}
onChange={(event) => {
setColor(event.target.value);
setColorSaved(false);
}}
disabled={colorBusy}
/>
</label>
{colorError && (
<p role="alert" className="error-message">
{t(colorError)}
</p>
)}
{colorSaved && (
<p role="status" className="success-message">
{t("Glow color saved.")}
</p>
)}
<button className="button primary" disabled={colorBusy}>
{colorBusy ? t("Saving…") : t("Save glow color")}
</button>
</form>
)}
</section>
<section className="settings-card">
<div className="settings-heading">
<LockKeyhole size={22} />
@@ -134,7 +334,7 @@ export function AccountSettings({
)}
<PasswordForm />
<div className="settings-recovery">
<h3>{t("Signed up with an email link?")}</h3>
<h3>{t("Forgot your password?")}</h3>
<p className="muted">
{t(
"Set your first password, or recover a forgotten one, using a link sent to your account email.",
@@ -145,12 +345,12 @@ export function AccountSettings({
</button>
{recoveryMessage && (
<p role="status" className="success-message">
{recoveryMessage}
{t(recoveryMessage)}
</p>
)}
{recoveryError && (
<p role="alert" className="error-message">
{recoveryError}
{t(recoveryError)}
</p>
)}
</div>

View File

@@ -0,0 +1,82 @@
"use client";
import { useI18n } from "@/components/I18nProvider";
import { useCallback, useEffect, useState } from "react";
import { api } from "./StartGame";
export type AdminInvite = {
id: string;
created_at: string;
mapName: string;
ownerName: string;
};
export function AdminInvites({ onChange }: { onChange: () => void }) {
const { t } = useI18n();
const [invites, setInvites] = useState<AdminInvite[]>([]);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
try {
setInvites((await api<{ invites: AdminInvite[] }>("admin-invites")).invites);
setError("");
} catch (e) {
setError((e as Error).message);
}
}, []);
useEffect(() => {
load();
const timer = setInterval(load, 10000);
return () => clearInterval(timer);
}, [load]);
async function respond(id: string, decision: "accept" | "decline") {
setBusy(true);
setError("");
try {
await api(`admin-invites/${id}`, { decision });
await load();
onChange();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
if (!invites.length && !error) return null;
return (
<section className="token-box">
<span className="eyebrow">{t("SHARED WITH YOU")}</span>
<h2>{t("Map admin invites")}</h2>
{invites.map((invite) => (
<div className="draft-row" key={invite.id}>
<span>
{t("{owner} invited you to manage {map}.", {
owner: invite.ownerName,
map: invite.mapName,
})}
</span>
<div className="inline-actions">
<button
className="button primary"
disabled={busy}
onClick={() => respond(invite.id, "accept")}
>
{t("Accept")}
</button>
<button
className="button"
disabled={busy}
onClick={() => respond(invite.id, "decline")}
>
{t("Decline")}
</button>
</div>
</div>
))}
{error && (
<p role="alert" className="error-message">
{t(error)}
</p>
)}
</section>
);
}

View File

@@ -0,0 +1,34 @@
"use client";
import { useLayoutEffect, useRef } from "react";
import { usePathname, useSearchParams } from "next/navigation";
/** Animate newly committed pages without delaying navigation. */
export function BackNavigationTransition() {
const pathname = usePathname();
const search = useSearchParams();
// Documentation pages share a pathname; other query changes are filters or
// state updates and should not animate the entire page.
const route =
pathname === "/docs"
? `${pathname}?page=${search.get("page") ?? "welcome"}`
: pathname;
const previousPath = useRef(route);
useLayoutEffect(() => {
if (previousPath.current === route) return;
previousPath.current = route;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const main = document.getElementById("main-content");
if (!main || typeof main.animate !== "function") return;
const animation = main.animate(
[
{ opacity: 0.7, transform: "translateY(8px)" },
{ opacity: 1, transform: "translateY(0)" },
],
{ duration: 200, easing: "cubic-bezier(0.23, 1, 0.32, 1)" },
);
return () => animation.cancel();
}, [route]);
return null;
}

View File

@@ -8,10 +8,16 @@ export function Brand() {
const { settings } = useSiteSettings();
return (
<Link href="/" className="brand" aria-label={t("MineGeo home")}>
<span className="brand-cube">
<span />
<img
className="brand-mark"
src="/images/minegeo-mark.webp"
width={42}
height={46}
alt=""
/>
<span className="brand-wordmark">
Mine<span>Geo</span>
</span>
Mine<span>Geo</span>
{settings.betaBadgeEnabled && <sup>BETA</sup>}
</Link>
);

View File

@@ -4,7 +4,14 @@ import { useI18n } from "@/components/I18nProvider";
import { useState } from "react";
import { Search } from "lucide-react";
import { MapCard, type CardMap } from "./MapCard";
export function CatalogBrowser({ maps }: { maps: CardMap[] }) {
import { ImportMap } from "./MapTransfer";
export function CatalogBrowser({
maps,
signedIn = false,
}: {
maps: CardMap[];
signedIn?: boolean;
}) {
const { t } = useI18n();
const [query, setQuery] = useState(""),
[source, setSource] = useState("all");
@@ -18,15 +25,18 @@ export function CatalogBrowser({ maps }: { maps: CardMap[] }) {
return (
<>
<div className="catalog-controls">
<label className="search-field">
<Search size={17} />
<input
aria-label={t("Search worlds")}
placeholder={t("Search worlds or servers…")}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</label>
<div className="catalog-search">
<label className="search-field">
<Search size={17} />
<input
aria-label={t("Search worlds")}
placeholder={t("Search worlds or servers…")}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</label>
<ImportMap signedIn={signedIn} />
</div>
<div className="tabs" aria-label={t("Capture source")}>
{[
["all", t("All worlds")],
@@ -44,7 +54,7 @@ export function CatalogBrowser({ maps }: { maps: CardMap[] }) {
))}
</div>
</div>
<p className="muted">
<p className="muted catalog-count" role="status">
{t("Worlds to explore: {count}", { count: shown.length })}
</p>
<div className="cards-grid">
@@ -53,7 +63,20 @@ export function CatalogBrowser({ maps }: { maps: CardMap[] }) {
))}
</div>
{!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

@@ -0,0 +1,19 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
export function ChangelogRefresh() {
const router = useRouter();
useEffect(() => {
const refresh = () => {
if (document.visibilityState === "visible") router.refresh();
};
const timer = window.setInterval(refresh, 65_000);
document.addEventListener("visibilitychange", refresh);
return () => {
window.clearInterval(timer);
document.removeEventListener("visibilitychange", refresh);
};
}, [router]);
return null;
}

View File

@@ -0,0 +1,65 @@
import "server-only";
import { MessageCircle, Send, ArrowUpRight } from "lucide-react";
import { getI18n } from "@/lib/i18n";
function communityUrl(value: string | undefined, hosts: readonly string[]) {
if (!value?.trim()) return null;
try {
const url = new URL(value.trim());
if (
url.protocol !== "https:" ||
url.username ||
url.password ||
url.port ||
!hosts.includes(url.hostname) ||
url.pathname === "/"
)
return null;
return url.href;
} catch {
return null;
}
}
export async function CommunityLinks() {
const { t } = await getI18n();
const discord = communityUrl(process.env.MINEGEO_DISCORD_URL, [
"discord.gg",
"discord.com",
"www.discord.com",
"discordapp.com",
]);
const telegram = communityUrl(process.env.MINEGEO_TELEGRAM_URL, [
"t.me",
"telegram.me",
]);
if (!discord && !telegram) return null;
return (
<div className="community-links" role="group" aria-label={t("Community")}>
{discord && (
<a
className="community-link community-discord"
href={discord}
target="_blank"
rel="noopener noreferrer"
>
<MessageCircle size={18} aria-hidden="true" />
{t("Join us on Discord")}
<ArrowUpRight size={14} aria-hidden="true" />
</a>
)}
{telegram && (
<a
className="community-link community-telegram"
href={telegram}
target="_blank"
rel="noopener noreferrer"
>
<Send size={18} aria-hidden="true" />
{t("Follow us on Telegram")}
<ArrowUpRight size={14} aria-hidden="true" />
</a>
)}
</div>
);
}

View File

@@ -0,0 +1,193 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { api } from "@/lib/client-api";
import {
communityCategories,
type CommunityReportView,
} from "@/shared/community";
import { useI18n } from "./I18nProvider";
type Queue = { reports: CommunityReportView[]; nextCursor: string | null };
export function CommunityModeration() {
const { t } = useI18n();
const [queue, setQueue] = useState<Queue | null>(null);
const [reasons, setReasons] = useState<Record<string, string>>({});
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function load() {
setQueue(await api<Queue>("moderation/community-reports"));
}
useEffect(() => {
let active = true;
api<Queue>("moderation/community-reports")
.then((result) => {
if (active) setQueue(result);
})
.catch((e) => {
if (active) setError(e.message);
});
return () => {
active = false;
};
}, []);
async function act(action: () => Promise<unknown>) {
setBusy(true);
setError("");
try {
await action();
await load();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
async function more() {
if (queue?.nextCursor == null) return;
setBusy(true);
setError("");
try {
const next = await api<Queue>(
`moderation/community-reports?cursor=${encodeURIComponent(queue.nextCursor)}`,
);
setQueue((current) => ({
...next,
reports: [
...(current?.reports ?? []),
...next.reports.filter(
(r) => !current?.reports.some((old) => old.id === r.id),
),
],
}));
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<section className="token-box">
<h2>{t("Comment and account reports")}</h2>
<p className="muted">
{t(
"Review the evidence before taking action. Resolving a report does not automatically ban an account or remove a comment.",
)}
</p>
{error && (
<p role="alert" className="error-message">
{t(error)}{" "}
<button
className="button"
disabled={busy}
onClick={() => act(async () => {})}
>
{t("Try again")}
</button>
</p>
)}
{!queue && !error && <p role="status">{t("Loading review queue…")}</p>}
{queue?.reports.length === 0 && (
<p>{t("No open comment or account reports.")}</p>
)}
{queue?.reports.map((report) => (
<article className="tool-card" key={report.id}>
<h3>
{t(
report.target === "comment"
? "Reported comment"
: "Reported account",
)}{" "}
· {t(communityCategories[report.category])}
</h3>
{report.target === "account" && (
<p>
{report.accountName ?? t("Deleted player")} ·{" "}
<code>{report.account_id}</code>
</p>
)}
{report.comment && (
<>
<blockquote className="community-body">
{report.comment.body}
</blockquote>
<p>
<Link
className="button"
href={`/servers/${report.comment.dataset_id}`}
>
{t("Open map")}
</Link>{" "}
{report.comment.removed_at ? (
t("Comment removed.")
) : (
<button
className="button"
disabled={busy}
onClick={() =>
act(() =>
api(
`community/comments/${report.comment_id}`,
undefined,
"DELETE",
),
)
}
>
{t("Remove comment")}
</button>
)}
</p>
<p className="muted">
{t("Account ID:")}{" "}
<code>{report.comment.author_id ?? t("Deleted player")}</code>
</p>
</>
)}
<p className="community-body">{report.reason}</p>
<label className="field">
{t("Decision reason")}
<textarea
minLength={10}
maxLength={2000}
value={reasons[report.id] ?? ""}
onChange={(e) =>
setReasons((current) => ({
...current,
[report.id]: e.target.value,
}))
}
/>
</label>
<div className="inline-actions">
{(["resolved", "dismissed"] as const).map((decision) => (
<button
key={decision}
className="button"
disabled={busy || (reasons[report.id]?.trim().length ?? 0) < 10}
onClick={() =>
act(() =>
api(
`moderation/community-reports/${report.id}`,
{ decision, reason: reasons[report.id] },
"PATCH",
),
)
}
>
{t(
decision === "resolved" ? "Resolve report" : "Dismiss report",
)}
</button>
))}
</div>
</article>
))}
{queue?.nextCursor != null && (
<button className="button" disabled={busy} onClick={more}>
{t("Load more reports")}
</button>
)}
</section>
);
}

View File

@@ -22,7 +22,7 @@ export function ContributionInstructions({
<code>{serverAddress}</code>
<p>
{t(
"Capture on this server. Uploads from a different address are blocked.",
"Capture using any listed address. Uploads from other addresses are blocked.",
)}
</p>
</div>

View File

@@ -10,7 +10,10 @@ import { api } from "./StartGame";
import type { MapListing } from "@/lib/catalog";
import type { ProcessingJob } from "@/shared/processing-progress";
import { ProcessingProgress } from "./ProcessingProgress";
import { ImportMap } from "./MapTransfer";
import { AdminInvites } from "./AdminInvites";
type Draft = MapListing & {
access?: "owner" | "admin";
upload_jobs: ProcessingJob | null;
};
export function Dashboard() {
@@ -23,6 +26,7 @@ export function Dashboard() {
[token, setToken] = useState(""),
[busy, setBusy] = useState(false);
const router = useRouter();
const [loaded, setLoaded] = useState(false);
const load = useCallback(async () => {
try {
const [m, t] = await Promise.all([
@@ -33,6 +37,7 @@ export function Dashboard() {
]);
setMaps(m.maps);
setTokens(t.tokens);
setLoaded(true);
setError("");
} catch (e) {
setError((e as Error).message);
@@ -86,6 +91,7 @@ export function Dashboard() {
)}
</p>
<div className="inline-actions">
<ImportMap signedIn />
<Link className="button" href="/settings">
{t("Account settings")}
</Link>
@@ -96,10 +102,22 @@ export function Dashboard() {
</div>
{error && (
<p role="alert" className="error-message">
{t(error)}
{t(error)}{" "}
<button
type="button"
className="text-link"
onClick={() => void load()}
>
{t("Retry")}
</button>
</p>
)}
{!maps.length && (
{!loaded && !error && (
<p role="status" className="muted">
{t("Loading…")}
</p>
)}
{loaded && !maps.length && (
<div className="empty-state">
<h2>{t("A blank map is a beginning.")}</h2>
<p>
@@ -112,6 +130,7 @@ export function Dashboard() {
</Link>
</div>
)}
<AdminInvites onChange={load} />
{maps.map((m) => (
<article className="draft-row" key={m.id}>
<div>
@@ -119,6 +138,9 @@ export function Dashboard() {
{m.source === "plugin" ? t("SERVER EXPORT") : t("PLAYER CAPTURE")}
</span>
<h3>{m.name}</h3>
{m.access === "admin" && (
<span className="badge">{t("Map admin")}</span>
)}{" "}
<span className="badge">
{m.contribution_status
? t("Contribution: {status}", {

View File

@@ -0,0 +1,219 @@
import Link from "next/link";
import { BookOpen, ShieldCheck } from "lucide-react";
import {
developerEndpoints,
developerPythonExample,
} from "@/shared/developer-docs";
import styles from "@/app/dev/api/page.module.css";
export function DeveloperDocumentation({ locale }: { locale: string }) {
const ru = locale === "ru";
const l = (en: string, russian: string) => (ru ? russian : en);
return (
<section
id="documentation"
className={styles.docs}
aria-labelledby="documentation-title"
>
<div className={styles.heading}>
<BookOpen size={24} aria-hidden="true" />
<h1 id="documentation-title">
{l("API documentation", "Документация API")}
</h1>
</div>
<p>
{l(
"Build integrations with ordinary HTTPS requests. There is no MineGeo SDK; the Python examples use the requests library.",
"Создавайте интеграции обычными HTTPS-запросами. SDK MineGeo пока нет; примеры на Python используют библиотеку requests.",
)}
</p>
<p>
<strong>{l("Base URL", "Базовый адрес")}</strong>:{" "}
<code>https://minegeo.site/api/dev/v1</code>
</p>
<p>
<a href="/api/dev/openapi.json">
{l(
"Download the OpenAPI specification",
"Открыть спецификацию OpenAPI",
)}
</a>
</p>
<h3>{l("1. Create a key", "1. Создайте ключ")}</h3>
<p>
<Link href="/dev/api">
{l("Open the developer panel →", "Открыть панель разработчика →")}
</Link>
</p>
<p>
{l(
"Open the developer panel, log in, name your integration and select its permissions. Public maps are selected by default. Access to your profile, private maps and game history is separate and optional. You can have up to 5 active keys, with an expiry of 1–365 days (90 by default).",
"Откройте панель разработчика, войдите, назовите интеграцию и выберите права. По умолчанию выбраны только общие карты. Доступ к профилю, личным картам и истории игр выдаётся отдельно и необязательно. Можно создать до 5 активных ключей со сроком действия от 1 до 365 дней (по умолчанию — 90).",
)}
</p>
<p>
{l(
"Store the copied key in the MINEGEO_API_KEY environment variable on your computer or server. Keep it out of source code, repositories, browser JavaScript and URLs. If it leaks, revoke it and create a replacement.",
"Сохраните скопированный ключ в переменной окружения MINEGEO_API_KEY на компьютере или сервере. Не добавляйте его в код, репозитории, JavaScript браузера или URL. При утечке отзовите ключ и создайте новый.",
)}
</p>
<h3>
{l("2. Make a request with Python", "2. Сделайте запрос на Python")}
</h3>
<pre className={styles.code}>
<code>python -m pip install requests</code>
</pre>
<pre className={styles.code}>
<code>{developerPythonExample}</code>
</pre>
<p>
{l(
"Every v1 request needs Authorization: Bearer <your-key>. MineGeo developer keys start with mg_dev_. Contributor upload tokens, metrics tokens and your browser login cookie do not authenticate this API.",
"Каждый запрос v1 требует заголовок Authorization: Bearer <ваш-ключ>. Ключи API MineGeo начинаются с mg_dev_. Токены загрузки карт, токены метрик и cookie входа в браузере не подходят для этого API.",
)}
</p>
<h3>{l("Endpoints and permissions", "Методы и права доступа")}</h3>
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
<th>{l("GET endpoint", "Метод GET")}</th>
<th>{l("Required permission", "Нужное право")}</th>
<th>{l("Response", "Ответ")}</th>
</tr>
</thead>
<tbody>
{developerEndpoints.map((endpoint) => (
<tr key={endpoint.path}>
<td>
<code>{endpoint.path}</code>
</td>
<td>
<code>{endpoint.scope}</code>
</td>
<td>{endpoint.description[ru ? 1 : 0]}</td>
</tr>
))}
</tbody>
</table>
</div>
<p>
{l(
"Account endpoints always refer to the owner of the API key. They cannot read another player's private data. /me returns your account ID, display name, creation date and username appearance; it does not expose your email or account role.",
"Методы аккаунта всегда относятся к владельцу ключа API. Они не позволяют читать личные данные другого игрока. /me возвращает ID аккаунта, отображаемое имя, дату создания и оформление имени; email и роль аккаунта не передаются.",
)}
</p>
<p>
{l(
"Map details include metadata, map bounds and panorama count. Raw storage paths, panorama coordinates, moderation notes and game answers are excluded. Game history exposes a score only after a game is complete.",
"Сведения о карте включают метаданные, границы карты и число панорам. Внутренние пути к файлам, координаты панорам, заметки модераторов и ответы игры не передаются. История игр содержит итоговый счёт только после завершения игры.",
)}
</p>
<h3>{l("Pagination and search", "Страницы и поиск")}</h3>
<p>
{l(
"Lists accept limit (default 20, maximum 50) and offset (default 0, maximum 10000). /maps and /me/maps also accept q (up to 80 characters) to search map names. Unknown or repeated query parameters return HTTP 400. Use pagination.nextOffset for the next request; null means the end of the supported result window. Narrow q when you reach the offset limit.",
"Списки принимают limit (по умолчанию 20, максимум 50) и offset (по умолчанию 0, максимум 10000). /maps и /me/maps также принимают q длиной до 80 символов для поиска по названию. Неизвестные или повторяющиеся параметры возвращают HTTP 400. Используйте pagination.nextOffset для следующего запроса; null означает конец доступного диапазона. При достижении предела offset уточните q.",
)}
</p>
<pre className={styles.code}>
<code>{`{
"maps": [],
"pagination": {"limit": 20, "offset": 0, "total": 0, "nextOffset": null}
}`}</code>
</pre>
<p>
{l(
"GET /maps/{id} returns {map: {...}}. GET /me returns {account: {...}}. GET /me/maps uses the maps list envelope; GET /me/games uses {games: [...], pagination: {...}}. Map url values are relative to https://minegeo.site.",
"GET /maps/{id} возвращает {map: {...}}, GET /me — {account: {...}}. GET /me/maps использует список maps; GET /me/games — {games: [...], pagination: {...}}. Поле url карты содержит путь относительно https://minegeo.site.",
)}
</p>
<h3>{l("Rate limits", "Ограничения запросов")}</h3>
<p>
{l(
"Each key can make 60 requests per minute. All keys belonging to one account share a limit of 180 requests per minute. Creating more keys does not bypass the account limit. These limits apply to authenticated requests, including requests that fail permission or input checks.",
"Каждый ключ может выполнять 60 запросов в минуту. У всех ключей одного аккаунта общий лимит — 180 запросов в минуту. Создание новых ключей не отменяет общий лимит. Лимиты применяются к запросам с действительным ключом, включая ошибки прав доступа и проверки параметров.",
)}
</p>
<ul>
<li>
<code>RateLimit-Limit</code>:{" "}
{l("per-key request limit", "лимит запросов ключа")}
</li>
<li>
<code>RateLimit-Remaining</code>:{" "}
{l("requests remaining for this key", "оставшиеся запросы ключа")}
</li>
<li>
<code>RateLimit-Reset</code>:{" "}
{l(
"seconds until the current minute window resets",
"секунды до сброса текущего минутного окна",
)}
</li>
<li>
<code>X-RateLimit-Owner-Limit</code> /{" "}
<code>X-RateLimit-Owner-Remaining</code>:{" "}
{l("shared account quota", "общая квота аккаунта")}
</li>
</ul>
<p>
{l(
"If you receive HTTP 429, wait for the number of seconds in Retry-After before retrying. Avoid tight retry loops. Cache catalog responses in your integration when appropriate.",
"При ответе HTTP 429 подождите указанное в Retry-After число секунд перед повтором. Не повторяйте запросы в бесконечном быстром цикле. При необходимости кешируйте каталог в своей интеграции.",
)}
</p>
<h3>{l("Errors", "Ошибки")}</h3>
<pre className={styles.code}>
<code>{`{"error":{"code":"insufficient_scope","message":"This API key does not have the required permission."}}`}</code>
</pre>
<ul>
<li>
<code>400</code> —{" "}
{l(
"invalid input or pagination",
"некорректные параметры или пагинация",
)}
</li>
<li>
<code>401</code> —{" "}
{l(
"missing, invalid, revoked or expired key",
"ключ отсутствует, неверен, отозван или просрочен",
)}
</li>
<li>
<code>403</code> —{" "}
{l(
"key is missing a required permission",
"у ключа нет нужного права",
)}
</li>
<li>
<code>404</code> —{" "}
{l("map or endpoint unavailable", "карта или метод недоступны")}
</li>
<li>
<code>429</code> —{" "}
{l("request quota exhausted", "лимит запросов исчерпан")}
</li>
<li>
<code>500</code> / <code>503</code> —{" "}
{l(
"temporary service failure; retry with a delay",
"временная ошибка сервиса; повторите позже",
)}
</li>
</ul>
<div className={styles.notice}>
<ShieldCheck size={20} aria-hidden="true" />
<p>
{l(
"You can revoke a key at any time in the developer panel. Keys expire automatically and cannot be recovered. A new key can use different permissions; existing keys cannot gain additional permissions.",
"Ключ можно отозвать в панели разработчика в любой момент. Ключи истекают автоматически и не подлежат восстановлению. Новый ключ может иметь другой набор прав; существующему ключу нельзя добавить права.",
)}
</p>
</div>
</section>
);
}

View File

@@ -0,0 +1,496 @@
"use client";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import {
BookOpen,
Code2,
Copy,
KeyRound,
RefreshCw,
Trash2,
X,
} from "lucide-react";
import { useI18n } from "@/components/I18nProvider";
import { developerScopes } from "@/shared/developer-docs";
import styles from "@/app/dev/api/page.module.css";
import type { DeveloperKey, DeveloperScope } from "@/shared/developer-api";
type Scope = DeveloperScope;
type Key = DeveloperKey;
async function request<T>(
method = "GET",
body?: unknown,
signal?: AbortSignal,
): Promise<T> {
const response = await fetch("/api/dev/keys", {
method,
cache: "no-store",
credentials: "same-origin",
signal: signal ?? AbortSignal.timeout(30000),
headers:
body === undefined ? undefined : { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
const result = await response.json();
if (!response.ok)
throw new Error(
typeof result.error === "string"
? result.error
: (result.error?.message ?? "Request failed. Please try again."),
);
return result as T;
}
export function DeveloperPanel({ signedIn }: { signedIn: boolean }) {
const { locale } = useI18n();
const ru = locale === "ru";
const l = (en: string, russian: string) => (ru ? russian : en);
const [keys, setKeys] = useState<Key[]>([]);
const [loaded, setLoaded] = useState(false);
const [loading, setLoading] = useState(signedIn);
const [busy, setBusy] = useState(false);
const busyRef = useRef(false);
const [name, setName] = useState("");
const [expiresDays, setExpiresDays] = useState(90);
const [scopes, setScopes] = useState<Scope[]>(["maps:read"]);
const [token, setToken] = useState<string | null>(null);
const [confirmRevoke, setConfirmRevoke] = useState<string | null>(null);
const [error, setError] = useState("");
const [message, setMessage] = useState("");
useEffect(() => {
if (!signedIn) return;
const controller = new AbortController();
void request<{ keys: Key[] }>("GET", undefined, controller.signal)
.then((data) => {
if (!controller.signal.aborted) {
setKeys(data.keys);
setLoaded(true);
}
})
.catch((e: unknown) => {
if (!controller.signal.aborted)
setError(
e instanceof Error
? e.message
: ru
? "Не удалось загрузить ключи API."
: "Could not load API keys.",
);
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, [signedIn, ru]);
async function reload() {
setLoading(true);
setError("");
try {
const data = await request<{ keys: Key[] }>();
setKeys(data.keys);
setLoaded(true);
} catch (e) {
setError(
e instanceof Error
? e.message
: l("Could not load API keys.", "Не удалось загрузить ключи API."),
);
} finally {
setLoading(false);
}
}
async function create(event: React.FormEvent) {
event.preventDefault();
if (busyRef.current) return;
busyRef.current = true;
setBusy(true);
setError("");
setMessage("");
try {
const result = await request<{ key: Key; token: string }>("POST", {
name: name.trim(),
scopes,
expiresDays,
});
setToken(result.token);
setKeys((list) => [result.key, ...list]);
setName("");
setMessage(
l(
"Key created. Copy it before leaving or hiding it.",
"Ключ создан. Скопируйте его перед уходом со страницы или скрытием.",
),
);
} catch (e) {
setError(
e instanceof Error
? e.message
: l("Could not create the key.", "Не удалось создать ключ."),
);
} finally {
busyRef.current = false;
setBusy(false);
}
}
async function revoke(id: string) {
if (busyRef.current) return;
busyRef.current = true;
setBusy(true);
setError("");
setMessage("");
try {
await request("DELETE", { id });
setKeys((list) =>
list.map((key) =>
key.id === id ? { ...key, revokedAt: new Date().toISOString() } : key,
),
);
setConfirmRevoke(null);
setToken(null);
setMessage(
l(
"Key revoked. Requests using it will no longer work.",
"Ключ отозван. Запросы с ним больше не работают.",
),
);
} catch (e) {
setError(
e instanceof Error
? e.message
: l("Could not revoke the key.", "Не удалось отозвать ключ."),
);
} finally {
busyRef.current = false;
setBusy(false);
}
}
async function copy(value: string) {
try {
await navigator.clipboard.writeText(value);
setMessage(l("Copied to clipboard.", "Скопировано в буфер обмена."));
setError("");
} catch {
setError(
l(
"Could not copy. Select the key below and copy it manually.",
"Не удалось скопировать. Выделите ключ ниже и скопируйте вручную.",
),
);
}
}
const activeCount = keys.filter(
(key) => !key.revokedAt && new Date(key.expiresAt).getTime() > Date.now(),
).length;
const date = (value: string) =>
new Date(value).toLocaleString(ru ? "ru-RU" : "en-US");
return (
<div className={styles.stack}>
<div className={styles.intro}>
<span className="eyebrow">
{l("BUILD WITH MINEGEO", "СОЗДАВАЙТЕ С MINEGEO")}
</span>
<h1>{l("Developer API.", "API для разработчиков.")}</h1>
<p className="lead">
{l(
"Use MineGeo maps and your own account data in scripts and integrations. Choose exactly what each key can read.",
"Используйте карты MineGeo и данные своего аккаунта в скриптах и интеграциях. Выберите, что именно сможет читать каждый ключ.",
)}
</p>
<nav
className={styles.tabs}
aria-label={l("Developer sections", "Разделы для разработчиков")}
>
<a className="button" href="#api-keys">
<KeyRound size={16} aria-hidden="true" />
{l("API keys", "Ключи API")}
</a>
<Link className="button" href="/docs?page=developer-api">
<BookOpen size={16} aria-hidden="true" />
{l("Documentation", "Документация")}
</Link>
<a className="button" href="/api/dev/openapi.json">
<Code2 size={16} aria-hidden="true" />
OpenAPI JSON
</a>
</nav>
</div>
<section
id="api-keys"
className={styles.panel}
aria-labelledby="api-keys-title"
>
<div className={styles.heading}>
<KeyRound size={24} aria-hidden="true" />
<h2 id="api-keys-title">{l("Your API keys", "Ваши ключи API")}</h2>
</div>
<p>
{l(
"Keys belong to your account. Keep them private and grant only the access your integration needs. This API is read-only: keys cannot upload, publish, change maps or use moderation tools.",
"Ключи принадлежат вашему аккаунту. Храните их в секрете и выдавайте только необходимые права. Этот API доступен только для чтения: ключи не позволяют загружать, публиковать, изменять карты или использовать инструменты модерации.",
)}
</p>
{!signedIn ? (
<div className={styles.notice}>
<p>
{l(
"Everyone can read the documentation. Log in to create and manage your own keys; no Admin permission is required.",
"Документация доступна всем. Войдите, чтобы создавать свои ключи и управлять ими; права администратора не нужны.",
)}
</p>
<Link className="button primary" href="/log-in?next=%2Fdev%2Fapi">
{l("Log in to manage keys", "Войти для управления ключами")}
</Link>
</div>
) : (
<>
{error && (
<p role="alert" className={`${styles.notice} ${styles.error}`}>
{error}
</p>
)}
{message && (
<p role="status" className={styles.notice}>
{message}
</p>
)}
{token && (
<div className={styles.token}>
<h3>
{l("Copy your new key now", "Скопируйте новый ключ сейчас")}
</h3>
<p>
{l(
"This is the only time the full key is shown. Hiding it or leaving this page removes it from view. MineGeo stores only its hash.",
"Полный ключ показывается только один раз. После скрытия или ухода со страницы его нельзя посмотреть снова. MineGeo хранит только хеш ключа.",
)}
</p>
<label className="field">
{l("New API key", "Новый ключ API")}
<input
readOnly
value={token}
autoComplete="off"
spellCheck={false}
onFocus={(e) => e.currentTarget.select()}
/>
</label>
<div className={styles.actions}>
<button className="button" onClick={() => void copy(token)}>
<Copy size={16} aria-hidden="true" />
{l("Copy key", "Копировать ключ")}
</button>
<button
className="button"
onClick={() => {
setToken(null);
setMessage("");
}}
>
<X size={16} aria-hidden="true" />
{l("Hide key", "Скрыть ключ")}
</button>
</div>
</div>
)}
{loading && (
<p role="status">{l("Loading keys…", "Загрузка ключей…")}</p>
)}
{!loading && !loaded && (
<button className="button" onClick={() => void reload()}>
<RefreshCw size={16} aria-hidden="true" />
{l("Try again", "Повторить")}
</button>
)}
{loaded && (
<>
<form onSubmit={(event) => void create(event)}>
<h3>{l("Create a key", "Создать ключ")}</h3>
<p className="muted">
{l(
`${activeCount} of 5 active keys. Each key expires after the number of days you choose.`,
`Активных ключей: ${activeCount} из 5. Срок действия каждого ключа задаётся в днях.`,
)}
</p>
<div className={styles.fields}>
<label className="field">
{l("Key name", "Название ключа")}
<input
value={name}
onChange={(e) => setName(e.target.value)}
required
minLength={1}
maxLength={60}
placeholder={l(
"My Python project",
"Мой проект на Python",
)}
disabled={busy}
/>
</label>
<label className="field">
{l("Expires in days", "Срок действия, дней")}
<input
type="number"
min={1}
max={365}
required
value={expiresDays}
onChange={(e) => setExpiresDays(Number(e.target.value))}
disabled={busy}
/>
</label>
</div>
<fieldset className={styles.fieldset} disabled={busy}>
<legend>{l("Permissions", "Права доступа")}</legend>
<div className={styles.scopes}>
{developerScopes.map((scope) => (
<label className={styles.scope} key={scope.id}>
<input
type="checkbox"
checked={scopes.includes(scope.id)}
onChange={(e) =>
setScopes((selected) =>
e.target.checked
? [...selected, scope.id]
: selected.filter((id) => id !== scope.id),
)
}
/>
<span>
<strong>{scope.name[ru ? 1 : 0]}</strong>
<small>
<code>{scope.id}</code> ·{" "}
{scope.description[ru ? 1 : 0]}
</small>
</span>
</label>
))}
</div>
</fieldset>
<button
className="button primary"
disabled={
busy ||
loading ||
scopes.length === 0 ||
activeCount >= 5 ||
token !== null
}
>
<KeyRound size={16} aria-hidden="true" />
{busy
? l("Working…", "Подождите…")
: l("Create API key", "Создать ключ API")}
</button>
{token && (
<p className="muted">
{l(
"Copy and hide the current key before creating another.",
"Скопируйте и скройте текущий ключ перед созданием следующего.",
)}
</p>
)}
</form>
<div className={styles.actions}>
<button
className="button"
disabled={loading || busy}
onClick={() => void reload()}
>
<RefreshCw size={16} aria-hidden="true" />
{l("Refresh keys", "Обновить ключи")}
</button>
</div>
{keys.length === 0 && (
<p className={styles.notice}>
{l(
"You have no API keys yet.",
"У вас пока нет ключей API.",
)}
</p>
)}
<ul className={styles.keyList}>
{keys.map((key) => {
const expired =
new Date(key.expiresAt).getTime() <= Date.now();
return (
<li key={key.id} className={styles.key}>
<div className={styles.heading}>
<h3>{key.name}</h3>
<span className="badge">
{key.revokedAt
? l("Revoked", "Отозван")
: expired
? l("Expired", "Истёк")
: l("Active", "Активен")}
</span>
</div>
<code>{key.prefix}…</code>
<dl>
<dt>{l("Permissions", "Права")}</dt>
<dd>{key.scopes.join(", ")}</dd>
<dt>{l("Created", "Создан")}</dt>
<dd>{date(key.createdAt)}</dd>
<dt>{l("Expires", "Истекает")}</dt>
<dd>{date(key.expiresAt)}</dd>
<dt>{l("Last used", "Последнее использование")}</dt>
<dd>
{key.lastUsedAt
? date(key.lastUsedAt)
: l("Not used yet", "Ещё не использован")}
</dd>
</dl>
{!key.revokedAt &&
(confirmRevoke === key.id ? (
<div>
<p>
{l(
"Revoke this key? Every integration using it will lose access immediately.",
"Отозвать этот ключ? Все интеграции с ним сразу потеряют доступ.",
)}
</p>
<div className={styles.actions}>
<button
className="button"
disabled={busy}
onClick={() => void revoke(key.id)}
>
{l("Confirm revocation", "Подтвердить отзыв")}
</button>
<button
className="button"
disabled={busy}
onClick={() => setConfirmRevoke(null)}
>
{l("Cancel", "Отмена")}
</button>
</div>
</div>
) : (
<button
className="button"
disabled={busy}
onClick={() => setConfirmRevoke(key.id)}
>
<Trash2 size={16} aria-hidden="true" />
{l("Revoke key", "Отозвать ключ")}
</button>
))}
</li>
);
})}
</ul>
</>
)}
</>
)}
</section>
</div>
);
}

View File

@@ -0,0 +1,118 @@
"use client";
import { useEffect, useState } from "react";
import { MessageCircle } from "lucide-react";
import { api } from "@/lib/client-api";
import { useI18n } from "./I18nProvider";
import type { DiscordStatus } from "@/shared/discord";
export function DiscordIntegration() {
const { t } = useI18n();
const [status, setStatus] = useState<DiscordStatus | null>(null),
[error, setError] = useState(""),
[message, setMessage] = useState(""),
[busy, setBusy] = useState(false);
async function load() {
try {
setStatus(await api<DiscordStatus>("moderation/discord"));
setError("");
} catch (e) {
setError((e as Error).message);
}
}
useEffect(() => {
void load();
}, []);
const configured = !!(
status?.botConfigured &&
status.publicUrlConfigured &&
(status.mapsConfigured || status.partiesConfigured)
);
async function run(action: "check" | "retry") {
setBusy(true);
setError("");
setMessage("");
try {
await api("moderation/discord", { action });
setMessage(
action === "check"
? "Discord bot and channel access checked. Send Messages and Embed Links are also required."
: "Saved Discord announcements will retry.",
);
await load();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<section className="tool-card" aria-labelledby="discord-integration-title">
<div className="settings-heading">
<MessageCircle size={24} aria-hidden="true" />
<h2 id="discord-integration-title">{t("Discord integration")}</h2>
</div>
<p>
{t(
"New approved maps are announced automatically. Hosts can publish an open party to the shared Discord channel.",
)}
</p>
{status && (
<>
<p className="muted">
{configured
? t("Discord bot configured")
: t("Discord setup required in Vercel environment variables")}
</p>
<p>
{t("Map announcements: {state}", {
state: status.mapsConfigured
? t("Configured")
: t("Not configured"),
})}{" "}
·{" "}
{t("Party announcements: {state}", {
state: status.partiesConfigured
? t("Configured")
: t("Not configured"),
})}
</p>
<p>
{t("{pending} pending · {failed} need attention · {sent} sent", {
pending: status.pending,
failed: status.failed,
sent: status.sent,
})}
</p>
</>
)}
{error && (
<p className="error-message" role="alert">
{t(error)}
</p>
)}
{message && <p role="status">{t(message)}</p>}
<div className="inline-actions">
<button
className="button"
disabled={busy || !configured}
onClick={() => void run("check")}
>
{t("Check Discord setup")}
</button>
<button
className="button"
disabled={busy || !configured}
onClick={() => void run("retry")}
>
{t("Retry saved announcements")}
</button>
<button
className="text-link"
disabled={busy}
onClick={() => void load()}
>
{t("Refresh status")}
</button>
</div>
</section>
);
}

View File

@@ -4,7 +4,7 @@ import { useI18n } from "@/components/I18nProvider";
import { useEffect, useRef, useState } from "react";
import Image from "next/image";
import { api } from "./StartGame";
import { api, StartGame } from "./StartGame";
import type { DatasetManifest } from "@/shared/protocol";
import type { ProcessingJob } from "@/shared/processing-progress";
import { ProcessingProgress } from "./ProcessingProgress";
@@ -13,7 +13,13 @@ import { OwnerContributions } from "./OwnerContributions";
import Link from "next/link";
import { MapRefresh } from "./MapRefresh";
import { MapAppeal } from "./MapAppeal";
import { MapAdmins } from "./MapAdmins";
import { ExportMap } from "./MapTransfer";
type Draft = {
access: "owner" | "admin";
imported_from_id: string | null;
imported_owner_name: string | null;
ownsPublishedParent: boolean;
id: string;
name: string;
description: string;
@@ -33,7 +39,13 @@ type Draft = {
banned_at: string | null;
ban_reason: string | null;
};
export function DraftEditor({ id }: { id: string }) {
export function DraftEditor({
id,
hostName,
}: {
id: string;
hostName: string;
}) {
const { t } = useI18n();
const dirty = useRef(false);
const [draft, setDraft] = useState<Draft | null>(null),
@@ -78,7 +90,7 @@ export function DraftEditor({ id }: { id: string }) {
clearTimeout(timer);
};
}, [id, version]);
async function save(submit = false) {
async function save(submit = false, publishOwnContribution = false) {
setBusy(true);
setError("");
setMessage("");
@@ -90,12 +102,25 @@ export function DraftEditor({ id }: { id: string }) {
);
dirty.current = false;
if (submit) {
await api("datasets/" + id + "/submit", {});
const publishUpdate =
draft?.state === "published" && !draft.parent_dataset_id;
await api(
"datasets/" +
id +
(publishOwnContribution
? "/publish-own-contribution"
: publishUpdate
? "/publish-update"
: "/submit"),
{},
);
setVersion((v) => v + 1);
setMessage(
draft?.parent_dataset_id
? "Submitted for map owner approval."
: "Submitted for moderator review.",
publishUpdate || publishOwnContribution
? "Map update published."
: draft?.parent_dataset_id
? "Submitted for map owner approval."
: "Submitted for moderator review.",
);
} else setMessage("Draft saved.");
} catch (e) {
@@ -147,9 +172,48 @@ export function DraftEditor({ id }: { id: string }) {
<div className="editor-navigation">
<Link href="/dashboard">{t("← Your maps")}</Link>
<a href="#locations">{t("Choose locations")}</a>
{!draft.parent_dataset_id && <a href="#map-tools">{t("Map tools")}</a>}
{!draft.imported_from_id && !draft.parent_dataset_id && (
<a href="#map-tools">{t("Map tools")}</a>
)}
</div>
<span className="badge">{t(statusMessage(draft.state))}</span>
{draft.access === "admin" && (
<p className="muted">
{t(
"You manage this map as an admin. The owner invited you to help run it.",
)}
</p>
)}
{!draft.imported_from_id &&
!draft.parent_dataset_id &&
draft.map &&
!draft.banned_at && (
<ExportMap
id={id}
disabled={
busy ||
refreshing ||
["uploading", "processing", "failed"].includes(draft.state)
}
/>
)}
{draft.imported_from_id && (
<section className="editor-panel">
<h2>
{t("Owned by {owner}", {
owner: draft.imported_owner_name ?? "Explorer",
})}
</h2>
<p className="muted">
{t(
"This shared map is private and playable without moderator review. Only the original owner can submit it for public review.",
)}
</p>
{draft.state === "draft" && (
<StartGame datasetId={id} hostName={hostName} />
)}
</section>
)}
{!draft.parent_dataset_id &&
(draft.report_strikes > 0 || draft.banned_at) && (
<MapAppeal
@@ -333,30 +397,48 @@ export function DraftEditor({ id }: { id: string }) {
</button>
<button
className="button primary"
disabled={busy || refreshing || selected.length < minimum}
disabled={
busy ||
refreshing ||
selected.length < minimum ||
!!draft.imported_from_id
}
onClick={() => save(true)}
>
{draft.parent_dataset_id
? t("Submit to map owner →")
: t("Submit for review →")}
: draft.state === "published"
? t("Publish update →")
: t("Submit for review →")}
</button>
{draft.parent_dataset_id && draft.ownsPublishedParent && (
<button
className="button primary"
disabled={busy || refreshing || selected.length < minimum}
onClick={() => save(true, true)}
>
{t("Update my map now →")}
</button>
)}
</div>
)}
{!draft.parent_dataset_id && (
{!draft.imported_from_id && !draft.parent_dataset_id && (
<div id="map-tools">
<MapRefresh
id={id}
refreshKey={version}
job={draft.upload_jobs}
disabled={busy || !editable || refreshing}
onQueued={() => {
setMessage(
"Map refresh queued. Keep this page open to follow progress.",
"Map refresh queued. Progress and the result are saved; you can reopen this page later.",
);
setVersion((v) => v + 1);
}}
/>
</div>
)}
{!draft.parent_dataset_id && (
{!draft.imported_from_id && !draft.parent_dataset_id && (
<OwnerContributions
id={id}
allowed={draft.allow_contributions}
@@ -367,6 +449,9 @@ export function DraftEditor({ id }: { id: string }) {
onChange={() => setVersion((v) => v + 1)}
/>
)}
{!draft.imported_from_id && !draft.parent_dataset_id && (
<MapAdmins id={id} access={draft.access} />
)}
{(["uploading", "processing"].includes(draft.state) ||
(draft.parent_dataset_id &&
["pending", "submitted"].includes(

View File

@@ -0,0 +1,111 @@
"use client";
import { useEffect, useId, useRef } from "react";
import { MapPin } from "lucide-react";
import { useI18n } from "./I18nProvider";
const storageKey = "minegeo:intro:v1";
let seenThisSession = false;
/** Home-only, decorative welcome. The underlying page always renders normally. */
export function FirstVisitIntro() {
const { t } = useI18n();
const titleId = useId();
const dialog = useRef<HTMLDialogElement>(null);
useEffect(() => {
const element = dialog.current;
if (!element) return;
const motion = window.matchMedia("(prefers-reduced-motion: reduce)");
let end: ReturnType<typeof setTimeout> | undefined;
const finish = () => {
clearTimeout(end);
if (element.open) element.close();
};
// Defer the browser-only decision; Strict Mode can clean up its first setup.
const start = setTimeout(() => {
if (seenThisSession) return;
try {
if (localStorage.getItem(storageKey)) return;
localStorage.setItem(storageKey, "seen");
} catch {
try {
if (sessionStorage.getItem(storageKey)) return;
sessionStorage.setItem(storageKey, "seen");
} catch {
// Both stores denied: keep the fallback for this page session in memory.
}
}
seenThisSession = true;
// This is decorative delight, so reduced motion goes straight to the site.
if (motion.matches || !element.isConnected) return;
// A welcome must never interrupt another modal, such as account notices.
if (document.querySelector("dialog[open]")) return;
element.showModal();
end = setTimeout(finish, 4000);
}, 0);
const motionChanged = () => {
if (motion.matches) finish();
};
motion.addEventListener("change", motionChanged);
element.addEventListener("close", finish);
return () => {
clearTimeout(start);
finish();
motion.removeEventListener("change", motionChanged);
element.removeEventListener("close", finish);
};
}, []);
return (
<dialog
ref={dialog}
className="first-visit-intro"
aria-labelledby={titleId}
>
<div className="intro-cartography" aria-hidden="true" />
<div className="intro-content">
<div className="intro-map-stage" aria-hidden="true">
<span className="intro-coordinate intro-coordinate-left">
X: −128
</span>
<span className="intro-coordinate intro-coordinate-right">
Z: +256
</span>
<div className="intro-map-enter">
<div className="intro-map-surface">
<span className="intro-map-water" />
<span className="intro-map-path" />
<i className="intro-map-block intro-map-block-one" />
<i className="intro-map-block intro-map-block-two" />
<i className="intro-map-block intro-map-block-three" />
<span className="intro-map-target" />
</div>
</div>
<div className="intro-pin-anchor">
<MapPin className="intro-pin" strokeWidth={1.5} />
</div>
<span className="intro-bearing">N</span>
</div>
<div className="intro-title">
<p className="eyebrow">{t("YOUR NEXT EXPEDITION")}</p>
<h2 id={titleId} aria-label={t("MineGeo")}>
<span aria-hidden="true">MINE</span>
<span aria-hidden="true">GEO</span>
</h2>
<p className="intro-subtitle">
{t("Find your place in a blocky world.")}
</p>
</div>
</div>
<button
type="button"
className="intro-skip button"
autoFocus
onClick={() => dialog.current?.close()}
>
{t("Skip intro")} <span aria-hidden="true">Esc</span>
</button>
</dialog>
);
}

View File

@@ -9,12 +9,13 @@ import {
MousePointer2,
Move,
MapPin,
Map as MapIcon,
} from "lucide-react";
import { Brand } from "./Brand";
import { Panorama } from "./Panorama";
import { GuessMap, type Pin } from "./GuessMap";
import { api } from "./StartGame";
import { difficulties, type Difficulty, type Bounds } from "@/shared/protocol";
import type { DifficultyPreset, Bounds } from "@/shared/protocol";
import { roundClock } from "@/shared/round-clock";
import { ReportContent } from "./ReportContent";
export type RoundResult = {
@@ -27,7 +28,8 @@ export type RoundResult = {
export type SessionView = {
id: string;
name: string;
difficulty: Difficulty;
difficulty: string;
difficultySettings: DifficultyPreset;
round: number;
bounds: Bounds;
map: string;
@@ -73,7 +75,7 @@ export function Game({ id }: { id: string }) {
s.rounds[s.round].deadline!,
performance.now(),
)
: difficulties[s.difficulty].seconds,
: s.difficultySettings.seconds,
);
} catch (e) {
if (version === loadVersion.current) setError((e as Error).message);
@@ -174,6 +176,7 @@ export function Game({ id }: { id: string }) {
setSession(null);
setResult(null);
setPin(null);
setExpanded(false);
setLoaded({ panorama: false, map: false });
timingOut.current = false;
setError("");
@@ -189,12 +192,13 @@ export function Game({ id }: { id: string }) {
const total =
session?.rounds.reduce((n, r) => n + (r.result?.score || 0), 0) || 0;
return (
<main className="game-page">
<main className="game-page" id="main-content" tabIndex={-1}>
{session && current && (
<Panorama
key={session.round}
urls={current.faces}
zoom={difficulties[session.difficulty].zoom}
zoom={session.difficultySettings.zoom}
pan={session.difficultySettings.pan}
onReady={ready}
onError={onError}
/>
@@ -213,13 +217,15 @@ export function Game({ id }: { id: string }) {
<small>{t("ROUND")}</small>
<strong>{session ? session.round + 1 : "—"} / 5</strong>
</div>
<div>
<small>{t("TIME LEFT")}</small>
<strong className={remaining <= 10 ? "urgent" : ""}>
{Math.floor(remaining / 60)}:
{String(remaining % 60).padStart(2, "0")}
</strong>
</div>
{session?.difficultySettings.timerVisible !== false && (
<div>
<small>{t("TIME LEFT")}</small>
<strong className={remaining <= 10 ? "urgent" : ""}>
{Math.floor(remaining / 60)}:
{String(remaining % 60).padStart(2, "0")}
</strong>
</div>
)}
<div>
<small>{t("POINTS")}</small>
<strong>{formatNumber(total)}</strong>
@@ -228,50 +234,84 @@ export function Game({ id }: { id: string }) {
</div>
{session && (
<div className={"game-map " + (expanded ? "expanded" : "")}>
<div className="map-topline">
<span>{t("↑ N · PLACE YOUR GUESS")}</span>
<button
aria-label={expanded ? t("Collapse map") : t("Expand map")}
onClick={() => setExpanded((v) => !v)}
>
{expanded ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
</button>
</div>
<GuessMap
key={"map-" + session.round}
bounds={session.bounds}
image={session.map}
pin={result ? result.guess : pin}
actual={result?.actual}
onPin={setPin}
onReady={mapReady}
onError={onError}
disabled={!!result || busy}
/>
<div className="map-actions">
<small>
{pin
? `X ${Math.round(pin.x)} · Z ${Math.round(pin.z)}`
: t("Click the map to drop a pin")}
</small>
<button
className="button primary full"
disabled={!pin || busy || !!result || !current?.deadline}
onClick={() => guess()}
>
<MapPin size={15} />
{busy ? t("Checking your guess…") : t("Submit guess")}
</button>
<button
className="mobile-map-toggle"
aria-expanded={expanded}
aria-controls="guess-map-panel"
onClick={() => setExpanded(true)}
>
<MapIcon size={20} aria-hidden="true" />
<span>{pin ? t("Edit your guess") : t("Open guess map")}</span>
{pin && session.difficultySettings.coordinates !== false && (
<small>
X {Math.round(pin.x)} · Z {Math.round(pin.z)}
</small>
)}
</button>
<div className="game-map-content" id="guess-map-panel">
<div className="map-topline">
<span>
{t(
session.difficultySettings.compass === false
? "PLACE YOUR GUESS"
: "↑ N · PLACE YOUR GUESS",
)}
</span>
<button
aria-label={expanded ? t("Collapse map") : t("Expand map")}
onClick={() => setExpanded((v) => !v)}
>
{expanded ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
</button>
</div>
<GuessMap
key={"map-" + session.round}
bounds={session.bounds}
image={session.map}
pin={result ? result.guess : pin}
actual={result?.actual}
onPin={setPin}
onReady={mapReady}
onError={onError}
disabled={!!result || busy}
zoom={session.difficultySettings.mapZoom}
pan={session.difficultySettings.mapPan}
compass={session.difficultySettings.compass}
/>
<div className="map-actions">
<small>
{pin
? session.difficultySettings.coordinates !== false
? `X ${Math.round(pin.x)} · Z ${Math.round(pin.z)}`
: t("Guess selected")
: t("Click the map to drop a pin")}
</small>
<button
className="button primary full"
disabled={!pin || busy || !!result || !current?.deadline}
onClick={() => guess()}
>
<MapPin size={15} />
{busy ? t("Checking your guess…") : t("Submit guess")}
</button>
</div>
</div>
</div>
)}
<div className="look-help">
<span>
<Move size={12} /> {t("DRAG TO LOOK AROUND")}
<Move size={12} />{" "}
{t(
session?.difficultySettings.pan === false
? "FIXED VIEW"
: "DRAG TO LOOK AROUND",
)}
</span>
<span>
<MousePointer2 size={12} />{" "}
{session?.difficulty === "hard" ? t("NO ZOOM") : t("SCROLL TO ZOOM")}
{session && !session.difficultySettings.zoom
? t("NO ZOOM")
: t("SCROLL TO ZOOM")}
</span>
</div>
{(!session || !loaded.panorama || !loaded.map) && !error && (

View File

@@ -15,6 +15,9 @@ export function GuessMap({
onReady,
onError,
disabled = false,
zoom = true,
pan = true,
compass = true,
}: {
bounds: Bounds;
image: string;
@@ -24,6 +27,9 @@ export function GuessMap({
onReady?: () => void;
onError?: (message: string) => void;
disabled?: boolean;
zoom?: boolean;
pan?: boolean;
compass?: boolean;
}) {
const { t } = useI18n();
const root = useRef<HTMLDivElement>(null),
@@ -59,7 +65,21 @@ export function GuessMap({
zoomSnap: 0,
maxBounds: b.pad(0.15),
attributionControl: false,
zoomControl: false,
scrollWheelZoom: zoom && (pan ? true : "center"),
touchZoom: zoom && (pan ? true : "center"),
doubleClickZoom: zoom && (pan ? true : "center"),
boxZoom: zoom && pan,
dragging: pan,
keyboard: false,
});
if (zoom)
L.control
.zoom({ zoomInTitle: t("Zoom in"), zoomOutTitle: t("Zoom out") })
.addTo(m);
// Leaflet's keyboard handler couples panning and +/- zoom. Gate them
// separately so a fixed map cannot be moved or zoomed by keyboard.
if (pan && zoom) m.keyboard.enable();
// Install cleanup immediately, including when later initialization fails.
let observer: ResizeObserver | undefined;
cleanup = () => {
@@ -90,6 +110,7 @@ export function GuessMap({
m.fitBounds(b, { padding: [0, 0], animate: false });
const fitZoom = m.getZoom();
m.setMinZoom(fitZoom);
if (!zoom) m.setMaxZoom(fitZoom);
markers.current = L.layerGroup().addTo(m);
setGeneration((v) => v + 1);
m.on("click", (e: Leaflet.LeafletMouseEvent) => {
@@ -105,6 +126,29 @@ export function GuessMap({
callbacks.current.onPin?.({ x, z });
});
const keydown = (event: KeyboardEvent) => {
if (
event.target === m.getContainer() &&
pan &&
!zoom &&
["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(
event.key,
)
) {
event.preventDefault();
m.panBy([
event.key === "ArrowLeft"
? -80
: event.key === "ArrowRight"
? 80
: 0,
event.key === "ArrowUp"
? -80
: event.key === "ArrowDown"
? 80
: 0,
]);
return;
}
if (
event.key !== "Enter" ||
event.target !== m.getContainer() ||
@@ -132,11 +176,14 @@ export function GuessMap({
const wasFitted = Math.abs(m.getZoom() - fittedZoom) < 0.01;
m.invalidateSize({ pan: false });
m.setMinZoom(-5);
// Unlock only while refitting a resized fixed-zoom map.
if (!zoom) m.setMaxZoom(Infinity);
fittedZoom = m.getBoundsZoom(b);
m.setMaxZoom(Math.max(3, fittedZoom + 3));
if (wasFitted || m.getZoom() < fittedZoom)
m.fitBounds(b, { padding: [0, 0], animate: false });
m.setMinZoom(fittedZoom);
if (!zoom) m.setMaxZoom(fittedZoom);
});
observer.observe(root.current);
})
@@ -146,7 +193,16 @@ export function GuessMap({
clearTimeout(loadTimer);
cleanup();
};
}, [bounds.minX, bounds.maxX, bounds.minZ, bounds.maxZ, image, attempt]);
}, [
bounds.minX,
bounds.maxX,
bounds.minZ,
bounds.maxZ,
image,
attempt,
zoom,
pan,
]);
useEffect(() => {
const L = lib.current,
group = markers.current;
@@ -176,14 +232,23 @@ export function GuessMap({
<div className="map-surface">
<div
ref={root}
tabIndex={0}
style={{ height: "100%", width: "100%" }}
role="application"
aria-label={
disabled
? t("Minecraft map. North is up.")
: t(
"Minecraft map. North is up. Click to place a guess, or use arrow keys to pan and Enter to pin the center.",
!pan
? t(
"Minecraft map. Click to place a guess, or press Enter to pin the center.",
)
: !compass
? t(
"Minecraft map. Click to place a guess, or use arrow keys to pan and Enter to pin the center.",
)
: disabled
? t("Minecraft map. North is up.")
: t(
"Minecraft map. North is up. Click to place a guess, or use arrow keys to pan and Enter to pin the center.",
)
}
/>
{error && (

View File

@@ -3,6 +3,7 @@ import { useI18n } from "@/components/I18nProvider";
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { PixelTrophy, PlayerAvatar } from "./PixelArt";
import { Username } from "./Username";
import {
type LeaderboardData,
type LeaderboardEntry,
@@ -103,9 +104,27 @@ export function LeaderboardTable({
</td>
<th scope="row">
<div className="leaderboard-player">
<PlayerAvatar id={entry.player_id} />
<PlayerAvatar
id={entry.player_id}
avatarPath={entry.avatar_path}
/>
<span className="player-name">
{entry.display_name}
<Username name={entry.display_name} appearance={entry} />
{entry.tournament_champion && (
<Link
className="champion-badge"
href={`/tournaments/${entry.tournament_champion.tournament_id}`}
title={t("Champion spotlight until {date}", {
date:
new Date(entry.tournament_champion.expires_at)
.toISOString()
.slice(0, 16)
.replace("T", " ") + " UTC",
})}
>
🏆 {t("TOURNAMENT CHAMPION")}
</Link>
)}
{entry.player_id === playerId && (
<span className="you-label">{t("You")}</span>
)}
@@ -151,8 +170,18 @@ export function WinnersPodium({ leaders }: { leaders: LeaderboardEntry[] }) {
place: String(index + 1).padStart(2, "0"),
})}
</span>
<PlayerAvatar id={entry.player_id} />
<h2>{entry.display_name}</h2>
<PlayerAvatar id={entry.player_id} avatarPath={entry.avatar_path} />
<h2>
<Username name={entry.display_name} appearance={entry} />
</h2>
{entry.tournament_champion && (
<Link
className="champion-badge"
href={`/tournaments/${entry.tournament_champion.tournament_id}`}
>
🏆 {t("TOURNAMENT CHAMPION")}
</Link>
)}
<p>
{formatNumber(entry.best_score)} <span>/ 25,000</span>
</p>

View File

@@ -0,0 +1,147 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { usePathname } from "next/navigation";
import { Copy, KeyRound } from "lucide-react";
import { useI18n } from "./I18nProvider";
import { api } from "@/lib/client-api";
import { generateInitialPassword } from "@/shared/initial-password";
export function LegacyPasswordNotice({ enabled }: { enabled: boolean }) {
const { t } = useI18n();
const pathname = usePathname();
const dialog = useRef<HTMLDialogElement>(null);
const [needed, setNeeded] = useState(false);
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [saved, setSaved] = useState(false);
const [copied, setCopied] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!enabled || pathname?.startsWith("/auth/") || password) return;
let active = true;
api<{ needed: boolean }>("auth/initial-password")
.then((data) => {
if (active) setNeeded(data.needed);
})
.catch(() => {});
return () => {
active = false;
};
}, [enabled, pathname, password]);
useEffect(() => {
if (!needed) return;
const element = dialog.current;
element?.showModal();
return () => {
element?.close();
};
}, [needed]);
async function generate() {
if (busy) return;
setBusy(true);
setError("");
const generated = generateInitialPassword();
try {
await api("auth/initial-password", { password: generated });
// Only keep the confirmed password in this mounted dialog's memory.
setPassword(generated);
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
function dismiss() {
setNeeded(false);
setPassword("");
setSaved(false);
setCopied(false);
setError("");
}
async function copy() {
try {
await navigator.clipboard.writeText(password);
setCopied(true);
} catch {
setError("Select the password and copy it manually.");
}
}
if (!needed) return null;
return (
<dialog
ref={dialog}
className="report-dialog legacy-password-dialog"
aria-labelledby="legacy-password-title"
onCancel={(event) => {
if (password || busy) event.preventDefault();
else dismiss();
}}
>
<KeyRound size={28} aria-hidden="true" />
<h2 id="legacy-password-title">
{password
? t("Your one-time password screen.")
: t("Your account needs a password.")}
</h2>
<p>
{password
? t(
"Save this password now. After you close this screen, it cannot be shown again. You can change it in account settings.",
)
: t(
"You joined before passwords were required. Create a strong password for email-and-password login.",
)}
</p>
{password ? (
<>
<label className="field">
{t("Your new account password")}
<input
readOnly
value={password}
autoComplete="off"
spellCheck={false}
/>
</label>
<button className="button" onClick={copy}>
<Copy size={16} aria-hidden="true" />
{copied ? t("Copied") : t("Copy password")}
</button>
<label className="password-saved">
<input
type="checkbox"
checked={saved}
onChange={(e) => setSaved(e.target.checked)}
/>
{t("I saved this password somewhere safe.")}
</label>
<button
className="button primary full"
disabled={!saved}
onClick={dismiss}
>
{t("Done — I saved it")}
</button>
</>
) : (
<>
<button
className="button primary full"
disabled={busy}
onClick={generate}
>
{busy ? t("Creating password…") : t("Create and show my password")}
</button>
<button className="text-link" disabled={busy} onClick={dismiss}>
{t("Not now")}
</button>
</>
)}
{error && (
<p role="alert" className="error-message">
{t(error)}
</p>
)}
</dialog>
);
}

131
components/MapAdmins.tsx Normal file
View File

@@ -0,0 +1,131 @@
"use client";
import { useI18n } from "@/components/I18nProvider";
import { useCallback, useEffect, useState } from "react";
import { api } from "./StartGame";
export type MapAdmin = {
id: string;
user_id: string;
state: string;
created_at: string;
name: string;
username: string | null;
};
export function MapAdmins({ id, access }: { id: string; access: string }) {
const { t } = useI18n();
const [admins, setAdmins] = useState<MapAdmin[]>([]);
const [caller, setCaller] = useState("");
const [name, setName] = useState("");
const [error, setError] = useState("");
const [message, setMessage] = useState("");
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
try {
const result = await api<{ admins: MapAdmin[]; caller: string }>(
`datasets/${id}/admins`,
);
setAdmins(result.admins);
setCaller(result.caller);
setError("");
} catch (e) {
setError((e as Error).message);
}
}, [id]);
useEffect(() => {
load();
const timer = setInterval(load, 10000);
return () => clearInterval(timer);
}, [load]);
async function invite(event: React.FormEvent) {
event.preventDefault();
setBusy(true);
setError("");
setMessage("");
try {
await api(`datasets/${id}/admins`, { name });
setName("");
setMessage(
"Invite sent. Your friend can accept it from their dashboard.",
);
await load();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
async function remove(userId: string) {
setBusy(true);
setError("");
try {
await api(`datasets/${id}/admins/${userId}`, undefined, "DELETE");
await load();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<section className="tool-card">
<h2>{t("Map admins")}</h2>
<p className="muted">
{t(
"Invite friends to manage this map with you. They can edit details, choose locations, refresh the map and handle contributions after accepting your invite.",
)}
</p>
{admins.map((a) => (
<div className="draft-row" key={a.id}>
<span>
{a.name}
{a.username ? <small className="muted"> @{a.username}</small> : null}{" "}
<small className="muted">
{a.state === "accepted" ? t("· admin") : t("· invite pending")}
</small>
</span>
{(access === "owner" || a.user_id === caller) && (
<button
className="button"
disabled={busy}
onClick={() => remove(a.user_id)}
>
{a.user_id === caller && access !== "owner"
? t("Leave map")
: a.state === "accepted"
? t("Remove")
: t("Cancel invite")}
</button>
)}
</div>
))}
{access === "owner" && (
<form onSubmit={invite}>
<label className="field">
{t("Player name or username")}
<input
value={name}
onChange={(e) => setName(e.target.value)}
required
minLength={1}
maxLength={80}
/>
</label>
<button className="button primary" disabled={busy || !name.trim()}>
{busy ? t("Sending…") : t("Send invite")}
</button>
</form>
)}
{error && (
<p role="alert" className="error-message">
{t(error)}
</p>
)}
{message && (
<p role="status" className="success-message">
{t(message)}
</p>
)}
</section>
);
}

Some files were not shown because too many files have changed in this diff Show More