Compare commits
53 Commits
233e4d57f0
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 05854818a0 | |||
| 0d62f08e77 | |||
| 48d4058e06 | |||
| 50b27cf498 | |||
| 658c8974ac | |||
| 5898ef35a5 | |||
| ed4d264801 | |||
| 1fc59d4ceb | |||
| 9c314ecb4e | |||
| b8a430d4c2 | |||
| 927923dc95 | |||
| 060e8f27ef | |||
| 0330c4072f | |||
| 690fe12c2c | |||
| de0e891b62 | |||
| 3ba398a838 | |||
| 425b0e0805 | |||
| da1a50a6ad | |||
| a7085e4e80 | |||
| ad73132f15 | |||
| 128caff2a2 | |||
| 8dc8ee3bd7 | |||
| 2cb34fff86 | |||
| e1a6ab5730 | |||
| 0bd05d6e0b | |||
| 1a34eb71c2 | |||
| c9fc9496eb | |||
| 1f254b29a2 | |||
| 0e4412c99b | |||
| c541cc09b4 | |||
| 44e62cff2f | |||
| 930e00a736 | |||
| 07cd0993a0 | |||
| 3c3d27b35c | |||
|
|
1637ad922e | ||
|
|
a9515d96fc | ||
|
|
d2108a1f99 | ||
| 663061d03a | |||
| de812453ec | |||
| 8c3de302e7 | |||
| cfe5820802 | |||
| 018be5049e | |||
| 3cc3136762 | |||
| a54a3e7705 | |||
| fc6da6756d | |||
| 9af71c85c2 | |||
| bf01d9801d | |||
| 0b08672f4a | |||
| 36e8d11eb4 | |||
| 1307436e65 | |||
| dac8650ac1 | |||
| 48b34db6f1 | |||
| ce26e6fdbc |
|
After Width: | Height: | Size: 74 KiB |
23
.dockerignore
Normal 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
|
||||||
32
.env.example
@@ -4,6 +4,31 @@ MINEGEO_URL=http://localhost:3000
|
|||||||
MINEGEO_HOST=127.0.0.1
|
MINEGEO_HOST=127.0.0.1
|
||||||
# Optional comma-separated exact public origins for a reverse proxy or aliases.
|
# Optional comma-separated exact public origins for a reverse proxy or aliases.
|
||||||
MINEGEO_ALLOWED_ORIGINS=
|
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.
|
# Development testing only; production rejects this bypass of CSRF protection.
|
||||||
MINEGEO_ALLOW_ANY_ORIGIN=false
|
MINEGEO_ALLOW_ANY_ORIGIN=false
|
||||||
NEXT_PUBLIC_SUPABASE_URL=
|
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.
|
# Allow Chromium to use the GPU; software forces SwiftShader for headless hosts.
|
||||||
MINEGEO_RENDER_BACKEND=auto
|
MINEGEO_RENDER_BACKEND=auto
|
||||||
MINEGEO_MAX_UPLOAD_BYTES=268435456
|
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=
|
||||||
|
|||||||
154
.gitea/workflows/deploy-dokploy.yml
Normal 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
@@ -1,4 +1,5 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
|
desktop/dist/
|
||||||
.next/
|
.next/
|
||||||
.env*
|
.env*
|
||||||
!.env.example
|
!.env.example
|
||||||
@@ -18,3 +19,4 @@ public/downloads/*.jar
|
|||||||
|
|
||||||
# Generated by Next.js
|
# Generated by Next.js
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
/.swc
|
||||||
|
|||||||
54
Dockerfile
Normal 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"]
|
||||||
37
MineGeo community improvement report.md
Normal 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
|
||||||
BIN
MineGeo community report.docx
Normal file
37
MineGeo community report.txt
Normal 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
|
||||||
BIN
MineGeo report sanitized.docx
Normal file
@@ -1,52 +1,5 @@
|
|||||||
import { getI18n } from "@/lib/i18n";
|
import { permanentRedirect } from "next/navigation";
|
||||||
import { Header, Footer } from "@/components/Shell";
|
|
||||||
import Link from "next/link";
|
export default function About() {
|
||||||
export default async function About() {
|
permanentRedirect("/docs?page=guide");
|
||||||
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 />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
/* Shared account and authentication surfaces, layered after the Minecraft theme. */
|
/* 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 {
|
.auth-page {
|
||||||
padding-top: 48px;
|
padding-top: 48px;
|
||||||
padding-bottom: 72px;
|
padding-bottom: 72px;
|
||||||
@@ -166,6 +177,29 @@
|
|||||||
box-shadow: 0 12px 30px #0002;
|
box-shadow: 0 12px 30px #0002;
|
||||||
min-width: 0;
|
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 {
|
.settings-heading {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -284,3 +318,34 @@
|
|||||||
gap: 8px;
|
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
@@ -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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
app/api/dev/keys/route.ts
Normal 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;
|
||||||
10
app/api/dev/openapi.json/route.ts
Normal 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
@@ -0,0 +1,3 @@
|
|||||||
|
export function GET(request: Request) {
|
||||||
|
return Response.redirect(new URL("/dev/api", request.url), 307);
|
||||||
|
}
|
||||||
17
app/api/dev/v1/[...path]/route.ts
Normal 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,
|
||||||
|
};
|
||||||
33
app/api/integrations/discord/dispatch/route.ts
Normal 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
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
112
app/api/support/linear/route.ts
Normal 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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { NextRequest } from "next/server";
|
|||||||
import { handle } from "@/lib/api";
|
import { handle } from "@/lib/api";
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
export const maxDuration = 300;
|
||||||
async function route(
|
async function route(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ path: string[] }> },
|
{ params }: { params: Promise<{ path: string[] }> },
|
||||||
|
|||||||
581
app/appearance.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -11,11 +11,13 @@ export async function GET(request: NextRequest) {
|
|||||||
const legacyToken = request.nextUrl.searchParams.get("legacy_recovery_token");
|
const legacyToken = request.nextUrl.searchParams.get("legacy_recovery_token");
|
||||||
const type = request.nextUrl.searchParams.get("type");
|
const type = request.nextUrl.searchParams.get("type");
|
||||||
const next = authDestination(request.nextUrl.searchParams.get("next"));
|
const next = authDestination(request.nextUrl.searchParams.get("next"));
|
||||||
|
if (type === "magiclink")
|
||||||
|
return NextResponse.redirect(new URL("/log-in?auth=failed", origin));
|
||||||
if (
|
if (
|
||||||
code ||
|
code ||
|
||||||
legacyToken ||
|
legacyToken ||
|
||||||
(tokenHash &&
|
(tokenHash &&
|
||||||
(type === "email" || type === "recovery" || type === "magiclink"))
|
(type === "email" || type === "signup" || type === "recovery"))
|
||||||
) {
|
) {
|
||||||
const client = await authClient();
|
const client = await authClient();
|
||||||
let { data, error } = code
|
let { data, error } = code
|
||||||
@@ -24,7 +26,7 @@ export async function GET(request: NextRequest) {
|
|||||||
? await client.auth.exchangeCodeForSession(legacyToken)
|
? await client.auth.exchangeCodeForSession(legacyToken)
|
||||||
: await client.auth.verifyOtp({
|
: await client.auth.verifyOtp({
|
||||||
token_hash: tokenHash!,
|
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=...
|
// Older email templates could place a recovery token hash in /code=...
|
||||||
// instead of a PKCE code. Only Supabase can decide whether it is valid.
|
// instead of a PKCE code. Only Supabase can decide whether it is valid.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { cookies } from "next/headers";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Header, Footer } from "@/components/Shell";
|
import { Header, Footer } from "@/components/Shell";
|
||||||
import { PasswordForm } from "@/components/PasswordForm";
|
import { PasswordForm } from "@/components/PasswordForm";
|
||||||
import { authClient, user } from "@/lib/supabase";
|
import { authClient, rawUser } from "@/lib/supabase";
|
||||||
import { recoveryCookie, verifyRecoveryGrant } from "@/lib/password-recovery";
|
import { recoveryCookie, verifyRecoveryGrant } from "@/lib/password-recovery";
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
const { t } = await getI18n();
|
const { t } = await getI18n();
|
||||||
@@ -12,7 +12,7 @@ export async function generateMetadata() {
|
|||||||
export default async function ResetPassword() {
|
export default async function ResetPassword() {
|
||||||
const { t } = await getI18n();
|
const { t } = await getI18n();
|
||||||
const [account, jar, client] = await Promise.all([
|
const [account, jar, client] = await Promise.all([
|
||||||
user(),
|
rawUser(),
|
||||||
cookies(),
|
cookies(),
|
||||||
authClient(),
|
authClient(),
|
||||||
]);
|
]);
|
||||||
@@ -30,7 +30,7 @@ export default async function ResetPassword() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<Header />
|
||||||
<main className="section page-content">
|
<main className="section page-content" id="main-content" tabIndex={-1}>
|
||||||
<section className="auth-card reset-card">
|
<section className="auth-card reset-card">
|
||||||
<span className="eyebrow">{t("ACCOUNT RECOVERY")}</span>
|
<span className="eyebrow">{t("ACCOUNT RECOVERY")}</span>
|
||||||
<h1>{t("A fresh start.")}</h1>
|
<h1>{t("A fresh start.")}</h1>
|
||||||
|
|||||||
74
app/ban/appeal/page.module.css
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { getI18n } from "@/lib/i18n";
|
|||||||
import { Header, Footer } from "@/components/Shell";
|
import { Header, Footer } from "@/components/Shell";
|
||||||
import { DraftEditor } from "@/components/DraftEditor";
|
import { DraftEditor } from "@/components/DraftEditor";
|
||||||
import { user } from "@/lib/supabase";
|
import { user } from "@/lib/supabase";
|
||||||
|
import { accountProfile } from "@/lib/profile";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
export default async function Draft({
|
export default async function Draft({
|
||||||
params,
|
params,
|
||||||
@@ -10,15 +11,17 @@ export default async function Draft({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = await getI18n();
|
const { t } = await getI18n();
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
if (!(await user()))
|
const account = await user();
|
||||||
|
if (!account)
|
||||||
redirect(`/log-in?next=${encodeURIComponent("/dashboard/" + id)}`);
|
redirect(`/log-in?next=${encodeURIComponent("/dashboard/" + id)}`);
|
||||||
|
const profile = await accountProfile(account);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<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>
|
<span className="eyebrow">{t("YOUR WORLD · YOUR LOCATIONS")}</span>
|
||||||
<h1>{t("Map workshop")}</h1>
|
<h1>{t("Map workshop")}</h1>
|
||||||
<DraftEditor id={id} />
|
<DraftEditor id={id} hostName={profile.display_name} />
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Dashboard } from "@/components/Dashboard";
|
|||||||
import { admin, user, required } from "@/lib/supabase";
|
import { admin, user, required } from "@/lib/supabase";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { accountProfile } from "@/lib/profile";
|
import { accountProfile } from "@/lib/profile";
|
||||||
|
import { Username } from "@/components/Username";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
const { t, formatDate } = await getI18n();
|
const { t, formatDate } = await getI18n();
|
||||||
@@ -17,7 +18,7 @@ export default async function DashboardPage() {
|
|||||||
? required(
|
? required(
|
||||||
await admin()
|
await admin()
|
||||||
.from("game_sessions")
|
.from("game_sessions")
|
||||||
.select("id,difficulty,created_at,current_round")
|
.select("id,difficulty,difficulty_settings,created_at,current_round")
|
||||||
.eq("owner_id", account.id)
|
.eq("owner_id", account.id)
|
||||||
.gte("created_at", historyCutoff)
|
.gte("created_at", historyCutoff)
|
||||||
.order("created_at", { ascending: false })
|
.order("created_at", { ascending: false })
|
||||||
@@ -27,13 +28,18 @@ export default async function DashboardPage() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<Header />
|
||||||
<main className="section page-content">
|
<main className="section page-content" id="main-content" tabIndex={-1}>
|
||||||
{account ? (
|
{account ? (
|
||||||
<>
|
<>
|
||||||
<span className="eyebrow">{t("THE CARTOGRAPHER’S DESK")}</span>
|
<span className="eyebrow">{t("THE CARTOGRAPHER’S DESK")}</span>
|
||||||
<h1>{t("My expedition.")}</h1>
|
<h1>{t("My expedition.")}</h1>
|
||||||
<p className="lead">
|
<p className="lead">
|
||||||
{t("Welcome back,")} {profile?.display_name}.
|
{t("Welcome back,")}{" "}
|
||||||
|
<Username
|
||||||
|
name={profile?.display_name ?? "Explorer"}
|
||||||
|
appearance={profile ?? undefined}
|
||||||
|
/>
|
||||||
|
.
|
||||||
</p>
|
</p>
|
||||||
<Dashboard />
|
<Dashboard />
|
||||||
{history.length > 0 && (
|
{history.length > 0 && (
|
||||||
@@ -46,7 +52,17 @@ export default async function DashboardPage() {
|
|||||||
<div className="draft-row" key={s.id}>
|
<div className="draft-row" key={s.id}>
|
||||||
<span>
|
<span>
|
||||||
{formatDate(s.created_at)} ·{" "}
|
{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}
|
· {s.current_round}
|
||||||
{t("/5 rounds")}
|
{t("/5 rounds")}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
225
app/dev/api/page.module.css
Normal 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
@@ -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
@@ -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
@@ -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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,15 +3,46 @@ import { Header, Footer } from "@/components/Shell";
|
|||||||
import { Blocks, Server, Download, ExternalLink } from "lucide-react";
|
import { Blocks, Server, Download, ExternalLink } from "lucide-react";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import Link from "next/link";
|
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 const dynamic = "force-dynamic";
|
||||||
export default async function Downloads() {
|
export default async function Downloads() {
|
||||||
const { t } = await getI18n();
|
const { t } = await getI18n();
|
||||||
const fabric = existsSync("public/tools/mod/minegeo-fabric-1.21.11.jar"),
|
const pluginPath = `/tools/plugin/minegeo-plugin-${release}.jar`;
|
||||||
plugin = existsSync("public/tools/plugin/minegeo-plugin-1.21.11.jar");
|
const plugin = existsSync(`public${pluginPath}`);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<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">
|
<span className="eyebrow">
|
||||||
{t("MADE FOR THE PEOPLE WHO KNOW THEIR WORLDS")}
|
{t("MADE FOR THE PEOPLE WHO KNOW THEIR WORLDS")}
|
||||||
</span>
|
</span>
|
||||||
@@ -33,7 +64,9 @@ export default async function Downloads() {
|
|||||||
</p>
|
</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<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>
|
||||||
<li>{t("Open the capture panel with G")}</li>
|
<li>{t("Open the capture panel with G")}</li>
|
||||||
<li>{t("Link your account through a browser pairing code")}</li>
|
<li>{t("Link your account through a browser pairing code")}</li>
|
||||||
@@ -48,24 +81,34 @@ export default async function Downloads() {
|
|||||||
</li>
|
</li>
|
||||||
<li>{t("Contributions check the recorded server address")}</li>
|
<li>{t("Contributions check the recorded server address")}</li>
|
||||||
</ul>
|
</ul>
|
||||||
{fabric ? (
|
{versions.map((version) =>
|
||||||
<a
|
existsSync(
|
||||||
className="button primary"
|
`public/tools/mod/minegeo-fabric-${version}-${release}.jar`,
|
||||||
href="/tools/mod/minegeo-fabric-1.21.11.jar"
|
) ? (
|
||||||
download
|
<a
|
||||||
>
|
key={version}
|
||||||
<Download size={16} /> {t("Download Fabric mod")}
|
className="button primary download-build-link"
|
||||||
</a>
|
href={`/tools/mod/minegeo-fabric-${version}-${release}.jar`}
|
||||||
) : (
|
download
|
||||||
<p className="muted">
|
>
|
||||||
{t("The Fabric build is not available yet.")}
|
<Download size={16} /> {t("Download Fabric mod")} · {version}
|
||||||
</p>
|
</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(
|
{t(
|
||||||
"Install the JAR in your Minecraft mods folder alongside Fabric API.",
|
"Install the JAR in your Minecraft mods folder alongside Fabric API.",
|
||||||
)}
|
)}
|
||||||
</p>
|
</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>
|
||||||
<article className="tool-card">
|
<article className="tool-card">
|
||||||
<Server size={34} />
|
<Server size={34} />
|
||||||
@@ -77,7 +120,7 @@ export default async function Downloads() {
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<ul>
|
<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("Select a bounded area and export existing chunks")}</li>
|
||||||
<li>{t("Choose up to 24 random, safe surface viewpoints")}</li>
|
<li>{t("Choose up to 24 random, safe surface viewpoints")}</li>
|
||||||
<li>
|
<li>
|
||||||
@@ -90,11 +133,7 @@ export default async function Downloads() {
|
|||||||
<li>{t("Vanilla blocks/fluids, simplified lighting")}</li>
|
<li>{t("Vanilla blocks/fluids, simplified lighting")}</li>
|
||||||
</ul>
|
</ul>
|
||||||
{plugin ? (
|
{plugin ? (
|
||||||
<a
|
<a className="button primary" href={pluginPath} download>
|
||||||
className="button primary"
|
|
||||||
href="/tools/plugin/minegeo-plugin-1.21.11.jar"
|
|
||||||
download
|
|
||||||
>
|
|
||||||
<Download size={16} /> {t("Download server plugin")}
|
<Download size={16} /> {t("Download server plugin")}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
@@ -104,7 +143,7 @@ export default async function Downloads() {
|
|||||||
)}
|
)}
|
||||||
<pre>
|
<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>
|
</pre>
|
||||||
<p className="muted">
|
<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.",
|
". The manifest appears when export finishes. Upload sends the latest completed export; it does not capture the world.",
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p className="muted" style={{ fontSize: 11 }}>
|
<p className="muted tool-card-note">
|
||||||
Put the JAR in plugins/ and restart. Set website, server-name and
|
{t(
|
||||||
server-address in plugins/MineGeo/config.yml, then run /minegeo
|
"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.",
|
||||||
reload. HTTP LAN addresses are supported for testing. Permission:
|
)}
|
||||||
minegeo.admin.
|
|
||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useI18n } from "@/components/I18nProvider";
|
|||||||
export default function ErrorPage({ retry }: { retry: () => void }) {
|
export default function ErrorPage({ retry }: { retry: () => void }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<main className="section page-content">
|
<main className="section page-content" id="main-content" tabIndex={-1}>
|
||||||
<h1>{t("Lost connection.")}</h1>
|
<h1>{t("Lost connection.")}</h1>
|
||||||
<p>
|
<p>
|
||||||
{t(
|
{t(
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
@import "tailwindcss" source(none);
|
@import "tailwindcss" source(none);
|
||||||
@source ".";
|
@source ".";
|
||||||
@source "../components";
|
@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 {
|
:root {
|
||||||
--bg: #0b100f;
|
--bg: #0b100f;
|
||||||
--panel: #121b17;
|
--panel: #121b17;
|
||||||
@@ -19,7 +26,12 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
font-family:
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
"Segoe UI",
|
||||||
|
sans-serif;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
@@ -43,7 +55,7 @@ button {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
button:disabled {
|
button:disabled {
|
||||||
cursor: wait;
|
cursor: not-allowed;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
::selection {
|
::selection {
|
||||||
@@ -263,6 +275,7 @@ nav a:hover,
|
|||||||
}
|
}
|
||||||
.hero-actions {
|
.hero-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 26px;
|
gap: 26px;
|
||||||
margin-top: 30px;
|
margin-top: 30px;
|
||||||
@@ -706,10 +719,32 @@ nav a:hover,
|
|||||||
}
|
}
|
||||||
.catalog-controls {
|
.catalog-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin: 35px 0;
|
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 {
|
.search-field {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
@@ -771,7 +806,7 @@ nav a:hover,
|
|||||||
}
|
}
|
||||||
.difficulty-options {
|
.difficulty-options {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin: 25px 0;
|
margin: 25px 0;
|
||||||
}
|
}
|
||||||
@@ -799,6 +834,51 @@ nav a:hover,
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
margin-top: 5px;
|
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 {
|
.prose {
|
||||||
max-width: 850px;
|
max-width: 850px;
|
||||||
}
|
}
|
||||||
@@ -1399,6 +1479,7 @@ pre {
|
|||||||
}
|
}
|
||||||
.catalog-controls {
|
.catalog-controls {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
.difficulty-options {
|
.difficulty-options {
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -1669,3 +1750,13 @@ pre {
|
|||||||
display: block;
|
display: block;
|
||||||
position: relative;
|
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
|
After Width: | Height: | Size: 6.5 KiB |
101
app/layout.tsx
@@ -1,4 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { Analytics } from "@vercel/analytics/next";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import "./block-theme.css";
|
import "./block-theme.css";
|
||||||
import "./account.css";
|
import "./account.css";
|
||||||
@@ -12,8 +13,22 @@ import { SiteCursor } from "@/components/SiteCursor";
|
|||||||
import { I18nProvider } from "@/components/I18nProvider";
|
import { I18nProvider } from "@/components/I18nProvider";
|
||||||
import { getI18n } from "@/lib/i18n";
|
import { getI18n } from "@/lib/i18n";
|
||||||
import "./polish.css";
|
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 { SiteSettingsProvider } from "@/components/SiteSettingsProvider";
|
||||||
import { initialSiteSettings } from "@/lib/site-settings";
|
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> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const { t } = await getI18n();
|
const { t } = await getI18n();
|
||||||
return {
|
return {
|
||||||
@@ -31,24 +46,100 @@ export default async function RootLayout({
|
|||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [{ locale, preference, detectedLocale }, settings] = await Promise.all([
|
const [{ locale, preference, detectedLocale, t }, settings] =
|
||||||
getI18n(),
|
await Promise.all([getI18n(), initialSiteSettings()]);
|
||||||
initialSiteSettings(),
|
|
||||||
]);
|
|
||||||
return (
|
return (
|
||||||
<html lang={locale}>
|
<html lang={locale} data-theme="dark" suppressHydrationWarning>
|
||||||
|
<head>
|
||||||
|
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
|
||||||
|
</head>
|
||||||
<body>
|
<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
|
<I18nProvider
|
||||||
locale={locale}
|
locale={locale}
|
||||||
preference={preference}
|
preference={preference}
|
||||||
detectedLocale={detectedLocale}
|
detectedLocale={detectedLocale}
|
||||||
>
|
>
|
||||||
<SiteSettingsProvider initialSettings={settings}>
|
<SiteSettingsProvider initialSettings={settings}>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<BackNavigationTransition />
|
||||||
|
</Suspense>
|
||||||
{children}
|
{children}
|
||||||
<SiteCursor />
|
<SiteCursor />
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<AccountPasswordNotice />
|
||||||
|
</Suspense>
|
||||||
</SiteSettingsProvider>
|
</SiteSettingsProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
|
<Analytics />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
async function AccountPasswordNotice() {
|
||||||
|
const account = await rawUser();
|
||||||
|
const active = account
|
||||||
|
? (await accountState(account.id)) === "active"
|
||||||
|
: false;
|
||||||
|
return <LegacyPasswordNotice enabled={active && !!account?.email} />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -238,6 +238,8 @@ input:focus-visible {
|
|||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
display: block;
|
||||||
|
object-fit: cover;
|
||||||
background: #314034;
|
background: #314034;
|
||||||
box-shadow: 2px 2px #03070b;
|
box-shadow: 2px 2px #03070b;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export default async function LoadingLeaderboard() {
|
|||||||
className="section page-content leaderboard-page"
|
className="section page-content leaderboard-page"
|
||||||
aria-busy="true"
|
aria-busy="true"
|
||||||
aria-label={t("Loading leaderboard")}
|
aria-label={t("Loading leaderboard")}
|
||||||
|
id="main-content"
|
||||||
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
<span className="eyebrow">{t("THE EXPLORERS’ BOARD")}</span>
|
<span className="eyebrow">{t("THE EXPLORERS’ BOARD")}</span>
|
||||||
<h1>{t("Every block counts.")}</h1>
|
<h1>{t("Every block counts.")}</h1>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
return {
|
return {
|
||||||
title: t("Leaderboard"),
|
title: t("Leaderboard"),
|
||||||
description: t(
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<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">
|
<Link href="/" className="text-link leaderboard-back">
|
||||||
<ArrowLeft size={14} /> {t("Back to home")}
|
<ArrowLeft size={14} /> {t("Back to home")}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -79,6 +79,13 @@ export default async function LeaderboardPage({
|
|||||||
</div>
|
</div>
|
||||||
{data ? (
|
{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 && (
|
{data.leaders.length > 0 && !search && data.page === 1 && (
|
||||||
<WinnersPodium leaders={data.leaders} />
|
<WinnersPodium leaders={data.leaders} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { getI18n } from "@/lib/i18n";
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { Header, Footer } from "@/components/Shell";
|
import { Header, Footer } from "@/components/Shell";
|
||||||
import { SignIn } from "@/components/SignIn";
|
import { SignIn } from "@/components/SignIn";
|
||||||
import { user } from "@/lib/supabase";
|
import { rawUser, accountState } from "@/lib/supabase";
|
||||||
import { authDestination } from "@/shared/account";
|
import { authDestination } from "@/shared/account";
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
const { t } = await getI18n();
|
const { t } = await getI18n();
|
||||||
@@ -15,12 +15,14 @@ export default async function LogIn({
|
|||||||
}) {
|
}) {
|
||||||
const query = await searchParams;
|
const query = await searchParams;
|
||||||
const next = authDestination(query.next);
|
const next = authDestination(query.next);
|
||||||
if (await user())
|
const account = await rawUser();
|
||||||
redirect(next === "/auth/reset-password" ? "/settings" : next);
|
if (account && (await accountState(account.id)) !== "active")
|
||||||
|
redirect("/ban/appeal");
|
||||||
|
if (account) redirect(next === "/auth/reset-password" ? "/settings" : next);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<Header />
|
||||||
<main className="section page-content auth-page">
|
<main className="section page-content auth-page" id="main-content" tabIndex={-1}>
|
||||||
<SignIn
|
<SignIn
|
||||||
next={next}
|
next={next}
|
||||||
initialError={
|
initialError={
|
||||||
|
|||||||
192
app/mobile-game.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,28 @@
|
|||||||
import { getI18n } from "@/lib/i18n";
|
import { getI18n } from "@/lib/i18n";
|
||||||
import { Header, Footer } from "@/components/Shell";
|
import { Header, Footer } from "@/components/Shell";
|
||||||
import { Moderation } from "@/components/Moderation";
|
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() {
|
export default async function Review() {
|
||||||
const { t } = await getI18n();
|
const [{ t }, staff] = await Promise.all([getI18n(), staffPage()]);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<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>
|
<span className="eyebrow">{t("THE COMMUNITY’S COMPASS")}</span>
|
||||||
<h1>{t("Map moderation.")}</h1>
|
<h1>{t("Moderation Tools.")}</h1>
|
||||||
<p className="lead">
|
<p className="lead">
|
||||||
{t(
|
{t("Review pending maps, map updates and account ban appeals.")}
|
||||||
"Review map quality and coverage before a world enters the public catalog.",
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
|
{isAdministrator(staff.role) && (
|
||||||
|
<div className="staff-page-switch">
|
||||||
|
<Link className="button" href="/admin">
|
||||||
|
{t("Open Admin Tools")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Moderation />
|
<Moderation />
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|||||||
32
app/moderator-application/[token]/page.tsx
Normal 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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
app/multiplayer/[id]/page.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import Link from "next/link";
|
|||||||
export default async function NotFound() {
|
export default async function NotFound() {
|
||||||
const { t } = await getI18n();
|
const { t } = await getI18n();
|
||||||
return (
|
return (
|
||||||
<main className="section page-content">
|
<main className="section page-content" id="main-content" tabIndex={-1}>
|
||||||
<span className="eyebrow">{t("UNKNOWN COORDINATES")}</span>
|
<span className="eyebrow">{t("UNKNOWN COORDINATES")}</span>
|
||||||
<h1>{t("Uncharted territory.")}</h1>
|
<h1>{t("Uncharted territory.")}</h1>
|
||||||
<p>{t("This map or page isn’t available.")}</p>
|
<p>{t("This map or page isn’t available.")}</p>
|
||||||
|
|||||||
72
app/page.tsx
@@ -8,28 +8,32 @@ import {
|
|||||||
Timer,
|
Timer,
|
||||||
Blocks,
|
Blocks,
|
||||||
Upload,
|
Upload,
|
||||||
|
Download,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Header, Footer } from "@/components/Shell";
|
import { Header, Footer } from "@/components/Shell";
|
||||||
import { MapCard } from "@/components/MapCard";
|
import { MapCard } from "@/components/MapCard";
|
||||||
import { catalog } from "@/lib/catalog";
|
import { catalog } from "@/lib/catalog";
|
||||||
import { leaderboard } from "@/lib/leaderboard";
|
import { leaderboard } from "@/lib/leaderboard";
|
||||||
import { LeaderboardPreview } from "@/components/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 const dynamic = "force-dynamic";
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
const { t, formatNumber } = await getI18n();
|
const { t, formatNumber } = await getI18n();
|
||||||
const [maps, rankings] = await Promise.all([
|
const rankings = leaderboard({ limit: 5 });
|
||||||
catalog(),
|
const maps = await catalog();
|
||||||
leaderboard({ limit: 5 }),
|
|
||||||
]);
|
|
||||||
const servers = new Set(maps.map((map) => map.server_id)).size;
|
const servers = new Set(maps.map((map) => map.server_id)).size;
|
||||||
const locations = maps.reduce((total, map) => total + map.panorama_count, 0);
|
const locations = maps.reduce((total, map) => total + map.panorama_count, 0);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<FirstVisitIntro />
|
||||||
<Header />
|
<Header />
|
||||||
<main className="home-page" id="main-content">
|
<main className="home-page" id="main-content" tabIndex={-1}>
|
||||||
<section className="hero">
|
<section className="hero">
|
||||||
<div className="hero-copy">
|
<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>
|
<h1>
|
||||||
{t("Guess where you are")}
|
{t("Guess where you are")}
|
||||||
<br />
|
<br />
|
||||||
@@ -45,6 +49,9 @@ export default async function Home() {
|
|||||||
<Link href="/servers" className="button primary">
|
<Link href="/servers" className="button primary">
|
||||||
{t("Start exploring")} <ArrowRight size={18} />
|
{t("Start exploring")} <ArrowRight size={18} />
|
||||||
</Link>
|
</Link>
|
||||||
|
<a href={desktopRelease.file} className="button" download>
|
||||||
|
<Download size={18} /> {t("Download for Windows")}
|
||||||
|
</a>
|
||||||
<Link href="/leaderboard" className="text-link">
|
<Link href="/leaderboard" className="text-link">
|
||||||
{t("Meet the top explorers")} <ArrowRight size={14} />
|
{t("Meet the top explorers")} <ArrowRight size={14} />
|
||||||
</Link>
|
</Link>
|
||||||
@@ -70,6 +77,7 @@ export default async function Home() {
|
|||||||
width={1024}
|
width={1024}
|
||||||
height={1024}
|
height={1024}
|
||||||
loading="eager"
|
loading="eager"
|
||||||
|
fetchPriority="high"
|
||||||
unoptimized
|
unoptimized
|
||||||
/>
|
/>
|
||||||
<span className="square-pin pin-green" />
|
<span className="square-pin pin-green" />
|
||||||
@@ -123,8 +131,8 @@ export default async function Home() {
|
|||||||
<section className="section catalog-section">
|
<section className="section catalog-section">
|
||||||
<div className="section-heading">
|
<div className="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<span className="eyebrow">{t("COMMUNITY MAPS")}</span>
|
<span className="eyebrow">{t("Community maps")}</span>
|
||||||
<h2>{t("Where will you go today?")}</h2>
|
<h2>{t("Explore community worlds.")}</h2>
|
||||||
</div>
|
</div>
|
||||||
<Link className="text-link" href="/servers">
|
<Link className="text-link" href="/servers">
|
||||||
{t("All maps ({count})", { count: maps.length })}{" "}
|
{t("All maps ({count})", { count: maps.length })}{" "}
|
||||||
@@ -134,7 +142,13 @@ export default async function Home() {
|
|||||||
{maps.length ? (
|
{maps.length ? (
|
||||||
<div className="cards-grid">
|
<div className="cards-grid">
|
||||||
{maps.slice(0, 4).map((map, index) => (
|
{maps.slice(0, 4).map((map, index) => (
|
||||||
<MapCard map={map} index={index} key={map.id} minecraftPaper />
|
<MapCard
|
||||||
|
map={map}
|
||||||
|
index={index}
|
||||||
|
key={map.id}
|
||||||
|
minecraftPaper
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -166,10 +180,23 @@ export default async function Home() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</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">
|
<section className="section home-panels">
|
||||||
<article className="home-panel">
|
<article className="home-panel">
|
||||||
<span className="eyebrow">{t("HOW TO PLAY")}</span>
|
<span className="eyebrow">{t("How to play")}</span>
|
||||||
<h2>{t("Know your blocks?")}</h2>
|
<h2>{t("Know your blocks?")}</h2>
|
||||||
<ol className="play-steps">
|
<ol className="play-steps">
|
||||||
<li>
|
<li>
|
||||||
@@ -202,13 +229,13 @@ export default async function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
<Link className="text-link" href="/about">
|
<Link className="text-link" href="/docs?page=guide">
|
||||||
{t("Read the field guide")} <ArrowRight size={14} />
|
{t("Read the field guide")} <ArrowRight size={14} />
|
||||||
</Link>
|
</Link>
|
||||||
</article>
|
</article>
|
||||||
<article className="home-panel">
|
<article className="home-panel">
|
||||||
<span className="eyebrow">{t("CHOOSE YOUR CHALLENGE")}</span>
|
<span className="eyebrow">{t("Choose a difficulty")}</span>
|
||||||
<h2>{t("One world. Three ways.")}</h2>
|
<h2>{t("Pick a difficulty.")}</h2>
|
||||||
<div className="mode-list">
|
<div className="mode-list">
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
@@ -229,6 +256,12 @@ export default async function Home() {
|
|||||||
detail: t("Think fast. No zoom allowed."),
|
detail: t("Think fast. No zoom allowed."),
|
||||||
color: "red",
|
color: "red",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: t("Void"),
|
||||||
|
time: t("10 seconds"),
|
||||||
|
detail: t("The ultimate test. Fixed view, strict scoring."),
|
||||||
|
color: "void",
|
||||||
|
},
|
||||||
].map((mode) => (
|
].map((mode) => (
|
||||||
<Link href="/servers" className="mode-row" key={mode.name}>
|
<Link href="/servers" className="mode-row" key={mode.name}>
|
||||||
<i className={`stat-cube ${mode.color}-cube`} />
|
<i className={`stat-cube ${mode.color}-cube`} />
|
||||||
@@ -254,8 +287,8 @@ export default async function Home() {
|
|||||||
<section className="section contribute-banner">
|
<section className="section contribute-banner">
|
||||||
<Blocks size={38} />
|
<Blocks size={38} />
|
||||||
<div>
|
<div>
|
||||||
<span className="eyebrow">{t("BUILT BY THE COMMUNITY")}</span>
|
<span className="eyebrow">{t("Made by MineGeo players")}</span>
|
||||||
<h2>{t("Your world deserves to be explored.")}</h2>
|
<h2>{t("Put your world on the map.")}</h2>
|
||||||
<p>
|
<p>
|
||||||
{t("Share your builds and your favorite corners of Minecraft.")}
|
{t("Share your builds and your favorite corners of Minecraft.")}
|
||||||
</p>
|
</p>
|
||||||
@@ -269,3 +302,10 @@ export default async function Home() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
async function HomeLeaderboard({
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
data: ReturnType<typeof leaderboard>;
|
||||||
|
}) {
|
||||||
|
return <LeaderboardPreview data={await data} />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default async function Pair({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<Header />
|
||||||
<main className="section page-content">
|
<main className="section page-content" id="main-content" tabIndex={-1}>
|
||||||
{!account ? (
|
{!account ? (
|
||||||
<>
|
<>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
|
|||||||
362
app/polish.css
@@ -1,4 +1,21 @@
|
|||||||
/* Responsive corrections shared by both languages. */
|
/* 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 {
|
.language-switcher {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -28,6 +45,12 @@ select:focus-visible {
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
/* Fallback for user-provided Cyrillic names on English pages. */
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3 {
|
||||||
|
font-family: MineGeoPixel, MineGeoPixelCyrillic, monospace;
|
||||||
|
}
|
||||||
html[lang="ru"] h1,
|
html[lang="ru"] h1,
|
||||||
html[lang="ru"] h2,
|
html[lang="ru"] h2,
|
||||||
html[lang="ru"] h3,
|
html[lang="ru"] h3,
|
||||||
@@ -101,6 +124,22 @@ html[lang="ru"] .mode-row h3 span {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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,
|
.tool-card pre,
|
||||||
.token-box code {
|
.token-box code {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -125,6 +164,29 @@ html[lang="ru"] .mode-row h3 span {
|
|||||||
.auth-tabs button {
|
.auth-tabs button {
|
||||||
border-radius: var(--radius-control);
|
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) {
|
@media (max-width: 850px) {
|
||||||
.site-header {
|
.site-header {
|
||||||
height: auto;
|
height: auto;
|
||||||
@@ -209,12 +271,300 @@ html[lang="ru"] .mode-row h3 span {
|
|||||||
.home-panel + .home-panel {
|
.home-panel + .home-panel {
|
||||||
min-width: 0;
|
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) {
|
@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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export default async function ResultPage({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<Header />
|
||||||
<main className="section page-content">
|
<main className="section page-content" id="main-content" tabIndex={-1}>
|
||||||
<Results id={(await params).id} />
|
<Results id={(await params).id} />
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
--text: #eef1f4;
|
--text: #eef1f4;
|
||||||
--board-stone: #1a222c;
|
--board-stone: #1a222c;
|
||||||
--board-edge: #35404c;
|
--board-edge: #35404c;
|
||||||
--radius-panel: 16px;
|
--radius-panel: 12px;
|
||||||
--radius-control: 9px;
|
--radius-control: 9px;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
|
|||||||
1
app/servers/[id]/loading.tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { PageLoading as default } from "@/components/PageLoading";
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getI18n } from "@/lib/i18n";
|
import { getI18n } from "@/lib/i18n";
|
||||||
|
import { Username } from "@/components/Username";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { Header, Footer } from "@/components/Shell";
|
import { Header, Footer } from "@/components/Shell";
|
||||||
@@ -6,6 +7,11 @@ import { StartGame } from "@/components/StartGame";
|
|||||||
import { getMap } from "@/lib/catalog";
|
import { getMap } from "@/lib/catalog";
|
||||||
import { ContributionInstructions } from "@/components/ContributionInstructions";
|
import { ContributionInstructions } from "@/components/ContributionInstructions";
|
||||||
import { ReportContent } from "@/components/ReportContent";
|
import { ReportContent } from "@/components/ReportContent";
|
||||||
|
import { user, 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({
|
export default async function ServerPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
@@ -13,25 +19,42 @@ export default async function ServerPage({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = await getI18n();
|
const { t } = await getI18n();
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const map = await getMap(id);
|
const [map, account] = await Promise.all([getMap(id), user()]);
|
||||||
if (!map) notFound();
|
if (!map) notFound();
|
||||||
|
const 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<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">
|
<span className="eyebrow">
|
||||||
{map.source === "plugin"
|
{map.source === "plugin"
|
||||||
? t("SERVER EXPORT")
|
? t("SERVER EXPORT")
|
||||||
: t("PLAYER CONTRIBUTION")}{" "}
|
: t("PLAYER CONTRIBUTION")}{" "}
|
||||||
{t("· MINECRAFT 1.21.11")}
|
{t("· MINECRAFT 1.21.11")}
|
||||||
</span>
|
</span>
|
||||||
<h1>{map.name}</h1>
|
<h1 className="map-detail-title">{map.name}</h1>
|
||||||
<p className="lead">{map.description}</p>
|
<p className="lead">{map.description}</p>
|
||||||
<p className="map-credit">
|
<p className="map-credit">
|
||||||
{t("Created by")} {map.credit}
|
{t("Created by")}{" "}
|
||||||
|
<Username name={map.credit} appearance={map.creditAppearance} />
|
||||||
</p>
|
</p>
|
||||||
<ReportContent datasetId={id} />
|
<div className="map-detail-actions">
|
||||||
<p className="muted">
|
<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>
|
{t("Map ID:")} <code>{map.id}</code>
|
||||||
</p>
|
</p>
|
||||||
<div className="detail-grid">
|
<div className="detail-grid">
|
||||||
@@ -57,7 +80,10 @@ export default async function ServerPage({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<StartGame datasetId={id} />
|
<StartGame
|
||||||
|
datasetId={id}
|
||||||
|
hostName={account ? (profile?.display_name ?? "Explorer") : null}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{map.allow_contributions && (
|
{map.allow_contributions && (
|
||||||
<ContributionInstructions
|
<ContributionInstructions
|
||||||
@@ -66,6 +92,7 @@ export default async function ServerPage({
|
|||||||
serverAddress={map.contribution_server_address}
|
serverAddress={map.contribution_server_address}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<MapCommunity datasetId={id} ownerId={map.owner_id} />
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</>
|
||||||
|
|||||||
1
app/servers/loading.tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { PageLoading as default } from "@/components/PageLoading";
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
import { getI18n } from "@/lib/i18n";
|
import { getI18n } from "@/lib/i18n";
|
||||||
import { Header, Footer, EmptyState } from "@/components/Shell";
|
import { Header, Footer } from "@/components/Shell";
|
||||||
import { catalog } from "@/lib/catalog";
|
import { catalog } from "@/lib/catalog";
|
||||||
import { CatalogBrowser } from "@/components/CatalogBrowser";
|
import { CatalogBrowser } from "@/components/CatalogBrowser";
|
||||||
|
import { user } from "@/lib/supabase";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
export default async function Servers() {
|
export default async function Servers() {
|
||||||
const { t } = await getI18n();
|
const { t } = await getI18n();
|
||||||
const maps = await catalog();
|
const [maps, account] = await Promise.all([catalog(), user()]);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<Header />
|
||||||
<main className="section page-content">
|
<main className="section page-content" id="main-content" tabIndex={-1}>
|
||||||
<span className="eyebrow">
|
<span className="eyebrow">
|
||||||
{t("THE COMMUNITY AT YOUR COORDINATES")}
|
{t("THE COMMUNITY AT YOUR COORDINATES")}
|
||||||
</span>
|
</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.",
|
"From familiar spawn towns to places nobody’s quite sure about. Pick your next expedition.",
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
{maps.length ? (
|
<CatalogBrowser maps={maps} signedIn={!!account} />
|
||||||
<CatalogBrowser maps={maps} />
|
|
||||||
) : (
|
|
||||||
<EmptyState title={t("Fresh chunks, fresh start")}>
|
|
||||||
{t(
|
|
||||||
"No maps have been approved yet. Your server could be the first.",
|
|
||||||
)}
|
|
||||||
</EmptyState>
|
|
||||||
)}
|
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import Link from "next/link";
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { Header, Footer } from "@/components/Shell";
|
import { Header, Footer } from "@/components/Shell";
|
||||||
import { AccountSettings } from "@/components/AccountSettings";
|
import { AccountSettings } from "@/components/AccountSettings";
|
||||||
import { user } from "@/lib/supabase";
|
import { rawUser } from "@/lib/supabase";
|
||||||
import { accountProfile } from "@/lib/profile";
|
import { accountProfile } from "@/lib/profile";
|
||||||
|
import { Username } from "@/components/Username";
|
||||||
|
import { ShieldCheck } from "lucide-react";
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
const { t } = await getI18n();
|
const { t } = await getI18n();
|
||||||
return { title: t("Account settings") };
|
return { title: t("Account settings") };
|
||||||
@@ -15,31 +17,80 @@ export default async function Settings({
|
|||||||
searchParams: Promise<{ password?: string }>;
|
searchParams: Promise<{ password?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { t } = await getI18n();
|
const { t } = await getI18n();
|
||||||
const account = await user();
|
const account = await rawUser();
|
||||||
if (!account) redirect("/log-in?next=%2Fsettings");
|
if (!account) redirect("/log-in?next=%2Fsettings");
|
||||||
const [profile, query] = await Promise.all([
|
const [profile, query] = await Promise.all([
|
||||||
accountProfile(account),
|
accountProfile(account),
|
||||||
searchParams,
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<Header />
|
||||||
<main className="section page-content">
|
<main className="section page-content" id="main-content" tabIndex={-1}>
|
||||||
<Link className="text-link" href="/dashboard">
|
<Link
|
||||||
{t("← My account")}
|
className="text-link"
|
||||||
|
href={active ? "/dashboard" : "/ban/appeal"}
|
||||||
|
>
|
||||||
|
{t(active ? "← My account" : "← Account access & appeals")}
|
||||||
</Link>
|
</Link>
|
||||||
<div className="account-page-heading">
|
<div className="account-page-heading">
|
||||||
<span className="eyebrow">{t("YOUR CARTOGRAPHER ACCOUNT")}</span>
|
<span className="eyebrow">{t("YOUR CARTOGRAPHER ACCOUNT")}</span>
|
||||||
<h1>{t("Account settings.")}</h1>
|
<h1>{t("Account settings.")}</h1>
|
||||||
<p className="lead">
|
<p className="lead">
|
||||||
|
<Username name={profile.display_name} appearance={profile} /> ·{" "}
|
||||||
{t("Your name, your sign-in, your next expedition.")}
|
{t("Your name, your sign-in, your next expedition.")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<AccountSettings
|
<section
|
||||||
email={account.email ?? ""}
|
className="tool-card account-status-card"
|
||||||
displayName={profile.display_name}
|
aria-labelledby="account-status-title"
|
||||||
passwordUpdated={query.password === "updated"}
|
>
|
||||||
/>
|
<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>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</>
|
||||||
|
|||||||
191
app/site-glass.css
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/tournaments/[id]/page.tsx
Normal 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
@@ -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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,12 +11,12 @@
|
|||||||
"repeated upload is idempotent",
|
"repeated upload is idempotent",
|
||||||
"cancellation preserves completed export",
|
"cancellation preserves completed export",
|
||||||
"24 random positions change on the next 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",
|
"wrong server blocked before upload, including cached retry",
|
||||||
"owner override/default port and cleared address supported"
|
"owner override/default port and cleared address supported"
|
||||||
],
|
],
|
||||||
"blocks": 10112,
|
"blocks": 10112,
|
||||||
"chunks": 16,
|
"chunks": 16,
|
||||||
"viewpoints": 24,
|
"viewpoints": 24,
|
||||||
"directory": ".cache/plugin-regression-1791207258927"
|
"directory": ".cache/plugin-regression-1791360575134"
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
assets/branding/minegeo-mark-source.png
Normal file
|
After Width: | Height: | Size: 819 KiB |
274
cinematics/composition.mjs
Normal 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
@@ -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
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
BIN
cinematics/screenshots/catalog.jpg
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
cinematics/screenshots/game.jpg
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
cinematics/screenshots/home.jpg
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
cinematics/screenshots/map.jpg
Normal file
|
After Width: | Height: | Size: 162 KiB |
281
components/AccountAppeal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
452
components/AccountControls.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,13 +7,29 @@ import { UserRound, LockKeyhole, Mail } from "lucide-react";
|
|||||||
import { api } from "./StartGame";
|
import { api } from "./StartGame";
|
||||||
import { PasswordForm } from "./PasswordForm";
|
import { PasswordForm } from "./PasswordForm";
|
||||||
import { displayNameSchema } from "@/shared/account";
|
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({
|
export function AccountSettings({
|
||||||
|
accountId,
|
||||||
email,
|
email,
|
||||||
displayName,
|
displayName,
|
||||||
|
username,
|
||||||
|
avatarPath,
|
||||||
|
appearance,
|
||||||
passwordUpdated = false,
|
passwordUpdated = false,
|
||||||
}: {
|
}: {
|
||||||
|
accountId: string;
|
||||||
email: string;
|
email: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
username: string | null;
|
||||||
|
avatarPath: string | null;
|
||||||
|
appearance: ProfileAppearance;
|
||||||
passwordUpdated?: boolean;
|
passwordUpdated?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -25,6 +41,95 @@ export function AccountSettings({
|
|||||||
[recoveryError, setRecoveryError] = useState(""),
|
[recoveryError, setRecoveryError] = useState(""),
|
||||||
[sending, setSending] = useState(false);
|
[sending, setSending] = useState(false);
|
||||||
const router = useRouter();
|
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) {
|
async function save(event: React.FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError("");
|
setError("");
|
||||||
@@ -53,6 +158,7 @@ export function AccountSettings({
|
|||||||
async function recover() {
|
async function recover() {
|
||||||
setSending(true);
|
setSending(true);
|
||||||
setRecoveryError("");
|
setRecoveryError("");
|
||||||
|
setRecoveryMessage("");
|
||||||
try {
|
try {
|
||||||
const data = await api<{ message: string }>("auth", {
|
const data = await api<{ message: string }>("auth", {
|
||||||
action: "recover",
|
action: "recover",
|
||||||
@@ -78,6 +184,45 @@ export function AccountSettings({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={save}>
|
<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">
|
<label className="field">
|
||||||
{t("Display name")}
|
{t("Display name")}
|
||||||
<input
|
<input
|
||||||
@@ -119,6 +264,61 @@ export function AccountSettings({
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</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">
|
<section className="settings-card">
|
||||||
<div className="settings-heading">
|
<div className="settings-heading">
|
||||||
<LockKeyhole size={22} />
|
<LockKeyhole size={22} />
|
||||||
@@ -134,7 +334,7 @@ export function AccountSettings({
|
|||||||
)}
|
)}
|
||||||
<PasswordForm />
|
<PasswordForm />
|
||||||
<div className="settings-recovery">
|
<div className="settings-recovery">
|
||||||
<h3>{t("Signed up with an email link?")}</h3>
|
<h3>{t("Forgot your password?")}</h3>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
{t(
|
{t(
|
||||||
"Set your first password, or recover a forgotten one, using a link sent to your account email.",
|
"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>
|
</button>
|
||||||
{recoveryMessage && (
|
{recoveryMessage && (
|
||||||
<p role="status" className="success-message">
|
<p role="status" className="success-message">
|
||||||
{recoveryMessage}
|
{t(recoveryMessage)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{recoveryError && (
|
{recoveryError && (
|
||||||
<p role="alert" className="error-message">
|
<p role="alert" className="error-message">
|
||||||
{recoveryError}
|
{t(recoveryError)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
82
components/AdminInvites.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
components/BackNavigationTransition.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -8,10 +8,16 @@ export function Brand() {
|
|||||||
const { settings } = useSiteSettings();
|
const { settings } = useSiteSettings();
|
||||||
return (
|
return (
|
||||||
<Link href="/" className="brand" aria-label={t("MineGeo home")}>
|
<Link href="/" className="brand" aria-label={t("MineGeo home")}>
|
||||||
<span className="brand-cube">
|
<img
|
||||||
<span />
|
className="brand-mark"
|
||||||
|
src="/images/minegeo-mark.webp"
|
||||||
|
width={42}
|
||||||
|
height={46}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
<span className="brand-wordmark">
|
||||||
|
Mine<span>Geo</span>
|
||||||
</span>
|
</span>
|
||||||
Mine<span>Geo</span>
|
|
||||||
{settings.betaBadgeEnabled && <sup>BETA</sup>}
|
{settings.betaBadgeEnabled && <sup>BETA</sup>}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,14 @@ import { useI18n } from "@/components/I18nProvider";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Search } from "lucide-react";
|
import { Search } from "lucide-react";
|
||||||
import { MapCard, type CardMap } from "./MapCard";
|
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 { t } = useI18n();
|
||||||
const [query, setQuery] = useState(""),
|
const [query, setQuery] = useState(""),
|
||||||
[source, setSource] = useState("all");
|
[source, setSource] = useState("all");
|
||||||
@@ -18,15 +25,18 @@ export function CatalogBrowser({ maps }: { maps: CardMap[] }) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="catalog-controls">
|
<div className="catalog-controls">
|
||||||
<label className="search-field">
|
<div className="catalog-search">
|
||||||
<Search size={17} />
|
<label className="search-field">
|
||||||
<input
|
<Search size={17} />
|
||||||
aria-label={t("Search worlds")}
|
<input
|
||||||
placeholder={t("Search worlds or servers…")}
|
aria-label={t("Search worlds")}
|
||||||
value={query}
|
placeholder={t("Search worlds or servers…")}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
value={query}
|
||||||
/>
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
</label>
|
/>
|
||||||
|
</label>
|
||||||
|
<ImportMap signedIn={signedIn} />
|
||||||
|
</div>
|
||||||
<div className="tabs" aria-label={t("Capture source")}>
|
<div className="tabs" aria-label={t("Capture source")}>
|
||||||
{[
|
{[
|
||||||
["all", t("All worlds")],
|
["all", t("All worlds")],
|
||||||
@@ -44,7 +54,7 @@ export function CatalogBrowser({ maps }: { maps: CardMap[] }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="muted">
|
<p className="muted catalog-count" role="status">
|
||||||
{t("Worlds to explore: {count}", { count: shown.length })}
|
{t("Worlds to explore: {count}", { count: shown.length })}
|
||||||
</p>
|
</p>
|
||||||
<div className="cards-grid">
|
<div className="cards-grid">
|
||||||
@@ -53,7 +63,20 @@ export function CatalogBrowser({ maps }: { maps: CardMap[] }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{!shown.length && (
|
{!shown.length && (
|
||||||
<p className="empty-state">{t("No worlds match that search.")}</p>
|
<div className="empty-state catalog-empty">
|
||||||
|
<Search size={28} aria-hidden="true" />
|
||||||
|
<p>{t("No worlds match that search.")}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setQuery("");
|
||||||
|
setSource("all");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("Clear filters")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
19
components/ChangelogRefresh.tsx
Normal 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;
|
||||||
|
}
|
||||||
65
components/CommunityLinks.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
193
components/CommunityModeration.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ export function ContributionInstructions({
|
|||||||
<code>{serverAddress}</code>
|
<code>{serverAddress}</code>
|
||||||
<p>
|
<p>
|
||||||
{t(
|
{t(
|
||||||
"Capture on this server. Uploads from a different address are blocked.",
|
"Capture using any listed address. Uploads from other addresses are blocked.",
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ import { api } from "./StartGame";
|
|||||||
import type { MapListing } from "@/lib/catalog";
|
import type { MapListing } from "@/lib/catalog";
|
||||||
import type { ProcessingJob } from "@/shared/processing-progress";
|
import type { ProcessingJob } from "@/shared/processing-progress";
|
||||||
import { ProcessingProgress } from "./ProcessingProgress";
|
import { ProcessingProgress } from "./ProcessingProgress";
|
||||||
|
import { ImportMap } from "./MapTransfer";
|
||||||
|
import { AdminInvites } from "./AdminInvites";
|
||||||
type Draft = MapListing & {
|
type Draft = MapListing & {
|
||||||
|
access?: "owner" | "admin";
|
||||||
upload_jobs: ProcessingJob | null;
|
upload_jobs: ProcessingJob | null;
|
||||||
};
|
};
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
@@ -23,6 +26,7 @@ export function Dashboard() {
|
|||||||
[token, setToken] = useState(""),
|
[token, setToken] = useState(""),
|
||||||
[busy, setBusy] = useState(false);
|
[busy, setBusy] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [m, t] = await Promise.all([
|
const [m, t] = await Promise.all([
|
||||||
@@ -33,6 +37,7 @@ export function Dashboard() {
|
|||||||
]);
|
]);
|
||||||
setMaps(m.maps);
|
setMaps(m.maps);
|
||||||
setTokens(t.tokens);
|
setTokens(t.tokens);
|
||||||
|
setLoaded(true);
|
||||||
setError("");
|
setError("");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError((e as Error).message);
|
setError((e as Error).message);
|
||||||
@@ -86,6 +91,7 @@ export function Dashboard() {
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<div className="inline-actions">
|
<div className="inline-actions">
|
||||||
|
<ImportMap signedIn />
|
||||||
<Link className="button" href="/settings">
|
<Link className="button" href="/settings">
|
||||||
{t("Account settings")}
|
{t("Account settings")}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -96,10 +102,22 @@ export function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" className="error-message">
|
<p role="alert" className="error-message">
|
||||||
{t(error)}
|
{t(error)}{" "}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-link"
|
||||||
|
onClick={() => void load()}
|
||||||
|
>
|
||||||
|
{t("Retry")}
|
||||||
|
</button>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{!maps.length && (
|
{!loaded && !error && (
|
||||||
|
<p role="status" className="muted">
|
||||||
|
{t("Loading…")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{loaded && !maps.length && (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<h2>{t("A blank map is a beginning.")}</h2>
|
<h2>{t("A blank map is a beginning.")}</h2>
|
||||||
<p>
|
<p>
|
||||||
@@ -112,6 +130,7 @@ export function Dashboard() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<AdminInvites onChange={load} />
|
||||||
{maps.map((m) => (
|
{maps.map((m) => (
|
||||||
<article className="draft-row" key={m.id}>
|
<article className="draft-row" key={m.id}>
|
||||||
<div>
|
<div>
|
||||||
@@ -119,6 +138,9 @@ export function Dashboard() {
|
|||||||
{m.source === "plugin" ? t("SERVER EXPORT") : t("PLAYER CAPTURE")}
|
{m.source === "plugin" ? t("SERVER EXPORT") : t("PLAYER CAPTURE")}
|
||||||
</span>
|
</span>
|
||||||
<h3>{m.name}</h3>
|
<h3>{m.name}</h3>
|
||||||
|
{m.access === "admin" && (
|
||||||
|
<span className="badge">{t("Map admin")}</span>
|
||||||
|
)}{" "}
|
||||||
<span className="badge">
|
<span className="badge">
|
||||||
{m.contribution_status
|
{m.contribution_status
|
||||||
? t("Contribution: {status}", {
|
? t("Contribution: {status}", {
|
||||||
|
|||||||
219
components/DeveloperDocumentation.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
496
components/DeveloperPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
components/DiscordIntegration.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { useI18n } from "@/components/I18nProvider";
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { api } from "./StartGame";
|
import { api, StartGame } from "./StartGame";
|
||||||
import type { DatasetManifest } from "@/shared/protocol";
|
import type { DatasetManifest } from "@/shared/protocol";
|
||||||
import type { ProcessingJob } from "@/shared/processing-progress";
|
import type { ProcessingJob } from "@/shared/processing-progress";
|
||||||
import { ProcessingProgress } from "./ProcessingProgress";
|
import { ProcessingProgress } from "./ProcessingProgress";
|
||||||
@@ -13,7 +13,13 @@ import { OwnerContributions } from "./OwnerContributions";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { MapRefresh } from "./MapRefresh";
|
import { MapRefresh } from "./MapRefresh";
|
||||||
import { MapAppeal } from "./MapAppeal";
|
import { MapAppeal } from "./MapAppeal";
|
||||||
|
import { MapAdmins } from "./MapAdmins";
|
||||||
|
import { ExportMap } from "./MapTransfer";
|
||||||
type Draft = {
|
type Draft = {
|
||||||
|
access: "owner" | "admin";
|
||||||
|
imported_from_id: string | null;
|
||||||
|
imported_owner_name: string | null;
|
||||||
|
ownsPublishedParent: boolean;
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -33,7 +39,13 @@ type Draft = {
|
|||||||
banned_at: string | null;
|
banned_at: string | null;
|
||||||
ban_reason: 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 { t } = useI18n();
|
||||||
const dirty = useRef(false);
|
const dirty = useRef(false);
|
||||||
const [draft, setDraft] = useState<Draft | null>(null),
|
const [draft, setDraft] = useState<Draft | null>(null),
|
||||||
@@ -78,7 +90,7 @@ export function DraftEditor({ id }: { id: string }) {
|
|||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
};
|
};
|
||||||
}, [id, version]);
|
}, [id, version]);
|
||||||
async function save(submit = false) {
|
async function save(submit = false, publishOwnContribution = false) {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
setMessage("");
|
setMessage("");
|
||||||
@@ -90,12 +102,25 @@ export function DraftEditor({ id }: { id: string }) {
|
|||||||
);
|
);
|
||||||
dirty.current = false;
|
dirty.current = false;
|
||||||
if (submit) {
|
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);
|
setVersion((v) => v + 1);
|
||||||
setMessage(
|
setMessage(
|
||||||
draft?.parent_dataset_id
|
publishUpdate || publishOwnContribution
|
||||||
? "Submitted for map owner approval."
|
? "Map update published."
|
||||||
: "Submitted for moderator review.",
|
: draft?.parent_dataset_id
|
||||||
|
? "Submitted for map owner approval."
|
||||||
|
: "Submitted for moderator review.",
|
||||||
);
|
);
|
||||||
} else setMessage("Draft saved.");
|
} else setMessage("Draft saved.");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -147,9 +172,48 @@ export function DraftEditor({ id }: { id: string }) {
|
|||||||
<div className="editor-navigation">
|
<div className="editor-navigation">
|
||||||
<Link href="/dashboard">{t("← Your maps")}</Link>
|
<Link href="/dashboard">{t("← Your maps")}</Link>
|
||||||
<a href="#locations">{t("Choose locations")}</a>
|
<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>
|
</div>
|
||||||
<span className="badge">{t(statusMessage(draft.state))}</span>
|
<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.parent_dataset_id &&
|
||||||
(draft.report_strikes > 0 || draft.banned_at) && (
|
(draft.report_strikes > 0 || draft.banned_at) && (
|
||||||
<MapAppeal
|
<MapAppeal
|
||||||
@@ -333,30 +397,48 @@ export function DraftEditor({ id }: { id: string }) {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="button primary"
|
className="button primary"
|
||||||
disabled={busy || refreshing || selected.length < minimum}
|
disabled={
|
||||||
|
busy ||
|
||||||
|
refreshing ||
|
||||||
|
selected.length < minimum ||
|
||||||
|
!!draft.imported_from_id
|
||||||
|
}
|
||||||
onClick={() => save(true)}
|
onClick={() => save(true)}
|
||||||
>
|
>
|
||||||
{draft.parent_dataset_id
|
{draft.parent_dataset_id
|
||||||
? t("Submit to map owner →")
|
? t("Submit to map owner →")
|
||||||
: t("Submit for review →")}
|
: draft.state === "published"
|
||||||
|
? t("Publish update →")
|
||||||
|
: t("Submit for review →")}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!draft.parent_dataset_id && (
|
{!draft.imported_from_id && !draft.parent_dataset_id && (
|
||||||
<div id="map-tools">
|
<div id="map-tools">
|
||||||
<MapRefresh
|
<MapRefresh
|
||||||
id={id}
|
id={id}
|
||||||
|
refreshKey={version}
|
||||||
|
job={draft.upload_jobs}
|
||||||
disabled={busy || !editable || refreshing}
|
disabled={busy || !editable || refreshing}
|
||||||
onQueued={() => {
|
onQueued={() => {
|
||||||
setMessage(
|
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);
|
setVersion((v) => v + 1);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!draft.parent_dataset_id && (
|
{!draft.imported_from_id && !draft.parent_dataset_id && (
|
||||||
<OwnerContributions
|
<OwnerContributions
|
||||||
id={id}
|
id={id}
|
||||||
allowed={draft.allow_contributions}
|
allowed={draft.allow_contributions}
|
||||||
@@ -367,6 +449,9 @@ export function DraftEditor({ id }: { id: string }) {
|
|||||||
onChange={() => setVersion((v) => v + 1)}
|
onChange={() => setVersion((v) => v + 1)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{!draft.imported_from_id && !draft.parent_dataset_id && (
|
||||||
|
<MapAdmins id={id} access={draft.access} />
|
||||||
|
)}
|
||||||
{(["uploading", "processing"].includes(draft.state) ||
|
{(["uploading", "processing"].includes(draft.state) ||
|
||||||
(draft.parent_dataset_id &&
|
(draft.parent_dataset_id &&
|
||||||
["pending", "submitted"].includes(
|
["pending", "submitted"].includes(
|
||||||
|
|||||||
111
components/FirstVisitIntro.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,12 +9,13 @@ import {
|
|||||||
MousePointer2,
|
MousePointer2,
|
||||||
Move,
|
Move,
|
||||||
MapPin,
|
MapPin,
|
||||||
|
Map as MapIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Brand } from "./Brand";
|
import { Brand } from "./Brand";
|
||||||
import { Panorama } from "./Panorama";
|
import { Panorama } from "./Panorama";
|
||||||
import { GuessMap, type Pin } from "./GuessMap";
|
import { GuessMap, type Pin } from "./GuessMap";
|
||||||
import { api } from "./StartGame";
|
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 { roundClock } from "@/shared/round-clock";
|
||||||
import { ReportContent } from "./ReportContent";
|
import { ReportContent } from "./ReportContent";
|
||||||
export type RoundResult = {
|
export type RoundResult = {
|
||||||
@@ -27,7 +28,8 @@ export type RoundResult = {
|
|||||||
export type SessionView = {
|
export type SessionView = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
difficulty: Difficulty;
|
difficulty: string;
|
||||||
|
difficultySettings: DifficultyPreset;
|
||||||
round: number;
|
round: number;
|
||||||
bounds: Bounds;
|
bounds: Bounds;
|
||||||
map: string;
|
map: string;
|
||||||
@@ -73,7 +75,7 @@ export function Game({ id }: { id: string }) {
|
|||||||
s.rounds[s.round].deadline!,
|
s.rounds[s.round].deadline!,
|
||||||
performance.now(),
|
performance.now(),
|
||||||
)
|
)
|
||||||
: difficulties[s.difficulty].seconds,
|
: s.difficultySettings.seconds,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (version === loadVersion.current) setError((e as Error).message);
|
if (version === loadVersion.current) setError((e as Error).message);
|
||||||
@@ -174,6 +176,7 @@ export function Game({ id }: { id: string }) {
|
|||||||
setSession(null);
|
setSession(null);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
setPin(null);
|
setPin(null);
|
||||||
|
setExpanded(false);
|
||||||
setLoaded({ panorama: false, map: false });
|
setLoaded({ panorama: false, map: false });
|
||||||
timingOut.current = false;
|
timingOut.current = false;
|
||||||
setError("");
|
setError("");
|
||||||
@@ -189,12 +192,13 @@ export function Game({ id }: { id: string }) {
|
|||||||
const total =
|
const total =
|
||||||
session?.rounds.reduce((n, r) => n + (r.result?.score || 0), 0) || 0;
|
session?.rounds.reduce((n, r) => n + (r.result?.score || 0), 0) || 0;
|
||||||
return (
|
return (
|
||||||
<main className="game-page">
|
<main className="game-page" id="main-content" tabIndex={-1}>
|
||||||
{session && current && (
|
{session && current && (
|
||||||
<Panorama
|
<Panorama
|
||||||
key={session.round}
|
key={session.round}
|
||||||
urls={current.faces}
|
urls={current.faces}
|
||||||
zoom={difficulties[session.difficulty].zoom}
|
zoom={session.difficultySettings.zoom}
|
||||||
|
pan={session.difficultySettings.pan}
|
||||||
onReady={ready}
|
onReady={ready}
|
||||||
onError={onError}
|
onError={onError}
|
||||||
/>
|
/>
|
||||||
@@ -213,13 +217,15 @@ export function Game({ id }: { id: string }) {
|
|||||||
<small>{t("ROUND")}</small>
|
<small>{t("ROUND")}</small>
|
||||||
<strong>{session ? session.round + 1 : "—"} / 5</strong>
|
<strong>{session ? session.round + 1 : "—"} / 5</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
{session?.difficultySettings.timerVisible !== false && (
|
||||||
<small>{t("TIME LEFT")}</small>
|
<div>
|
||||||
<strong className={remaining <= 10 ? "urgent" : ""}>
|
<small>{t("TIME LEFT")}</small>
|
||||||
{Math.floor(remaining / 60)}:
|
<strong className={remaining <= 10 ? "urgent" : ""}>
|
||||||
{String(remaining % 60).padStart(2, "0")}
|
{Math.floor(remaining / 60)}:
|
||||||
</strong>
|
{String(remaining % 60).padStart(2, "0")}
|
||||||
</div>
|
</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<small>{t("POINTS")}</small>
|
<small>{t("POINTS")}</small>
|
||||||
<strong>{formatNumber(total)}</strong>
|
<strong>{formatNumber(total)}</strong>
|
||||||
@@ -228,50 +234,84 @@ export function Game({ id }: { id: string }) {
|
|||||||
</div>
|
</div>
|
||||||
{session && (
|
{session && (
|
||||||
<div className={"game-map " + (expanded ? "expanded" : "")}>
|
<div className={"game-map " + (expanded ? "expanded" : "")}>
|
||||||
<div className="map-topline">
|
<button
|
||||||
<span>{t("↑ N · PLACE YOUR GUESS")}</span>
|
className="mobile-map-toggle"
|
||||||
<button
|
aria-expanded={expanded}
|
||||||
aria-label={expanded ? t("Collapse map") : t("Expand map")}
|
aria-controls="guess-map-panel"
|
||||||
onClick={() => setExpanded((v) => !v)}
|
onClick={() => setExpanded(true)}
|
||||||
>
|
>
|
||||||
{expanded ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
|
<MapIcon size={20} aria-hidden="true" />
|
||||||
</button>
|
<span>{pin ? t("Edit your guess") : t("Open guess map")}</span>
|
||||||
</div>
|
{pin && session.difficultySettings.coordinates !== false && (
|
||||||
<GuessMap
|
<small>
|
||||||
key={"map-" + session.round}
|
X {Math.round(pin.x)} · Z {Math.round(pin.z)}
|
||||||
bounds={session.bounds}
|
</small>
|
||||||
image={session.map}
|
)}
|
||||||
pin={result ? result.guess : pin}
|
</button>
|
||||||
actual={result?.actual}
|
<div className="game-map-content" id="guess-map-panel">
|
||||||
onPin={setPin}
|
<div className="map-topline">
|
||||||
onReady={mapReady}
|
<span>
|
||||||
onError={onError}
|
{t(
|
||||||
disabled={!!result || busy}
|
session.difficultySettings.compass === false
|
||||||
/>
|
? "PLACE YOUR GUESS"
|
||||||
<div className="map-actions">
|
: "↑ N · PLACE YOUR GUESS",
|
||||||
<small>
|
)}
|
||||||
{pin
|
</span>
|
||||||
? `X ${Math.round(pin.x)} · Z ${Math.round(pin.z)}`
|
<button
|
||||||
: t("Click the map to drop a pin")}
|
aria-label={expanded ? t("Collapse map") : t("Expand map")}
|
||||||
</small>
|
onClick={() => setExpanded((v) => !v)}
|
||||||
<button
|
>
|
||||||
className="button primary full"
|
{expanded ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
|
||||||
disabled={!pin || busy || !!result || !current?.deadline}
|
</button>
|
||||||
onClick={() => guess()}
|
</div>
|
||||||
>
|
<GuessMap
|
||||||
<MapPin size={15} />
|
key={"map-" + session.round}
|
||||||
{busy ? t("Checking your guess…") : t("Submit guess")}
|
bounds={session.bounds}
|
||||||
</button>
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="look-help">
|
<div className="look-help">
|
||||||
<span>
|
<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>
|
||||||
<span>
|
<span>
|
||||||
<MousePointer2 size={12} />{" "}
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{(!session || !loaded.panorama || !loaded.map) && !error && (
|
{(!session || !loaded.panorama || !loaded.map) && !error && (
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ export function GuessMap({
|
|||||||
onReady,
|
onReady,
|
||||||
onError,
|
onError,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
zoom = true,
|
||||||
|
pan = true,
|
||||||
|
compass = true,
|
||||||
}: {
|
}: {
|
||||||
bounds: Bounds;
|
bounds: Bounds;
|
||||||
image: string;
|
image: string;
|
||||||
@@ -24,6 +27,9 @@ export function GuessMap({
|
|||||||
onReady?: () => void;
|
onReady?: () => void;
|
||||||
onError?: (message: string) => void;
|
onError?: (message: string) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
zoom?: boolean;
|
||||||
|
pan?: boolean;
|
||||||
|
compass?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const root = useRef<HTMLDivElement>(null),
|
const root = useRef<HTMLDivElement>(null),
|
||||||
@@ -59,7 +65,21 @@ export function GuessMap({
|
|||||||
zoomSnap: 0,
|
zoomSnap: 0,
|
||||||
maxBounds: b.pad(0.15),
|
maxBounds: b.pad(0.15),
|
||||||
attributionControl: false,
|
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.
|
// Install cleanup immediately, including when later initialization fails.
|
||||||
let observer: ResizeObserver | undefined;
|
let observer: ResizeObserver | undefined;
|
||||||
cleanup = () => {
|
cleanup = () => {
|
||||||
@@ -90,6 +110,7 @@ export function GuessMap({
|
|||||||
m.fitBounds(b, { padding: [0, 0], animate: false });
|
m.fitBounds(b, { padding: [0, 0], animate: false });
|
||||||
const fitZoom = m.getZoom();
|
const fitZoom = m.getZoom();
|
||||||
m.setMinZoom(fitZoom);
|
m.setMinZoom(fitZoom);
|
||||||
|
if (!zoom) m.setMaxZoom(fitZoom);
|
||||||
markers.current = L.layerGroup().addTo(m);
|
markers.current = L.layerGroup().addTo(m);
|
||||||
setGeneration((v) => v + 1);
|
setGeneration((v) => v + 1);
|
||||||
m.on("click", (e: Leaflet.LeafletMouseEvent) => {
|
m.on("click", (e: Leaflet.LeafletMouseEvent) => {
|
||||||
@@ -105,6 +126,29 @@ export function GuessMap({
|
|||||||
callbacks.current.onPin?.({ x, z });
|
callbacks.current.onPin?.({ x, z });
|
||||||
});
|
});
|
||||||
const keydown = (event: KeyboardEvent) => {
|
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 (
|
if (
|
||||||
event.key !== "Enter" ||
|
event.key !== "Enter" ||
|
||||||
event.target !== m.getContainer() ||
|
event.target !== m.getContainer() ||
|
||||||
@@ -132,11 +176,14 @@ export function GuessMap({
|
|||||||
const wasFitted = Math.abs(m.getZoom() - fittedZoom) < 0.01;
|
const wasFitted = Math.abs(m.getZoom() - fittedZoom) < 0.01;
|
||||||
m.invalidateSize({ pan: false });
|
m.invalidateSize({ pan: false });
|
||||||
m.setMinZoom(-5);
|
m.setMinZoom(-5);
|
||||||
|
// Unlock only while refitting a resized fixed-zoom map.
|
||||||
|
if (!zoom) m.setMaxZoom(Infinity);
|
||||||
fittedZoom = m.getBoundsZoom(b);
|
fittedZoom = m.getBoundsZoom(b);
|
||||||
m.setMaxZoom(Math.max(3, fittedZoom + 3));
|
m.setMaxZoom(Math.max(3, fittedZoom + 3));
|
||||||
if (wasFitted || m.getZoom() < fittedZoom)
|
if (wasFitted || m.getZoom() < fittedZoom)
|
||||||
m.fitBounds(b, { padding: [0, 0], animate: false });
|
m.fitBounds(b, { padding: [0, 0], animate: false });
|
||||||
m.setMinZoom(fittedZoom);
|
m.setMinZoom(fittedZoom);
|
||||||
|
if (!zoom) m.setMaxZoom(fittedZoom);
|
||||||
});
|
});
|
||||||
observer.observe(root.current);
|
observer.observe(root.current);
|
||||||
})
|
})
|
||||||
@@ -146,7 +193,16 @@ export function GuessMap({
|
|||||||
clearTimeout(loadTimer);
|
clearTimeout(loadTimer);
|
||||||
cleanup();
|
cleanup();
|
||||||
};
|
};
|
||||||
}, [bounds.minX, bounds.maxX, bounds.minZ, bounds.maxZ, image, attempt]);
|
}, [
|
||||||
|
bounds.minX,
|
||||||
|
bounds.maxX,
|
||||||
|
bounds.minZ,
|
||||||
|
bounds.maxZ,
|
||||||
|
image,
|
||||||
|
attempt,
|
||||||
|
zoom,
|
||||||
|
pan,
|
||||||
|
]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const L = lib.current,
|
const L = lib.current,
|
||||||
group = markers.current;
|
group = markers.current;
|
||||||
@@ -176,14 +232,23 @@ export function GuessMap({
|
|||||||
<div className="map-surface">
|
<div className="map-surface">
|
||||||
<div
|
<div
|
||||||
ref={root}
|
ref={root}
|
||||||
|
tabIndex={0}
|
||||||
style={{ height: "100%", width: "100%" }}
|
style={{ height: "100%", width: "100%" }}
|
||||||
role="application"
|
role="application"
|
||||||
aria-label={
|
aria-label={
|
||||||
disabled
|
!pan
|
||||||
? t("Minecraft map. North is up.")
|
? t(
|
||||||
: t(
|
"Minecraft map. Click to place a guess, or press Enter to pin the center.",
|
||||||
"Minecraft map. North is up. Click to place a guess, or use arrow keys to pan and 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 && (
|
{error && (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useI18n } from "@/components/I18nProvider";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ArrowRight } from "lucide-react";
|
import { ArrowRight } from "lucide-react";
|
||||||
import { PixelTrophy, PlayerAvatar } from "./PixelArt";
|
import { PixelTrophy, PlayerAvatar } from "./PixelArt";
|
||||||
|
import { Username } from "./Username";
|
||||||
import {
|
import {
|
||||||
type LeaderboardData,
|
type LeaderboardData,
|
||||||
type LeaderboardEntry,
|
type LeaderboardEntry,
|
||||||
@@ -103,9 +104,27 @@ export function LeaderboardTable({
|
|||||||
</td>
|
</td>
|
||||||
<th scope="row">
|
<th scope="row">
|
||||||
<div className="leaderboard-player">
|
<div className="leaderboard-player">
|
||||||
<PlayerAvatar id={entry.player_id} />
|
<PlayerAvatar
|
||||||
|
id={entry.player_id}
|
||||||
|
avatarPath={entry.avatar_path}
|
||||||
|
/>
|
||||||
<span className="player-name">
|
<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 && (
|
{entry.player_id === playerId && (
|
||||||
<span className="you-label">{t("You")}</span>
|
<span className="you-label">{t("You")}</span>
|
||||||
)}
|
)}
|
||||||
@@ -151,8 +170,18 @@ export function WinnersPodium({ leaders }: { leaders: LeaderboardEntry[] }) {
|
|||||||
place: String(index + 1).padStart(2, "0"),
|
place: String(index + 1).padStart(2, "0"),
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
<PlayerAvatar id={entry.player_id} />
|
<PlayerAvatar id={entry.player_id} avatarPath={entry.avatar_path} />
|
||||||
<h2>{entry.display_name}</h2>
|
<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>
|
<p>
|
||||||
{formatNumber(entry.best_score)} <span>/ 25,000</span>
|
{formatNumber(entry.best_score)} <span>/ 25,000</span>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
147
components/LegacyPasswordNotice.tsx
Normal 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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||