Compare commits
85 Commits
c279f35e14
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 08fe8c9c53 | |||
| 10b86ae23e | |||
| cd79b0e540 | |||
| 25df9f9398 | |||
| 1958a96acf | |||
| e45ce210ba | |||
| d12071df04 | |||
| a67b9cf59e | |||
| d303d23d18 | |||
| 614e47598b | |||
| 69871c8a93 | |||
| 015bf7fbe3 | |||
| d0bc81f0f7 | |||
| 35be2e6b76 | |||
| 33b9ebc7f6 | |||
| b9929da54d | |||
| 36c28362bc | |||
| 07a4ae469d | |||
| 2c3e4cf385 | |||
| 4855447b29 | |||
| 14ef545bbb | |||
| 7222904c91 | |||
| 8a71709048 | |||
| db63aa7d10 | |||
| 39b9262ee9 | |||
| 037b1777d6 | |||
| 1019da66a3 | |||
| 0b098ae06e | |||
| db270a9695 | |||
| 4750c0d6c7 | |||
| 220b43d007 | |||
| 37d21cacf9 | |||
| 3af14d0144 | |||
| 369b4dc0a0 | |||
| c3b2d89ddc | |||
| 362228f985 | |||
| c8a6c77a8e | |||
| c233b1bfbc | |||
| de1ac2ee17 | |||
| 5d5602fbd7 | |||
| 97347693b6 | |||
| a138bbfd72 | |||
| 6c7e259fcb | |||
| 096ef82420 | |||
| c249172baa | |||
| fa8cede6ae | |||
| 84196d59d0 | |||
| 27237bc7c1 | |||
| 90c3be7bb5 | |||
| d21a7f0e92 | |||
| df8ffca53e | |||
| 067e990306 | |||
| 673202e1e4 | |||
| 1ec3a9c1ee | |||
| 663b87a4e4 | |||
| a2821f2bbc | |||
| cfd60131fb | |||
| 95c63e1ee0 | |||
| 46103495fb | |||
| 1709bcd340 | |||
| 67d1f82e6a | |||
| ff645de2c8 | |||
| 6df885eb13 | |||
| 6fac1647aa | |||
| a6155819b6 | |||
| 3185fea4c1 | |||
| f3c38da56c | |||
| 21e6080e78 | |||
| ab3d2c201b | |||
| 3486546fb3 | |||
| 3d52951355 | |||
| 5cec2993d7 | |||
| 843347ad00 | |||
| 4c874e20ed | |||
| 67718d8e05 | |||
| 6e91749f59 | |||
| 853405afe7 | |||
| f7858d4ec4 | |||
| 39be74d028 | |||
| c40cbe7594 | |||
| 58f0312018 | |||
| 8c3d85f691 | |||
| 2405eee57d | |||
| e1ccdda961 | |||
| 5172316919 |
126
.claude/skills/deploy/SKILL.md
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
---
|
||||||
|
name: deploy
|
||||||
|
description: Redeploy the Ulicraft Minecraft stack to the production host `cochi` — git pull on main then a hard restart (compose down + up). Use when the user says "deploy", "redeploy", "deploy to cochi", "push to prod", "ship it", or "restart the server" for this project. Causes brief Minecraft downtime.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Deploy Ulicraft to `cochi`
|
||||||
|
|
||||||
|
Redeploy the running stack on the production host. Full reference:
|
||||||
|
`plan/14-deploy.md`.
|
||||||
|
|
||||||
|
## Fixed parameters
|
||||||
|
|
||||||
|
- Host: `cochi` (SSH alias)
|
||||||
|
- Path: `/home/ubuntu/mc/ulicraft-server-v1`
|
||||||
|
- Branch: `main`
|
||||||
|
- Stack: single unified `docker-compose.yml` (drasl, minecraft, mc-backup,
|
||||||
|
caddy, nmsr, mc-status, uptime-kuma, filestash)
|
||||||
|
- Auth: Drasl
|
||||||
|
- Restart: **hard** (down → up) — players are disconnected for a few minutes
|
||||||
|
|
||||||
|
## Procedure
|
||||||
|
|
||||||
|
This is an outward-facing, downtime-causing action on production. The user
|
||||||
|
invoking this skill is the authorization to proceed, but:
|
||||||
|
|
||||||
|
1. **Pre-check (read-only).** See what would change before tearing anything down:
|
||||||
|
```bash
|
||||||
|
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && git fetch && git log --oneline HEAD..@{u}'
|
||||||
|
```
|
||||||
|
Report the incoming commits. If the list is empty, tell the user the host is
|
||||||
|
already up to date and ask whether to restart anyway (they may want a plain
|
||||||
|
restart). If `git fetch` fails (host unreachable, auth), STOP and report.
|
||||||
|
|
||||||
|
**If the incoming commits ADD A SERVICE, check `.env` on the host first.**
|
||||||
|
A new service with `${FOO:?...}` guards (filestash uses them) fails the
|
||||||
|
**whole compose file**, not just that service — so a missing var means
|
||||||
|
`up` refuses to start *anything*, and a `--hard` deploy has already run
|
||||||
|
`down`. The stack stays down. Check before tearing it down:
|
||||||
|
```bash
|
||||||
|
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && grep -c "^FILES_" .env'
|
||||||
|
```
|
||||||
|
Same for a new **subdomain**: it needs a cert (`LE_SUBDOMAINS` +
|
||||||
|
`issue-letsencrypt.sh`) and an nginx vhost — see step 2b, which the deploy
|
||||||
|
does NOT do for you.
|
||||||
|
|
||||||
|
2. **Pull, ensure authlib, then `update-all.sh --hard`** (single SSH session,
|
||||||
|
halts on any error):
|
||||||
|
```bash
|
||||||
|
ssh cochi 'set -e
|
||||||
|
cd /home/ubuntu/mc/ulicraft-server-v1
|
||||||
|
git pull --ff-only
|
||||||
|
tooling/fetch-authlib.sh
|
||||||
|
./update-all.sh --hard'
|
||||||
|
```
|
||||||
|
- `--ff-only` refuses to merge — if the pull is not a fast-forward, STOP and
|
||||||
|
report (local changes on the host need manual resolution).
|
||||||
|
- `fetch-authlib.sh` ensures `runtime/authlib-injector.jar` exists (gitignored;
|
||||||
|
skips if already valid; NOT part of update-all since it rarely changes).
|
||||||
|
Missing jar = minecraft crashloops with "Error opening zip file or JAR
|
||||||
|
manifest missing". Run it before `update-all` so the jar is present when
|
||||||
|
compose brings minecraft up.
|
||||||
|
- **`update-all.sh` is the single source of truth** for the post-pull build +
|
||||||
|
restart steps (render drasl/nmsr configs from `.env`, sync server mods, regen
|
||||||
|
the landing mod list, rebuild `www/`, then apply via docker compose). It is
|
||||||
|
also runnable on its own for a lighter rolling update (no `--hard` =
|
||||||
|
change-detected restart, no world downtime). `--hard` here does
|
||||||
|
`down --remove-orphans && up -d --build` for the deliberate full restart.
|
||||||
|
`render-config.sh` halts on an invalid `REGISTRATION_MODE` (must be `invite`
|
||||||
|
or `open`); a missing distribution jar halts `sync-server-mods.sh`. The
|
||||||
|
landing rebuild is unconditional (the gitignored `www/` is never updated by
|
||||||
|
`git pull`), so no separate landing step is needed.
|
||||||
|
|
||||||
|
2b. **Only if the deploy changed `nginx/ulicraft-caddy.conf.tmpl`** (a new
|
||||||
|
subdomain, a new body-size/rate-limit rule, a changed `CADDY_HTTP_PORT`).
|
||||||
|
`update-all.sh` does **not** touch the host nginx, so a new vhost simply
|
||||||
|
won't exist and the subdomain 404s / hits the wrong server block:
|
||||||
|
```bash
|
||||||
|
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && tooling/render-nginx.sh --install'
|
||||||
|
```
|
||||||
|
It runs `nginx -t` before reloading, so a bad render fails safe. The cert
|
||||||
|
must already exist (`certs/<name>/cert.pem`) or nginx won't load the block —
|
||||||
|
issue it first with `tooling/issue-letsencrypt.sh` (reads `LE_SUBDOMAINS`).
|
||||||
|
|
||||||
|
3. **Verify.**
|
||||||
|
```bash
|
||||||
|
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && docker compose ps'
|
||||||
|
```
|
||||||
|
Confirm `caddy`, `drasl`, `minecraft`, `mc-backup`, `nmsr`, `mc-status`,
|
||||||
|
`uptime-kuma`, `filestash` are Up. Tail the minecraft log briefly and confirm
|
||||||
|
it reaches the ready marker (match `Done (Ns)! For help` — a bare `Done`
|
||||||
|
also matches unrelated mod-loading lines):
|
||||||
|
```bash
|
||||||
|
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && timeout 180 docker compose logs -f minecraft' 2>/dev/null | grep -m1 'Done ([0-9.]*s)! For help'
|
||||||
|
```
|
||||||
|
If a public vhost changed, verify it end-to-end from your workstation rather
|
||||||
|
than trusting `compose ps` (a container can be Up while nginx never routes to
|
||||||
|
it): `curl -s -o /dev/null -w '%{http_code}' https://<vhost>/`.
|
||||||
|
|
||||||
|
## Guardrails
|
||||||
|
|
||||||
|
- Never `git reset --hard` or discard changes on `cochi` without telling the user
|
||||||
|
first — there may be host-local edits.
|
||||||
|
- **Caddy conf changes need a recreate, not reload/restart.** If the deploy
|
||||||
|
touched any `caddy/conf.d/*.caddy` or the Caddyfile and you did a *rolling*
|
||||||
|
`update-all.sh` (not `--hard`), the change WON'T apply: those are single-file
|
||||||
|
bind mounts pinned to the host inode, and `git pull` gives the file a new inode.
|
||||||
|
`restart`/`caddy reload` reuse the stale inode. Run
|
||||||
|
`docker compose up -d --force-recreate caddy`, then verify with
|
||||||
|
`docker compose exec caddy cat /etc/caddy/conf.d/<file>`. The `--hard` path
|
||||||
|
(full `down`/`up`) already recreates, so it's unaffected.
|
||||||
|
- **Same trap for `filestash/config.json`** — also a single-file bind mount, and
|
||||||
|
it is *re-rendered on every run* by `render-config.sh`, so a rolling
|
||||||
|
`update-all.sh` leaves filestash reading the stale inode after any `FILES_*`
|
||||||
|
change (rotating the shared password, flipping `FILES_AUTH`). Apply with
|
||||||
|
`docker compose up -d --force-recreate filestash`. `--hard` is unaffected.
|
||||||
|
- **Never let `filestash/config.json` be missing when compose runs.** It is
|
||||||
|
gitignored and rendered; if it doesn't exist, Docker creates a *directory* at
|
||||||
|
that path and filestash breaks in a confusing way. `update-all.sh` renders
|
||||||
|
before `up`, so the normal path is safe — just don't hand-run `docker compose
|
||||||
|
up` on the host without rendering first. If it happened: `rm -rf
|
||||||
|
filestash/config.json && tooling/render-config.sh && docker compose up -d
|
||||||
|
--force-recreate filestash`.
|
||||||
|
- Volumes (`mc_data`, `drasl_state`, `kuma_data`) persist across `down`; the
|
||||||
|
world and monitors are safe. Do NOT pass `-v`/`--volumes` to `down`.
|
||||||
|
- If a step fails, STOP and surface the exact error + the failing command. Do not
|
||||||
|
improvise recovery on production.
|
||||||
117
.env.example
@@ -1,19 +1,112 @@
|
|||||||
# Copy to .env and fill in real values
|
# Copy to .env and fill in real values
|
||||||
|
|
||||||
# Single base domain for the whole stack; every service is a subdomain of this.
|
# Single base domain for the whole stack; every service is a subdomain of this.
|
||||||
# Use .lan (NOT .local) — .local is intercepted by mDNS on macOS/Linux and
|
# DNS is handled OUTSIDE this repo — point ${BASE_DOMAIN} and every subdomain
|
||||||
# won't resolve through a normal unicast DNS server.
|
# (auth. avatar. status. distribution. www.) at the host running nginx.
|
||||||
BASE_DOMAIN=ulicraft.lan
|
BASE_DOMAIN=ulicraft.net
|
||||||
# The Docker host's LAN IP — every service name resolves here.
|
|
||||||
HOST_LAN_IP=192.168.1.10
|
|
||||||
|
|
||||||
# mDNS toggle. true + BASE_DOMAIN=*.local → an Avahi container publishes the
|
|
||||||
# service names over mDNS (zero-config for macOS/Linux guests; Windows needs
|
|
||||||
# Bonjour). false → use your own party DNS (records: tooling/dns-records.sh).
|
|
||||||
# NOTE: mDNS resolves ONLY *.local; it cannot serve the air-gap upstream-host
|
|
||||||
# spoofs — those always need a real DNS server.
|
|
||||||
ENABLE_MDNS=false
|
|
||||||
|
|
||||||
RCON_PASSWORD=change-me-to-something-random
|
RCON_PASSWORD=change-me-to-something-random
|
||||||
|
|
||||||
|
# Registration mode (BACKEND gate) for self-hosted accounts (Drasl).
|
||||||
|
# invite = closed; guests need an admin-minted invite code (recommended on a
|
||||||
|
# public, internet-present host). Admin mints via POST /drasl/api/v2/invites.
|
||||||
|
# open = anyone can self-register with username+password (set [RateLimit]!).
|
||||||
|
# Consumed by render-config.sh (-> Drasl RegistrationNewPlayer.RequireInvite).
|
||||||
|
# Default: invite. NOTE: this no longer controls the landing invite FIELD — that's
|
||||||
|
# REGISTRATION_SHOW_INVITE below.
|
||||||
|
REGISTRATION_MODE=invite
|
||||||
|
|
||||||
|
# Show the invite-code FIELD on the landing register form. Read by the landing
|
||||||
|
# BUILD (data/site.ts) only; default false (hidden). Independent of the backend
|
||||||
|
# gate above. CAVEAT: if REGISTRATION_MODE=invite (Drasl requires a code) but this
|
||||||
|
# is false, public self-registration can't supply one — enable both together, or
|
||||||
|
# mint accounts admin-side. true | false.
|
||||||
|
REGISTRATION_SHOW_INVITE=false
|
||||||
|
|
||||||
|
# NMSR avatar render mode for the landing's avatar tiles. Read by the landing
|
||||||
|
# BUILD (data/site.ts) only. Avatar URL =
|
||||||
|
# https://avatar.${BASE_DOMAIN}/<mode>/<uuid>?size=N. e.g. headiso | head | fullbody.
|
||||||
|
# AVATAR_MODE → ServerCard online row + account preview (heads)
|
||||||
|
# ROSTER_AVATAR_MODE → Members grid (full body; default fullbodyiso)
|
||||||
|
AVATAR_MODE=headiso
|
||||||
|
ROSTER_AVATAR_MODE=fullbodyiso
|
||||||
|
|
||||||
|
# Drasl admin creds for the registered-players roster (/api/mcstatus/players).
|
||||||
|
# Consumed ONLY by the mc-status container (server-side login → sanitized
|
||||||
|
# [{uuid,name}] list); these NEVER reach the browser. Use a DEDICATED, rotatable
|
||||||
|
# admin account (Drasl has no read-only role) to limit blast radius. Leave blank
|
||||||
|
# to disable the roster (the Members grid then just shows its empty state).
|
||||||
|
DRASL_ADMIN_USERNAME=
|
||||||
|
DRASL_ADMIN_PASSWORD=
|
||||||
|
|
||||||
|
# caddy host-published port. The host's own nginx terminates TLS and
|
||||||
|
# reverse-proxies to caddy by Host header (nginx/ulicraft-caddy.conf.tmpl), so
|
||||||
|
# bind caddy to a high localhost-only port — only nginx should reach it.
|
||||||
|
CADDY_HTTP_PORT=8880
|
||||||
|
CADDY_HTTP_BIND=127.0.0.1
|
||||||
|
|
||||||
|
# distribution.${BASE_DOMAIN} static vhost. Absolute host path to the built
|
||||||
|
# static site — it lives in ANOTHER repo. Bind-mounted read-only into caddy.
|
||||||
|
DISTRIBUTION_WEB_ROOT=/home/oier/projects/mc-mods/ulicraft-group/ulicraft-distribution/dist
|
||||||
|
|
||||||
|
# ── files.${BASE_DOMAIN} — Filestash player file share ────────────────
|
||||||
|
# Screenshots / schematics / maps. ONE shared login for all players, writable.
|
||||||
|
# Full design + gotchas: plan/20-files.md.
|
||||||
|
|
||||||
|
# Host path holding the shared files. Keep it OUTSIDE the repo — guest-writable
|
||||||
|
# data must not live in a git tree we `git pull` into.
|
||||||
|
FILES_DATA_DIR=/srv/ulicraft-files
|
||||||
|
|
||||||
|
# Literal mount flag for FILES_DATA_DIR: rw | ro. `ro` makes the share
|
||||||
|
# read-only at the KERNEL, regardless of what the Filestash UI thinks. It is a
|
||||||
|
# mount flag rather than a boolean because compose has no conditionals — the
|
||||||
|
# value is substituted straight into the volume string.
|
||||||
|
FILES_MOUNT_MODE=rw
|
||||||
|
|
||||||
|
# Auth middleware (rendered into config.json by render-config.sh):
|
||||||
|
# htpasswd one shared user/pass — the only internet-safe value
|
||||||
|
# passthrough NO LOGIN. Anyone reaching the vhost is in.
|
||||||
|
# none alias of passthrough
|
||||||
|
# passthrough/none on a WRITABLE, internet-reachable share is an open upload
|
||||||
|
# relay (malware/phishing hosted on your domain, under your cert, with your
|
||||||
|
# bandwidth — and a domain blocklisting would take auth. and the apex down
|
||||||
|
# with it). Only set it once this host is unreachable from the internet.
|
||||||
|
FILES_AUTH=htpasswd
|
||||||
|
|
||||||
|
# The shared player credential (FILES_AUTH=htpasswd).
|
||||||
|
# FILES_PASSWORD_HASH: openssl passwd -6 '<password>'
|
||||||
|
# MUST be $6$ (sha512). The htpasswd plugin's bcrypt branch only accepts $2a$,
|
||||||
|
# so a $2y$ hash from `htpasswd -B` fails EVERY login, silently.
|
||||||
|
# render-config.sh rejects the wrong format rather than shipping it.
|
||||||
|
FILES_USER=uli
|
||||||
|
FILES_PASSWORD_HASH=
|
||||||
|
|
||||||
|
# Filestash /admin console password — bcrypt, and /admin IS publicly exposed, so
|
||||||
|
# use a 20+ char generated password. It is the only credential guarding config.
|
||||||
|
# docker run --rm caddy:alpine caddy hash-password --plaintext '<password>'
|
||||||
|
FILES_ADMIN_PASSWORD_HASH=
|
||||||
|
|
||||||
|
# Session key (openssl rand -hex 16). Must be non-empty: an empty secret_key
|
||||||
|
# makes filestash rewrite config.json at boot, which fails on the :ro mount.
|
||||||
|
FILES_SECRET_KEY=
|
||||||
|
|
||||||
|
# Unlocks the local storage backend, which filestash admin-gates: it refuses to
|
||||||
|
# init unless the connection params carry this secret. config.json supplies it
|
||||||
|
# server-side; players never see it. openssl rand -hex 24.
|
||||||
|
FILES_LOCAL_SECRET=
|
||||||
|
|
||||||
# Only needed if using CurseForge-exclusive mods:
|
# Only needed if using CurseForge-exclusive mods:
|
||||||
# CF_API_KEY=your-curseforge-api-key
|
# CF_API_KEY=your-curseforge-api-key
|
||||||
|
|
||||||
|
# ── Let's Encrypt via OVH DNS-01 (tooling/issue-letsencrypt.sh) ───────
|
||||||
|
# Only needed to issue real TLS certs. DNS-01 needs no inbound ports.
|
||||||
|
LE_EMAIL=you@example.com
|
||||||
|
# space-separated subdomains; apex ${BASE_DOMAIN} is always included
|
||||||
|
LE_SUBDOMAINS=auth distribution www avatar status files
|
||||||
|
# OVH API creds (DNS zone write). Create at https://eu.api.ovh.com/createToken/
|
||||||
|
# OVH_CK can be blank on first run — acme.sh prints an auth URL, then paste it.
|
||||||
|
OVH_END_POINT=ovh-eu
|
||||||
|
OVH_AK=
|
||||||
|
OVH_AS=
|
||||||
|
OVH_CK=
|
||||||
|
# LE_STAGING=1 # use the untrusted staging CA for dry runs
|
||||||
|
|||||||
31
.gitignore
vendored
@@ -5,11 +5,17 @@ runtime/authlib-injector.jar
|
|||||||
|
|
||||||
# rendered configs (from tooling/render-config.sh)
|
# rendered configs (from tooling/render-config.sh)
|
||||||
drasl/config/config.toml
|
drasl/config/config.toml
|
||||||
dnsmasq/dnsmasq.conf
|
nmsr/config.toml
|
||||||
|
# holds the admin bcrypt hash, the shared player hash, the session key and the
|
||||||
|
# local-backend secret — only the .tmpl is tracked
|
||||||
|
filestash/config.json
|
||||||
|
|
||||||
# vendored / mirrored binaries
|
# server mod subset synced from the distribution repo by
|
||||||
mirror/
|
# tooling/sync-server-mods.sh (source of truth is mods-sides.json + $DIST jars)
|
||||||
pack/mods-files/
|
server-mods/
|
||||||
|
|
||||||
|
# vendored binaries
|
||||||
|
launcher/
|
||||||
|
|
||||||
# landing page: generated build output + deps
|
# landing page: generated build output + deps
|
||||||
www/
|
www/
|
||||||
@@ -17,5 +23,18 @@ landing/node_modules/
|
|||||||
landing/.astro/
|
landing/.astro/
|
||||||
landing/dist/
|
landing/dist/
|
||||||
|
|
||||||
# exported Caddy CA (per-deploy artifact)
|
# launcher download list: the custom-launcher build output launcher.json,
|
||||||
caddy/caddy-root-ca.crt
|
# synced from UlicraftLauncher/dist/launcher.json (launcher.example.json is
|
||||||
|
# committed as the documented shape)
|
||||||
|
landing/src/data/launcher.json
|
||||||
|
|
||||||
|
# mod catalogue + extracted logos: generated by tooling/build-modlist.py from
|
||||||
|
# the distribution forgemods (the .example.json is committed as the empty shape)
|
||||||
|
landing/src/data/mods.json
|
||||||
|
landing/public/mod-logos/
|
||||||
|
|
||||||
|
# compiled mc-status binary (built into the image; stray local `go build` output)
|
||||||
|
docker/mc-status/mc-status
|
||||||
|
|
||||||
|
# Let's Encrypt certs + private keys (issued by tooling/issue-letsencrypt.sh)
|
||||||
|
certs/
|
||||||
|
|||||||
415
CLAUDE.md
@@ -1,280 +1,219 @@
|
|||||||
# Minecraft LAN Party Server — Project Context
|
# Ulicraft Server — Project Context
|
||||||
|
|
||||||
> Self-hosted modded Minecraft server for a LAN party (~8 players), with
|
> Self-hosted modded Minecraft (NeoForge 1.21.1) with self-hosted auth/skins
|
||||||
> persistent auth/skin infrastructure designed to outlive the LAN weekend
|
> (Drasl), a distribution-fed modpack, avatar rendering, a guest landing page, and uptime
|
||||||
> and integrate with the existing homelab.
|
> monitoring. Public, internet-present deployment behind the host's nginx + TLS.
|
||||||
|
|
||||||
|
**`plan/` is the source of truth for the design.** Start at `plan/00-overview.md`.
|
||||||
|
This file is a fast orientation + the durable decisions/gotchas; when it disagrees
|
||||||
|
with `plan/` or the code, those win.
|
||||||
|
|
||||||
## Goals & Constraints
|
## Goals & Constraints
|
||||||
|
|
||||||
- **Players**: 4–10 friends, LAN-based
|
- **Players**: 4–10 friends
|
||||||
- **Modpack vibe**: Kitchen-sink (tech + magic + exploration + QoL)
|
- **Modpack vibe**: Kitchen-sink (tech + magic + exploration + QoL)
|
||||||
- **Pack approach**: Manually curated (~50–100 mods), NOT a forked existing pack
|
- **Pack approach**: Manually curated (~50–100 mods), NOT a forked existing pack
|
||||||
- **Minecraft version**: 1.21.1
|
- **Minecraft version**: 1.21.1, **NeoForge** (not classic Forge)
|
||||||
- **Mod loader**: NeoForge (NOT classic Forge — see Decisions below)
|
- **Auth/skins**: Drasl (Yggdrasil-compatible), **password login** — no OIDC
|
||||||
- **Network**: LAN party context, but stack is persistent (lives past the weekend)
|
- **DNS**: handled OUTSIDE this repo; point `${BASE_DOMAIN}` + subdomains at the host
|
||||||
- **Auth**: Self-hosted Yggdrasil-compatible (Drasl) + Keycloak OIDC
|
- **Persistence horizon**: long-term service, not disposable
|
||||||
- **Skins**: Handled by Drasl
|
|
||||||
- **Persistence horizon**: Long-term homelab service, not disposable
|
|
||||||
|
|
||||||
## Existing Homelab Context (relevant to this project)
|
|
||||||
|
|
||||||
- **Keycloak 26.0.7** running in Docker Compose (PostgreSQL 17, production mode,
|
|
||||||
bind-mounted secrets, xforwarded proxy headers). This is the IdP for everything.
|
|
||||||
- **NetBird** as the proxy/networking layer for remote access (NOT used for the
|
|
||||||
LAN party itself — see Decisions).
|
|
||||||
- **AdGuard Home** as recursive DNS resolver — used here for `*.home.local` LAN records.
|
|
||||||
- **Multi-VPS topology**: this Minecraft project likely runs on a local box or
|
|
||||||
beefier homelab node, not a VPS (RAM/CPU requirements).
|
|
||||||
- Operator timezone: Europe/Madrid.
|
|
||||||
|
|
||||||
## Key Decisions (with reasoning)
|
|
||||||
|
|
||||||
### NeoForge over classic Forge
|
|
||||||
Despite the user originally saying "Forge", the 1.21.x kitchen-sink ecosystem
|
|
||||||
(ATM10, Leaking Kitchen Sink, etc.) is overwhelmingly NeoForge in 2025/2026.
|
|
||||||
The original Forge team essentially migrated to NeoForge. `itzg/minecraft-server`
|
|
||||||
supports both via `TYPE=NEOFORGE`. Going with NeoForge 1.21.1.
|
|
||||||
|
|
||||||
### Drasl over Ely.by or vanilla offline mode
|
|
||||||
- **Ely.by**: not self-hosted, Russian-hosted, no control over identity layer.
|
|
||||||
- **Vanilla offline + no auth server**: works for one weekend but skins break,
|
|
||||||
no persistent identity, doesn't match homelab philosophy.
|
|
||||||
- **Drasl**: self-hosted Go service, Yggdrasil-compatible, drop-in Mojang
|
|
||||||
replacement, supports skins + capes, **has first-class OIDC support with
|
|
||||||
PKCE** (perfect for Keycloak), actively maintained (current version 3.4.2+).
|
|
||||||
GPLv3 licensed.
|
|
||||||
|
|
||||||
### LAN (not NetBird) for the party itself
|
|
||||||
NetBird is the homelab's remote access layer, but adding a mesh VPN on top
|
|
||||||
of a LAN party introduces complexity for guests with no upside. Drasl and
|
|
||||||
Minecraft live on the LAN; clients reach them via `drasl.home.local` resolved
|
|
||||||
through AdGuard.
|
|
||||||
|
|
||||||
### Manual curation (user choice — flagged as expensive)
|
|
||||||
User insisted on manual curation despite my pushback that forking ATM10 or
|
|
||||||
Leaking Kitchen Sink would save weeks of crash-log triage. **Note for future
|
|
||||||
sessions**: if curation gets painful, the fork-and-trim option is still on
|
|
||||||
the table — Leaking Kitchen Sink is the closest match to the requested vibe.
|
|
||||||
|
|
||||||
### itzg/minecraft-server as base image
|
|
||||||
The de facto standard. Handles NeoForge installation automatically via
|
|
||||||
`TYPE=NEOFORGE` + `VERSION` + `NEOFORGE_VERSION`. Supports Modrinth and
|
|
||||||
CurseForge mod auto-download via `MODRINTH_PROJECTS` and `CURSEFORGE_FILES`.
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
One unified `docker-compose.yml`. The host's own nginx terminates TLS (Let's
|
||||||
LAN segment
|
Encrypt) and reverse-proxies every vhost to caddy (published localhost-only) by
|
||||||
│
|
Host header. caddy is the internal router; containers reach the stack's own names
|
||||||
├── AdGuard Home → resolves drasl.home.local, keycloak.home.local
|
via caddy's `mcnet` aliases (`auth.`).
|
||||||
│ to the Docker host's LAN IP
|
|
||||||
│
|
|
||||||
├── Keycloak (existing, separate compose)
|
|
||||||
│ realm: homelab
|
|
||||||
│ client: drasl (confidential, PKCE S256)
|
|
||||||
│
|
|
||||||
└── Minecraft host (this project's compose)
|
|
||||||
│
|
|
||||||
├── drasl :25585 (Yggdrasil API + web UI)
|
|
||||||
│ └── OIDC → Keycloak
|
|
||||||
│
|
|
||||||
├── minecraft :25565 (server)
|
|
||||||
│ :24454/udp (Simple Voice Chat, optional)
|
|
||||||
│ └── -javaagent:authlib-injector.jar=http://drasl.home.local:25585/authlib-injector
|
|
||||||
│ └── ONLINE_MODE=false (required for authlib-injector)
|
|
||||||
│
|
|
||||||
└── mc-backup (itzg/mc-backup, 6h interval)
|
|
||||||
└── RCON → minecraft
|
|
||||||
|
|
||||||
Clients (LAN party guests):
|
```
|
||||||
Prism Launcher → authlib-injector account pointing at Drasl
|
Internet ── host nginx (TLS, LE certs, HSTS) ──► caddy (127.0.0.1:8880)
|
||||||
→ joins minecraft:25565
|
│ routes by Host:
|
||||||
|
${BASE_DOMAIN} ─► /srv/www landing + /launcher/ downloads
|
||||||
|
auth.${BASE_DOMAIN} ─► drasl (Yggdrasil API + web UI)
|
||||||
|
avatar.${BASE_DOMAIN} ─► nmsr (skin/avatar renderer, Drasl-backed)
|
||||||
|
status.${BASE_DOMAIN} ─► uptime-kuma (status page + monitors)
|
||||||
|
files.${BASE_DOMAIN} ─► filestash (player file share, ONE shared login, writable)
|
||||||
|
distribution.${BASE} ─► static site from ANOTHER repo (DISTRIBUTION_WEB_ROOT)
|
||||||
|
|
||||||
|
minecraft :25565 (+ 24454/udp Simple Voice Chat)
|
||||||
|
└ ONLINE_MODE=true (validated against Drasl, NOT offline), -javaagent authlib-injector → http://auth.${BASE_DOMAIN}/authlib-injector
|
||||||
|
└ mods from ./server-mods volume (/mods, REMOVE_OLD_MODS) — see plan/04-mods.md
|
||||||
|
mc-backup ── RCON → minecraft (6h interval, prune 14d, world-only)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Auth Flow (end-to-end)
|
Bring-up: render configs → sync server mods → build landing → fetch launcher → `docker compose up -d --build`.
|
||||||
|
See `plan/12-build-order.md` and `plan/14-deploy.md`. Deploy to prod host `cochi`
|
||||||
|
via the `deploy` skill.
|
||||||
|
|
||||||
1. Guest opens `http://drasl.home.local:25585` in browser.
|
## Key Decisions
|
||||||
2. Clicks "Register with Keycloak" → redirected to Keycloak login.
|
|
||||||
3. Logs in with homelab Keycloak credentials.
|
- **NeoForge over Forge** — the 1.21.x kitchen-sink ecosystem is overwhelmingly
|
||||||
4. Returned to Drasl, picks a player name, uploads a skin.
|
NeoForge; the Forge team migrated. `itzg/minecraft-server` via `TYPE=NEOFORGE`.
|
||||||
5. Drasl shows them a "Minecraft Token" on their profile page.
|
- **Drasl over Ely.by / vanilla offline** — self-hosted Go service, Yggdrasil
|
||||||
6. In Prism Launcher: Settings → Accounts → Add authlib-injector account
|
drop-in, skins + capes, actively maintained. Run with **password login**
|
||||||
with URL `http://drasl.home.local:25585/authlib-injector` and the
|
(no OIDC/Keycloak in this stack).
|
||||||
Minecraft Token as password.
|
- **Manual curation** (user choice, flagged expensive) — if it gets painful,
|
||||||
7. Joins Minecraft server. Server validates session against Drasl (via
|
fork-and-trim Leaking Kitchen Sink is still on the table (closest vibe match).
|
||||||
authlib-injector JVM agent), Drasl confirms, player enters with their skin.
|
- **itzg/minecraft-server** base image — de-facto standard, auto-installs NeoForge.
|
||||||
|
- **Distribution-fed mod volume (replaced packwiz)** — the modset's source of
|
||||||
|
truth is the HeliosLauncher distribution repo (`$DISTRIBUTION_WEB_ROOT`).
|
||||||
|
Clients install the full set via `distribution.json`. The SERVER loads a
|
||||||
|
filtered subset (`both`/`server`) from a mounted `./server-mods` volume
|
||||||
|
(`REMOVE_OLD_MODS=TRUE`). See `plan/04-mods.md`.
|
||||||
|
|
||||||
## Critical Gotchas
|
## Critical Gotchas
|
||||||
|
|
||||||
### Minecraft 1.21+ secure profile incompatibility
|
### Minecraft 1.21+ secure profile
|
||||||
- Server: must set `enforce-secure-profile=false` (compose: `ENFORCE_SECURE_PROFILE=FALSE`)
|
- Server: `ENFORCE_SECURE_PROFILE=FALSE` (`enforce-secure-profile=false`)
|
||||||
- Drasl: must set `SignPublicKeys = false`
|
- Drasl: `SignPublicKeys = false`
|
||||||
- These are linked. The Drasl docs explicitly warn: *"Mixed authentication does
|
- Linked. Drasl docs: *"Mixed authentication does not work with
|
||||||
not work with `SignPublicKeys = true` on Minecraft 1.21+."*
|
`SignPublicKeys = true` on Minecraft 1.21+."*
|
||||||
|
- **This is the ONLY auth flag that goes off. `ONLINE_MODE` stays TRUE** — see
|
||||||
|
next gotcha. Do not conflate "secure profile off" with "offline mode".
|
||||||
|
|
||||||
|
### ONLINE_MODE must be TRUE (authlib-injector ≠ offline)
|
||||||
|
authlib-injector redirects the server's normal **online**-auth handshake from
|
||||||
|
Mojang to Drasl. `ONLINE_MODE=FALSE` skips that handshake → offline-hash UUIDs,
|
||||||
|
no Drasl session validation, and **no skins** (everyone joins but everyone is
|
||||||
|
Steve; `usercache.json` shows offline-hash UUIDs). Keep `ONLINE_MODE=TRUE`.
|
||||||
|
|
||||||
### authlib-injector JVM agent
|
### authlib-injector JVM agent
|
||||||
- Syntax: `-javaagent:/path/to/authlib-injector.jar=<yggdrasil-api-url>`
|
- Syntax: `-javaagent:/extras/authlib-injector.jar=<yggdrasil-api-url>`
|
||||||
- The URL after `=` is the **API root**, which for Drasl is
|
- URL after `=` is the **API root** = `<BaseURL>/authlib-injector`. For this
|
||||||
`<BaseURL>/authlib-injector`.
|
stack: `http://auth.${BASE_DOMAIN}/authlib-injector` (internal, over mcnet).
|
||||||
- **Must match between server and client.** If server uses
|
- **Must match between server and client** (clients use the public
|
||||||
`http://drasl.home.local:25585/authlib-injector` and client uses anything
|
`https://auth.${BASE_DOMAIN}/authlib-injector`), else session validation fails
|
||||||
else (e.g. an IP), session validation fails silently with "Invalid session".
|
silently with "Invalid session".
|
||||||
- Download from: https://github.com/yushijinhun/authlib-injector/releases
|
- Releases: https://github.com/yushijinhun/authlib-injector — jar lives in
|
||||||
- Mount into the itzg container at `/extras/authlib-injector.jar`.
|
`runtime/` (mounted at `/extras/authlib-injector.jar`).
|
||||||
|
|
||||||
### Keycloak reachability on LAN day
|
|
||||||
If Keycloak is only reachable via NetBird or only from the public internet,
|
|
||||||
**Drasl OIDC registration breaks on LAN-only day**. Options:
|
|
||||||
1. Expose Keycloak on the LAN (simplest)
|
|
||||||
2. Pre-register all guests before the party
|
|
||||||
3. Temporarily disable OIDC and allow password registration during the LAN
|
|
||||||
(`AllowPasswordLogin = true`, comment out the OIDC block)
|
|
||||||
|
|
||||||
### HTTP vs HTTPS for OIDC
|
|
||||||
Modern browsers warn on plain HTTP for OIDC flows but they work. For a
|
|
||||||
production-grade setup, front Drasl with Caddy or Traefik using a local CA
|
|
||||||
cert (smallstep/step-ca pairs well with the existing homelab). For LAN-only
|
|
||||||
HTTP is fine.
|
|
||||||
|
|
||||||
### NeoForge version pinning
|
### NeoForge version pinning
|
||||||
Mods are picky about exact NeoForge minor versions. The compose currently
|
Mods are picky about exact minor versions. Compose pins `NEOFORGE_VERSION:
|
||||||
pins `NEOFORGE_VERSION: "21.1.209"` as a placeholder. **Verify against
|
"21.1.233"` — **must match the distribution's NeoForge** (`distribution.json`),
|
||||||
https://projects.neoforged.net/neoforged/neoforge before deploying.** Don't
|
since mods are built against it (e.g. ferritecore≥218, farmersdelight≥219).
|
||||||
use `"latest"` for a stable server.
|
**Verify against https://projects.neoforged.net/neoforged/neoforge before
|
||||||
|
deploying.** Never `"latest"` for a stable server.
|
||||||
|
|
||||||
### Drasl username vs player name
|
### Mod source + server filtering
|
||||||
- **Drasl username** = OIDC user's email (used for web UI login)
|
The full modset lives in the distribution repo (`$DISTRIBUTION_WEB_ROOT/servers/
|
||||||
- **Player name** = `preferred_username` from OIDC, or user-chosen if
|
ulicraft-1.21.1/forgemods/required/*.jar`), managed by Nebula. Clients get
|
||||||
`AllowChoosingPlayerName = true`
|
everything via `distribution.json`. The server runs a **filtered subset**:
|
||||||
- Keycloak users must have email set. Verify the realm's email-as-username
|
`tooling/classify-mods.py` reads each jar's `neoforge.mods.toml` with tomllib and
|
||||||
setting and the `preferred_username` mapper are configured.
|
seeds `mods-sides.json` (`client|server|both`, default `both`), then
|
||||||
|
`tooling/sync-server-mods.sh` copies the `both`/`server` jars into `./server-mods`
|
||||||
|
(mounted at `/mods`). Hard client-only mods (sodium/iris) classify as `client`
|
||||||
|
automatically; cosmetic-client mods that declare `BOTH` stay `both` until you
|
||||||
|
hand-edit `mods-sides.json` to `client`. No Modrinth/packwiz, no network at
|
||||||
|
classify/sync time. See `plan/04-mods.md`.
|
||||||
|
|
||||||
### Mod source preference
|
### Filestash (files.) — four traps
|
||||||
Prefer **Modrinth over CurseForge** for itzg auto-download. CurseForge
|
Full list in `plan/20-files.md`; the ones that cost real time:
|
||||||
requires an API key (`CF_API_KEY`) and has been historically flakier with
|
- **`general.host` is a BARE HOSTNAME** (`files.${BASE_DOMAIN}`, no scheme) +
|
||||||
the itzg image. Use CF only for mods that are exclusive to it.
|
`force_ssl: true`. The SPA builds its origin as `scheme + host`, so a URL there
|
||||||
|
yields `http://https://files...` and every client-side redirect dies. The
|
||||||
|
server strips the scheme before its own Host check, so **curl and the uptime
|
||||||
|
monitor stay green while every browser is broken**. Cost a debugging round on
|
||||||
|
2026-07-14.
|
||||||
|
- The `local` backend is **admin-gated**: without `LOCAL_BACKEND_SECRET` in the
|
||||||
|
connection params, every login fails with `backend error - Not Allowed` (looks
|
||||||
|
like bad credentials, isn't).
|
||||||
|
- `FILES_PASSWORD_HASH` must be **`$6$`** (`openssl passwd -6`). A `$2y$` bcrypt
|
||||||
|
from `htpasswd -B` fails every login silently — the plugin only matches `$2a$`.
|
||||||
|
- `config.json` is rendered + mounted `:ro` (the admin console can't save, on
|
||||||
|
purpose). Single-file bind mount ⇒ **`--force-recreate` after re-rendering**,
|
||||||
|
never `restart`.
|
||||||
|
|
||||||
## Compose Stack
|
**Verifying an SPA:** a 200 on `/` proves the server is up, not that the app
|
||||||
|
works. After any filestash config change, check the browser-facing values
|
||||||
The working compose file is at `./docker-compose.yml`. Key environment vars
|
(`curl /api/config` → `origin`) and re-read `docker compose logs filestash` —
|
||||||
documented inline. Adjacent files:
|
the broken redirect announced itself there (`msg=Redirecting to http://https://`)
|
||||||
|
while every other check passed.
|
||||||
```
|
|
||||||
.
|
|
||||||
├── docker-compose.yml
|
|
||||||
├── .env # RCON_PASSWORD, CF_API_KEY (if needed)
|
|
||||||
├── drasl/
|
|
||||||
│ └── config/
|
|
||||||
│ ├── config.toml # see ./drasl-config.toml example
|
|
||||||
│ └── keycloak-client-secret # single line, no trailing newline
|
|
||||||
├── extras/
|
|
||||||
│ ├── authlib-injector.jar # downloaded from yushijinhun's releases
|
|
||||||
│ ├── modrinth-mods.txt # one mod slug per line
|
|
||||||
│ └── cf-mods.txt # only if using CurseForge mods
|
|
||||||
├── backups/ # mc-backup writes here
|
|
||||||
└── CLAUDE.md # this file
|
|
||||||
```
|
|
||||||
|
|
||||||
### Memory sizing
|
### Memory sizing
|
||||||
With ~50–100 mods and 8 concurrent players on 1.21.1:
|
~50–100 mods, 8 players, 1.21.1: `INIT_MEMORY: 4G`, `MAX_MEMORY: 10G`,
|
||||||
- `INIT_MEMORY: 4G`, `MAX_MEMORY: 10G` is a safe starting point
|
`USE_AIKAR_FLAGS: TRUE`.
|
||||||
- Use `USE_AIKAR_FLAGS: TRUE` for the well-known GC tuning
|
|
||||||
- Monitor with `mc-monitor` (itzg makes one) if you want metrics
|
|
||||||
|
|
||||||
### Backups
|
## Config (.env)
|
||||||
`itzg/mc-backup` runs alongside, talks to the server via RCON, takes
|
|
||||||
snapshots every 6h, prunes after 14 days. Backups are world-only (no mod
|
|
||||||
jars), which is correct — mods are reproducible from the mod list files.
|
|
||||||
|
|
||||||
## Keycloak Client Configuration
|
`BASE_DOMAIN`, `RCON_PASSWORD`, `CADDY_HTTP_PORT`/`CADDY_HTTP_BIND`,
|
||||||
|
`DISTRIBUTION_WEB_ROOT` (source of the modset + caddy `distribution.` mount), the
|
||||||
|
Let's Encrypt block (`LE_EMAIL`, `LE_SUBDOMAINS`, `OVH_*`). Rendered configs
|
||||||
|
(drasl, nmsr) come from `*.tmpl` via `tooling/render-config.sh` (substitutes
|
||||||
|
`${BASE_DOMAIN}` only). Server mods are synced (not rendered) by
|
||||||
|
`tooling/sync-server-mods.sh`.
|
||||||
|
|
||||||
In the `homelab` realm (or whatever the realm is called):
|
## Client Distribution (guests)
|
||||||
|
|
||||||
1. **Clients → Create client**
|
Launcher: **FjordLauncherUnlocked** (Prism fork, first-class authlib-injector),
|
||||||
- Client type: `OpenID Connect`
|
mirrored at apex `/launcher/`. Per guest:
|
||||||
- Client ID: `drasl`
|
1. Download from `https://${BASE_DOMAIN}` → `/launcher/`.
|
||||||
- Name: `Drasl Minecraft Auth`
|
2. Add an authlib-injector account, URL
|
||||||
|
`https://auth.${BASE_DOMAIN}/authlib-injector` (register/login at
|
||||||
2. **Capability config**
|
`https://auth.${BASE_DOMAIN}`).
|
||||||
- Client authentication: **ON** (confidential client)
|
3. The launcher installs the modpack from the distribution (`distribution.json`).
|
||||||
- Authentication flow: **Standard flow** only
|
(packwiz and the `pack.` service/subdomain have been fully removed.)
|
||||||
- Disable: Direct access grants, Implicit, Service accounts
|
4. Connect to `${BASE_DOMAIN}` (`:25565`).
|
||||||
|
|
||||||
3. **Login settings**
|
|
||||||
- Root URL: `http://drasl.home.local:25585`
|
|
||||||
- Valid redirect URIs: `http://drasl.home.local:25585/web/oidc-callback/Keycloak`
|
|
||||||
(the `Keycloak` at the end MUST match the `Name = "Keycloak"` in Drasl's
|
|
||||||
`[[RegistrationOIDC]]` block — case sensitive)
|
|
||||||
- Web origins: `http://drasl.home.local:25585`
|
|
||||||
|
|
||||||
4. **Credentials tab** → copy Client Secret to
|
|
||||||
`./drasl/config/keycloak-client-secret` (no trailing newline).
|
|
||||||
|
|
||||||
5. **Advanced tab** → PKCE Code Challenge Method: `S256`
|
|
||||||
|
|
||||||
## Client Distribution (LAN guests)
|
|
||||||
|
|
||||||
### Recommended launcher
|
|
||||||
**Prism Launcher** — open source, cross-platform (Win/Mac/Linux), first-class
|
|
||||||
authlib-injector support, can import/export instance ZIPs.
|
|
||||||
|
|
||||||
### Per-guest one-time setup
|
|
||||||
1. Install Prism Launcher.
|
|
||||||
2. Add authlib-injector account:
|
|
||||||
- URL: `http://drasl.home.local:25585/authlib-injector`
|
|
||||||
- Username/password: their Drasl credentials (or Minecraft Token if
|
|
||||||
registered via Keycloak OIDC)
|
|
||||||
3. Import the modpack instance ZIP.
|
|
||||||
|
|
||||||
### What to ship guests
|
|
||||||
- A Prism instance ZIP (right-click instance → Export Instance)
|
|
||||||
- A one-page README with the auth URL and import steps
|
|
||||||
- Optionally `authlib-injector.jar` for guests who refuse Prism
|
|
||||||
|
|
||||||
## Open / Pending Work
|
## Open / Pending Work
|
||||||
|
|
||||||
These were on the roadmap when we ended the brainstorm:
|
### Roadmap — mod migration follow-ups (post packwiz→04-mods)
|
||||||
|
- [ ] **Curate `mods-sides.json` cosmetic-client mods.** tomllib auto-flagged 11
|
||||||
|
hard client-only mods; the other 65 default `both`, including client-cosmetic
|
||||||
|
ones (BetterF3, entityculling, MouseTweaks, ponderjs, fancymenu, drippy, melody,
|
||||||
|
welcomescreen, Searchables, JustEnoughResources, TravelersTitles, yeetus…) that
|
||||||
|
load harmlessly on the server but waste RAM. Hand-edit them to `client` so
|
||||||
|
`sync-server-mods.sh` drops them from `./server-mods`. (Also review `appleskin`,
|
||||||
|
flagged `client` — flip to `both` if server-side food data wanted.)
|
||||||
|
### Landing rework + mod list (PLANNED — see `plan/18-landing-rework.md`)
|
||||||
|
Full design settled across WS1–WS6; all tasks unchecked, ready to execute.
|
||||||
|
- [ ] **WS1 — Join flow off packwiz.** Homepage = UlicraftLauncher, 3 steps
|
||||||
|
(register → download launcher → join); drop `packwizUrl` + packwiz step.
|
||||||
|
Per-OS downloads from a synced `launcher.json` (shape
|
||||||
|
`productName/version/files[]`, the build output of the `custom-launcher`
|
||||||
|
repo; `launcher.example.json` committed as the documented fallback). Fjord
|
||||||
|
moves to its own `/fjord` page.
|
||||||
|
- [ ] **WS2/3 — Player rosters.** Online (mc-status, live) + all-registered (new
|
||||||
|
`mc-status /api/mcstatus/players`, admin-login → Drasl `GET /players`, ~60s
|
||||||
|
cache). Shared avatar tile, `AVATAR_MODE` env (default `headiso`). Admin creds
|
||||||
|
→ mc-status only.
|
||||||
|
- [ ] **WS4 — Account page** (`/account`): skin CRUD via browser-direct Drasl
|
||||||
|
(reuses `register.astro` skin JS). Skins only, in-memory token, `players[0]`.
|
||||||
|
- [ ] **WS5 — Footer status link** to `status.${BASE_DOMAIN}` (nav unchanged).
|
||||||
|
|
||||||
- [ ] **Mod shortlist for the kitchen-sink pack.** Categories to fill:
|
- [ ] **WS6 — Mod shortlist + list component (`plan/17-mod-shortlist.md`).**
|
||||||
- Performance (Embeddium/Sodium-equivalent for NeoForge, FerriteCore, ModernFix)
|
Light gap doc: current 76-jar pack is vanilla+ QoL/perf; **empty** on tech &
|
||||||
- Tech/automation (Mekanism, Create, Immersive Engineering, AE2)
|
magic, thin worldgen, no map; orphan deps `ponderjs`/`Necronomicon`. Landing
|
||||||
- Magic (Ars Nouveau, Botania, Iron's Spells 'n Spellbooks)
|
mod-list component = auto-generated `mods.json` + extracted logos from the
|
||||||
- Storage (Sophisticated Storage/Backpacks, Functional Storage)
|
distribution forgemods (`tooling/build-modlist.py`), flat alphabetical, pretty
|
||||||
- Exploration (YUNG's structures, Repurposed Structures, biome packs)
|
names, no categories/links; feeds the "50+" stat tile.
|
||||||
- QoL (JEI, Jade, JEI Resources, Inventory Profiles Next, AppleSkin)
|
- [ ] Server-side perf mods (C2ME, etc.).
|
||||||
- World gen (Tectonic, Terralith — check NeoForge 1.21.1 compat)
|
- [x] **Filestash file share** (`files.`, 2026-07-14) — live in prod. One shared
|
||||||
- Compat glue (Polymorph for recipe conflicts)
|
htpasswd login, writable, rendered `:ro` config. See `plan/20-files.md`.
|
||||||
- Map (XaeroMinimap + Xaero World Map)
|
Follow-ups: add the Uptime Kuma HTTP monitor for `files.`; the share is
|
||||||
- Voice (Simple Voice Chat — port 24454/udp already in compose)
|
**outside mc-backup** and unquota'd (accepted — eyeballed).
|
||||||
- [ ] **Decide on dimension/server-side performance mods** (C2ME, Lithium-equivalent)
|
- [ ] **Verify NeoForge version** against current stable before first deploy.
|
||||||
- [ ] **Reverse proxy + local TLS** for Drasl (Caddy + step-ca recommended,
|
- [x] **Bootstrap Drasl admin** account (`admin`, 2026-06-10). Key is
|
||||||
given the existing homelab)
|
`DefaultAdmins` (not `DefaultAdminUsernames`); first admin needs an invite-flip.
|
||||||
- [ ] **DNS record** in AdGuard for `drasl.home.local` → Docker host LAN IP
|
See `plan/03-drasl.md` gotchas.
|
||||||
- [ ] **Keycloak client** creation per the section above
|
|
||||||
- [ ] **Verify NeoForge version** against current stable before first deploy
|
|
||||||
- [ ] **Bootstrap admin in Drasl**: leave `AllowPasswordLogin = true` initially,
|
|
||||||
create the admin account, then optionally disable password login to force OIDC
|
|
||||||
|
|
||||||
## Reference URLs
|
## Reference URLs
|
||||||
|
|
||||||
- Drasl repo: https://github.com/unmojang/drasl
|
- Drasl: https://github.com/unmojang/drasl — config docs:
|
||||||
- Drasl configuration docs: https://github.com/unmojang/drasl/blob/master/doc/configuration.md
|
https://github.com/unmojang/drasl/blob/master/doc/configuration.md
|
||||||
- Drasl recipes (example configs): https://github.com/unmojang/drasl/blob/master/doc/recipes.md
|
|
||||||
- authlib-injector: https://github.com/yushijinhun/authlib-injector
|
- authlib-injector: https://github.com/yushijinhun/authlib-injector
|
||||||
- itzg/minecraft-server: https://github.com/itzg/docker-minecraft-server
|
- itzg/minecraft-server: https://github.com/itzg/docker-minecraft-server
|
||||||
- itzg NeoForge docs: https://github.com/itzg/docker-minecraft-server/blob/master/docs/types-and-platforms/server-types/forge.md
|
(NeoForge: …/docs/types-and-platforms/server-types/forge.md)
|
||||||
- itzg/mc-backup: https://github.com/itzg/docker-mc-backup
|
- itzg/mc-backup: https://github.com/itzg/docker-mc-backup
|
||||||
- NeoForge versions: https://projects.neoforged.net/neoforged/neoforge
|
- NeoForge versions: https://projects.neoforged.net/neoforged/neoforge
|
||||||
- Prism Launcher: https://prismlauncher.org/
|
- NMSR: https://github.com/NickAcPT/nmsr-rs
|
||||||
- Reference modpacks for inspiration (NOT forking):
|
- Uptime Kuma: https://github.com/louislam/uptime-kuma
|
||||||
- ATM10 (Modrinth/CurseForge — ~500 mods, NeoForge 1.21.1)
|
- FjordLauncherUnlocked: https://github.com/hero-persson/FjordLauncherUnlocked
|
||||||
- Leaking Kitchen Sink (CurseForge — trimmed ~150 mods, NeoForge 1.21.1)
|
- Reference packs (inspiration, NOT forking): ATM10, Leaking Kitchen Sink.
|
||||||
|
|
||||||
## Communication Preferences (carry-over from past sessions)
|
## Communication Preferences (carry-over)
|
||||||
|
|
||||||
- Concise and direct
|
- Concise and direct; diagnose + resolve without demanding repro steps.
|
||||||
- Diagnose and resolve without demanding detailed reproduction steps
|
- Prefer iterative single-change updates over large rewrites.
|
||||||
- Prefer iterative single-change updates over large rewrites
|
- Cautious scripts that halt on ambiguity rather than proceeding silently.
|
||||||
- Cautious scripts that halt on ambiguity rather than proceeding silently
|
- Avoid over-engineering when a simple answer suffices.
|
||||||
- Avoid over-engineered responses when a simple answer suffices
|
- Spanish or English both fine.
|
||||||
- Spanish or English fine; user is comfortable in both
|
|
||||||
|
|||||||
267
README.md
@@ -1,111 +1,232 @@
|
|||||||
# Ulicraft Server
|
# Ulicraft Server
|
||||||
|
|
||||||
Self-hosted modded Minecraft (NeoForge 1.21.1) for a LAN party. Runs **online**
|
Self-hosted modded Minecraft (NeoForge 1.21.1) with self-hosted auth/skins
|
||||||
(internet present) or **fully air-gapped** (offline). Self-hosted auth/skins
|
(Drasl), a distribution-fed modpack, avatar rendering, a guest landing page, and
|
||||||
(Drasl), a packwiz modpack, a guest landing page, and a transparent mirror of
|
uptime monitoring. One unified `docker-compose.yml` runs the whole stack behind
|
||||||
Mojang/launcher/NeoForge assets so guest laptops need no internet.
|
the host's nginx + Let's Encrypt.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
|
One compose file, one `docker compose up -d`:
|
||||||
|
|
||||||
| Service | Image | Role |
|
| Service | Image | Role |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `minecraft` | itzg/minecraft-server | NeoForge 1.21.1 server, `:25565` |
|
| `minecraft` | itzg/minecraft-server | NeoForge 1.21.1 server, `:25565` |
|
||||||
| `drasl` | unmojang/drasl | Yggdrasil auth + skins (password login) |
|
| `drasl` | unmojang/drasl | Yggdrasil auth + skins (password login) |
|
||||||
| `caddy` | caddy:alpine | ingress: landing, auth/packwiz proxy, asset mirror (`tls internal`) |
|
| `caddy` | caddy:alpine | internal ingress: routes every vhost by Host header |
|
||||||
|
| `nmsr` | built from source | skin/avatar renderer at `avatar.${BASE_DOMAIN}` |
|
||||||
|
| `mc-status` | built from source | live server-status JSON for the landing card |
|
||||||
|
| `uptime-kuma` | louislam/uptime-kuma | status page at `status.${BASE_DOMAIN}` |
|
||||||
|
| `filestash` | machines/filestash (digest-pinned) | player file share at `files.${BASE_DOMAIN}` |
|
||||||
| `mc-backup` | itzg/mc-backup | world backups every 6h via RCON |
|
| `mc-backup` | itzg/mc-backup | world backups every 6h via RCON |
|
||||||
|
|
||||||
Optional layers: `avahi` (mDNS `.local`), `dnsmasq` (turnkey DNS) — only if you
|
Vhosts (all proxied through the host nginx → caddy):
|
||||||
don't run your own DNS.
|
|
||||||
|
|
||||||
Config lives in `.env`: `BASE_DOMAIN` (default `ulicraft.lan`), `HOST_LAN_IP`,
|
- apex `${BASE_DOMAIN}` — landing page + `/launcher/` downloads + `/api/mcstatus/*`
|
||||||
`RCON_PASSWORD`, `ENABLE_MDNS`. Services are subdomains: `auth.`, `packwiz.`,
|
- `auth.` — Drasl
|
||||||
`mc.`, plus the apex landing page.
|
- `avatar.` — NMSR
|
||||||
|
- `status.` — Uptime Kuma
|
||||||
|
- `files.` — Filestash player file share (one shared login, writable)
|
||||||
|
- `distribution.` — static site from another repo (`DISTRIBUTION_WEB_ROOT`)
|
||||||
|
|
||||||
## DNS (pick one)
|
Config lives in `.env`: `BASE_DOMAIN`, `RCON_PASSWORD`, `CADDY_HTTP_PORT` /
|
||||||
|
`CADDY_HTTP_BIND`, `DISTRIBUTION_WEB_ROOT`, the `FILES_*` block. See `.env.example`.
|
||||||
|
|
||||||
Names must resolve to `HOST_LAN_IP`. Use **your own party DNS server** (recommended):
|
## DNS
|
||||||
|
|
||||||
```sh
|
Handled **outside this repo**. Point `${BASE_DOMAIN}` and every subdomain
|
||||||
tooling/dns-records.sh online # service names only (internet present)
|
(`auth. avatar. status. files. distribution. www.`) at the host running nginx.
|
||||||
tooling/dns-records.sh airgap # + Mojang/launcher/NeoForge spoofs (offline)
|
|
||||||
```
|
|
||||||
Paste the printed records into your DNS. **Use `.lan`, not `.local`** — `.local`
|
|
||||||
is intercepted by mDNS and won't resolve via unicast DNS.
|
|
||||||
|
|
||||||
Alternatives: `ENABLE_MDNS=true` + `BASE_DOMAIN=*.local` (zero-config mDNS,
|
|
||||||
macOS/Linux native, Windows needs Bonjour) — but mDNS can't serve the air-gap
|
|
||||||
upstream spoofs. Or layer in `docker-compose.dnsmasq.yml` for a turnkey DNS.
|
|
||||||
|
|
||||||
### Simulate the DNS via /etc/hosts (single machine, testing)
|
|
||||||
|
|
||||||
No DNS server? Append the records straight to `/etc/hosts` (marker-wrapped):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
tooling/dns-records.sh online hosts | sudo tee -a /etc/hosts # services only
|
|
||||||
tooling/dns-records.sh airgap hosts | sudo tee -a /etc/hosts # + Mojang spoofs
|
|
||||||
```
|
|
||||||
|
|
||||||
Remove them later:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
sudo sed -i '/# >>> ulicraft/,/# <<< ulicraft/d' /etc/hosts
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- `/etc/hosts` has **no wildcard** — only the listed names resolve.
|
|
||||||
- `airgap` adds the Mojang/launcher/NeoForge hostnames pointing at the LAN; this
|
|
||||||
**breaks normal internet access to those domains** while present. Use it only
|
|
||||||
while offline; for an online box use `online`.
|
|
||||||
- `mc.<domain>` works (client defaults to :25565); SRV isn't used via `/etc/hosts`.
|
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
Docker + Docker Compose; for prep also `pnpm`, `packwiz`, `jq`, `curl`,
|
Docker + Docker Compose; for prep also `pnpm`, `jq`, `curl`, `envsubst`.
|
||||||
`envsubst`, `sha1sum`.
|
|
||||||
|
|
||||||
## Launch
|
## Launch
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cp .env.example .env # set BASE_DOMAIN, HOST_LAN_IP, RCON_PASSWORD
|
cp .env.example .env # set BASE_DOMAIN, RCON_PASSWORD, DISTRIBUTION_WEB_ROOT
|
||||||
|
|
||||||
tooling/build-stack.sh --prep # ONLINE, once: render, build landing,
|
# One-time / on change — render configs + build content:
|
||||||
# mirror mods+assets, fetch launcher +
|
tooling/render-config.sh # drasl + nmsr configs from *.tmpl
|
||||||
# authlib, pre-bake the server volume
|
tooling/sync-server-mods.sh # filtered server mods → ./server-mods
|
||||||
|
( cd landing && pnpm install && BASE_DOMAIN="$BASE_DOMAIN" pnpm run build ) # → www/
|
||||||
|
tooling/fetch-launcher.sh # FjordLauncher assets → launcher/ (served at /launcher/)
|
||||||
|
tooling/fetch-authlib.sh # authlib-injector.jar → runtime/ (server JVM agent)
|
||||||
|
|
||||||
tooling/build-stack.sh --up online # internet present, no mirror
|
docker compose up -d --build # first run installs NeoForge + mods, builds nmsr
|
||||||
tooling/build-stack.sh --up airgap # offline; full asset mirror on :443 (default)
|
docker compose down # stop
|
||||||
tooling/build-stack.sh --up core # base only (debugging)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`--up` prints the DNS records to add for that mode. Down:
|
First boot: itzg installs NeoForge + the synced server mods into the `mc_data` volume;
|
||||||
`docker compose -f docker-compose.yml -f docker-compose.static.yml -f docker-compose.mirror.yml down`
|
nmsr does a one-time Rust compile. Watch `docker compose logs -f minecraft` until
|
||||||
|
`Done`.
|
||||||
|
|
||||||
|
## Public TLS (host nginx in front of caddy)
|
||||||
|
|
||||||
|
caddy is published on a localhost-only port (`CADDY_HTTP_BIND=127.0.0.1`,
|
||||||
|
`CADDY_HTTP_PORT=8880`). The host's own nginx terminates TLS with Let's Encrypt
|
||||||
|
certs and reverse-proxies every vhost to caddy by Host header. HTTPS-only: HTTP
|
||||||
|
301s to HTTPS and every TLS vhost sends HSTS.
|
||||||
|
|
||||||
|
1. **Issue certs** (OVH DNS-01, no inbound ports needed):
|
||||||
|
```sh
|
||||||
|
# .env: BASE_DOMAIN, LE_EMAIL, OVH_* creds,
|
||||||
|
# LE_SUBDOMAINS="auth distribution www avatar status"
|
||||||
|
tooling/issue-letsencrypt.sh # → certs/<name>/{cert,key}.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Render + install the nginx vhost** with `tooling/render-nginx.sh`. It pulls
|
||||||
|
`BASE_DOMAIN` + `CADDY_HTTP_PORT` from `.env`, defaults `APP_DIR` to the repo
|
||||||
|
checkout (where `certs/` live), and renders `nginx/ulicraft-caddy.conf.tmpl`:
|
||||||
|
```sh
|
||||||
|
tooling/render-nginx.sh # preview to stdout (dry run)
|
||||||
|
tooling/render-nginx.sh --install # write, symlink, nginx -t, reload
|
||||||
|
```
|
||||||
|
Override `APP_DIR=/path` if the repo isn't at the cert location. Re-run
|
||||||
|
whenever you add a subdomain so its server block is rendered.
|
||||||
|
|
||||||
|
## Avatar renderer (NMSR)
|
||||||
|
|
||||||
|
`avatar.${BASE_DOMAIN}` renders players' skins/avatars via
|
||||||
|
[NMSR-aas](https://github.com/NickAcPT/nmsr-rs), sourced from **Drasl** (not
|
||||||
|
Mojang). No upstream image exists, so it's built from source — `docker/nmsr/`
|
||||||
|
pins a commit; bump `ARG NMSR_REF` to update.
|
||||||
|
|
||||||
|
- **Config**: `nmsr/config.toml.tmpl` → rendered to `nmsr/config.toml` by
|
||||||
|
`tooling/render-config.sh`. `[mojank]` points every Mojang endpoint at
|
||||||
|
`http://auth.${BASE_DOMAIN}`; Drasl serves the Mojang-compatible routes at its
|
||||||
|
bare BaseURL and embeds absolute skin URLs that NMSR fetches directly.
|
||||||
|
`allow_offline_mode_uuids = true` (Drasl issues offline v3 UUIDs).
|
||||||
|
- **Network**: skin resolution stays internal over `mcnet` (caddy alias
|
||||||
|
`auth.${BASE_DOMAIN}` → drasl). caddy reverse-proxies `avatar.` → `nmsr:8080`.
|
||||||
|
- **Endpoints**: e.g. `https://avatar.${BASE_DOMAIN}/skin/<player>`,
|
||||||
|
`/face/<player>`, `/fullbody/<player>` — by username or UUID.
|
||||||
|
|
||||||
|
First build is heavy (Rust compile). Headless rendering uses lavapipe (software
|
||||||
|
Vulkan); if renders fail, check `docker logs nmsr` for Vulkan device init.
|
||||||
|
|
||||||
|
## Status monitoring (Uptime Kuma)
|
||||||
|
|
||||||
|
`status.${BASE_DOMAIN}` runs [Uptime Kuma](https://github.com/louislam/uptime-kuma)
|
||||||
|
— uptime probes for every vhost **and** a native Minecraft-protocol ping (player
|
||||||
|
count + up/down), plus a public status page. It joins `mcnet`, so monitors can
|
||||||
|
probe the stack by internal service name. caddy reverse-proxies `status.` →
|
||||||
|
`uptime-kuma:3001`.
|
||||||
|
|
||||||
|
First run, open `https://status.${BASE_DOMAIN}`, create the admin account, and
|
||||||
|
add monitors. Kuma has no config-as-code — add these once via the UI (data
|
||||||
|
persists in the `kuma_data` volume):
|
||||||
|
|
||||||
|
| Monitor | Type | Target |
|
||||||
|
|---|---|---|
|
||||||
|
| Minecraft | Minecraft Server | `minecraft` : `25565` |
|
||||||
|
| Drasl auth | HTTP(s) | `https://auth.${BASE_DOMAIN}` |
|
||||||
|
| Landing (apex) | HTTP(s) | `https://${BASE_DOMAIN}` |
|
||||||
|
| Distribution | HTTP(s) | `https://distribution.${BASE_DOMAIN}` |
|
||||||
|
| Avatar (NMSR) | HTTP(s) | `https://avatar.${BASE_DOMAIN}` |
|
||||||
|
| Files (Filestash) | HTTP(s) | `https://files.${BASE_DOMAIN}` (expect 200 — the login page is a 200) |
|
||||||
|
|
||||||
|
Internal-name targets (`minecraft`, `drasl:25585`, `nmsr:8080`) isolate "service
|
||||||
|
down" from "ingress/TLS/DNS down"; public-URL targets test the whole chain.
|
||||||
|
`status` is in the default `LE_SUBDOMAINS`; the nginx vhost adds websocket
|
||||||
|
upgrade headers for Kuma's live UI.
|
||||||
|
|
||||||
|
## Server status JSON (mc-status)
|
||||||
|
|
||||||
|
`mc-status` is a self-hosted SLP→JSON pinger (built from `docker/mc-status`, an
|
||||||
|
mcutil wrapper) that the landing's `ServerCard` reads for live player count + MOTD.
|
||||||
|
It emits **mcstatus.io v2** JSON, so the card consumes it unchanged — but
|
||||||
|
same-origin, with no CORS and no third-party calls. caddy proxies the apex
|
||||||
|
`/api/mcstatus/*` path to `mc-status:8080` (strips the prefix); the container has
|
||||||
|
no published port. The pinger only ever probes its configured `MC_STATUS_TARGET`,
|
||||||
|
so the path after the prefix is ignored.
|
||||||
|
|
||||||
|
Endpoint (apex, same-origin):
|
||||||
|
|
||||||
|
```
|
||||||
|
https://${BASE_DOMAIN}/api/mcstatus/v2/status/java/${BASE_DOMAIN}
|
||||||
|
```
|
||||||
|
|
||||||
|
Hitting caddy directly (localhost-only, routes by Host header) — e.g. `ulicraft.net`
|
||||||
|
on `127.0.0.1:8880`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -H "Host: ulicraft.net" \
|
||||||
|
http://127.0.0.1:8880/api/mcstatus/v2/status/java/ulicraft.net
|
||||||
|
```
|
||||||
|
|
||||||
|
Wired in `landing/src/data/site.ts` (`statusApi`) and `caddy/conf.d/10-static.caddy`.
|
||||||
|
|
||||||
|
## Player file share (Filestash)
|
||||||
|
|
||||||
|
`files.${BASE_DOMAIN}` is a web file share for player media — screenshots,
|
||||||
|
schematics, maps, guides. **One shared username/password for everybody**
|
||||||
|
(`FILES_USER` / `FILES_PASSWORD_HASH`), read **and** write. It is *not* a backup
|
||||||
|
browser and *not* a mod mirror: world snapshots contain every player's inventory
|
||||||
|
and coordinates, and the modset already ships via `distribution.`.
|
||||||
|
|
||||||
|
Full design, deploy steps and gotchas: **`plan/20-files.md`**. The short version:
|
||||||
|
|
||||||
|
- Config is `filestash/config.json`, **rendered from a template and mounted
|
||||||
|
`:ro`** — git is the source of truth. The admin console at `/admin` loads but
|
||||||
|
**cannot save**; that's deliberate. Change config by editing the `.tmpl`,
|
||||||
|
re-rendering, and **recreating** the container (`up -d --force-recreate
|
||||||
|
filestash` — a single-file bind mount pins the inode, so `restart` reads the
|
||||||
|
stale file).
|
||||||
|
- Files live in `${FILES_DATA_DIR}` on the host, outside the repo. **Not backed
|
||||||
|
up** by mc-backup — treat the share as disposable.
|
||||||
|
- `FILES_MOUNT_MODE=ro|rw` is the literal mount flag: `ro` makes the share
|
||||||
|
read-only at the *kernel*, whatever the UI thinks.
|
||||||
|
- `FILES_AUTH=htpasswd|passthrough` — `passthrough` means **no login at all**.
|
||||||
|
Only valid once the host is off the public internet: an unauthenticated
|
||||||
|
*writable* share on a public domain is an open upload relay.
|
||||||
|
|
||||||
|
Four traps that cost real time (all enforced or documented in the tooling):
|
||||||
|
|
||||||
|
- **`general.host` is a bare hostname**, no scheme (`files.${BASE_DOMAIN}`), with
|
||||||
|
`force_ssl: true`. The SPA builds its redirect origin as `scheme + host`, so a
|
||||||
|
URL there produces `http://https://files...` and every client-side redirect
|
||||||
|
breaks. The server strips the scheme before its *own* Host check, so `curl`
|
||||||
|
and the uptime monitor stay **green while every browser is broken**.
|
||||||
|
- **`FILES_PASSWORD_HASH` must be `$6$`** (`openssl passwd -6`). The htpasswd
|
||||||
|
plugin's bcrypt branch only matches `$2a$`, so a `$2y$` hash from
|
||||||
|
`htpasswd -B` fails *every* login, silently. `render-config.sh` rejects it.
|
||||||
|
- **The `local` storage backend is admin-gated** — without `FILES_LOCAL_SECRET`
|
||||||
|
reaching it through the connection params, every login dies with
|
||||||
|
`backend error - Not Allowed`, which looks like bad credentials and isn't.
|
||||||
|
- **Don't set `APPLICATION_URL`/`ADMIN_PASSWORD` env** — either makes Filestash
|
||||||
|
rewrite `config.json` at boot, which fails against the `:ro` mount.
|
||||||
|
|
||||||
|
> Verifying this service: a 200 on `/` only proves the server is up. Check the
|
||||||
|
> browser-facing origin — `curl -s -H 'X-Requested-With: XmlHttpRequest`
|
||||||
|
> `https://files.${BASE_DOMAIN}/api/config | jq -r .result.origin` must print
|
||||||
|
> `https://files.${BASE_DOMAIN}` — and re-read `docker compose logs filestash`.
|
||||||
|
|
||||||
## Join (guests)
|
## Join (guests)
|
||||||
|
|
||||||
1. Open `http://ulicraft.lan`, download FjordLauncherUnlocked for your OS.
|
1. Open `https://${BASE_DOMAIN}`, download FjordLauncherUnlocked for your OS.
|
||||||
2. Add an **authlib-injector** account, URL `http://auth.ulicraft.lan/authlib-injector`
|
2. Add an **authlib-injector** account, URL
|
||||||
(register/login at `http://auth.ulicraft.lan`).
|
`https://auth.${BASE_DOMAIN}/authlib-injector` (register/login at
|
||||||
3. **Air-gap only:** trust the local CA — download `http://ulicraft.lan/ca.crt`
|
`https://auth.${BASE_DOMAIN}`).
|
||||||
and add it to your OS trust store (so the HTTPS asset mirror is trusted).
|
3. The launcher installs the modpack from the distribution automatically.
|
||||||
4. Import the pack: `http://packwiz.ulicraft.lan/pack.toml`.
|
4. Connect to **`${BASE_DOMAIN}`** (Minecraft, `:25565`).
|
||||||
5. Connect to **`mc.ulicraft.lan`** (no port needed).
|
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
docker-compose.yml # core: drasl, minecraft, mc-backup, caddy
|
docker-compose.yml # the whole stack (one file)
|
||||||
.static.yml .mirror.yml # online/airgap layers
|
caddy/Caddyfile + conf.d/*.caddy # ingress vhost snippets
|
||||||
.avahi.yml .dnsmasq.yml # optional DNS layers
|
# (core/static/distribution/avatar/status)
|
||||||
caddy/Caddyfile + conf.d/*.caddy # ingress vhost snippets (core/static/mirror)
|
|
||||||
drasl/config/config.toml.tmpl # auth config (rendered)
|
drasl/config/config.toml.tmpl # auth config (rendered)
|
||||||
dnsmasq/dnsmasq.conf.tmpl # optional DNS config (rendered)
|
nmsr/config.toml.tmpl # avatar renderer config, Drasl-backed (rendered)
|
||||||
docker/avahi/ # mDNS responder image
|
docker/nmsr/ # built-from-source NMSR image
|
||||||
pack/ # packwiz modpack source
|
|
||||||
landing/ # Astro site → www/
|
landing/ # Astro site → www/
|
||||||
tooling/ # build-stack, render-config, dns-records,
|
launcher/ # FjordLauncher assets (gitignored)
|
||||||
# mirror-mods, mirror-airgap, fetch-launcher
|
server-mods/ # filtered server mod jars (synced, gitignored)
|
||||||
mirror/ # vendored jars + launcher + asset mirror (gitignored)
|
tooling/ # render-config, sync-server-mods, render-nginx,
|
||||||
plan/ # full design docs (00–12)
|
# issue-letsencrypt, fetch-launcher
|
||||||
|
nginx/ulicraft-caddy.conf.tmpl # host-nginx TLS vhost template (front of caddy)
|
||||||
|
plan/ # design docs
|
||||||
```
|
```
|
||||||
|
|
||||||
See `plan/00-overview.md` for the full design.
|
See `plan/00-overview.md` for the full design.
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
# Base Caddy config. Vhosts live in conf.d/*.caddy snippets that are mounted
|
# Base Caddy config. Vhosts live in conf.d/*.caddy snippets:
|
||||||
# in selectively so the stack can run with or without the static site / mirror:
|
# conf.d/00-core.caddy auth
|
||||||
# conf.d/00-core.caddy auth + packwiz (always — core ingress)
|
# conf.d/10-static.caddy apex landing + launcher downloads
|
||||||
# conf.d/10-static.caddy apex landing + launcher downloads (static toggle)
|
# conf.d/30-distribution.caddy static site from another repo
|
||||||
# conf.d/20-mirror.caddy air-gap upstream mirror (tls internal) (mirror toggle)
|
# conf.d/40-avatar.caddy nmsr skin/avatar renderer
|
||||||
# auto_https disable_redirects: http:// sites stay plaintext on :80; the mirror
|
# conf.d/50-status.caddy uptime-kuma status page
|
||||||
# snippet's hosts still get internal TLS on :443 when that snippet is present.
|
# All snippets are plain http:// on :80 — the host's nginx terminates TLS in
|
||||||
|
# front (nginx/ulicraft-caddy.conf.tmpl). disable_redirects keeps caddy from
|
||||||
|
# upgrading to its own https.
|
||||||
{
|
{
|
||||||
auto_https disable_redirects
|
auto_https disable_redirects
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
# Core ingress — always mounted. Drasl auth + packwiz pack metadata.
|
# Core ingress — always mounted. Drasl auth.
|
||||||
http://auth.{$BASE_DOMAIN} {
|
http://auth.{$BASE_DOMAIN} {
|
||||||
reverse_proxy drasl:25585
|
reverse_proxy drasl:25585
|
||||||
}
|
}
|
||||||
|
|
||||||
http://packwiz.{$BASE_DOMAIN} {
|
|
||||||
root * /srv/pack
|
|
||||||
file_server browse
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
# Static toggle — apex landing page + launcher downloads.
|
# Apex — landing page (/srv/www) + launcher downloads (/srv/launcher).
|
||||||
# Mounted only when the stack runs with static files (build-stack.sh up static|full).
|
|
||||||
http://{$BASE_DOMAIN} {
|
http://{$BASE_DOMAIN} {
|
||||||
# Caddy's local CA root, so guests can trust the air-gap HTTPS mirror.
|
|
||||||
# Mounted from ./caddy/caddy-root-ca.crt (exported by build-stack prep;
|
|
||||||
# the live pki dir is root-only 0600 and can't be served directly).
|
|
||||||
handle /ca.crt {
|
|
||||||
root * /srv/ca-pub
|
|
||||||
file_server
|
|
||||||
}
|
|
||||||
# Launcher downloads are a sibling mount (/srv/launcher), not nested under
|
# Launcher downloads are a sibling mount (/srv/launcher), not nested under
|
||||||
# the read-only /srv/www. handle_path strips the /launcher prefix.
|
# the read-only /srv/www. handle_path strips the /launcher prefix.
|
||||||
handle_path /launcher/* {
|
handle_path /launcher/* {
|
||||||
root * /srv/launcher
|
root * /srv/launcher
|
||||||
file_server browse
|
file_server browse
|
||||||
}
|
}
|
||||||
|
# Self-hosted Minecraft status JSON (mc-status service). Same-origin, so the
|
||||||
|
# landing's ServerCard fetch needs no CORS. The <addr> in the path is
|
||||||
|
# ignored by mc-status — it only ever pings its configured MC_STATUS_TARGET.
|
||||||
|
handle /api/mcstatus/* {
|
||||||
|
uri strip_prefix /api/mcstatus
|
||||||
|
reverse_proxy mc-status:8080
|
||||||
|
}
|
||||||
handle {
|
handle {
|
||||||
root * /srv/www
|
root * /srv/www
|
||||||
file_server
|
file_server
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
# Mirror toggle — full client air-gap mirror (see plan/11-full-airgap-mirror.md).
|
|
||||||
# Byte-exact copies of the real upstream hosts over HTTPS with Caddy's local CA.
|
|
||||||
# dnsmasq spoofs these hostnames -> our host; {host} roots each at its subtree.
|
|
||||||
# Mounted only when the stack runs with the mirror (build-stack.sh up mirror|full).
|
|
||||||
meta.prismlauncher.org, piston-meta.mojang.com, piston-data.mojang.com, libraries.minecraft.net, maven.neoforged.net, resources.download.minecraft.net {
|
|
||||||
tls internal
|
|
||||||
root * /srv/mirror/{host}
|
|
||||||
file_server
|
|
||||||
}
|
|
||||||
10
caddy/conf.d/30-distribution.caddy
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Distribution — static site served from a web root in ANOTHER repo. The host
|
||||||
|
# path is set by DISTRIBUTION_WEB_ROOT in .env and bind-mounted to
|
||||||
|
# /srv/distribution by the caddy service in docker-compose.yml.
|
||||||
|
http://distribution.{$BASE_DOMAIN} {
|
||||||
|
root * /srv/distribution
|
||||||
|
# The landing page (apex origin) fetches launcher.json cross-origin to render
|
||||||
|
# the download buttons. Allow it — launcher assets are fully public, no creds.
|
||||||
|
header /launcher/* Access-Control-Allow-Origin "*"
|
||||||
|
file_server browse
|
||||||
|
}
|
||||||
4
caddy/conf.d/40-avatar.caddy
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Avatar — NMSR skin/avatar renderer (nmsr service), Drasl-backed.
|
||||||
|
http://avatar.{$BASE_DOMAIN} {
|
||||||
|
reverse_proxy nmsr:8080
|
||||||
|
}
|
||||||
4
caddy/conf.d/50-status.caddy
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Status — Uptime Kuma monitoring + public status page (uptime-kuma service).
|
||||||
|
http://status.{$BASE_DOMAIN} {
|
||||||
|
reverse_proxy uptime-kuma:3001
|
||||||
|
}
|
||||||
10
caddy/conf.d/60-files.caddy
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Files — Filestash player file share (filestash service). Shared login,
|
||||||
|
# writable. See plan/20-files.md.
|
||||||
|
#
|
||||||
|
# Filestash blocks any request whose Host header doesn't match its configured
|
||||||
|
# `general.host` (https://files.${BASE_DOMAIN}), with "only traffic from X is
|
||||||
|
# allowed". The host nginx forwards the original Host, and caddy passes it
|
||||||
|
# through untouched — do NOT rewrite it here or every request 403s.
|
||||||
|
http://files.{$BASE_DOMAIN} {
|
||||||
|
reverse_proxy filestash:8334
|
||||||
|
}
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# Resolve every *.${BASE_DOMAIN} (and the apex) to the Docker host LAN IP.
|
|
||||||
# Rendered by tooling/render-config.sh -> dnsmasq/dnsmasq.conf (gitignored).
|
|
||||||
address=/${BASE_DOMAIN}/${HOST_LAN_IP}
|
|
||||||
no-resolv
|
|
||||||
|
|
||||||
# Minecraft SRV record: guests connect to "mc.${BASE_DOMAIN}" with NO port —
|
|
||||||
# the SRV lookup points the client at port 25565. Target mc.${BASE_DOMAIN}
|
|
||||||
# resolves to HOST_LAN_IP via the wildcard above.
|
|
||||||
# Format: srv-host=_service._proto.name,target,port
|
|
||||||
srv-host=_minecraft._tcp.mc.${BASE_DOMAIN},mc.${BASE_DOMAIN},25565
|
|
||||||
|
|
||||||
# Full client air-gap (see plan/11-full-airgap-mirror.md): spoof the real
|
|
||||||
# upstream hosts -> our Caddy mirror. Auth hosts (session/textures/api.mojang)
|
|
||||||
# are NOT spoofed — drasl handles those via authlib-injector. Keep this list in
|
|
||||||
# sync with the proxy-capture step.
|
|
||||||
address=/meta.prismlauncher.org/${HOST_LAN_IP}
|
|
||||||
address=/piston-meta.mojang.com/${HOST_LAN_IP}
|
|
||||||
address=/piston-data.mojang.com/${HOST_LAN_IP}
|
|
||||||
address=/libraries.minecraft.net/${HOST_LAN_IP}
|
|
||||||
address=/maven.neoforged.net/${HOST_LAN_IP}
|
|
||||||
address=/resources.download.minecraft.net/${HOST_LAN_IP}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
# mDNS toggle — publish *.local service names via the HOST's avahi-daemon for
|
|
||||||
# zero-config guest resolution. Enabled when ENABLE_MDNS=true in .env and only
|
|
||||||
# meaningful when BASE_DOMAIN=*.local.
|
|
||||||
#
|
|
||||||
# Host prerequisites:
|
|
||||||
# - avahi-daemon running on the host
|
|
||||||
# - if the host has many interfaces (e.g. lots of docker bridges), restrict
|
|
||||||
# avahi to the LAN NIC in /etc/avahi/avahi-daemon.conf:
|
|
||||||
# allow-interfaces=<lan-iface>
|
|
||||||
# otherwise avahi sees its own announcements across bridges → "Local name
|
|
||||||
# collision" and publishing fails.
|
|
||||||
services:
|
|
||||||
avahi:
|
|
||||||
build: ./docker/avahi
|
|
||||||
image: ulicraft-avahi:local
|
|
||||||
container_name: avahi
|
|
||||||
restart: unless-stopped
|
|
||||||
network_mode: host
|
|
||||||
# AppArmor's docker-default profile blocks the D-Bus publish to host avahi.
|
|
||||||
security_opt:
|
|
||||||
- apparmor=unconfined
|
|
||||||
environment:
|
|
||||||
BASE_DOMAIN: ${BASE_DOMAIN}
|
|
||||||
HOST_LAN_IP: ${HOST_LAN_IP}
|
|
||||||
DBUS_SYSTEM_BUS_ADDRESS: "unix:path=/run/dbus/system_bus_socket"
|
|
||||||
volumes:
|
|
||||||
# Talk to the host's avahi-daemon (host must run avahi-daemon).
|
|
||||||
- /run/dbus/system_bus_socket:/run/dbus/system_bus_socket
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# Optional turnkey DNS — only if you DON'T run your own party DNS server.
|
|
||||||
# Layer on: `docker compose -f docker-compose.yml -f docker-compose.dnsmasq.yml ... up -d`.
|
|
||||||
# Serves dnsmasq/dnsmasq.conf (rendered from the template, includes the airgap
|
|
||||||
# spoof records). If you have your own DNS, skip this and use the records from
|
|
||||||
# `tooling/dns-records.sh` instead.
|
|
||||||
services:
|
|
||||||
dnsmasq:
|
|
||||||
image: jpillora/dnsmasq
|
|
||||||
container_name: dnsmasq
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
# Bind DNS to the host LAN IP so it doesn't clash with systemd-resolved on :53.
|
|
||||||
- "${HOST_LAN_IP}:53:53/udp"
|
|
||||||
- "${HOST_LAN_IP}:53:53/tcp"
|
|
||||||
volumes:
|
|
||||||
- ./dnsmasq/dnsmasq.conf:/etc/dnsmasq.conf:ro
|
|
||||||
networks:
|
|
||||||
- mcnet
|
|
||||||
# webproc UI (:8080) can be exposed behind Caddy as dns.${BASE_DOMAIN};
|
|
||||||
# guard with HTTP_USER / HTTP_PASS.
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# Mirror toggle — full client air-gap upstream mirror (see plan/11).
|
|
||||||
# Layer on top of the base: `docker compose -f docker-compose.yml -f docker-compose.mirror.yml up -d`
|
|
||||||
# (or `tooling/build-stack.sh --up mirror`). Adds the mirror vhost snippet, the
|
|
||||||
# upstream content mount, and publishes :443 for Caddy's `tls internal` certs.
|
|
||||||
# Populate the mirror first: tooling/mirror-airgap.sh (+ the proxy-capture step).
|
|
||||||
services:
|
|
||||||
caddy:
|
|
||||||
ports:
|
|
||||||
- "443:443"
|
|
||||||
volumes:
|
|
||||||
- ./caddy/conf.d/20-mirror.caddy:/etc/caddy/conf.d/20-mirror.caddy:ro
|
|
||||||
- ./mirror/upstream:/srv/mirror:ro
|
|
||||||
networks:
|
|
||||||
mcnet:
|
|
||||||
# Spoof the real upstream hosts -> Caddy (air-gap runtime only). Includes
|
|
||||||
# the base aliases because compose replaces (not merges) the alias list.
|
|
||||||
aliases:
|
|
||||||
- "auth.${BASE_DOMAIN}"
|
|
||||||
- "packwiz.${BASE_DOMAIN}"
|
|
||||||
- "meta.prismlauncher.org"
|
|
||||||
- "piston-meta.mojang.com"
|
|
||||||
- "piston-data.mojang.com"
|
|
||||||
- "libraries.minecraft.net"
|
|
||||||
- "maven.neoforged.net"
|
|
||||||
- "resources.download.minecraft.net"
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# Static toggle — apex landing page + launcher downloads.
|
|
||||||
# Layer on top of the base: `docker compose -f docker-compose.yml -f docker-compose.static.yml up -d`
|
|
||||||
# (or `tooling/build-stack.sh --up static`). Adds the static vhost snippet plus
|
|
||||||
# the www + launcher content mounts. Build www first: cd landing && pnpm run build.
|
|
||||||
services:
|
|
||||||
caddy:
|
|
||||||
volumes:
|
|
||||||
- ./caddy/conf.d/10-static.caddy:/etc/caddy/conf.d/10-static.caddy:ro
|
|
||||||
- ./www:/srv/www:ro
|
|
||||||
- ./mirror/launcher:/srv/launcher:ro
|
|
||||||
- ./caddy/caddy-root-ca.crt:/srv/ca-pub/ca.crt:ro
|
|
||||||
@@ -1,48 +1,31 @@
|
|||||||
# ──────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────
|
||||||
# Ulicraft LAN-party stack — Minecraft 1.21.1 NeoForge + Drasl auth
|
# Ulicraft LAN-party stack — Minecraft 1.21.1 NeoForge + Drasl auth
|
||||||
# ──────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────
|
||||||
# Single source of config: BASE_DOMAIN + HOST_LAN_IP in .env.
|
# Single, unified compose. One file holds the whole stack:
|
||||||
# DNS is provided by YOUR party DNS server — see tooling/dns-records.sh for the
|
# drasl auth + skins (Yggdrasil), internal only
|
||||||
# records to add (online vs airgap). A turnkey dnsmasq is available as an
|
# minecraft the server (itzg/NEOFORGE), mods from ./server-mods volume
|
||||||
# optional layer: docker-compose.dnsmasq.yml.
|
# mc-backup world backups every 6h via RCON
|
||||||
# - caddy is the only HTTP ingress; holds mcnet aliases so the stack
|
# caddy internal ingress — routes auth./apex/launcher/avatar.
|
||||||
# resolves auth.${BASE_DOMAIN} / packwiz.${BASE_DOMAIN} internally too
|
# /distribution. by Host header (conf.d/*.caddy)
|
||||||
# - drasl handles auth (password login; NO Keycloak/OIDC in this stack)
|
# nmsr skin/avatar renderer at avatar.${BASE_DOMAIN}
|
||||||
# - packwiz pack is served by caddy at packwiz.${BASE_DOMAIN}
|
# uptime-kuma status monitoring at status.${BASE_DOMAIN}
|
||||||
# - minecraft installs mods via PACKWIZ_URL, authlib-injector -> auth.
|
#
|
||||||
|
# Bring up: docker compose up -d (render configs first: tooling/render-config.sh)
|
||||||
|
#
|
||||||
|
# DNS is handled OUTSIDE this repo — point every service name at the host.
|
||||||
|
# Production TLS terminates at the HOST's nginx in front of caddy
|
||||||
|
# (nginx/ulicraft-caddy.conf.tmpl + Let's Encrypt); caddy is published on a
|
||||||
|
# localhost-only port (CADDY_HTTP_BIND/CADDY_HTTP_PORT in .env). Containers reach
|
||||||
|
# the stack's own names internally via caddy's mcnet alias (auth.).
|
||||||
# ──────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# Core ingress. Static site + air-gap mirror are layered in via override
|
|
||||||
# files (docker-compose.static.yml / .mirror.yml) — see build-stack.sh --up.
|
|
||||||
caddy:
|
|
||||||
image: caddy:alpine
|
|
||||||
container_name: caddy
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "80:80"
|
|
||||||
environment:
|
|
||||||
BASE_DOMAIN: ${BASE_DOMAIN}
|
|
||||||
volumes:
|
|
||||||
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
|
|
||||||
- ./caddy/conf.d/00-core.caddy:/etc/caddy/conf.d/00-core.caddy:ro
|
|
||||||
- ./pack:/srv/pack:ro
|
|
||||||
networks:
|
|
||||||
mcnet:
|
|
||||||
# Only the stack's own subdomains in the base. The upstream spoof-host
|
|
||||||
# aliases live in docker-compose.mirror.yml — adding them here would
|
|
||||||
# hijack maven.neoforged.net / libraries.minecraft.net for ALL mcnet
|
|
||||||
# containers, breaking the ONLINE NeoForge install during pre-bake.
|
|
||||||
aliases:
|
|
||||||
- "auth.${BASE_DOMAIN}"
|
|
||||||
- "packwiz.${BASE_DOMAIN}"
|
|
||||||
|
|
||||||
drasl:
|
drasl:
|
||||||
image: unmojang/drasl:latest
|
image: unmojang/drasl:latest
|
||||||
container_name: drasl
|
container_name: drasl
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# Internal-only: no published host port. Reached via Caddy at
|
# Internal-only: no published host port. Reached via caddy at
|
||||||
# auth.${BASE_DOMAIN} (Caddy holds the network alias on mcnet).
|
# auth.${BASE_DOMAIN} (caddy holds the network alias on mcnet).
|
||||||
volumes:
|
volumes:
|
||||||
- ./drasl/config:/etc/drasl:ro
|
- ./drasl/config:/etc/drasl:ro
|
||||||
- drasl_state:/var/lib/drasl
|
- drasl_state:/var/lib/drasl
|
||||||
@@ -65,7 +48,9 @@ services:
|
|||||||
TYPE: "NEOFORGE"
|
TYPE: "NEOFORGE"
|
||||||
VERSION: "1.21.1"
|
VERSION: "1.21.1"
|
||||||
# Verify NEOFORGE_VERSION against projects.neoforged.net before deploy.
|
# Verify NEOFORGE_VERSION against projects.neoforged.net before deploy.
|
||||||
NEOFORGE_VERSION: "21.1.209" # pin a known-good build; update deliberately
|
# Must match the distribution's NeoForge (distribution.json → 21.1.233);
|
||||||
|
# several mods (ferritecore≥218, farmersdelight≥219, configured≥211) need it.
|
||||||
|
NEOFORGE_VERSION: "21.1.233" # pin a known-good build; update deliberately
|
||||||
|
|
||||||
# ── Memory / performance ────────────────────────────────────
|
# ── Memory / performance ────────────────────────────────────
|
||||||
INIT_MEMORY: "4G"
|
INIT_MEMORY: "4G"
|
||||||
@@ -73,23 +58,37 @@ services:
|
|||||||
USE_AIKAR_FLAGS: "TRUE"
|
USE_AIKAR_FLAGS: "TRUE"
|
||||||
JVM_XX_OPTS: "-XX:+UseG1GC"
|
JVM_XX_OPTS: "-XX:+UseG1GC"
|
||||||
|
|
||||||
# ── Auth: offline mode + authlib-injector pointing at Drasl ─
|
# ── Auth: ONLINE mode validated against Drasl via authlib-injector ─
|
||||||
ONLINE_MODE: "FALSE"
|
# MUST be true: authlib-injector redirects the normal online-auth
|
||||||
# mount authlib-injector.jar at /extras/authlib-injector.jar
|
# handshake from Mojang to Drasl. online-mode=false skips the handshake
|
||||||
JVM_OPTS: "-javaagent:/extras/authlib-injector.jar=http://auth.${BASE_DOMAIN}/authlib-injector"
|
# entirely -> offline UUIDs, no session validation, NO SKINS for anyone.
|
||||||
|
# Secure-profile is the part that must stay off on 1.21+ (see
|
||||||
|
# ENFORCE_SECURE_PROFILE + Drasl SignPublicKeys=false), NOT online-mode.
|
||||||
|
ONLINE_MODE: "TRUE"
|
||||||
|
# Resolves over mcnet to caddy (alias auth.${BASE_DOMAIN}) -> drasl.
|
||||||
|
JVM_OPTS: "-javaagent:/extras/authlib-injector.jar=https://auth.${BASE_DOMAIN}/authlib-injector"
|
||||||
|
|
||||||
# ── Server config ───────────────────────────────────────────
|
# ── Server config ───────────────────────────────────────────
|
||||||
DIFFICULTY: "normal"
|
DIFFICULTY: "normal"
|
||||||
MODE: "survival"
|
MODE: "survival"
|
||||||
MOTD: "LAN party — kitchen sink"
|
MOTD: "Uli LAN party"
|
||||||
|
# Server-list icon (auto-scaled to 64x64). PNG lives in ./runtime (mounted
|
||||||
|
# /extras); OVERRIDE_ICON re-applies it on every boot. Also surfaces in the
|
||||||
|
# landing ServerCard via the SLP favicon.
|
||||||
|
ICON: "/extras/server-icon.png"
|
||||||
|
OVERRIDE_ICON: "TRUE"
|
||||||
MAX_PLAYERS: "10"
|
MAX_PLAYERS: "10"
|
||||||
VIEW_DISTANCE: "10"
|
VIEW_DISTANCE: "10"
|
||||||
SIMULATION_DISTANCE: "8"
|
SIMULATION_DISTANCE: "8"
|
||||||
ENFORCE_SECURE_PROFILE: "FALSE" # required for authlib-injector on 1.21+
|
ENFORCE_SECURE_PROFILE: "FALSE" # required for authlib-injector on 1.21+
|
||||||
ALLOW_FLIGHT: "TRUE" # many tech mods need this
|
ALLOW_FLIGHT: "TRUE" # many tech mods need this
|
||||||
|
|
||||||
# ── Mod source: packwiz pack served by caddy ────────────────
|
# ── Mod source: filtered subset of the distribution modset ──
|
||||||
PACKWIZ_URL: "http://packwiz.${BASE_DOMAIN}/pack.toml"
|
# Jars are synced into ./server-mods by tooling/sync-server-mods.sh
|
||||||
|
# (both/server entries from mods-sides.json) and mounted at /mods below;
|
||||||
|
# itzg auto-syncs /mods → /data/mods. REMOVE_OLD_MODS makes that mirror
|
||||||
|
# authoritative so removed mods get pruned. See plan/04-mods.md.
|
||||||
|
REMOVE_OLD_MODS: "TRUE"
|
||||||
|
|
||||||
# ── RCON for remote admin ───────────────────────────────────
|
# ── RCON for remote admin ───────────────────────────────────
|
||||||
ENABLE_RCON: "TRUE"
|
ENABLE_RCON: "TRUE"
|
||||||
@@ -99,14 +98,29 @@ services:
|
|||||||
TZ: "Europe/Madrid"
|
TZ: "Europe/Madrid"
|
||||||
volumes:
|
volumes:
|
||||||
- mc_data:/data
|
- mc_data:/data
|
||||||
- ./runtime:/extras:ro # authlib-injector.jar lives here
|
- ./runtime:/extras:ro # authlib-injector.jar lives here
|
||||||
|
- ./server-mods:/mods:ro # itzg auto-syncs /mods → /data/mods
|
||||||
|
# Distribution client-override files the server also needs, from the live
|
||||||
|
# distribution (DISTRIBUTION_WEB_ROOT), same source as caddy. Only the kubejs
|
||||||
|
# *source* subdirs are bind-mounted ro — kubejs itself writes config/, cache/,
|
||||||
|
# generated/, logs/ at boot, so the kubejs ROOT must stay writable (lives in
|
||||||
|
# mc_data). Mounting the whole kubejs dir ro makes BaseProperties.save() NPE
|
||||||
|
# and kubejs never initialises. CustomSkinLoader is ro (read-only json).
|
||||||
|
- ${DISTRIBUTION_WEB_ROOT:?DISTRIBUTION_WEB_ROOT unset in .env}/servers/ulicraft-1.21.1/files/kubejs/server_scripts:/data/kubejs/server_scripts:ro
|
||||||
|
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/kubejs/startup_scripts:/data/kubejs/startup_scripts:ro
|
||||||
|
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/kubejs/data:/data/kubejs/data:ro
|
||||||
|
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/kubejs/assets:/data/kubejs/assets:ro
|
||||||
|
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/CustomSkinLoader:/data/CustomSkinLoader:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
- drasl
|
- drasl # authlib (auth.) must resolve at boot
|
||||||
- caddy # packwiz pack is served by caddy
|
# Containers don't inherit the host's /etc/hosts; the LAN resolver returns a
|
||||||
|
# dead address for the public names. Pin auth. to the host so HTTPS to it
|
||||||
|
# lands on the host's nginx (valid LE cert) -> caddy -> drasl.
|
||||||
|
extra_hosts:
|
||||||
|
- "auth.${BASE_DOMAIN}:host-gateway"
|
||||||
networks:
|
networks:
|
||||||
- mcnet
|
- mcnet
|
||||||
|
|
||||||
# Optional: backups
|
|
||||||
mc-backup:
|
mc-backup:
|
||||||
image: itzg/mc-backup
|
image: itzg/mc-backup
|
||||||
container_name: mc-backup
|
container_name: mc-backup
|
||||||
@@ -125,9 +139,129 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- mcnet
|
- mcnet
|
||||||
|
|
||||||
|
# Internal ingress. Routes every vhost by Host header (conf.d/*.caddy):
|
||||||
|
# auth. -> drasl, apex -> landing + /launcher,
|
||||||
|
# avatar. -> nmsr, status. -> uptime-kuma, distribution. -> static site.
|
||||||
|
# Published on a localhost-only port; the host's nginx terminates TLS in
|
||||||
|
# front of it (nginx/ulicraft-caddy.conf.tmpl).
|
||||||
|
caddy:
|
||||||
|
image: caddy:alpine
|
||||||
|
container_name: caddy
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${CADDY_HTTP_BIND:-127.0.0.1}:${CADDY_HTTP_PORT:-8880}:80"
|
||||||
|
environment:
|
||||||
|
BASE_DOMAIN: ${BASE_DOMAIN}
|
||||||
|
volumes:
|
||||||
|
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
- ./caddy/conf.d/00-core.caddy:/etc/caddy/conf.d/00-core.caddy:ro
|
||||||
|
- ./caddy/conf.d/10-static.caddy:/etc/caddy/conf.d/10-static.caddy:ro
|
||||||
|
- ./caddy/conf.d/30-distribution.caddy:/etc/caddy/conf.d/30-distribution.caddy:ro
|
||||||
|
- ./caddy/conf.d/40-avatar.caddy:/etc/caddy/conf.d/40-avatar.caddy:ro
|
||||||
|
- ./caddy/conf.d/50-status.caddy:/etc/caddy/conf.d/50-status.caddy:ro
|
||||||
|
- ./caddy/conf.d/60-files.caddy:/etc/caddy/conf.d/60-files.caddy:ro
|
||||||
|
- ./www:/srv/www:ro
|
||||||
|
- ./launcher:/srv/launcher:ro
|
||||||
|
# Static site from ANOTHER repo (absolute host path in .env).
|
||||||
|
- ${DISTRIBUTION_WEB_ROOT:?DISTRIBUTION_WEB_ROOT unset in .env}:/srv/distribution:ro
|
||||||
|
networks:
|
||||||
|
mcnet:
|
||||||
|
# Containers resolve the stack's own public names to caddy internally.
|
||||||
|
aliases:
|
||||||
|
- "auth.${BASE_DOMAIN}"
|
||||||
|
|
||||||
|
# Avatar/skin renderer — built from source (docker/nmsr/Dockerfile), pulls
|
||||||
|
# player profiles from Drasl over mcnet. caddy proxies avatar. -> nmsr:8080.
|
||||||
|
nmsr:
|
||||||
|
build:
|
||||||
|
context: ./docker/nmsr
|
||||||
|
image: ulicraft/nmsr:local
|
||||||
|
container_name: nmsr
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./nmsr/config.toml:/nmsr/config.toml:ro
|
||||||
|
# Drasl embeds absolute HTTPS skin URLs (BaseURL is https) in the profile.
|
||||||
|
# The mcnet alias resolves auth. -> caddy (http :80 only), so NMSR's https
|
||||||
|
# skin fetch to auth.:443 finds no listener and every avatar falls back to
|
||||||
|
# the default skin. Pin auth. to the host (same as the minecraft service) so
|
||||||
|
# NMSR reaches the host nginx on :443 with the real LE cert.
|
||||||
|
extra_hosts:
|
||||||
|
- "auth.${BASE_DOMAIN}:host-gateway"
|
||||||
|
networks:
|
||||||
|
- mcnet
|
||||||
|
|
||||||
|
# Self-hosted SLP→JSON pinger (built from docker/mc-status, mcutil wrapper).
|
||||||
|
# Emits mcstatus.io v2 JSON so the landing's ServerCard reads it unchanged.
|
||||||
|
# No published port — caddy proxies apex /api/mcstatus/* -> mc-status:8080.
|
||||||
|
# Pings the minecraft service internally over mcnet; result cached 30s.
|
||||||
|
mc-status:
|
||||||
|
build:
|
||||||
|
context: ./docker/mc-status
|
||||||
|
image: ulicraft/mc-status:local
|
||||||
|
container_name: mc-status
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MC_STATUS_TARGET: "minecraft:25565"
|
||||||
|
MC_STATUS_CACHE_TTL: "30s"
|
||||||
|
# Registered-players roster (/api/mcstatus/players). mc-status logs into
|
||||||
|
# the Drasl admin API server-side and exposes a sanitized [{uuid,name}]
|
||||||
|
# list; these creds NEVER reach the browser. Use a dedicated, rotatable
|
||||||
|
# admin account. Isolated from the status pinger (own TTL/client).
|
||||||
|
DRASL_API_URL: "http://drasl:25585/drasl/api/v2"
|
||||||
|
DRASL_ADMIN_USERNAME: ${DRASL_ADMIN_USERNAME}
|
||||||
|
DRASL_ADMIN_PASSWORD: ${DRASL_ADMIN_PASSWORD}
|
||||||
|
MC_STATUS_ROSTER_TTL: "60s"
|
||||||
|
networks:
|
||||||
|
- mcnet
|
||||||
|
|
||||||
|
# Player file share (screenshots, schematics, maps) at files.${BASE_DOMAIN}.
|
||||||
|
# ONE shared login for everybody (FILES_AUTH=htpasswd), writable. See
|
||||||
|
# plan/20-files.md — it documents every non-obvious bit of this service.
|
||||||
|
#
|
||||||
|
# Config is rendered read-only from filestash/config.json.tmpl: filestash has
|
||||||
|
# no env-var config, and its admin console writes config.json back at runtime,
|
||||||
|
# so pinning it :ro is what keeps git authoritative. The console still loads —
|
||||||
|
# it just cannot save. That is intentional, not a bug.
|
||||||
|
filestash:
|
||||||
|
image: machines/filestash@sha256:8cb09d42470328a02aec6e3861f912addf8a9d54ad63d9c965bcb48df5ad36b1
|
||||||
|
container_name: filestash
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
# The `local` storage backend is admin-gated in filestash: it refuses to
|
||||||
|
# init unless the connection params carry this secret (or the admin
|
||||||
|
# password). config.json's attribute_mapping supplies it server-side, so
|
||||||
|
# players never see it. Without this, every login dies on "Not Allowed".
|
||||||
|
LOCAL_BACKEND_SECRET: ${FILES_LOCAL_SECRET:?FILES_LOCAL_SECRET unset in .env}
|
||||||
|
# Do NOT set APPLICATION_URL / ADMIN_PASSWORD here: either one makes
|
||||||
|
# filestash rewrite config.json at boot, which fails on the :ro mount.
|
||||||
|
# Both live in the rendered config instead (general.host / auth.admin).
|
||||||
|
volumes:
|
||||||
|
# /app/data/state holds config + sqlite + search index + logs, so the
|
||||||
|
# DIRECTORY must stay writable. Only config.json is pinned read-only.
|
||||||
|
- filestash_state:/app/data/state
|
||||||
|
- ./filestash/config.json:/app/data/state/config/config.json:ro
|
||||||
|
# The share itself. FILES_MOUNT_MODE is the literal mount flag (ro|rw) —
|
||||||
|
# `ro` enforces a read-only share at the KERNEL, whatever the UI thinks.
|
||||||
|
- ${FILES_DATA_DIR:?FILES_DATA_DIR unset in .env}:/data/files:${FILES_MOUNT_MODE:-ro}
|
||||||
|
networks:
|
||||||
|
- mcnet
|
||||||
|
|
||||||
|
# Status monitoring + public status page. caddy proxies status. -> :3001.
|
||||||
|
# Joins mcnet so monitors probe the stack by internal service name.
|
||||||
|
uptime-kuma:
|
||||||
|
image: louislam/uptime-kuma:1
|
||||||
|
container_name: uptime-kuma
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- kuma_data:/app/data
|
||||||
|
networks:
|
||||||
|
- mcnet
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
drasl_state:
|
drasl_state:
|
||||||
mc_data:
|
mc_data:
|
||||||
|
kuma_data:
|
||||||
|
filestash_state:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
mcnet:
|
mcnet:
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
# Publishes the stack's service names on .local via the HOST's avahi-daemon
|
|
||||||
# (over its D-Bus socket — no second daemon, avoids the :5353 collision).
|
|
||||||
# Requires the host to run avahi-daemon and the socket to be mounted (see
|
|
||||||
# docker-compose.avahi.yml). Used when ENABLE_MDNS=true and BASE_DOMAIN=*.local.
|
|
||||||
FROM alpine:3.20
|
|
||||||
RUN apk add --no-cache avahi-tools
|
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
|
||||||
RUN chmod +x /entrypoint.sh
|
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# Publish A records for the stack's service names on .local via the HOST's
|
|
||||||
# avahi-daemon (reached over its mounted D-Bus socket). One avahi-publish per
|
|
||||||
# name; if any drops the container exits so Docker restarts it.
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
: "${BASE_DOMAIN:?BASE_DOMAIN unset}"
|
|
||||||
: "${HOST_LAN_IP:?HOST_LAN_IP unset}"
|
|
||||||
|
|
||||||
case "$BASE_DOMAIN" in
|
|
||||||
*.local) ;;
|
|
||||||
*) echo "WARN: mDNS resolves only .local — BASE_DOMAIN=$BASE_DOMAIN will NOT be discoverable via avahi. Set BASE_DOMAIN=*.local to use mDNS." >&2 ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
bus="${DBUS_SYSTEM_BUS_ADDRESS:-unix:path=/run/dbus/system_bus_socket}"
|
|
||||||
sock=${bus#unix:path=}
|
|
||||||
if [ ! -S "$sock" ]; then
|
|
||||||
echo "ERROR: host D-Bus socket '$sock' not found. Mount the host's" >&2
|
|
||||||
echo " /run/dbus/system_bus_socket and ensure avahi-daemon runs on the host." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
export DBUS_SYSTEM_BUS_ADDRESS="$bus"
|
|
||||||
|
|
||||||
pids=""
|
|
||||||
for n in "$BASE_DOMAIN" "auth.$BASE_DOMAIN" "packwiz.$BASE_DOMAIN" "mc.$BASE_DOMAIN"; do
|
|
||||||
echo "mDNS publish (host avahi): $n -> $HOST_LAN_IP"
|
|
||||||
avahi-publish -a "$n" "$HOST_LAN_IP" &
|
|
||||||
pids="$pids $!"
|
|
||||||
done
|
|
||||||
|
|
||||||
# shellcheck disable=SC2086
|
|
||||||
wait -n $pids 2>/dev/null || wait
|
|
||||||
15
docker/mc-status/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# mc-status — self-hosted SLP→JSON pinger (mcutil wrapper). Multi-stage:
|
||||||
|
# build a static binary, ship it on scratch.
|
||||||
|
FROM golang:1.25-alpine AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/mc-status .
|
||||||
|
|
||||||
|
FROM scratch
|
||||||
|
# CA roots so SRV/DNS over the resolver and any TLS lookups work.
|
||||||
|
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||||
|
COPY --from=build /out/mc-status /mc-status
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["/mc-status"]
|
||||||
5
docker/mc-status/go.mod
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module ulicraft/mc-status
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require github.com/mcstatus-io/mcutil/v4 v4.0.1
|
||||||
2
docker/mc-status/go.sum
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
github.com/mcstatus-io/mcutil/v4 v4.0.1 h1:/AQkHrz7irCU7USGnrH3kneQw80aDQOVdOWc8xu/NUY=
|
||||||
|
github.com/mcstatus-io/mcutil/v4 v4.0.1/go.mod h1:yC91WInI1U2GAMFWgpPgsAULPVS2o+4JCZbiiWhHwxM=
|
||||||
397
docker/mc-status/main.go
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
// mc-status — a tiny self-hosted Minecraft Server List Ping → JSON service.
|
||||||
|
//
|
||||||
|
// It wraps github.com/mcstatus-io/mcutil (the same library that powers
|
||||||
|
// api.mcstatus.io) and re-emits the result in mcstatus.io's *v2* JSON shape, so
|
||||||
|
// the landing's ServerCard.astro can talk to it with zero changes — just point
|
||||||
|
// `statusApi` at this service instead of the public API.
|
||||||
|
//
|
||||||
|
// Hardening notes:
|
||||||
|
// - The address in the request path is IGNORED. We only ever ping the single
|
||||||
|
// allow-listed MC_STATUS_TARGET. This is deliberate: a path-controlled
|
||||||
|
// pinger would be an open SSRF relay. The path segment is kept only so the
|
||||||
|
// URL stays drop-in compatible with api.mcstatus.io/v2/status/java/<addr>.
|
||||||
|
// - Results are cached in-memory for MC_STATUS_CACHE_TTL so a busy landing
|
||||||
|
// page can't hammer the Minecraft server with a ping per visitor.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mcstatus-io/mcutil/v4/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- mcstatus.io v2 response shape (only the fields ServerCard reads) ---
|
||||||
|
|
||||||
|
type v2Version struct {
|
||||||
|
NameRaw string `json:"name_raw"`
|
||||||
|
NameClean string `json:"name_clean"`
|
||||||
|
NameHTML string `json:"name_html"`
|
||||||
|
Protocol int64 `json:"protocol"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type v2Player struct {
|
||||||
|
UUID string `json:"uuid"`
|
||||||
|
NameRaw string `json:"name_raw"`
|
||||||
|
NameClean string `json:"name_clean"`
|
||||||
|
NameHTML string `json:"name_html"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type v2Players struct {
|
||||||
|
Online int64 `json:"online"`
|
||||||
|
Max int64 `json:"max"`
|
||||||
|
List []v2Player `json:"list"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type v2MOTD struct {
|
||||||
|
Raw string `json:"raw"`
|
||||||
|
Clean string `json:"clean"`
|
||||||
|
HTML string `json:"html"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type v2Response struct {
|
||||||
|
Online bool `json:"online"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port uint16 `json:"port"`
|
||||||
|
Version *v2Version `json:"version,omitempty"`
|
||||||
|
Players *v2Players `json:"players,omitempty"`
|
||||||
|
MOTD *v2MOTD `json:"motd,omitempty"`
|
||||||
|
Icon *string `json:"icon"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- tiny TTL cache (single target → single entry) ---
|
||||||
|
|
||||||
|
type cache struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
payload []byte
|
||||||
|
expires time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func deref[T any](p *T) (v T) {
|
||||||
|
if p != nil {
|
||||||
|
v = *p
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- registered-players roster (Drasl admin) ---
|
||||||
|
//
|
||||||
|
// The roster path is DELIBERATELY isolated from the status path: its own
|
||||||
|
// http.Client (with a timeout), its own TTL cache, and its own cached admin
|
||||||
|
// token. Drasl being slow or down must never block or break /v2/status — on any
|
||||||
|
// failure we serve stale-or-empty JSON rather than hanging. Mirrors the status
|
||||||
|
// cache style above (single entry, mutex-guarded).
|
||||||
|
|
||||||
|
// rosterPlayer is the sanitized shape we expose publicly: uuid + name only,
|
||||||
|
// everything else from Drasl stripped (no emails, capes, admin flags, etc.).
|
||||||
|
type rosterPlayer struct {
|
||||||
|
UUID string `json:"uuid"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// draslPlayer is the subset of Drasl's GET /players entries we read.
|
||||||
|
type draslPlayer struct {
|
||||||
|
UUID string `json:"uuid"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// roster holds the cached public payload plus the cached admin token. Both are
|
||||||
|
// guarded by the same mutex; token reuse avoids a login per request.
|
||||||
|
type roster struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
payload []byte
|
||||||
|
expires time.Time
|
||||||
|
|
||||||
|
token string
|
||||||
|
|
||||||
|
apiURL string
|
||||||
|
username string
|
||||||
|
password string
|
||||||
|
ttl time.Duration
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// payloadOrEmpty returns the cached payload, or an empty JSON array if nothing
|
||||||
|
// has ever been fetched. Used so a Drasl failure on the very first request
|
||||||
|
// still yields valid JSON ("[]") instead of an error.
|
||||||
|
func (rs *roster) payloadOrEmpty() []byte {
|
||||||
|
if rs.payload != nil {
|
||||||
|
return rs.payload
|
||||||
|
}
|
||||||
|
return []byte("[]")
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetch logs in (if no token) and lists players, re-logging in once on a 401.
|
||||||
|
// On any error it returns the previous payload (stale-or-empty), never an error
|
||||||
|
// to the caller — the handler always serves something.
|
||||||
|
func (rs *roster) fetch() []byte {
|
||||||
|
players, err := rs.listPlayers(false)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("roster: list players failed: %v", err)
|
||||||
|
return rs.payloadOrEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]rosterPlayer, 0, len(players))
|
||||||
|
for _, p := range players {
|
||||||
|
out = append(out, rosterPlayer{UUID: p.UUID, Name: p.Name})
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(out)
|
||||||
|
if err != nil {
|
||||||
|
return rs.payloadOrEmpty()
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// listPlayers performs GET {apiURL}/players with the cached token, logging in
|
||||||
|
// first if needed. On 401 it clears the token and retries once (retried=true
|
||||||
|
// prevents a loop). Caller holds rs.mu.
|
||||||
|
func (rs *roster) listPlayers(retried bool) ([]draslPlayer, error) {
|
||||||
|
if rs.token == "" {
|
||||||
|
if err := rs.login(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodGet, rs.apiURL+"/players", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+rs.token)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := rs.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusUnauthorized && !retried {
|
||||||
|
// Token expired/invalid — drop it and re-login once.
|
||||||
|
rs.token = ""
|
||||||
|
io.Copy(io.Discard, resp.Body)
|
||||||
|
return rs.listPlayers(true)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
io.Copy(io.Discard, resp.Body)
|
||||||
|
return nil, &httpError{status: resp.StatusCode, op: "list players"}
|
||||||
|
}
|
||||||
|
|
||||||
|
var players []draslPlayer
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&players); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return players, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// login posts admin creds to {apiURL}/login and caches the returned apiToken.
|
||||||
|
// Caller holds rs.mu.
|
||||||
|
func (rs *roster) login() error {
|
||||||
|
body, err := json.Marshal(map[string]string{
|
||||||
|
"username": rs.username,
|
||||||
|
"password": rs.password,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodPost, rs.apiURL+"/login", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := rs.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
io.Copy(io.Discard, resp.Body)
|
||||||
|
return &httpError{status: resp.StatusCode, op: "login"}
|
||||||
|
}
|
||||||
|
|
||||||
|
var out struct {
|
||||||
|
APIToken string `json:"apiToken"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if out.APIToken == "" {
|
||||||
|
return &httpError{status: resp.StatusCode, op: "login (no apiToken)"}
|
||||||
|
}
|
||||||
|
rs.token = out.APIToken
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type httpError struct {
|
||||||
|
status int
|
||||||
|
op string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *httpError) Error() string {
|
||||||
|
return "drasl " + e.op + ": status " + strconv.Itoa(e.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
target := envOr("MC_STATUS_TARGET", "minecraft:25565")
|
||||||
|
listen := envOr("MC_STATUS_LISTEN", ":8080")
|
||||||
|
ttl, err := time.ParseDuration(envOr("MC_STATUS_CACHE_TTL", "30s"))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("invalid MC_STATUS_CACHE_TTL: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rosterTTL, err := time.ParseDuration(envOr("MC_STATUS_ROSTER_TTL", "60s"))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("invalid MC_STATUS_ROSTER_TTL: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
host, port := splitHostPort(target)
|
||||||
|
c := &cache{}
|
||||||
|
|
||||||
|
// Registered-players roster, fed by the Drasl admin API. Isolated from the
|
||||||
|
// status pinger above (own client + timeout + cache). If the admin creds are
|
||||||
|
// unset, /players still works — it just always serves "[]".
|
||||||
|
rs := &roster{
|
||||||
|
apiURL: strings.TrimRight(envOr("DRASL_API_URL", "http://drasl:25585/drasl/api/v2"), "/"),
|
||||||
|
username: os.Getenv("DRASL_ADMIN_USERNAME"),
|
||||||
|
password: os.Getenv("DRASL_ADMIN_PASSWORD"),
|
||||||
|
ttl: rosterTTL,
|
||||||
|
client: &http.Client{Timeout: 5 * time.Second},
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// Drop-in path: /v2/status/java/<addr> — <addr> is ignored (see file header).
|
||||||
|
mux.HandleFunc("/v2/status/java/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
fresh := c.payload != nil && time.Now().Before(c.expires)
|
||||||
|
if fresh {
|
||||||
|
payload := c.payload
|
||||||
|
c.mu.Unlock()
|
||||||
|
w.Write(payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
payload := buildPayload(host, port)
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
c.payload = payload
|
||||||
|
c.expires = time.Now().Add(ttl)
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
w.Write(payload)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Registered-players roster — public path /api/mcstatus/players (caddy
|
||||||
|
// strips the /api/mcstatus prefix → internal /players). Serves a sanitized
|
||||||
|
// [{uuid,name}] of all Drasl players. Same CORS:* as the status route.
|
||||||
|
// Isolated from the status path: own TTL cache, own token, own client; on
|
||||||
|
// any Drasl failure it serves stale-or-empty rather than hanging.
|
||||||
|
mux.HandleFunc("/players", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
|
||||||
|
rs.mu.Lock()
|
||||||
|
defer rs.mu.Unlock()
|
||||||
|
|
||||||
|
if rs.payload != nil && time.Now().Before(rs.expires) {
|
||||||
|
w.Write(rs.payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := rs.fetch()
|
||||||
|
rs.payload = payload
|
||||||
|
rs.expires = time.Now().Add(rs.ttl)
|
||||||
|
w.Write(payload)
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("ok"))
|
||||||
|
})
|
||||||
|
|
||||||
|
log.Printf("mc-status listening on %s, target=%s:%d, ttl=%s, roster-ttl=%s", listen, host, port, ttl, rosterTTL)
|
||||||
|
log.Fatal(http.ListenAndServe(listen, mux))
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildPayload pings the target and marshals the v2 response. On any ping
|
||||||
|
// failure it returns a minimal {"online":false} so the frontend can show an
|
||||||
|
// offline state without erroring.
|
||||||
|
func buildPayload(host string, port uint16) []byte {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
resp, err := status.Modern(ctx, host, port)
|
||||||
|
if err != nil {
|
||||||
|
b, _ := json.Marshal(v2Response{Online: false, Host: host, Port: port})
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
out := v2Response{
|
||||||
|
Online: true,
|
||||||
|
Host: host,
|
||||||
|
Port: port,
|
||||||
|
Icon: resp.Favicon,
|
||||||
|
Version: &v2Version{
|
||||||
|
NameRaw: resp.Version.Name.Raw,
|
||||||
|
NameClean: resp.Version.Name.Clean,
|
||||||
|
NameHTML: resp.Version.Name.HTML,
|
||||||
|
Protocol: resp.Version.Protocol,
|
||||||
|
},
|
||||||
|
MOTD: &v2MOTD{
|
||||||
|
Raw: resp.MOTD.Raw,
|
||||||
|
Clean: resp.MOTD.Clean,
|
||||||
|
HTML: resp.MOTD.HTML,
|
||||||
|
},
|
||||||
|
Players: &v2Players{
|
||||||
|
Online: deref(resp.Players.Online),
|
||||||
|
Max: deref(resp.Players.Max),
|
||||||
|
List: make([]v2Player, 0, len(resp.Players.Sample)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range resp.Players.Sample {
|
||||||
|
out.Players.List = append(out.Players.List, v2Player{
|
||||||
|
UUID: p.ID,
|
||||||
|
NameRaw: p.Name.Raw,
|
||||||
|
NameClean: p.Name.Clean,
|
||||||
|
NameHTML: p.Name.HTML,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
b, _ := json.Marshal(out)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(key, def string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitHostPort(target string) (string, uint16) {
|
||||||
|
host, portStr, found := strings.Cut(target, ":")
|
||||||
|
if !found {
|
||||||
|
return target, 25565
|
||||||
|
}
|
||||||
|
p, err := strconv.ParseUint(portStr, 10, 16)
|
||||||
|
if err != nil {
|
||||||
|
return host, 25565
|
||||||
|
}
|
||||||
|
return host, uint16(p)
|
||||||
|
}
|
||||||
33
docker/nmsr/Dockerfile
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# NMSR-as-a-Service (https://github.com/NickAcPT/nmsr-rs) — built from source.
|
||||||
|
# Upstream ships no published image, so we vendor a pinned build. Adapted from
|
||||||
|
# the upstream Dockerfile but: pinned to a commit (reproducible), and the config
|
||||||
|
# is bind-mounted at runtime (not COPYed) so it can change without a rebuild.
|
||||||
|
#
|
||||||
|
# Pin — bump deliberately:
|
||||||
|
ARG NMSR_REF=948ba4bc027b
|
||||||
|
|
||||||
|
FROM rust:slim-bookworm AS builder
|
||||||
|
ARG NMSR_REF
|
||||||
|
WORKDIR /tmp
|
||||||
|
RUN apt-get update -y \
|
||||||
|
&& apt-get --no-install-recommends install -y git libssl-dev pkg-config ca-certificates
|
||||||
|
RUN git clone https://github.com/NickAcPT/nmsr-rs/
|
||||||
|
WORKDIR /tmp/nmsr-rs
|
||||||
|
RUN git checkout "${NMSR_REF}"
|
||||||
|
# target-cpu=native: this image is built on the host that runs it (self-host).
|
||||||
|
RUN RUSTFLAGS="-Ctarget-cpu=native" \
|
||||||
|
cargo build --release --bin nmsr-aas --features ears --package nmsr-aas
|
||||||
|
|
||||||
|
FROM rust:slim-bookworm
|
||||||
|
# mesa-vulkan-drivers provides lavapipe (software Vulkan) for headless rendering.
|
||||||
|
RUN apt-get update -y \
|
||||||
|
&& apt-get --no-install-recommends install -y mesa-vulkan-drivers ca-certificates
|
||||||
|
WORKDIR /nmsr
|
||||||
|
COPY --from=builder /tmp/nmsr-rs/target/release/nmsr-aas /nmsr/nmsr-aas
|
||||||
|
ENV NMSR_USE_SMAA=1 \
|
||||||
|
NMSR_SAMPLE_COUNT=1 \
|
||||||
|
WGPU_BACKEND=vulkan \
|
||||||
|
RUST_BACKTRACE=1
|
||||||
|
EXPOSE 8080
|
||||||
|
# config.toml is bind-mounted by docker-compose.nmsr.yml.
|
||||||
|
CMD ["/nmsr/nmsr-aas", "-c", "/nmsr/config.toml"]
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
# Drasl config — password-login mode (NO Keycloak/OIDC for now).
|
# Drasl config — password-login mode (NO Keycloak/OIDC for now).
|
||||||
# TOML only (drasl has no env support). Rendered by tooling/render-config.sh
|
# TOML only (drasl has no env support). Rendered by tooling/render-config.sh
|
||||||
# -> drasl/config/config.toml (gitignored). Only ${BASE_DOMAIN} is substituted.
|
# -> drasl/config/config.toml (gitignored). Substitutes ${BASE_DOMAIN} and
|
||||||
|
# ${REGISTRATION_REQUIRE_INVITE} (derived from REGISTRATION_MODE in .env).
|
||||||
# Reached only via Caddy at auth.${BASE_DOMAIN}; drasl has no published host port.
|
# Reached only via Caddy at auth.${BASE_DOMAIN}; drasl has no published host port.
|
||||||
|
|
||||||
BaseURL = "http://auth.${BASE_DOMAIN}"
|
# Public scheme is HTTPS — the host nginx terminates TLS in front of caddy.
|
||||||
|
# Drasl builds absolute URLs (textures, authlib-injector API location) from this;
|
||||||
|
# an http:// value triggers mixed-content + broken login on the https origin.
|
||||||
|
BaseURL = "https://auth.${BASE_DOMAIN}"
|
||||||
Domain = "auth.${BASE_DOMAIN}"
|
Domain = "auth.${BASE_DOMAIN}"
|
||||||
ListenAddress = "0.0.0.0:25585" # internal; Caddy reverse-proxies to it
|
ListenAddress = "0.0.0.0:25585" # internal; Caddy reverse-proxies to it
|
||||||
DefaultAdminUsernames = ["admin"]
|
DefaultAdmins = ["admin"]
|
||||||
|
|
||||||
# Password login: admin + guests register a username/password on the web UI.
|
# Password login: admin + guests register a username/password on the web UI.
|
||||||
AllowPasswordLogin = true
|
AllowPasswordLogin = true
|
||||||
@@ -18,5 +22,29 @@ SignPublicKeys = false
|
|||||||
# Free-text owner label shown in the web UI (a string, not a table).
|
# Free-text owner label shown in the web UI (a string, not a table).
|
||||||
ApplicationOwner = "Ulicraft"
|
ApplicationOwner = "Ulicraft"
|
||||||
|
|
||||||
|
# CORS: the landing register/account form (served from the apex) calls the
|
||||||
|
# Drasl API from the browser. Without this the API has NO CORS headers and the
|
||||||
|
# browser blocks every cross-origin call. Scope to the apex only — never "*",
|
||||||
|
# which would let any site script the auth API. Echo backfills AllowMethods
|
||||||
|
# (incl. PATCH/DELETE) and reflects requested headers (Content-Type/Authorization).
|
||||||
|
# MUST stay top-level (before any [Section]) — under a table it parses as
|
||||||
|
# <table>.CORSAllowOrigins and drasl ignores it (silent: no CORS headers).
|
||||||
|
CORSAllowOrigins = ["https://${BASE_DOMAIN}"]
|
||||||
|
|
||||||
|
# New-account registration (username+password). RequireInvite is derived from
|
||||||
|
# REGISTRATION_MODE in .env by render-config.sh: invite -> true, open -> false.
|
||||||
|
# When true, non-admin callers (web UI + landing register form) must supply a
|
||||||
|
# valid invite code; admins bypass. Mint codes via POST /drasl/api/v2/invites
|
||||||
|
# with an admin api token.
|
||||||
|
[RegistrationNewPlayer]
|
||||||
|
Allow = true
|
||||||
|
RequireInvite = ${REGISTRATION_REQUIRE_INVITE}
|
||||||
|
|
||||||
|
# Rate limit the now public-facing API (anonymous POST /users etc). Token
|
||||||
|
# bucket; admins are exempt. Conservative for a 4-10 player server.
|
||||||
|
[RateLimit]
|
||||||
|
RequestsPerSecond = 5
|
||||||
|
Burst = 10
|
||||||
|
|
||||||
# OIDC block intentionally omitted for now (no Keycloak).
|
# OIDC block intentionally omitted for now (no Keycloak).
|
||||||
# [[RegistrationOIDC]] <- future task
|
# [[RegistrationOIDC]] <- future task
|
||||||
|
|||||||
31
filestash/config.json.tmpl
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"general": {
|
||||||
|
"name": "Ulicraft Files",
|
||||||
|
"host": "files.${BASE_DOMAIN}",
|
||||||
|
"force_ssl": true,
|
||||||
|
"secret_key": "${FILES_SECRET_KEY}",
|
||||||
|
"editor": "base",
|
||||||
|
"display_hidden": false,
|
||||||
|
"upload_button": true,
|
||||||
|
"fork_button": false
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"admin": "${FILES_ADMIN_PASSWORD_HASH}"
|
||||||
|
},
|
||||||
|
"middleware": {
|
||||||
|
"identity_provider": {
|
||||||
|
"type": "${FILES_IDP_TYPE}",
|
||||||
|
"params": "${FILES_IDP_PARAMS}"
|
||||||
|
},
|
||||||
|
"attribute_mapping": {
|
||||||
|
"related_backend": "files",
|
||||||
|
"params": "{\"files\":{\"type\":\"local\",\"path\":\"/data/files/\",\"password\":\"${FILES_LOCAL_SECRET}\"}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"connections": [
|
||||||
|
{
|
||||||
|
"label": "files",
|
||||||
|
"type": "local"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
landing/design/.thumbnail
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
22
landing/design/Ulicraft Landing.html
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-mood="grass" data-hero="centered" data-head="pixelify">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Ulicraft — Java Survival SMP</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Pixelify+Sans:wght@400;500;600;700&family=Press+Start+2P&family=Silkscreen:wght@400;700&family=Space+Grotesk:wght@400;500;600;700&family=VT323&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||||
|
|
||||||
|
<script type="text/babel" src="tweaks-panel.jsx"></script>
|
||||||
|
<script type="text/babel" src="app.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
434
landing/design/app.jsx
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
// Ulicraft landing — pixel components, live status, copy-IP, scroll reveals, tweaks
|
||||||
|
const { useState, useEffect, useRef, useCallback } = React;
|
||||||
|
|
||||||
|
/* ---------- pixel art helpers ---------- */
|
||||||
|
// 8x8 creeper face
|
||||||
|
const CREEPER = [
|
||||||
|
"00000000",
|
||||||
|
"01100110",
|
||||||
|
"01100110",
|
||||||
|
"00011000",
|
||||||
|
"00111100",
|
||||||
|
"00111100",
|
||||||
|
"00100100",
|
||||||
|
"00000000",
|
||||||
|
];
|
||||||
|
function Creeper() {
|
||||||
|
const cells = CREEPER.join("").split("");
|
||||||
|
return (
|
||||||
|
<div className="creeper" aria-hidden="true">
|
||||||
|
{cells.map((c, i) => <i key={i} className={c === "1" ? "f" : ""} />)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7x7 feature glyphs
|
||||||
|
const GLYPHS = {
|
||||||
|
shield: ["0111110","1111111","1111111","1111111","0111110","0011100","0001000"],
|
||||||
|
diamond:["0001000","0011100","0111110","1111111","0111110","0011100","0001000"],
|
||||||
|
orb: ["0011100","0111110","1111111","1111111","1111111","0111110","0011100"],
|
||||||
|
heart: ["0110110","1111111","1111111","1111111","0111110","0011100","0001000"],
|
||||||
|
};
|
||||||
|
function PixelIcon({ glyph, gold }) {
|
||||||
|
const cells = GLYPHS[glyph].join("").split("");
|
||||||
|
return (
|
||||||
|
<div className="picon" aria-hidden="true">
|
||||||
|
{cells.map((c, i) => (
|
||||||
|
<i key={i} className={c === "1" ? (gold ? "go" : "on") : ""} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- copy to clipboard ---------- */
|
||||||
|
function copyText(text) {
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
const ta = document.createElement("textarea");
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.position = "fixed";
|
||||||
|
ta.style.opacity = "0";
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
try { document.execCommand("copy"); } catch (e) {}
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IpChip({ ip }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const onCopy = () => {
|
||||||
|
copyText(ip);
|
||||||
|
setCopied(true);
|
||||||
|
clearTimeout(onCopy._t);
|
||||||
|
onCopy._t = setTimeout(() => setCopied(false), 1600);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="ip-chip">
|
||||||
|
<span className="ip-val"><span className="pin">▸</span>{ip}</span>
|
||||||
|
<button className={copied ? "copied" : ""} onClick={onCopy}>
|
||||||
|
{copied ? "Copied!" : "Copy IP"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- live player count ---------- */
|
||||||
|
function useLivePlayers(base = 37, max = 100) {
|
||||||
|
const [n, setN] = useState(base);
|
||||||
|
useEffect(() => {
|
||||||
|
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||||
|
const id = setInterval(() => {
|
||||||
|
setN((p) => {
|
||||||
|
const step = Math.floor(Math.random() * 5) - 2;
|
||||||
|
return Math.max(base - 6, Math.min(max - 4, p + step));
|
||||||
|
});
|
||||||
|
}, 2600);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [base, max]);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- floating pixel dust ---------- */
|
||||||
|
function Dust({ on }) {
|
||||||
|
if (!on) return null;
|
||||||
|
const bits = Array.from({ length: 16 }, (_, i) => i);
|
||||||
|
return (
|
||||||
|
<div className="dust" aria-hidden="true">
|
||||||
|
{bits.map((i) => {
|
||||||
|
const size = 3 + (i % 3) * 2;
|
||||||
|
const st = {
|
||||||
|
position: "absolute",
|
||||||
|
left: ((i * 61) % 100) + "%",
|
||||||
|
bottom: "-20px",
|
||||||
|
width: size + "px",
|
||||||
|
height: size + "px",
|
||||||
|
background: i % 4 === 0 ? "var(--gold)" : "var(--accent)",
|
||||||
|
opacity: 0.18 + (i % 3) * 0.06,
|
||||||
|
imageRendering: "pixelated",
|
||||||
|
animation: `rise ${13 + (i % 7) * 3}s linear ${i * 0.7}s infinite`,
|
||||||
|
};
|
||||||
|
return <i key={i} style={st} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- server-list status panel ---------- */
|
||||||
|
function ServerListPanel({ players, max, version }) {
|
||||||
|
return (
|
||||||
|
<div className="serverlist">
|
||||||
|
<div className="icon"><Creeper /></div>
|
||||||
|
<div className="meta">
|
||||||
|
<div className="row1">
|
||||||
|
<span className="title">Ulicraft</span>
|
||||||
|
<span className="ver">Java {version}</span>
|
||||||
|
</div>
|
||||||
|
<p className="motd">
|
||||||
|
<span className="a">⛏ Season 3</span> · Survival SMP · <span className="g">whitelist open</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="stat">
|
||||||
|
<div className="players">
|
||||||
|
<span className="bars" aria-hidden="true"><i /><i /><i /><i /><i /></span>
|
||||||
|
<span><b>{players}</b>/{max}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- scroll reveal (fail-safe: visible at rest, .anim added when in view) ---------- */
|
||||||
|
function useReveal() {
|
||||||
|
useEffect(() => {
|
||||||
|
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||||
|
let els = [...document.querySelectorAll(".reveal")];
|
||||||
|
let stop = false;
|
||||||
|
const tick = () => {
|
||||||
|
if (stop) return;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
for (let i = els.length - 1; i >= 0; i--) {
|
||||||
|
const r = els[i].getBoundingClientRect();
|
||||||
|
if (r.top < vh * 0.9 && r.bottom > 0) {
|
||||||
|
els[i].classList.add("anim");
|
||||||
|
els.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (els.length) requestAnimationFrame(tick);
|
||||||
|
else stop = true;
|
||||||
|
};
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
return () => { stop = true; };
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================ */
|
||||||
|
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
|
||||||
|
"heroLayout": "centered",
|
||||||
|
"mood": "grass",
|
||||||
|
"headFont": "pixelify",
|
||||||
|
"serverIp": "play.ulicraft.net",
|
||||||
|
"tagline": "Hardcore survival, built to last.",
|
||||||
|
"particles": true
|
||||||
|
}/*EDITMODE-END*/;
|
||||||
|
|
||||||
|
const HEAD_FONTS = {
|
||||||
|
pixelify: "'Pixelify Sans', system-ui, sans-serif",
|
||||||
|
"8bit": "'Press Start 2P', monospace",
|
||||||
|
silkscreen: "'Silkscreen', system-ui, sans-serif",
|
||||||
|
};
|
||||||
|
|
||||||
|
const FEATURES = [
|
||||||
|
{ glyph: "shield", gold: false, h: "Grief-proof claims",
|
||||||
|
p: "Lock down your base with golden-shovel land claims. Your builds stay yours — even while you're offline." },
|
||||||
|
{ glyph: "diamond", gold: true, h: "Zero pay-to-win",
|
||||||
|
p: "The store sells cosmetics and nothing else. Every diamond is earned in-game, never bought." },
|
||||||
|
{ glyph: "orb", gold: false, h: "A living world",
|
||||||
|
p: "Seasonal map resets, custom bosses, and community events keep the overworld worth logging into." },
|
||||||
|
{ glyph: "heart", gold: true, h: "Real community",
|
||||||
|
p: "A whitelisted, moderated server with an active Discord. Toxicity meets the ban hammer, fast." },
|
||||||
|
];
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
|
||||||
|
const players = useLivePlayers(37, 100);
|
||||||
|
const peak = 84;
|
||||||
|
const version = "1.21.4";
|
||||||
|
|
||||||
|
// guarantee pixel head fonts are downloaded before first paint settles
|
||||||
|
useEffect(() => {
|
||||||
|
if (!document.fonts || !document.fonts.load) return;
|
||||||
|
["600 32px 'Pixelify Sans'", "700 32px 'Pixelify Sans'",
|
||||||
|
"400 32px 'Press Start 2P'", "400 32px 'Silkscreen'", "700 32px 'Silkscreen'"]
|
||||||
|
.forEach((f) => document.fonts.load(f).catch(() => {}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// apply mood / hero / font to <html>
|
||||||
|
useEffect(() => {
|
||||||
|
const r = document.documentElement;
|
||||||
|
r.setAttribute("data-mood", t.mood);
|
||||||
|
r.setAttribute("data-hero", t.heroLayout);
|
||||||
|
r.setAttribute("data-head", t.headFont);
|
||||||
|
r.style.setProperty("--font-head", HEAD_FONTS[t.headFont] || HEAD_FONTS.pixelify);
|
||||||
|
}, [t.mood, t.heroLayout, t.headFont]);
|
||||||
|
|
||||||
|
useReveal();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* NAV */}
|
||||||
|
<header className="nav">
|
||||||
|
<div className="wrap nav-in">
|
||||||
|
<a className="brand" href="#top">
|
||||||
|
<Creeper />
|
||||||
|
<span className="name">ULICRAFT</span>
|
||||||
|
</a>
|
||||||
|
<nav className="nav-links">
|
||||||
|
<a href="#status">Status</a>
|
||||||
|
<a href="#features">Features</a>
|
||||||
|
<a href="#join">How to Join</a>
|
||||||
|
</nav>
|
||||||
|
<span className="nav-spacer" />
|
||||||
|
<a className="mc-btn primary sm" href="#join">Play Now</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* HERO */}
|
||||||
|
<main id="top">
|
||||||
|
<section className="hero" data-screen-label="Hero">
|
||||||
|
<Dust on={t.particles} />
|
||||||
|
<div className="wrap hero-grid">
|
||||||
|
<div className="hero-logo">
|
||||||
|
<img src="assets/ulicraft-logo.png" alt="Ulicraft" width="707" height="148" />
|
||||||
|
</div>
|
||||||
|
<div className="hero-copy">
|
||||||
|
<span className="eyebrow">Java Edition · Survival SMP · Season 3</span>
|
||||||
|
<h1 className="hero-tagline">{t.tagline}</h1>
|
||||||
|
<p className="hero-sub">
|
||||||
|
A whitelisted Java SMP with land claims, seasonal events, and zero pay-to-win.
|
||||||
|
Grab the IP, fire up your launcher, and stake your claim.
|
||||||
|
</p>
|
||||||
|
<div className="hero-ip-row">
|
||||||
|
<IpChip ip={t.serverIp} />
|
||||||
|
<a className="mc-btn" href="#join">How to Join</a>
|
||||||
|
</div>
|
||||||
|
<div className="hero-meta">
|
||||||
|
<span className="live-pill">
|
||||||
|
<span className="live-dot" />
|
||||||
|
<span><b>{players}</b> playing now</span>
|
||||||
|
</span>
|
||||||
|
<span>Java {version}</span>
|
||||||
|
<span className="dot" />
|
||||||
|
<span>No mods required</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="hero-status">
|
||||||
|
<ServerListPanel players={players} max={100} version={version} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* STATUS */}
|
||||||
|
<section id="status" className="pad" data-screen-label="Status">
|
||||||
|
<div className="wrap">
|
||||||
|
<div className="sec-head reveal">
|
||||||
|
<span className="eyebrow">Server Status</span>
|
||||||
|
<h2 className="section-title">Live, and waiting for you.</h2>
|
||||||
|
<p className="lead">This is exactly what you'll see in your multiplayer list.</p>
|
||||||
|
</div>
|
||||||
|
<div className="reveal">
|
||||||
|
<ServerListPanel players={players} max={100} version={version} />
|
||||||
|
</div>
|
||||||
|
<div className="stat-grid">
|
||||||
|
{[
|
||||||
|
{ k: <>{players}<span className="u">/100</span></>, l: "Players Online" },
|
||||||
|
{ k: <>{peak}</>, l: "Peak Today" },
|
||||||
|
{ k: <>99.9<span className="u">%</span></>, l: "Uptime" },
|
||||||
|
{ k: <>20.0<span className="u"> tps</span></>, l: "Performance" },
|
||||||
|
].map((s, i) => (
|
||||||
|
<div className="tile reveal" key={i} style={{ transitionDelay: i * 60 + "ms" }}>
|
||||||
|
<div className="k">{s.k}</div>
|
||||||
|
<div className="l">{s.l}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* FEATURES */}
|
||||||
|
<section id="features" className="pad" data-screen-label="Features" style={{ background: "var(--bg-2)" }}>
|
||||||
|
<div className="wrap">
|
||||||
|
<div className="sec-head reveal">
|
||||||
|
<span className="eyebrow">What makes it Ulicraft</span>
|
||||||
|
<h2 className="section-title">Built for players who stay.</h2>
|
||||||
|
<p className="lead">No reset roulette, no whales buying god gear. Just a server that respects your time.</p>
|
||||||
|
</div>
|
||||||
|
<div className="feat-grid">
|
||||||
|
{FEATURES.map((f, i) => (
|
||||||
|
<div className="feat reveal" key={i} style={{ transitionDelay: (i % 2) * 80 + "ms" }}>
|
||||||
|
<PixelIcon glyph={f.glyph} gold={f.gold} />
|
||||||
|
<div>
|
||||||
|
<h3>{f.h}</h3>
|
||||||
|
<p>{f.p}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* HOW TO JOIN */}
|
||||||
|
<section id="join" className="pad" data-screen-label="How to Join">
|
||||||
|
<div className="wrap">
|
||||||
|
<div className="sec-head reveal">
|
||||||
|
<span className="eyebrow">How to Join · 3 Steps</span>
|
||||||
|
<h2 className="section-title">From zero to spawning in.</h2>
|
||||||
|
<p className="lead">Ulicraft runs on Minecraft: Java Edition. Here's the whole path, start to finish.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="steps">
|
||||||
|
{/* STEP 1 */}
|
||||||
|
<div className="step reveal">
|
||||||
|
<div className="num">1</div>
|
||||||
|
<div className="body">
|
||||||
|
<span className="kicker">Account</span>
|
||||||
|
<h3>Get Minecraft: Java Edition</h3>
|
||||||
|
<p>
|
||||||
|
Create a free Microsoft account, then buy and download Minecraft: Java & Bedrock
|
||||||
|
Edition from minecraft.net. Already own Java Edition? Skip straight to step 2.
|
||||||
|
</p>
|
||||||
|
<div className="actions">
|
||||||
|
<a className="mc-btn gold sm" href="https://www.minecraft.net/get-minecraft" target="_blank" rel="noopener">Get Java Edition →</a>
|
||||||
|
<span className="hint">Microsoft account required</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* STEP 2 */}
|
||||||
|
<div className="step reveal">
|
||||||
|
<div className="num">2</div>
|
||||||
|
<div className="body">
|
||||||
|
<span className="kicker">Launcher</span>
|
||||||
|
<h3>Download & set up the launcher</h3>
|
||||||
|
<p>
|
||||||
|
Install the official Minecraft Launcher, sign in with your Microsoft account, and select
|
||||||
|
the <strong style={{ color: "var(--text)" }}>Latest Release ({version})</strong> profile.
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Download the launcher for Windows, macOS, or Linux.</li>
|
||||||
|
<li>Sign in → pick <strong style={{ color: "var(--text)" }}>Latest Release</strong> → press <strong style={{ color: "var(--text)" }}>Play</strong> once to finish installing.</li>
|
||||||
|
<li>Optional: allocate 4–6 GB RAM under Installations → More Options for smoother play.</li>
|
||||||
|
</ul>
|
||||||
|
<div className="actions">
|
||||||
|
<a className="mc-btn sm" href="https://www.minecraft.net/download" target="_blank" rel="noopener">Download launcher →</a>
|
||||||
|
<span className="hint">No mods or modpack needed</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* STEP 3 */}
|
||||||
|
<div className="step reveal">
|
||||||
|
<div className="num">3</div>
|
||||||
|
<div className="body">
|
||||||
|
<span className="kicker">Connect</span>
|
||||||
|
<h3>Join the Ulicraft server</h3>
|
||||||
|
<p>
|
||||||
|
In Minecraft, open <kbd>Multiplayer</kbd> → <kbd>Add Server</kbd>. Name it Ulicraft,
|
||||||
|
paste the address below, hit <kbd>Done</kbd>, then double-click the server to join.
|
||||||
|
</p>
|
||||||
|
<div className="actions" style={{ marginBottom: "14px" }}>
|
||||||
|
<IpChip ip={t.serverIp} />
|
||||||
|
</div>
|
||||||
|
<span className="hint">Whitelisted server — apply in our Discord first to get added.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* FOOTER */}
|
||||||
|
<footer className="footer">
|
||||||
|
<div className="wrap footer-in">
|
||||||
|
<a className="brand" href="#top">
|
||||||
|
<Creeper />
|
||||||
|
<span className="name">ULICRAFT</span>
|
||||||
|
</a>
|
||||||
|
<div className="footer-links">
|
||||||
|
<a className="mc-btn primary sm" href="#join">Copy IP & Play</a>
|
||||||
|
<a className="mc-btn sm" href="#" onClick={(e) => e.preventDefault()}>Discord</a>
|
||||||
|
</div>
|
||||||
|
<p className="disc">
|
||||||
|
Not affiliated with Mojang or Microsoft. Minecraft is a trademark of Mojang AB.
|
||||||
|
© 2026 Ulicraft.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
{/* TWEAKS */}
|
||||||
|
<TweaksPanel title="Tweaks">
|
||||||
|
<TweakSection label="Layout" />
|
||||||
|
<TweakRadio label="Hero" value={t.heroLayout}
|
||||||
|
options={[{ value: "centered", label: "Centered" }, { value: "split", label: "Split" }, { value: "spotlight", label: "Spotlight" }]}
|
||||||
|
onChange={(v) => setTweak("heroLayout", v)} />
|
||||||
|
<TweakSection label="Mood" />
|
||||||
|
<TweakRadio label="Palette" value={t.mood}
|
||||||
|
options={[{ value: "grass", label: "Grass" }, { value: "nether", label: "Nether" }, { value: "end", label: "End" }]}
|
||||||
|
onChange={(v) => setTweak("mood", v)} />
|
||||||
|
<TweakSection label="Type" />
|
||||||
|
<TweakSelect label="Headline font" value={t.headFont}
|
||||||
|
options={[{ value: "pixelify", label: "Pixelify (readable)" }, { value: "8bit", label: "Press Start (8-bit)" }, { value: "silkscreen", label: "Silkscreen" }]}
|
||||||
|
onChange={(v) => setTweak("headFont", v)} />
|
||||||
|
<TweakSection label="Content" />
|
||||||
|
<TweakText label="Tagline" value={t.tagline} onChange={(v) => setTweak("tagline", v)} />
|
||||||
|
<TweakText label="Server IP" value={t.serverIp} onChange={(v) => setTweak("serverIp", v)} />
|
||||||
|
<TweakToggle label="Floating dust" value={t.particles} onChange={(v) => setTweak("particles", v)} />
|
||||||
|
</TweaksPanel>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
|
||||||
BIN
landing/design/assets/ulicraft-logo.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
landing/design/screenshots/01-full.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
landing/design/screenshots/01-s.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
landing/design/screenshots/01-sec.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
landing/design/screenshots/01-sec2.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
landing/design/screenshots/01-v.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
landing/design/screenshots/02-full.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
landing/design/screenshots/02-s.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
landing/design/screenshots/02-sec.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
landing/design/screenshots/02-sec2.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
landing/design/screenshots/02-v.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
landing/design/screenshots/03-full.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
landing/design/screenshots/03-s.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
landing/design/screenshots/03-sec.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
landing/design/screenshots/03-sec2.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
landing/design/screenshots/03-v.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
landing/design/screenshots/04-full.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
landing/design/screenshots/04-s.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
landing/design/screenshots/04-sec.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
landing/design/screenshots/04-sec2.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
landing/design/screenshots/04-v.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
landing/design/screenshots/05-s.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
landing/design/screenshots/06-s.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
landing/design/screenshots/hero-hq.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
579
landing/design/styles.css
Normal file
@@ -0,0 +1,579 @@
|
|||||||
|
/* ============================================================
|
||||||
|
ULICRAFT — "Carved from stone"
|
||||||
|
Design system: dark overworld, MC-GUI bevels, pixel type
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* ---- Mood palettes (data-mood on <html>) ---- */
|
||||||
|
:root,
|
||||||
|
:root[data-mood="grass"] {
|
||||||
|
--bg: oklch(0.165 0.014 150);
|
||||||
|
--bg-2: oklch(0.205 0.016 150);
|
||||||
|
--surface: oklch(0.235 0.017 150);
|
||||||
|
--surface-2: oklch(0.285 0.018 150);
|
||||||
|
--slot: oklch(0.185 0.014 150);
|
||||||
|
--bevel-hi: oklch(0.40 0.018 150);
|
||||||
|
--bevel-lo: oklch(0.115 0.012 150);
|
||||||
|
--line: oklch(0.33 0.016 150);
|
||||||
|
|
||||||
|
--accent: oklch(0.72 0.16 145); /* grass */
|
||||||
|
--accent-hi: oklch(0.80 0.15 145);
|
||||||
|
--accent-lo: oklch(0.55 0.15 145);
|
||||||
|
--accent-ink: oklch(0.17 0.05 150); /* text on accent */
|
||||||
|
|
||||||
|
--gold: oklch(0.82 0.135 85);
|
||||||
|
--glow: oklch(0.72 0.16 145 / 0.30);
|
||||||
|
|
||||||
|
--text: oklch(0.95 0.008 110);
|
||||||
|
--muted: oklch(0.72 0.012 145);
|
||||||
|
--dim: oklch(0.55 0.012 145);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-mood="nether"] {
|
||||||
|
--bg: oklch(0.165 0.018 35);
|
||||||
|
--bg-2: oklch(0.205 0.022 32);
|
||||||
|
--surface: oklch(0.235 0.026 32);
|
||||||
|
--surface-2: oklch(0.285 0.030 32);
|
||||||
|
--slot: oklch(0.185 0.020 32);
|
||||||
|
--bevel-hi: oklch(0.42 0.040 35);
|
||||||
|
--bevel-lo: oklch(0.115 0.016 32);
|
||||||
|
--line: oklch(0.34 0.030 32);
|
||||||
|
|
||||||
|
--accent: oklch(0.64 0.19 32); /* nether red */
|
||||||
|
--accent-hi: oklch(0.72 0.18 38);
|
||||||
|
--accent-lo: oklch(0.50 0.17 30);
|
||||||
|
--accent-ink: oklch(0.16 0.04 32);
|
||||||
|
|
||||||
|
--gold: oklch(0.83 0.14 75);
|
||||||
|
--glow: oklch(0.64 0.19 32 / 0.34);
|
||||||
|
|
||||||
|
--text: oklch(0.95 0.010 60);
|
||||||
|
--muted: oklch(0.74 0.020 45);
|
||||||
|
--dim: oklch(0.56 0.020 40);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-mood="end"] {
|
||||||
|
--bg: oklch(0.155 0.018 305);
|
||||||
|
--bg-2: oklch(0.195 0.022 305);
|
||||||
|
--surface: oklch(0.225 0.026 305);
|
||||||
|
--surface-2: oklch(0.275 0.030 305);
|
||||||
|
--slot: oklch(0.175 0.020 305);
|
||||||
|
--bevel-hi: oklch(0.42 0.040 305);
|
||||||
|
--bevel-lo: oklch(0.110 0.016 305);
|
||||||
|
--line: oklch(0.34 0.030 305);
|
||||||
|
|
||||||
|
--accent: oklch(0.74 0.135 175); /* end teal */
|
||||||
|
--accent-hi: oklch(0.82 0.13 175);
|
||||||
|
--accent-lo: oklch(0.58 0.13 178);
|
||||||
|
--accent-ink: oklch(0.16 0.04 200);
|
||||||
|
|
||||||
|
--gold: oklch(0.84 0.13 95);
|
||||||
|
--glow: oklch(0.70 0.16 300 / 0.34);
|
||||||
|
|
||||||
|
--text: oklch(0.96 0.010 300);
|
||||||
|
--muted: oklch(0.76 0.020 300);
|
||||||
|
--dim: oklch(0.58 0.020 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Fonts (switchable head face via --font-head) ---- */
|
||||||
|
:root {
|
||||||
|
--font-head: 'Pixelify Sans', system-ui, sans-serif;
|
||||||
|
--font-body: 'Space Grotesk', system-ui, sans-serif;
|
||||||
|
--font-pixel: 'Press Start 2P', monospace; /* tiny eyebrow labels */
|
||||||
|
--font-mono: 'VT323', monospace; /* IP / terminal */
|
||||||
|
--maxw: 1160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html { scroll-behavior: smooth; }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
html { scroll-behavior: auto; }
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.6;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* faint pixel-grid stone texture over everything */
|
||||||
|
body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
background-image:
|
||||||
|
repeating-linear-gradient(0deg, oklch(1 0 0 / 0.018) 0 1px, transparent 1px 4px),
|
||||||
|
repeating-linear-gradient(90deg, oklch(0 0 0 / 0.06) 0 1px, transparent 1px 4px);
|
||||||
|
background-size: 4px 4px, 4px 4px;
|
||||||
|
mix-blend-mode: overlay;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
img { display: block; max-width: 100%; }
|
||||||
|
|
||||||
|
::selection { background: var(--accent); color: var(--accent-ink); }
|
||||||
|
|
||||||
|
/* ---- Typography helpers ---- */
|
||||||
|
.eyebrow {
|
||||||
|
font-family: var(--font-pixel);
|
||||||
|
font-size: clamp(9px, 1.1vw, 11px);
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: var(--accent);
|
||||||
|
text-transform: uppercase;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
h1, h2, h3 {
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.02;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
font-size: clamp(34px, 5vw, 58px);
|
||||||
|
}
|
||||||
|
.lead { color: var(--muted); }
|
||||||
|
.mono { font-family: var(--font-mono); }
|
||||||
|
|
||||||
|
/* ---- Layout ---- */
|
||||||
|
.wrap { width: min(var(--maxw), calc(100% - 48px)); margin-inline: auto; }
|
||||||
|
section { position: relative; z-index: 1; }
|
||||||
|
.pad { padding-block: clamp(64px, 9vw, 130px); }
|
||||||
|
.sec-head { max-width: 640px; margin-bottom: 48px; }
|
||||||
|
.sec-head .eyebrow { display: block; margin-bottom: 14px; }
|
||||||
|
.sec-head p { margin: 16px 0 0; font-size: 18px; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
MINECRAFT-STYLE BUTTON
|
||||||
|
============================================================ */
|
||||||
|
.mc-btn {
|
||||||
|
--b: var(--surface-2);
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--b);
|
||||||
|
border: 0;
|
||||||
|
padding: 15px 24px 17px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
box-shadow:
|
||||||
|
inset 2px 2px 0 var(--bevel-hi),
|
||||||
|
inset -2px -2px 0 var(--bevel-lo),
|
||||||
|
0 4px 0 oklch(0 0 0 / 0.5),
|
||||||
|
0 8px 18px oklch(0 0 0 / 0.4);
|
||||||
|
transition: transform .08s ease, filter .12s ease;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
text-shadow: 0 2px 0 oklch(0 0 0 / 0.35);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.mc-btn:hover { filter: brightness(1.12); }
|
||||||
|
.mc-btn:active {
|
||||||
|
transform: translateY(4px);
|
||||||
|
box-shadow:
|
||||||
|
inset 2px 2px 0 var(--bevel-hi),
|
||||||
|
inset -2px -2px 0 var(--bevel-lo),
|
||||||
|
0 0 0 oklch(0 0 0 / 0.5),
|
||||||
|
0 2px 8px oklch(0 0 0 / 0.4);
|
||||||
|
}
|
||||||
|
.mc-btn.primary {
|
||||||
|
--b: var(--accent);
|
||||||
|
--bevel-hi: var(--accent-hi);
|
||||||
|
--bevel-lo: var(--accent-lo);
|
||||||
|
color: var(--accent-ink);
|
||||||
|
text-shadow: 0 2px 0 oklch(1 0 0 / 0.22);
|
||||||
|
}
|
||||||
|
.mc-btn.gold {
|
||||||
|
--b: var(--gold);
|
||||||
|
--bevel-hi: oklch(0.90 0.10 90);
|
||||||
|
--bevel-lo: oklch(0.66 0.13 75);
|
||||||
|
color: oklch(0.20 0.05 80);
|
||||||
|
text-shadow: 0 2px 0 oklch(1 0 0 / 0.25);
|
||||||
|
}
|
||||||
|
.mc-btn.sm { font-size: 14px; padding: 10px 16px 12px; }
|
||||||
|
|
||||||
|
/* ---- Copy-IP chip ---- */
|
||||||
|
.ip-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: stretch;
|
||||||
|
background: var(--slot);
|
||||||
|
box-shadow:
|
||||||
|
inset 2px 2px 0 var(--bevel-lo),
|
||||||
|
inset -2px -2px 0 var(--bevel-hi);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.ip-chip .ip-val {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 18px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--gold);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.ip-chip .ip-val .pin { color: var(--dim); font-size: 22px; }
|
||||||
|
.ip-chip button {
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 14px 18px;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-ink);
|
||||||
|
box-shadow: inset 2px 2px 0 var(--accent-hi), inset -2px -2px 0 var(--accent-lo);
|
||||||
|
transition: filter .12s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ip-chip button:hover { filter: brightness(1.1); }
|
||||||
|
.ip-chip button.copied { background: var(--gold); color: oklch(0.20 0.05 80); }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
NAV
|
||||||
|
============================================================ */
|
||||||
|
.nav {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
background: oklch(0.165 0.014 150 / 0.72);
|
||||||
|
-webkit-backdrop-filter: blur(14px) saturate(140%);
|
||||||
|
backdrop-filter: blur(14px) saturate(140%);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.nav-in {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
height: 68px;
|
||||||
|
}
|
||||||
|
.brand { display: flex; align-items: center; gap: 12px; text-decoration: none; color: var(--text); }
|
||||||
|
.brand .name {
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 22px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.nav-links { display: flex; gap: 6px; margin-left: 12px; }
|
||||||
|
.nav-links a {
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 8px 12px;
|
||||||
|
transition: color .12s ease, background .12s ease;
|
||||||
|
}
|
||||||
|
.nav-links a:hover { color: var(--text); background: var(--surface); }
|
||||||
|
.nav-spacer { flex: 1; }
|
||||||
|
|
||||||
|
/* ---- Creeper pixel mark ---- */
|
||||||
|
.creeper {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(8, 1fr);
|
||||||
|
grid-template-rows: repeat(8, 1fr);
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: inset 2px 2px 0 var(--accent-hi), inset -2px -2px 0 var(--accent-lo);
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
.creeper i { background: transparent; }
|
||||||
|
.creeper i.f { background: oklch(0.16 0.03 150); }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
HERO
|
||||||
|
============================================================ */
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
padding-top: clamp(48px, 7vw, 90px);
|
||||||
|
padding-bottom: clamp(56px, 8vw, 110px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.hero::before { /* spotlight glow behind logo */
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: -10%;
|
||||||
|
left: 50%;
|
||||||
|
width: 1100px;
|
||||||
|
height: 760px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: radial-gradient(closest-side, var(--glow), transparent 72%);
|
||||||
|
pointer-events: none;
|
||||||
|
filter: blur(8px);
|
||||||
|
}
|
||||||
|
.hero-grid { position: relative; display: grid; gap: clamp(36px, 5vw, 64px); align-items: center; }
|
||||||
|
|
||||||
|
/* layout: centered (default) */
|
||||||
|
:root[data-hero="centered"] .hero-grid { grid-template-columns: 1fr; justify-items: center; text-align: center; }
|
||||||
|
:root[data-hero="centered"] .hero-cta { justify-content: center; }
|
||||||
|
:root[data-hero="centered"] .hero-meta { justify-content: center; }
|
||||||
|
:root[data-hero="centered"] .hero-copy { max-width: 720px; }
|
||||||
|
:root[data-hero="centered"] .hero-status { display: none; }
|
||||||
|
|
||||||
|
/* layout: split (copy left, live panel right) */
|
||||||
|
:root[data-hero="split"] .hero-grid { grid-template-columns: 1.1fr 0.9fr; }
|
||||||
|
:root[data-hero="split"] .hero-logo { max-width: 560px; }
|
||||||
|
:root[data-hero="split"] .hero-logo img { margin-inline: 0; }
|
||||||
|
|
||||||
|
/* layout: spotlight (giant logo, minimal text, centered) */
|
||||||
|
:root[data-hero="spotlight"] .hero-grid { grid-template-columns: 1fr; justify-items: center; text-align: center; }
|
||||||
|
:root[data-hero="spotlight"] .hero-cta { justify-content: center; }
|
||||||
|
:root[data-hero="spotlight"] .hero-meta { justify-content: center; }
|
||||||
|
:root[data-hero="spotlight"] .hero-logo { max-width: 900px; }
|
||||||
|
:root[data-hero="spotlight"] .hero-tagline { font-size: clamp(20px, 2.4vw, 28px); }
|
||||||
|
:root[data-hero="spotlight"] .hero-status { display: none; }
|
||||||
|
:root[data-hero="spotlight"] .hero-features-note { display: none; }
|
||||||
|
|
||||||
|
.hero-logo { width: 100%; max-width: 760px; }
|
||||||
|
.hero-logo img {
|
||||||
|
width: 100%;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
filter: drop-shadow(0 8px 0 oklch(0 0 0 / 0.45)) drop-shadow(0 18px 30px oklch(0 0 0 / 0.55));
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
.hero-copy { display: flex; flex-direction: column; gap: 22px; }
|
||||||
|
.hero-tagline {
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: clamp(24px, 3vw, 38px);
|
||||||
|
line-height: 1.08;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
.hero-tagline .hl { color: var(--accent); }
|
||||||
|
.hero-sub { color: var(--muted); font-size: 18px; max-width: 52ch; margin: 0; }
|
||||||
|
.hero-cta { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; }
|
||||||
|
.hero-meta { display: flex; flex-wrap: wrap; gap: 10px 22px; align-items: center; color: var(--dim); font-size: 14.5px; }
|
||||||
|
.hero-meta .dot { width: 6px; height: 6px; background: var(--dim); }
|
||||||
|
.hero-ip-row { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; }
|
||||||
|
|
||||||
|
/* status pill */
|
||||||
|
.live-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 7px 14px 7px 11px;
|
||||||
|
background: var(--slot);
|
||||||
|
box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.live-dot {
|
||||||
|
width: 9px; height: 9px; background: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--glow);
|
||||||
|
animation: pulse 2.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
|
||||||
|
@media (prefers-reduced-motion: reduce){ .live-dot{ animation:none } }
|
||||||
|
.live-pill b { color: var(--text); font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
SERVER-LIST STATUS PANEL
|
||||||
|
============================================================ */
|
||||||
|
.serverlist {
|
||||||
|
display: flex;
|
||||||
|
gap: 18px;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--slot);
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow:
|
||||||
|
inset 2px 2px 0 var(--bevel-hi),
|
||||||
|
inset -2px -2px 0 var(--bevel-lo),
|
||||||
|
0 8px 24px oklch(0 0 0 / 0.4);
|
||||||
|
}
|
||||||
|
.serverlist .icon {
|
||||||
|
width: 72px; height: 72px; flex-shrink: 0;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
background: var(--bg-2);
|
||||||
|
box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
|
||||||
|
}
|
||||||
|
.serverlist .icon .creeper { width: 48px; height: 48px; }
|
||||||
|
.serverlist .meta { flex: 1; min-width: 0; }
|
||||||
|
.serverlist .row1 { display: flex; align-items: baseline; gap: 12px; flex-wrap: nowrap; }
|
||||||
|
.serverlist .title { font-family: var(--font-head); font-weight: 600; font-size: 22px; white-space: nowrap; }
|
||||||
|
.serverlist .ver { color: var(--dim); font-family: var(--font-mono); font-size: 17px; white-space: nowrap; }
|
||||||
|
.serverlist .motd { margin: 6px 0 0; color: var(--muted); font-size: 15px; }
|
||||||
|
.serverlist .motd .g { color: var(--gold); }
|
||||||
|
.serverlist .motd .a { color: var(--accent); }
|
||||||
|
.serverlist .stat { text-align: right; flex-shrink: 0; }
|
||||||
|
.serverlist .players { font-variant-numeric: tabular-nums; font-size: 15px; color: var(--muted); display:flex; align-items:center; gap:8px; justify-content:flex-end; }
|
||||||
|
.serverlist .players b { color: var(--text); }
|
||||||
|
|
||||||
|
/* signal bars */
|
||||||
|
.bars { display: inline-flex; align-items: flex-end; gap: 2px; height: 16px; }
|
||||||
|
.bars i { width: 4px; background: var(--accent); image-rendering: pixelated; }
|
||||||
|
.bars i:nth-child(1){height:25%}
|
||||||
|
.bars i:nth-child(2){height:45%}
|
||||||
|
.bars i:nth-child(3){height:65%}
|
||||||
|
.bars i:nth-child(4){height:85%}
|
||||||
|
.bars i:nth-child(5){height:100%}
|
||||||
|
|
||||||
|
/* stat tiles */
|
||||||
|
.stat-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 28px;
|
||||||
|
}
|
||||||
|
.tile {
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 22px;
|
||||||
|
box-shadow: inset 2px 2px 0 var(--bevel-hi), inset -2px -2px 0 var(--bevel-lo);
|
||||||
|
}
|
||||||
|
.tile .k { font-family: var(--font-head); font-weight: 600; font-size: 38px; color: var(--text); line-height: 1; font-variant-numeric: tabular-nums; }
|
||||||
|
.tile .k .u { color: var(--accent); font-size: 22px; }
|
||||||
|
.tile .l { margin-top: 8px; color: var(--dim); font-size: 13px; letter-spacing: 0.04em; text-transform: uppercase; font-family: var(--font-pixel); font-size: 9px; line-height: 1.8; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
FEATURES
|
||||||
|
============================================================ */
|
||||||
|
.feat-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
.feat {
|
||||||
|
display: flex;
|
||||||
|
gap: 18px;
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 26px;
|
||||||
|
box-shadow: inset 2px 2px 0 var(--bevel-hi), inset -2px -2px 0 var(--bevel-lo);
|
||||||
|
transition: transform .14s ease, filter .14s ease;
|
||||||
|
}
|
||||||
|
.feat:hover { transform: translateY(-3px); filter: brightness(1.06); }
|
||||||
|
.feat h3 { font-size: 21px; margin-bottom: 8px; }
|
||||||
|
.feat p { margin: 0; color: var(--muted); font-size: 16px; }
|
||||||
|
|
||||||
|
/* pixel icon */
|
||||||
|
.picon {
|
||||||
|
width: 52px; height: 52px; flex-shrink: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
grid-template-rows: repeat(7, 1fr);
|
||||||
|
background: var(--slot);
|
||||||
|
padding: 4px;
|
||||||
|
box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
.picon i { background: transparent; }
|
||||||
|
.picon i.on { background: var(--accent); }
|
||||||
|
.picon i.go { background: var(--gold); }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
HOW TO JOIN — steps
|
||||||
|
============================================================ */
|
||||||
|
.steps { display: flex; flex-direction: column; gap: 20px; }
|
||||||
|
.step {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 92px 1fr;
|
||||||
|
gap: 28px;
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 30px;
|
||||||
|
box-shadow: inset 2px 2px 0 var(--bevel-hi), inset -2px -2px 0 var(--bevel-lo);
|
||||||
|
}
|
||||||
|
.step .num {
|
||||||
|
width: 92px; height: 92px;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
background: var(--slot);
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 46px;
|
||||||
|
color: var(--accent);
|
||||||
|
box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
|
||||||
|
}
|
||||||
|
.step .body { padding-top: 2px; }
|
||||||
|
.step .kicker { font-family: var(--font-pixel); font-size: 9px; letter-spacing: 0.1em; color: var(--dim); text-transform: uppercase; }
|
||||||
|
.step h3 { font-size: 26px; margin: 10px 0 12px; }
|
||||||
|
.step p { margin: 0 0 16px; color: var(--muted); font-size: 16.5px; max-width: 64ch; }
|
||||||
|
.step .actions { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
|
||||||
|
.step ul { margin: 0 0 16px; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 9px; }
|
||||||
|
.step ul li { display: flex; gap: 12px; color: var(--muted); font-size: 16px; }
|
||||||
|
.step ul li::before { content: ""; width: 10px; height: 10px; margin-top: 8px; flex-shrink: 0; background: var(--accent); }
|
||||||
|
.step .hint { color: var(--dim); font-size: 14px; }
|
||||||
|
.step kbd {
|
||||||
|
font-family: var(--font-mono); font-size: 18px; line-height: 1;
|
||||||
|
background: var(--bg-2); color: var(--text);
|
||||||
|
padding: 4px 8px; box-shadow: inset 1px 1px 0 var(--bevel-lo), inset -1px -1px 0 var(--bevel-hi);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
FOOTER
|
||||||
|
============================================================ */
|
||||||
|
.footer { border-top: 1px solid var(--line); background: var(--bg-2); }
|
||||||
|
.footer-in { display: flex; flex-wrap: wrap; gap: 32px; align-items: center; justify-content: space-between; padding-block: 40px; }
|
||||||
|
.footer .brand .name { font-size: 20px; }
|
||||||
|
.footer-links { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.footer .disc { color: var(--dim); font-size: 13px; max-width: 46ch; }
|
||||||
|
|
||||||
|
/* ---- floating dust ---- */
|
||||||
|
.dust { position: absolute; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
|
||||||
|
@keyframes rise {
|
||||||
|
0% { transform: translateY(0) translateX(0); opacity: 0; }
|
||||||
|
10% { opacity: 1; }
|
||||||
|
90% { opacity: 1; }
|
||||||
|
100% { transform: translateY(-110vh) translateX(24px); opacity: 0; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) { .dust { display: none; } }
|
||||||
|
.hero-grid { z-index: 1; }
|
||||||
|
|
||||||
|
/* ---- head-font: Press Start 2P is wide/tall — scale down ---- */
|
||||||
|
:root[data-head="8bit"] .section-title { font-size: clamp(20px, 3vw, 38px); line-height: 1.25; }
|
||||||
|
:root[data-head="8bit"] .hero-tagline { font-size: clamp(15px, 2vw, 26px); line-height: 1.4; }
|
||||||
|
:root[data-head="8bit"] .brand .name { font-size: 15px; }
|
||||||
|
:root[data-head="8bit"] .mc-btn { font-size: 12px; }
|
||||||
|
:root[data-head="8bit"] .mc-btn.sm { font-size: 11px; }
|
||||||
|
:root[data-head="8bit"] .step h3 { font-size: 17px; line-height: 1.35; }
|
||||||
|
:root[data-head="8bit"] .step .num { font-size: 30px; }
|
||||||
|
:root[data-head="8bit"] .feat h3 { font-size: 15px; line-height: 1.4; }
|
||||||
|
:root[data-head="8bit"] .serverlist .title { font-size: 16px; }
|
||||||
|
:root[data-head="8bit"] .tile .k { font-size: 26px; }
|
||||||
|
:root[data-head="8bit"] .ip-chip button { font-size: 12px; }
|
||||||
|
:root[data-head="8bit"] .live-pill { font-size: 12px; }
|
||||||
|
|
||||||
|
/* ---- head-font: Silkscreen is small-cap-ish — nudge ---- */
|
||||||
|
:root[data-head="silkscreen"] .section-title { letter-spacing: 0; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
SCROLL REVEAL (fail-safe: visible at rest; animates only when JS adds .anim)
|
||||||
|
============================================================ */
|
||||||
|
.reveal { will-change: opacity, transform; }
|
||||||
|
@keyframes reveal-in {
|
||||||
|
from { opacity: 0; transform: translateY(22px); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.reveal.anim { animation: reveal-in .6s cubic-bezier(.2,.7,.3,1) both; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
RESPONSIVE
|
||||||
|
============================================================ */
|
||||||
|
@media (max-width: 880px) {
|
||||||
|
:root[data-hero="split"] .hero-grid { grid-template-columns: 1fr; }
|
||||||
|
:root[data-hero="split"] .hero-status { display: block; }
|
||||||
|
.stat-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.feat-grid { grid-template-columns: 1fr; }
|
||||||
|
.nav-links { display: none; }
|
||||||
|
.serverlist { flex-wrap: wrap; }
|
||||||
|
.serverlist .stat { text-align: left; }
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.step { grid-template-columns: 1fr; gap: 18px; }
|
||||||
|
.step .num { width: 64px; height: 64px; font-size: 32px; }
|
||||||
|
.ip-chip .ip-val { font-size: 22px; }
|
||||||
|
.stat-grid { grid-template-columns: 1fr 1fr; }
|
||||||
|
}
|
||||||
541
landing/design/tweaks-panel.jsx
Normal file
@@ -0,0 +1,541 @@
|
|||||||
|
// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design)
|
||||||
|
|
||||||
|
/* BEGIN USAGE */
|
||||||
|
// tweaks-panel.jsx
|
||||||
|
// Reusable Tweaks shell + form-control helpers.
|
||||||
|
// Exports (to window): useTweaks, TweaksPanel, TweakSection, TweakRow, TweakSlider,
|
||||||
|
// TweakToggle, TweakRadio, TweakSelect, TweakText, TweakNumber, TweakColor, TweakButton.
|
||||||
|
//
|
||||||
|
// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
|
||||||
|
// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
|
||||||
|
// individual prototypes don't re-roll it. Ships a consistent set of controls so you
|
||||||
|
// don't hand-draw <input type="range">, segmented radios, steppers, etc.
|
||||||
|
//
|
||||||
|
// Usage (in an HTML file that loads React + Babel):
|
||||||
|
//
|
||||||
|
// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
|
||||||
|
// "primaryColor": "#D97757",
|
||||||
|
// "palette": ["#D97757", "#29261b", "#f6f4ef"],
|
||||||
|
// "fontSize": 16,
|
||||||
|
// "density": "regular",
|
||||||
|
// "dark": false
|
||||||
|
// }/*EDITMODE-END*/;
|
||||||
|
//
|
||||||
|
// function App() {
|
||||||
|
// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
|
||||||
|
// return (
|
||||||
|
// <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
|
||||||
|
// Hello
|
||||||
|
// <TweaksPanel>
|
||||||
|
// <TweakSection label="Typography" />
|
||||||
|
// <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
|
||||||
|
// onChange={(v) => setTweak('fontSize', v)} />
|
||||||
|
// <TweakRadio label="Density" value={t.density}
|
||||||
|
// options={['compact', 'regular', 'comfy']}
|
||||||
|
// onChange={(v) => setTweak('density', v)} />
|
||||||
|
// <TweakSection label="Theme" />
|
||||||
|
// <TweakColor label="Primary" value={t.primaryColor}
|
||||||
|
// options={['#D97757', '#2A6FDB', '#1F8A5B', '#7A5AE0']}
|
||||||
|
// onChange={(v) => setTweak('primaryColor', v)} />
|
||||||
|
// <TweakColor label="Palette" value={t.palette}
|
||||||
|
// options={[['#D97757', '#29261b', '#f6f4ef'],
|
||||||
|
// ['#475569', '#0f172a', '#f1f5f9']]}
|
||||||
|
// onChange={(v) => setTweak('palette', v)} />
|
||||||
|
// <TweakToggle label="Dark mode" value={t.dark}
|
||||||
|
// onChange={(v) => setTweak('dark', v)} />
|
||||||
|
// </TweaksPanel>
|
||||||
|
// </div>
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// TweakRadio is the segmented control for 2–3 short options (auto-falls-back to
|
||||||
|
// TweakSelect past ~16/~10 chars per label); reach for TweakSelect directly when
|
||||||
|
// options are many or long. For color tweaks always curate 3-4 options rather than
|
||||||
|
// a free picker; an option can also be a whole 2–5 color palette (the stored value
|
||||||
|
// is the array). The Tweak* controls are a floor, not a ceiling — build custom
|
||||||
|
// controls inside the panel if a tweak calls for UI they don't cover.
|
||||||
|
/* END USAGE */
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const __TWEAKS_STYLE = `
|
||||||
|
.twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
|
||||||
|
max-height:calc(100vh - 32px);display:flex;flex-direction:column;
|
||||||
|
transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom right;
|
||||||
|
background:rgba(250,249,247,.78);color:#29261b;
|
||||||
|
-webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
|
||||||
|
border:.5px solid rgba(255,255,255,.6);border-radius:14px;
|
||||||
|
box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
|
||||||
|
font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
|
||||||
|
.twk-hd{display:flex;align-items:center;justify-content:space-between;
|
||||||
|
padding:10px 8px 10px 14px;cursor:move;user-select:none}
|
||||||
|
.twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
|
||||||
|
.twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
|
||||||
|
width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
|
||||||
|
.twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
|
||||||
|
.twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
|
||||||
|
overflow-y:auto;overflow-x:hidden;min-height:0;
|
||||||
|
scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
|
||||||
|
.twk-body::-webkit-scrollbar{width:8px}
|
||||||
|
.twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
|
||||||
|
.twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
|
||||||
|
border:2px solid transparent;background-clip:content-box}
|
||||||
|
.twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
|
||||||
|
border:2px solid transparent;background-clip:content-box}
|
||||||
|
.twk-row{display:flex;flex-direction:column;gap:5px}
|
||||||
|
.twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
|
||||||
|
.twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
|
||||||
|
color:rgba(41,38,27,.72)}
|
||||||
|
.twk-lbl>span:first-child{font-weight:500}
|
||||||
|
.twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
|
||||||
|
|
||||||
|
.twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
|
||||||
|
color:rgba(41,38,27,.45);padding:10px 0 0}
|
||||||
|
.twk-sect:first-child{padding-top:0}
|
||||||
|
|
||||||
|
.twk-field{appearance:none;box-sizing:border-box;width:100%;min-width:0;height:26px;padding:0 8px;
|
||||||
|
border:.5px solid rgba(0,0,0,.1);border-radius:7px;
|
||||||
|
background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
|
||||||
|
.twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
|
||||||
|
select.twk-field{padding-right:22px;
|
||||||
|
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
|
||||||
|
background-repeat:no-repeat;background-position:right 8px center}
|
||||||
|
|
||||||
|
.twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
|
||||||
|
border-radius:999px;background:rgba(0,0,0,.12);outline:none}
|
||||||
|
.twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
|
||||||
|
width:14px;height:14px;border-radius:50%;background:#fff;
|
||||||
|
border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
|
||||||
|
.twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
|
||||||
|
background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
|
||||||
|
|
||||||
|
.twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
|
||||||
|
background:rgba(0,0,0,.06);user-select:none}
|
||||||
|
.twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
|
||||||
|
background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
|
||||||
|
transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
|
||||||
|
.twk-seg.dragging .twk-seg-thumb{transition:none}
|
||||||
|
.twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
|
||||||
|
background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
|
||||||
|
border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
|
||||||
|
overflow-wrap:anywhere}
|
||||||
|
|
||||||
|
.twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
|
||||||
|
background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
|
||||||
|
.twk-toggle[data-on="1"]{background:#34c759}
|
||||||
|
.twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
|
||||||
|
background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
|
||||||
|
.twk-toggle[data-on="1"] i{transform:translateX(14px)}
|
||||||
|
|
||||||
|
.twk-num{display:flex;align-items:center;box-sizing:border-box;min-width:0;height:26px;padding:0 0 0 8px;
|
||||||
|
border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
|
||||||
|
.twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
|
||||||
|
user-select:none;padding-right:8px}
|
||||||
|
.twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
|
||||||
|
font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
|
||||||
|
outline:none;color:inherit;-moz-appearance:textfield}
|
||||||
|
.twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
|
||||||
|
-webkit-appearance:none;margin:0}
|
||||||
|
.twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
|
||||||
|
|
||||||
|
.twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
|
||||||
|
background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
|
||||||
|
.twk-btn:hover{background:rgba(0,0,0,.88)}
|
||||||
|
.twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
|
||||||
|
.twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
|
||||||
|
|
||||||
|
.twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
|
||||||
|
border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
|
||||||
|
background:transparent;flex-shrink:0}
|
||||||
|
.twk-swatch::-webkit-color-swatch-wrapper{padding:0}
|
||||||
|
.twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
|
||||||
|
.twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
|
||||||
|
|
||||||
|
.twk-chips{display:flex;gap:6px}
|
||||||
|
.twk-chip{position:relative;appearance:none;flex:1;min-width:0;height:46px;
|
||||||
|
padding:0;border:0;border-radius:6px;overflow:hidden;cursor:default;
|
||||||
|
box-shadow:0 0 0 .5px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.06);
|
||||||
|
transition:transform .12s cubic-bezier(.3,.7,.4,1),box-shadow .12s}
|
||||||
|
.twk-chip:hover{transform:translateY(-1px);
|
||||||
|
box-shadow:0 0 0 .5px rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.12)}
|
||||||
|
.twk-chip[data-on="1"]{box-shadow:0 0 0 1.5px rgba(0,0,0,.85),
|
||||||
|
0 2px 6px rgba(0,0,0,.15)}
|
||||||
|
.twk-chip>span{position:absolute;top:0;bottom:0;right:0;width:34%;
|
||||||
|
display:flex;flex-direction:column;box-shadow:-1px 0 0 rgba(0,0,0,.1)}
|
||||||
|
.twk-chip>span>i{flex:1;box-shadow:0 -1px 0 rgba(0,0,0,.1)}
|
||||||
|
.twk-chip>span>i:first-child{box-shadow:none}
|
||||||
|
.twk-chip svg{position:absolute;top:6px;left:6px;width:13px;height:13px;
|
||||||
|
filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ── useTweaks ───────────────────────────────────────────────────────────────
|
||||||
|
// Single source of truth for tweak values. setTweak persists via the host
|
||||||
|
// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk).
|
||||||
|
function useTweaks(defaults) {
|
||||||
|
const [values, setValues] = React.useState(defaults);
|
||||||
|
// Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
|
||||||
|
// useState-style call doesn't write a "[object Object]" key into the persisted
|
||||||
|
// JSON block.
|
||||||
|
const setTweak = React.useCallback((keyOrEdits, val) => {
|
||||||
|
const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
|
||||||
|
? keyOrEdits : { [keyOrEdits]: val };
|
||||||
|
setValues((prev) => ({ ...prev, ...edits }));
|
||||||
|
window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
|
||||||
|
// Same-window signal so in-page listeners (deck-stage rail thumbnails)
|
||||||
|
// can react — the parent message only reaches the host, not peers.
|
||||||
|
window.dispatchEvent(new CustomEvent('tweakchange', { detail: edits }));
|
||||||
|
}, []);
|
||||||
|
return [values, setTweak];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TweaksPanel ─────────────────────────────────────────────────────────────
|
||||||
|
// Floating shell. Registers the protocol listener BEFORE announcing
|
||||||
|
// availability — if the announce ran first, the host's activate could land
|
||||||
|
// before our handler exists and the toolbar toggle would silently no-op.
|
||||||
|
// The close button posts __edit_mode_dismissed so the host's toolbar toggle
|
||||||
|
// flips off in lockstep; the host echoes __deactivate_edit_mode back which
|
||||||
|
// is what actually hides the panel.
|
||||||
|
function TweaksPanel({ title = 'Tweaks', children }) {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const dragRef = React.useRef(null);
|
||||||
|
const offsetRef = React.useRef({ x: 16, y: 16 });
|
||||||
|
const PAD = 16;
|
||||||
|
|
||||||
|
const clampToViewport = React.useCallback(() => {
|
||||||
|
const panel = dragRef.current;
|
||||||
|
if (!panel) return;
|
||||||
|
const w = panel.offsetWidth, h = panel.offsetHeight;
|
||||||
|
const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
|
||||||
|
const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
|
||||||
|
offsetRef.current = {
|
||||||
|
x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
|
||||||
|
y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
|
||||||
|
};
|
||||||
|
panel.style.right = offsetRef.current.x + 'px';
|
||||||
|
panel.style.bottom = offsetRef.current.y + 'px';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
clampToViewport();
|
||||||
|
if (typeof ResizeObserver === 'undefined') {
|
||||||
|
window.addEventListener('resize', clampToViewport);
|
||||||
|
return () => window.removeEventListener('resize', clampToViewport);
|
||||||
|
}
|
||||||
|
const ro = new ResizeObserver(clampToViewport);
|
||||||
|
ro.observe(document.documentElement);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [open, clampToViewport]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const onMsg = (e) => {
|
||||||
|
const t = e?.data?.type;
|
||||||
|
if (t === '__activate_edit_mode') setOpen(true);
|
||||||
|
else if (t === '__deactivate_edit_mode') setOpen(false);
|
||||||
|
};
|
||||||
|
window.addEventListener('message', onMsg);
|
||||||
|
window.parent.postMessage({ type: '__edit_mode_available' }, '*');
|
||||||
|
return () => window.removeEventListener('message', onMsg);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dismiss = () => {
|
||||||
|
setOpen(false);
|
||||||
|
window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragStart = (e) => {
|
||||||
|
const panel = dragRef.current;
|
||||||
|
if (!panel) return;
|
||||||
|
const r = panel.getBoundingClientRect();
|
||||||
|
const sx = e.clientX, sy = e.clientY;
|
||||||
|
const startRight = window.innerWidth - r.right;
|
||||||
|
const startBottom = window.innerHeight - r.bottom;
|
||||||
|
const move = (ev) => {
|
||||||
|
offsetRef.current = {
|
||||||
|
x: startRight - (ev.clientX - sx),
|
||||||
|
y: startBottom - (ev.clientY - sy),
|
||||||
|
};
|
||||||
|
clampToViewport();
|
||||||
|
};
|
||||||
|
const up = () => {
|
||||||
|
window.removeEventListener('mousemove', move);
|
||||||
|
window.removeEventListener('mouseup', up);
|
||||||
|
};
|
||||||
|
window.addEventListener('mousemove', move);
|
||||||
|
window.addEventListener('mouseup', up);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style>{__TWEAKS_STYLE}</style>
|
||||||
|
<div ref={dragRef} className="twk-panel" data-omelette-chrome=""
|
||||||
|
style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
|
||||||
|
<div className="twk-hd" onMouseDown={onDragStart}>
|
||||||
|
<b>{title}</b>
|
||||||
|
<button className="twk-x" aria-label="Close tweaks"
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={dismiss}>✕</button>
|
||||||
|
</div>
|
||||||
|
<div className="twk-body">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Layout helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TweakSection({ label, children }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="twk-sect">{label}</div>
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakRow({ label, value, children, inline = false }) {
|
||||||
|
return (
|
||||||
|
<div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
|
||||||
|
<div className="twk-lbl">
|
||||||
|
<span>{label}</span>
|
||||||
|
{value != null && <span className="twk-val">{value}</span>}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Controls ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
|
||||||
|
return (
|
||||||
|
<TweakRow label={label} value={`${value}${unit}`}>
|
||||||
|
<input type="range" className="twk-slider" min={min} max={max} step={step}
|
||||||
|
value={value} onChange={(e) => onChange(Number(e.target.value))} />
|
||||||
|
</TweakRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakToggle({ label, value, onChange }) {
|
||||||
|
return (
|
||||||
|
<div className="twk-row twk-row-h">
|
||||||
|
<div className="twk-lbl"><span>{label}</span></div>
|
||||||
|
<button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
|
||||||
|
role="switch" aria-checked={!!value}
|
||||||
|
onClick={() => onChange(!value)}><i /></button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakRadio({ label, value, options, onChange }) {
|
||||||
|
const trackRef = React.useRef(null);
|
||||||
|
const [dragging, setDragging] = React.useState(false);
|
||||||
|
// The active value is read by pointer-move handlers attached for the lifetime
|
||||||
|
// of a drag — ref it so a stale closure doesn't fire onChange for every move.
|
||||||
|
const valueRef = React.useRef(value);
|
||||||
|
valueRef.current = value;
|
||||||
|
|
||||||
|
// Segments wrap mid-word once per-segment width runs out. The track is
|
||||||
|
// ~248px (280 panel − 28 body pad − 4 seg pad), each button loses 12px
|
||||||
|
// to its own padding, and 11.5px system-ui averages ~6.3px/char — so 2
|
||||||
|
// options fit ~16 chars each, 3 fit ~10. Past that (or >3 options), fall
|
||||||
|
// back to a dropdown rather than wrap.
|
||||||
|
const labelLen = (o) => String(typeof o === 'object' ? o.label : o).length;
|
||||||
|
const maxLen = options.reduce((m, o) => Math.max(m, labelLen(o)), 0);
|
||||||
|
const fitsAsSegments = maxLen <= ({ 2: 16, 3: 10 }[options.length] ?? 0);
|
||||||
|
if (!fitsAsSegments) {
|
||||||
|
// <select> emits strings — map back to the original option value so the
|
||||||
|
// fallback stays type-preserving (numbers, booleans) like the segment path.
|
||||||
|
const resolve = (s) => {
|
||||||
|
const m = options.find((o) => String(typeof o === 'object' ? o.value : o) === s);
|
||||||
|
return m === undefined ? s : typeof m === 'object' ? m.value : m;
|
||||||
|
};
|
||||||
|
return <TweakSelect label={label} value={value} options={options}
|
||||||
|
onChange={(s) => onChange(resolve(s))} />;
|
||||||
|
}
|
||||||
|
const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
|
||||||
|
const idx = Math.max(0, opts.findIndex((o) => o.value === value));
|
||||||
|
const n = opts.length;
|
||||||
|
|
||||||
|
const segAt = (clientX) => {
|
||||||
|
const r = trackRef.current.getBoundingClientRect();
|
||||||
|
const inner = r.width - 4;
|
||||||
|
const i = Math.floor(((clientX - r.left - 2) / inner) * n);
|
||||||
|
return opts[Math.max(0, Math.min(n - 1, i))].value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerDown = (e) => {
|
||||||
|
setDragging(true);
|
||||||
|
const v0 = segAt(e.clientX);
|
||||||
|
if (v0 !== valueRef.current) onChange(v0);
|
||||||
|
const move = (ev) => {
|
||||||
|
if (!trackRef.current) return;
|
||||||
|
const v = segAt(ev.clientX);
|
||||||
|
if (v !== valueRef.current) onChange(v);
|
||||||
|
};
|
||||||
|
const up = () => {
|
||||||
|
setDragging(false);
|
||||||
|
window.removeEventListener('pointermove', move);
|
||||||
|
window.removeEventListener('pointerup', up);
|
||||||
|
};
|
||||||
|
window.addEventListener('pointermove', move);
|
||||||
|
window.addEventListener('pointerup', up);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TweakRow label={label}>
|
||||||
|
<div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
|
||||||
|
className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
|
||||||
|
<div className="twk-seg-thumb"
|
||||||
|
style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
|
||||||
|
width: `calc((100% - 4px) / ${n})` }} />
|
||||||
|
{opts.map((o) => (
|
||||||
|
<button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TweakRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakSelect({ label, value, options, onChange }) {
|
||||||
|
return (
|
||||||
|
<TweakRow label={label}>
|
||||||
|
<select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
|
||||||
|
{options.map((o) => {
|
||||||
|
const v = typeof o === 'object' ? o.value : o;
|
||||||
|
const l = typeof o === 'object' ? o.label : o;
|
||||||
|
return <option key={v} value={v}>{l}</option>;
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</TweakRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakText({ label, value, placeholder, onChange }) {
|
||||||
|
return (
|
||||||
|
<TweakRow label={label}>
|
||||||
|
<input className="twk-field" type="text" value={value} placeholder={placeholder}
|
||||||
|
onChange={(e) => onChange(e.target.value)} />
|
||||||
|
</TweakRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
|
||||||
|
const clamp = (n) => {
|
||||||
|
if (min != null && n < min) return min;
|
||||||
|
if (max != null && n > max) return max;
|
||||||
|
return n;
|
||||||
|
};
|
||||||
|
const startRef = React.useRef({ x: 0, val: 0 });
|
||||||
|
const onScrubStart = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
startRef.current = { x: e.clientX, val: value };
|
||||||
|
const decimals = (String(step).split('.')[1] || '').length;
|
||||||
|
const move = (ev) => {
|
||||||
|
const dx = ev.clientX - startRef.current.x;
|
||||||
|
const raw = startRef.current.val + dx * step;
|
||||||
|
const snapped = Math.round(raw / step) * step;
|
||||||
|
onChange(clamp(Number(snapped.toFixed(decimals))));
|
||||||
|
};
|
||||||
|
const up = () => {
|
||||||
|
window.removeEventListener('pointermove', move);
|
||||||
|
window.removeEventListener('pointerup', up);
|
||||||
|
};
|
||||||
|
window.addEventListener('pointermove', move);
|
||||||
|
window.addEventListener('pointerup', up);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="twk-num">
|
||||||
|
<span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
|
||||||
|
<input type="number" value={value} min={min} max={max} step={step}
|
||||||
|
onChange={(e) => onChange(clamp(Number(e.target.value)))} />
|
||||||
|
{unit && <span className="twk-num-unit">{unit}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relative-luminance contrast pick — checkmarks drawn over a swatch need to
|
||||||
|
// read on both #111 and #fafafa without per-option configuration. Hex input
|
||||||
|
// only (#rgb / #rrggbb); named or rgb()/hsl() colors fall through to "light".
|
||||||
|
function __twkIsLight(hex) {
|
||||||
|
const h = String(hex).replace('#', '');
|
||||||
|
const x = h.length === 3 ? h.replace(/./g, (c) => c + c) : h.padEnd(6, '0');
|
||||||
|
const n = parseInt(x.slice(0, 6), 16);
|
||||||
|
if (Number.isNaN(n)) return true;
|
||||||
|
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
|
||||||
|
return r * 299 + g * 587 + b * 114 > 148000;
|
||||||
|
}
|
||||||
|
|
||||||
|
const __TwkCheck = ({ light }) => (
|
||||||
|
<svg viewBox="0 0 14 14" aria-hidden="true">
|
||||||
|
<path d="M3 7.2 5.8 10 11 4.2" fill="none" strokeWidth="2.2"
|
||||||
|
strokeLinecap="round" strokeLinejoin="round"
|
||||||
|
stroke={light ? 'rgba(0,0,0,.78)' : '#fff'} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// TweakColor — curated color/palette picker. Each option is either a single
|
||||||
|
// hex string or an array of 1-5 hex strings; the card adapts — a lone color
|
||||||
|
// renders solid, a palette renders colors[0] as the hero (left ~2/3) with the
|
||||||
|
// rest stacked in a sharp column on the right. onChange emits the
|
||||||
|
// option in the shape it was passed (string stays string, array stays array).
|
||||||
|
// Without options it falls back to the native color input for back-compat.
|
||||||
|
function TweakColor({ label, value, options, onChange }) {
|
||||||
|
if (!options || !options.length) {
|
||||||
|
return (
|
||||||
|
<div className="twk-row twk-row-h">
|
||||||
|
<div className="twk-lbl"><span>{label}</span></div>
|
||||||
|
<input type="color" className="twk-swatch" value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Native <input type=color> emits lowercase hex per the HTML spec, so
|
||||||
|
// compare case-insensitively. String() guards JSON.stringify(undefined),
|
||||||
|
// which returns the primitive undefined (no .toLowerCase).
|
||||||
|
const key = (o) => String(JSON.stringify(o)).toLowerCase();
|
||||||
|
const cur = key(value);
|
||||||
|
return (
|
||||||
|
<TweakRow label={label}>
|
||||||
|
<div className="twk-chips" role="radiogroup">
|
||||||
|
{options.map((o, i) => {
|
||||||
|
const colors = Array.isArray(o) ? o : [o];
|
||||||
|
const [hero, ...rest] = colors;
|
||||||
|
const sup = rest.slice(0, 4);
|
||||||
|
const on = key(o) === cur;
|
||||||
|
return (
|
||||||
|
<button key={i} type="button" className="twk-chip" role="radio"
|
||||||
|
aria-checked={on} data-on={on ? '1' : '0'}
|
||||||
|
aria-label={colors.join(', ')} title={colors.join(' · ')}
|
||||||
|
style={{ background: hero }}
|
||||||
|
onClick={() => onChange(o)}>
|
||||||
|
{sup.length > 0 && (
|
||||||
|
<span>
|
||||||
|
{sup.map((c, j) => <i key={j} style={{ background: c }} />)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{on && <__TwkCheck light={__twkIsLight(hero)} />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</TweakRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakButton({ label, onClick, secondary = false }) {
|
||||||
|
return (
|
||||||
|
<button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
|
||||||
|
onClick={onClick}>{label}</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(window, {
|
||||||
|
useTweaks, TweaksPanel, TweakSection, TweakRow,
|
||||||
|
TweakSlider, TweakToggle, TweakRadio, TweakSelect,
|
||||||
|
TweakText, TweakNumber, TweakColor, TweakButton,
|
||||||
|
});
|
||||||
BIN
landing/design/uploads/logo-1780868316271.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
5
landing/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
packages:
|
||||||
|
- '.'
|
||||||
|
onlyBuiltDependencies:
|
||||||
|
- esbuild
|
||||||
|
- sharp
|
||||||
BIN
landing/public/Goat_JE1_BE1.webp
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
landing/public/favicon.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
landing/public/fonts/BlockBlueprint.ttf
Normal file
BIN
landing/public/fonts/CHylV-3HFUT7aC4iv1TxGDR9Jn0Eiw.woff2
Normal file
BIN
landing/public/fonts/CHylV-3HFUT7aC4iv1TxGDR9JnMEi1lR.woff2
Normal file
BIN
landing/public/fonts/CHylV-3HFUT7aC4iv1TxGDR9JnkEi1lR.woff2
Normal file
BIN
landing/public/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPb54C-s0.woff2
Normal file
BIN
landing/public/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPb94C-s0.woff2
Normal file
BIN
landing/public/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPbF4Cw.woff2
Normal file
BIN
landing/public/fonts/e3t4euO8T-267oIAQAu6jDQyK3nRivN04w.woff2
Normal file
BIN
landing/public/fonts/e3t4euO8T-267oIAQAu6jDQyK3nVivM.woff2
Normal file
BIN
landing/public/fonts/e3t4euO8T-267oIAQAu6jDQyK3nWivN04w.woff2
Normal file
BIN
landing/public/fonts/e3t4euO8T-267oIAQAu6jDQyK3nYivN04w.woff2
Normal file
BIN
landing/public/fonts/e3t4euO8T-267oIAQAu6jDQyK3nbivN04w.woff2
Normal file
280
landing/public/fonts/fonts.css
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
/* cyrillic */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Pixelify Sans';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/CHylV-3HFUT7aC4iv1TxGDR9JnkEi1lR.woff2) format('woff2');
|
||||||
|
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Pixelify Sans';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/CHylV-3HFUT7aC4iv1TxGDR9JnMEi1lR.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Pixelify Sans';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/CHylV-3HFUT7aC4iv1TxGDR9Jn0Eiw.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
/* cyrillic */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Pixelify Sans';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/CHylV-3HFUT7aC4iv1TxGDR9JnkEi1lR.woff2) format('woff2');
|
||||||
|
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Pixelify Sans';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/CHylV-3HFUT7aC4iv1TxGDR9JnMEi1lR.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Pixelify Sans';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/CHylV-3HFUT7aC4iv1TxGDR9Jn0Eiw.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
/* cyrillic-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Press Start 2P';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/e3t4euO8T-267oIAQAu6jDQyK3nYivN04w.woff2) format('woff2');
|
||||||
|
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||||
|
}
|
||||||
|
/* cyrillic */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Press Start 2P';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/e3t4euO8T-267oIAQAu6jDQyK3nRivN04w.woff2) format('woff2');
|
||||||
|
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
|
}
|
||||||
|
/* greek */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Press Start 2P';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/e3t4euO8T-267oIAQAu6jDQyK3nWivN04w.woff2) format('woff2');
|
||||||
|
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Press Start 2P';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/e3t4euO8T-267oIAQAu6jDQyK3nbivN04w.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Press Start 2P';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/e3t4euO8T-267oIAQAu6jDQyK3nVivM.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Silkscreen';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/m8JXjfVPf62XiF7kO-i9YL1la1OD.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Silkscreen';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/m8JXjfVPf62XiF7kO-i9YLNlaw.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Silkscreen';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/m8JUjfVPf62XiF7kO-i9aAhAfmKi2Oud.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Silkscreen';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/m8JUjfVPf62XiF7kO-i9aAhAfmyi2A.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
/* vietnamese */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Space Grotesk';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPb54C-s0.woff2) format('woff2');
|
||||||
|
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Space Grotesk';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPb94C-s0.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Space Grotesk';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPbF4Cw.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
/* vietnamese */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Space Grotesk';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPb54C-s0.woff2) format('woff2');
|
||||||
|
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Space Grotesk';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPb94C-s0.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Space Grotesk';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPbF4Cw.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
/* vietnamese */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Space Grotesk';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPb54C-s0.woff2) format('woff2');
|
||||||
|
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Space Grotesk';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPb94C-s0.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Space Grotesk';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPbF4Cw.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
/* vietnamese */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Space Grotesk';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPb54C-s0.woff2) format('woff2');
|
||||||
|
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Space Grotesk';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPb94C-s0.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Space Grotesk';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPbF4Cw.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
/* vietnamese */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'VT323';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/pxiKyp0ihIEF2isQFJXGdg.woff2) format('woff2');
|
||||||
|
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'VT323';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/pxiKyp0ihIEF2isRFJXGdg.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'VT323';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/pxiKyp0ihIEF2isfFJU.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
/* Block Blueprint — vendored from 1001fonts (NOT on Google Fonts; appended by
|
||||||
|
fetch-fonts.sh after the Google rewrite). License: 1001Fonts Free For Personal
|
||||||
|
Use. Single weight, TTF (no woff2 toolchain on the build box). */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Block Blueprint';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/BlockBlueprint.ttf) format('truetype');
|
||||||
|
}
|
||||||
BIN
landing/public/fonts/m8JUjfVPf62XiF7kO-i9aAhAfmKi2Oud.woff2
Normal file
BIN
landing/public/fonts/m8JUjfVPf62XiF7kO-i9aAhAfmyi2A.woff2
Normal file
BIN
landing/public/fonts/m8JXjfVPf62XiF7kO-i9YL1la1OD.woff2
Normal file
BIN
landing/public/fonts/m8JXjfVPf62XiF7kO-i9YLNlaw.woff2
Normal file
BIN
landing/public/fonts/pxiKyp0ihIEF2isQFJXGdg.woff2
Normal file
BIN
landing/public/fonts/pxiKyp0ihIEF2isRFJXGdg.woff2
Normal file
BIN
landing/public/fonts/pxiKyp0ihIEF2isfFJU.woff2
Normal file
52
landing/public/ulicraft-logo-mini.svg
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
width="64"
|
||||||
|
height="64"
|
||||||
|
viewBox="0 0 9.677 9.667"
|
||||||
|
version="1.1"
|
||||||
|
id="svg3"
|
||||||
|
sodipodi:docname="mojang.svg"
|
||||||
|
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<defs
|
||||||
|
id="defs3" />
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview3"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:zoom="2.0467427"
|
||||||
|
inkscape:cx="-92.830429"
|
||||||
|
inkscape:cy="96.006206"
|
||||||
|
inkscape:window-width="1920"
|
||||||
|
inkscape:window-height="992"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="0"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="g3" />
|
||||||
|
<path
|
||||||
|
d="M1.54 2.844c.314-.76 1.31-.46 1.954-.528.785-.083 1.503.272 2.1.758l.164-.9c.327.345.587.756.964 1.052.28.254.655-.342.86-.013.42.864.408 1.86.54 2.795l-.788-.373C6.9 4.17 5.126 3.052 3.656 3.685c-1.294.592-1.156 2.65.06 3.255 1.354.703 2.953.51 4.405.292-.07.42-.34.87-.834.816l-4.95.002c-.5.055-.886-.413-.838-.89l.04-4.315z"
|
||||||
|
fill="#fff"
|
||||||
|
id="path3" />
|
||||||
|
<g
|
||||||
|
id="g3"
|
||||||
|
transform="matrix(0.33310606,0,0,0.33310606,8.7658015,-1.332)">
|
||||||
|
<circle
|
||||||
|
style="fill:#283c63;fill-opacity:1;stroke:none;stroke-width:4.73305"
|
||||||
|
id="path1-9"
|
||||||
|
r="14.345329"
|
||||||
|
cy="18.66217"
|
||||||
|
cx="-11.946259" />
|
||||||
|
<path
|
||||||
|
id="logo"
|
||||||
|
d="m -16.05726,28.400159 c -0.04546,-0.07393 -0.04146,-0.951547 0.009,-1.949545 l 0.09191,-1.81518 -0.526472,-0.542457 c -0.672825,-0.692806 -1.103893,-0.809189 -1.679816,-0.453545 -0.538461,0.333165 -0.830668,0.335663 -1.972023,0.01948 -0.862137,-0.23926 -1.268729,-0.459538 -1.120876,-0.607391 0.03746,-0.03797 0.539459,-0.211288 1.113883,-0.385613 1.338658,-0.405094 1.827167,-0.597401 1.740754,-0.684314 -0.03847,-0.03746 -0.615382,0.08842 -1.283213,0.280718 -1.289707,0.370628 -1.760235,0.354645 -1.986009,-0.06693 -0.109385,-0.204296 -0.07793,-0.311188 0.153347,-0.526972 0.159839,-0.148851 0.329669,-0.270729 0.376622,-0.270729 0.04745,0 0.407591,-0.182817 0.801196,-0.406593 l 0.714784,-0.406592 -0.627871,0.07393 c -0.554444,0.06494 -0.62787,0.04396 -0.62787,-0.177322 0,-0.497002 0.182316,-0.679319 1.807687,-1.811185 2.161834,-1.50599 2.788706,-2.085909 3.82167,-3.534457 0.472526,-0.662836 1.121376,-1.3966 1.441555,-1.630866 0.416083,-0.304693 0.627371,-0.578419 0.741756,-0.959537 0.174825,-0.584415 0.423575,-0.866132 0.643355,-0.730268 0.07793,0.04795 0.376124,0.554444 0.663335,1.124871 0.495503,0.985014 0.792705,1.382116 0.792705,1.057441 0,-0.08142 -0.279719,-0.693305 -0.620877,-1.359636 -0.56843,-1.107891 -0.770727,-1.918578 -0.537462,-2.151844 0.143857,-0.143855 0.308192,0.03196 1.558938,1.673323 1.3971002,1.833162 1.6083883,2.205789 1.7192766,3.026966 0.076421,0.567931 0.084919,0.57792 0.1873128,0.217782 0.07543,-0.264735 0.041463,-0.515983 -0.1143866,-0.842656 -0.1218821,-0.255744 -0.1923077,-0.494004 -0.1568436,-0.52947 0.076922,-0.07692 3.2342583,1.562934 4.8366523,2.511483 1.3516446,0.800198 1.8501452,1.238759 1.8501452,1.62637 0,0.467532 -1.0164816,2.36463 -1.8831125,3.514478 -1.1038935,1.465529 -3.3566354,3.276715 -4.0799095,3.280711 -0.2612379,0.0015 -2.1603357,-1.960035 -2.2577377,-2.332162 -0.04496,-0.173826 0.17882,-0.701797 0.63936,-1.505991 0.6992989,-1.221275 0.9160807,-1.784211 0.4735251,-1.227769 l -0.8541441,1.073923 c -0.469529,0.590908 -0.860637,0.910587 -1.561435,1.278718 -0.513984,0.270729 -0.969029,0.580419 -1.010487,0.689309 -0.04146,0.108396 0,0.510987 0.09141,0.894104 0.132368,0.551947 0.133367,0.803694 0.006,1.211286 -0.253246,0.809189 -1.090407,2.56343 -1.223774,2.56343 -0.06593,0 -0.0889,-0.104396 -0.05095,-0.231267 0.03747,-0.127373 0.104397,-0.40959 0.147852,-0.627872 0.07543,-0.376622 0.05095,-0.363135 -0.489509,0.264236 -0.742257,0.863135 -1.41708,1.519976 -1.561435,1.519976 -0.06293,0 -0.152347,-0.06044 -0.197802,-0.134865 z m 3.54145,-11.877094 c 0.213785,-0.214286 -0.03447,-0.411587 -0.516982,-0.411587 -0.567931,0 -0.974523,0.202296 -0.974523,0.485512 0,0.177822 0.110388,0.198801 0.683815,0.130869 0.375623,-0.04446 0.739259,-0.136863 0.80769,-0.204794 z m 2.467027,-4.450039 c -0.167332,-0.201798 0.631368,-0.91908 1.5134836,-1.359638 1.1278687,-0.563436 2.9155763,-0.601397 3.9170734,-0.08341 0.3346641,0.172825 0.9740234,0.845152 0.8866115,0.932564 -0.019979,0.01998 -0.3626379,-0.07642 -0.7617364,-0.214285 -1.3331643,-0.460038 -3.0534394,-0.26973 -4.6588303,0.514484 -0.5889094,0.288211 -0.7927059,0.335664 -0.8966018,0.210289 z m -0.83716,-0.906592 c -0.262737,-0.321178 -0.478521,-0.643355 -0.478521,-0.716281 0,-0.204296 1.587908,-1.2002978 2.160335,-1.3746227 0.5724257,-0.173826 1.252744,-0.2857133 2.3086856,-0.2857133 1.0559416,0 1.7432527,0.1608381 2.547446,0.5429553 0.8121863,0.3861131 2.468526,1.7382577 2.1448505,1.7507447 -0.085909,0.0035 -0.650848,-0.243257 -1.2557409,-0.547452 -1.0034949,-0.504993 -1.2057923,-0.5584397 -2.3261694,-0.6108863 -1.6183763,-0.07543 -2.2127818,0.1048983 -3.4750167,1.0559403 -0.5614371,0.423076 -1.0489481,0.769229 -1.0839131,0.769229 -0.03497,0 -0.27872,-0.262735 -0.541956,-0.584413 z"
|
||||||
|
style="fill:#bbbbbb;fill-opacity:1;stroke:none;stroke-width:0.499498;stroke-opacity:1" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.4 KiB |
17
landing/src/components/CopyChip.astro
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
// MC-GUI slot + copy button. variant "ip" = big gold mono (server address);
|
||||||
|
// "url" = smaller, wraps (auth / authlib URLs). Copy handled by the global
|
||||||
|
// [data-copy] script in [...lang].astro (and the equivalent on /fjord);
|
||||||
|
// data-label / data-copied drive the (localized) button text + feedback.
|
||||||
|
interface Props {
|
||||||
|
value: string;
|
||||||
|
variant?: "ip" | "url";
|
||||||
|
label?: string;
|
||||||
|
copiedLabel?: string;
|
||||||
|
}
|
||||||
|
const { value, variant = "ip", label = "Copy", copiedLabel = "Copied!" } = Astro.props;
|
||||||
|
---
|
||||||
|
<div class:list={["ip-chip", variant === "url" && "url"]}>
|
||||||
|
<span class="ip-val"><span class="pin">▸</span>{value}</span>
|
||||||
|
<button type="button" data-copy={value} data-label={label} data-copied={copiedLabel}>{label}</button>
|
||||||
|
</div>
|
||||||
17
landing/src/components/Creeper.astro
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
// 8x8 creeper face — pure markup, styled by .creeper in main.css.
|
||||||
|
const CREEPER = [
|
||||||
|
"00000000",
|
||||||
|
"01100110",
|
||||||
|
"01100110",
|
||||||
|
"00011000",
|
||||||
|
"00111100",
|
||||||
|
"00111100",
|
||||||
|
"00100100",
|
||||||
|
"00000000",
|
||||||
|
];
|
||||||
|
const cells = CREEPER.join("").split("");
|
||||||
|
---
|
||||||
|
<div class="creeper" aria-hidden="true">
|
||||||
|
{cells.map((c) => <i class={c === "1" ? "f" : undefined} />)}
|
||||||
|
</div>
|
||||||
84
landing/src/components/ModList.astro
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
---
|
||||||
|
// Installed-pack mod list (the "Mods" section). Build-time baked from the
|
||||||
|
// auto-generated catalogue (tooling/build-modlist.py → src/data/mods.json),
|
||||||
|
// loaded via ./data/mods.ts (resilient: missing file → empty list, build never
|
||||||
|
// fails). Flat alphabetical grid: extracted logo (or a pixel placeholder) +
|
||||||
|
// pretty name + version. description is exposed as a tooltip. No categories, no
|
||||||
|
// external links. Tiles share the MC-GUI bevel treatment used by .feat.
|
||||||
|
import { MODS } from "../data/mods";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
emptyLabel?: string;
|
||||||
|
}
|
||||||
|
const { emptyLabel = "Mod list not generated yet." } = Astro.props;
|
||||||
|
|
||||||
|
const mods = MODS.mods;
|
||||||
|
---
|
||||||
|
{mods.length === 0 ? (
|
||||||
|
<p class="mods-empty">{emptyLabel}</p>
|
||||||
|
) : (
|
||||||
|
<div class="mod-grid">
|
||||||
|
{mods.map((m) => (
|
||||||
|
<div class="mod" title={m.description || m.name}>
|
||||||
|
<div class="mod-logo">
|
||||||
|
{m.logo ? (
|
||||||
|
<img src={m.logo} alt="" width="40" height="40" loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<span class="mod-noicon" aria-hidden="true">{m.name.slice(0, 1).toUpperCase()}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div class="mod-meta">
|
||||||
|
<span class="mod-name">{m.name}</span>
|
||||||
|
{m.version && <span class="mod-ver">{m.version}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.mod-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.mod {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: inset 2px 2px 0 var(--bevel-hi), inset -2px -2px 0 var(--bevel-lo);
|
||||||
|
transition: transform .14s ease, filter .14s ease;
|
||||||
|
}
|
||||||
|
.mod:hover { transform: translateY(-2px); filter: brightness(1.06); }
|
||||||
|
.mod-logo {
|
||||||
|
width: 40px; height: 40px; flex-shrink: 0;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
background: var(--bg-2);
|
||||||
|
box-shadow: inset 1px 1px 0 var(--bevel-lo), inset -1px -1px 0 var(--bevel-hi);
|
||||||
|
}
|
||||||
|
.mod-logo img { width: 40px; height: 40px; image-rendering: pixelated; }
|
||||||
|
.mod-noicon {
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 20px;
|
||||||
|
color: var(--accent);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.mod-meta { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.mod-name {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.mod-ver {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--dim);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.mods-empty { margin: 0; color: var(--dim); font-size: 14px; }
|
||||||
|
</style>
|
||||||
27
landing/src/components/PixelIcon.astro
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
// Feature icon. Default: a 7x7 procedural glyph (styled by .picon in main.css).
|
||||||
|
// Optional: pass `img` (an absolute path like "/icons/foo.png|svg" under
|
||||||
|
// landing/public/) to render a custom image instead of a glyph.
|
||||||
|
interface Props {
|
||||||
|
glyph: "shield" | "diamond" | "orb" | "heart";
|
||||||
|
gold?: boolean;
|
||||||
|
img?: string; // custom icon path in /public/icons/ — overrides the glyph
|
||||||
|
}
|
||||||
|
const { glyph, gold = false, img } = Astro.props;
|
||||||
|
|
||||||
|
const GLYPHS: Record<Props["glyph"], string[]> = {
|
||||||
|
shield: ["0111110", "1111111", "1111111", "1111111", "0111110", "0011100", "0001000"],
|
||||||
|
diamond: ["0001000", "0011100", "0111110", "1111111", "0111110", "0011100", "0001000"],
|
||||||
|
orb: ["0011100", "0111110", "1111111", "1111111", "1111111", "0111110", "0011100"],
|
||||||
|
heart: ["0110110", "1111111", "1111111", "1111111", "0111110", "0011100", "0001000"],
|
||||||
|
};
|
||||||
|
const cells = GLYPHS[glyph].join("").split("");
|
||||||
|
const onClass = gold ? "go" : "on";
|
||||||
|
---
|
||||||
|
{img ? (
|
||||||
|
<img class="picon-img" src={img} alt="" aria-hidden="true" width="52" height="52" />
|
||||||
|
) : (
|
||||||
|
<div class="picon" aria-hidden="true">
|
||||||
|
{cells.map((c) => <i class={c === "1" ? onClass : undefined} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
91
landing/src/components/PlayerRoster.astro
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
---
|
||||||
|
// Registered-players roster (the "Members" section). Progressive enhancement,
|
||||||
|
// same shape as ServerCard:
|
||||||
|
// SSG → renders an empty skeleton (a single .roster grid + an empty-state
|
||||||
|
// line) so the page is valid with JS off.
|
||||||
|
// Client → fetches OUR mc-status roster endpoint (site.status.rosterApi,
|
||||||
|
// same-origin via caddy /api/mcstatus/* → internal /players). It
|
||||||
|
// returns a sanitized [{uuid,name}] of every Drasl player (admin login
|
||||||
|
// is server-side in mc-status — no admin token reaches the page). Each
|
||||||
|
// player renders as a SHARED avatar tile (.roster-tile, styled in
|
||||||
|
// main.css alongside ServerCard's online tiles).
|
||||||
|
//
|
||||||
|
// Empty/error → hide the grid and show the "no members yet" line (data-empty).
|
||||||
|
import { site } from "../data/site";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
// Empty-state copy (passed from the page for i18n; English default).
|
||||||
|
emptyLabel?: string;
|
||||||
|
}
|
||||||
|
const { emptyLabel = "No members yet." } = Astro.props;
|
||||||
|
|
||||||
|
const { status, avatarUrl, rosterAvatarMode } = site;
|
||||||
|
---
|
||||||
|
<div
|
||||||
|
class="roster-block"
|
||||||
|
data-api={status.rosterApi}
|
||||||
|
data-avatar={avatarUrl}
|
||||||
|
data-mode={rosterAvatarMode}
|
||||||
|
data-empty={emptyLabel}
|
||||||
|
>
|
||||||
|
<div class="roster" aria-label="Registered players"></div>
|
||||||
|
<p class="roster-empty" hidden></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function hydrate(el: HTMLElement) {
|
||||||
|
const { api, avatar, mode, empty } = el.dataset;
|
||||||
|
if (!api || !avatar || !mode) return;
|
||||||
|
|
||||||
|
const grid = el.querySelector<HTMLElement>(".roster");
|
||||||
|
const emptyEl = el.querySelector<HTMLElement>(".roster-empty");
|
||||||
|
if (!grid) return;
|
||||||
|
|
||||||
|
const showEmpty = () => {
|
||||||
|
grid.hidden = true;
|
||||||
|
if (emptyEl) {
|
||||||
|
emptyEl.textContent = empty ?? "No members yet.";
|
||||||
|
emptyEl.hidden = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let data: Array<{ uuid?: string; name?: string }>;
|
||||||
|
try {
|
||||||
|
const r = await fetch(api, { headers: { Accept: "application/json" } });
|
||||||
|
if (!r.ok) return showEmpty();
|
||||||
|
data = await r.json();
|
||||||
|
} catch {
|
||||||
|
return showEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop the service `admin` account from the public members grid.
|
||||||
|
const players = Array.isArray(data)
|
||||||
|
? data.filter((p) => p && p.uuid && p.name?.toLowerCase() !== "admin")
|
||||||
|
: [];
|
||||||
|
if (players.length === 0) return showEmpty();
|
||||||
|
|
||||||
|
grid.innerHTML = "";
|
||||||
|
for (const p of players) {
|
||||||
|
grid.appendChild(rosterTile(p.uuid!, p.name ?? "", avatar, mode));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared avatar tile (avatar + name) — identical markup to ServerCard's
|
||||||
|
// online tiles, so .roster / .roster-tile (global, main.css) style both.
|
||||||
|
function rosterTile(uuid: string, name: string, avatar: string, mode: string) {
|
||||||
|
const tile = document.createElement("div");
|
||||||
|
tile.className = "roster-tile";
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.loading = "lazy";
|
||||||
|
img.alt = name;
|
||||||
|
img.src = `${avatar}/${mode}/${uuid}?size=256`;
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.className = "roster-name";
|
||||||
|
label.textContent = name;
|
||||||
|
label.title = name;
|
||||||
|
tile.append(img, label);
|
||||||
|
return tile;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll<HTMLElement>(".roster-block").forEach(hydrate);
|
||||||
|
</script>
|
||||||
239
landing/src/components/ServerCard.astro
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
---
|
||||||
|
// Featured LIVE server card. Progressive enhancement:
|
||||||
|
// SSG → renders a static skeleton (server icon, MOTD, slot cap) identical in
|
||||||
|
// spirit to the old ServerListPanel, so it looks right with JS off or
|
||||||
|
// if the status API is unreachable.
|
||||||
|
// Client → fetches mcstatus.io (CORS:*, no key, 60s cache) for the public
|
||||||
|
// address and fills in: live online/offline pill, players online/max +
|
||||||
|
// fill bar, the real server favicon, color MOTD, and a row of player
|
||||||
|
// HEADS rendered by OUR NMSR from Drasl skins (not Mojang/Crafatar).
|
||||||
|
//
|
||||||
|
// Live labels are passed as data-* so copy stays translatable from the page
|
||||||
|
// (defaults are English). Heads use /headiso. Promo/placeholder players with
|
||||||
|
// the all-zero UUID are filtered out.
|
||||||
|
import { site } from "../data/site";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
// SSG fallback copy (same props the old ServerListPanel took).
|
||||||
|
modded: string;
|
||||||
|
motd: string;
|
||||||
|
upTo: string;
|
||||||
|
// Live labels (optional, English defaults). Pass t.* from the page to i18n.
|
||||||
|
onlineLabel?: string;
|
||||||
|
offlineLabel?: string;
|
||||||
|
emptyLabel?: string;
|
||||||
|
}
|
||||||
|
const {
|
||||||
|
modded,
|
||||||
|
motd,
|
||||||
|
upTo,
|
||||||
|
onlineLabel = "Online",
|
||||||
|
offlineLabel = "Offline",
|
||||||
|
emptyLabel = "Nobody online",
|
||||||
|
} = Astro.props;
|
||||||
|
|
||||||
|
const { status, avatarUrl, avatarMode } = site;
|
||||||
|
---
|
||||||
|
<div
|
||||||
|
class="servercard"
|
||||||
|
data-api={status.statusApi}
|
||||||
|
data-avatar={avatarUrl}
|
||||||
|
data-mode={avatarMode}
|
||||||
|
data-slots={status.slots}
|
||||||
|
data-online={onlineLabel}
|
||||||
|
data-offline={offlineLabel}
|
||||||
|
data-empty={emptyLabel}
|
||||||
|
>
|
||||||
|
<div class="sc-top">
|
||||||
|
<div class="sc-icon">
|
||||||
|
<!-- Replaced by the real server favicon on success; Ulicraft logo otherwise. -->
|
||||||
|
<img class="sc-favicon" alt="" hidden />
|
||||||
|
<span class="sc-creeper"><img src="/ulicraft-logo-mini.svg" alt="" width="56" height="56" /></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sc-meta">
|
||||||
|
<div class="sc-row1">
|
||||||
|
<span class="sc-title">{site.name}</span>
|
||||||
|
<span class="sc-ver">Java {site.mcVersion}</span>
|
||||||
|
</div>
|
||||||
|
<p class="sc-motd"><span class="a">⛏ {modded}</span> · <span class="sc-motd-text">{motd}</span></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sc-state">
|
||||||
|
<span class="sc-pill" data-up="">
|
||||||
|
<span class="sc-dot"></span>
|
||||||
|
<span class="sc-pill-txt">·</span>
|
||||||
|
</span>
|
||||||
|
<div class="sc-count">
|
||||||
|
<span class="sc-on">{upTo}</span> <b class="sc-max">{status.slots}</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Player fill bar (online/max). Hidden until a live count arrives. -->
|
||||||
|
<div class="sc-bar" hidden><span class="sc-bar-fill"></span></div>
|
||||||
|
|
||||||
|
<!-- Avatar tiles + empty state. Populated client-side. -->
|
||||||
|
<div class="sc-players" hidden>
|
||||||
|
<div class="roster" aria-label="Players online"></div>
|
||||||
|
<p class="sc-empty" hidden></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.servercard {
|
||||||
|
background: var(--slot);
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
box-shadow:
|
||||||
|
inset 2px 2px 0 var(--bevel-hi),
|
||||||
|
inset -2px -2px 0 var(--bevel-lo),
|
||||||
|
0 8px 24px oklch(0 0 0 / 0.4);
|
||||||
|
}
|
||||||
|
.sc-top { display: flex; gap: 18px; align-items: center; }
|
||||||
|
|
||||||
|
.sc-icon {
|
||||||
|
width: 72px; height: 72px; flex-shrink: 0;
|
||||||
|
display: grid; place-items: center; position: relative;
|
||||||
|
background: var(--bg-2);
|
||||||
|
}
|
||||||
|
.sc-favicon { width: 64px; height: 64px; image-rendering: pixelated; }
|
||||||
|
.sc-creeper img { width: 56px; height: 56px; }
|
||||||
|
|
||||||
|
.sc-meta { flex: 1; min-width: 0; }
|
||||||
|
.sc-row1 { display: flex; align-items: baseline; gap: 12px; }
|
||||||
|
.sc-title { font-family: var(--font-head); font-weight: 600; font-size: 22px; white-space: nowrap; }
|
||||||
|
.sc-ver { color: var(--dim); font-family: var(--font-mono); font-size: 17px; white-space: nowrap; }
|
||||||
|
.sc-motd { margin: 6px 0 0; color: var(--muted); font-size: 15px; }
|
||||||
|
.sc-motd .a { color: var(--accent); }
|
||||||
|
/* mcstatus.io motd.html ships inline color styles; keep its spans inline. */
|
||||||
|
.sc-motd-text :global(span) { display: inline; }
|
||||||
|
|
||||||
|
.sc-state { text-align: right; flex-shrink: 0; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
|
||||||
|
.sc-pill {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
font-size: 11px; font-family: var(--font-pixel, var(--font-mono));
|
||||||
|
letter-spacing: 0.04em; text-transform: uppercase;
|
||||||
|
padding: 3px 8px; color: var(--dim);
|
||||||
|
box-shadow: inset 1px 1px 0 var(--bevel-hi), inset -1px -1px 0 var(--bevel-lo);
|
||||||
|
}
|
||||||
|
.sc-dot { width: 9px; height: 9px; background: var(--dim); image-rendering: pixelated; }
|
||||||
|
.sc-pill[data-up="1"] { color: var(--accent-hi); }
|
||||||
|
.sc-pill[data-up="1"] .sc-dot { background: var(--accent); box-shadow: 0 0 6px var(--glow); }
|
||||||
|
.sc-pill[data-up="0"] { color: oklch(0.65 0.18 25); }
|
||||||
|
.sc-pill[data-up="0"] .sc-dot { background: oklch(0.62 0.2 25); }
|
||||||
|
.sc-count { font-variant-numeric: tabular-nums; font-size: 15px; color: var(--muted); }
|
||||||
|
.sc-count b { color: var(--text); }
|
||||||
|
|
||||||
|
.sc-bar { height: 6px; background: var(--bg-2); box-shadow: inset 1px 1px 0 var(--bevel-lo); }
|
||||||
|
.sc-bar-fill { display: block; height: 100%; width: 0; background: var(--accent); transition: width 0.6s ease; }
|
||||||
|
|
||||||
|
/* Online avatar tiles use the shared .roster / .roster-tile styles in
|
||||||
|
main.css (also used by PlayerRoster.astro). */
|
||||||
|
.sc-empty { margin: 0; color: var(--dim); font-size: 13px; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const ZERO_UUID = "00000000-0000-0000-0000-000000000000";
|
||||||
|
|
||||||
|
async function hydrate(el: HTMLElement) {
|
||||||
|
const { api, avatar, mode, slots, online, offline, empty } = el.dataset;
|
||||||
|
if (!api || !avatar || !mode) return;
|
||||||
|
|
||||||
|
const q = <T extends HTMLElement>(s: string) => el.querySelector<T>(s);
|
||||||
|
const pill = q(".sc-pill");
|
||||||
|
const pillTxt = q(".sc-pill-txt");
|
||||||
|
const favicon = q<HTMLImageElement>(".sc-favicon");
|
||||||
|
const creeper = q(".sc-creeper");
|
||||||
|
const onEl = q(".sc-on");
|
||||||
|
const maxEl = q(".sc-max");
|
||||||
|
const motdEl = q(".sc-motd-text");
|
||||||
|
const bar = q(".sc-bar");
|
||||||
|
const fill = q<HTMLElement>(".sc-bar-fill");
|
||||||
|
const players = q(".sc-players");
|
||||||
|
const roster = q(".roster");
|
||||||
|
const emptyEl = q(".sc-empty");
|
||||||
|
|
||||||
|
let data: any;
|
||||||
|
try {
|
||||||
|
const r = await fetch(api, { headers: { Accept: "application/json" } });
|
||||||
|
if (!r.ok) return; // keep SSG skeleton
|
||||||
|
data = await r.json();
|
||||||
|
} catch {
|
||||||
|
return; // offline/network → keep SSG skeleton
|
||||||
|
}
|
||||||
|
|
||||||
|
const up = data.online === true;
|
||||||
|
pill?.setAttribute("data-up", up ? "1" : "0");
|
||||||
|
if (pillTxt) pillTxt.textContent = up ? (online ?? "Online") : (offline ?? "Offline");
|
||||||
|
|
||||||
|
if (!up) return; // offline pill set; leave the rest as the static fallback
|
||||||
|
|
||||||
|
// Live count + bar.
|
||||||
|
const on = data.players?.online ?? 0;
|
||||||
|
const max = data.players?.max ?? (Number(slots) || 0);
|
||||||
|
if (onEl) onEl.textContent = String(on);
|
||||||
|
if (maxEl) maxEl.textContent = String(max);
|
||||||
|
if (bar && fill) {
|
||||||
|
bar.hidden = false;
|
||||||
|
fill.style.width = max > 0 ? `${Math.min(100, (on / max) * 100)}%` : "0%";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Real server favicon.
|
||||||
|
if (favicon && typeof data.icon === "string" && data.icon.startsWith("data:image")) {
|
||||||
|
favicon.src = data.icon;
|
||||||
|
favicon.hidden = false;
|
||||||
|
creeper?.setAttribute("hidden", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Color MOTD (mcstatus ships inline-styled spans for our own server).
|
||||||
|
if (motdEl && data.motd?.html) motdEl.innerHTML = data.motd.html;
|
||||||
|
|
||||||
|
// Labeled avatar tiles from OUR NMSR (Drasl skins). Filter promo/placeholder
|
||||||
|
// rows (all-zero UUID) and dedupe. Tiles share .roster styles with the
|
||||||
|
// Members grid (PlayerRoster.astro) — see rosterTile() below.
|
||||||
|
const list: Array<{ uuid?: string; name_clean?: string }> = data.players?.list ?? [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const real = list.filter((p) => {
|
||||||
|
const u = (p.uuid ?? "").toLowerCase();
|
||||||
|
if (!u || u === ZERO_UUID || seen.has(u)) return false;
|
||||||
|
seen.add(u);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (players && roster) {
|
||||||
|
players.hidden = false;
|
||||||
|
if (real.length === 0) {
|
||||||
|
if (emptyEl) {
|
||||||
|
emptyEl.textContent = empty ?? "Nobody online";
|
||||||
|
emptyEl.hidden = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
roster.innerHTML = "";
|
||||||
|
for (const p of real.slice(0, 16)) {
|
||||||
|
roster.appendChild(rosterTile(p.uuid!, p.name_clean ?? "", avatar, mode));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build one shared avatar tile (avatar + name). Same markup PlayerRoster
|
||||||
|
// builds, so .roster / .roster-tile (global, in main.css) style both.
|
||||||
|
function rosterTile(uuid: string, name: string, avatar: string, mode: string) {
|
||||||
|
const tile = document.createElement("div");
|
||||||
|
tile.className = "roster-tile";
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.loading = "lazy";
|
||||||
|
img.alt = name;
|
||||||
|
img.src = `${avatar}/${mode}/${uuid}?size=64`;
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.className = "roster-name";
|
||||||
|
label.textContent = name;
|
||||||
|
label.title = name;
|
||||||
|
tile.append(img, label);
|
||||||
|
return tile;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll<HTMLElement>(".servercard").forEach(hydrate);
|
||||||
|
</script>
|
||||||
31
landing/src/components/ServerListPanel.astro
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
// Static server-list panel — mirrors the multiplayer-list row. No live count
|
||||||
|
// (plan 09-landing.md option B); shows version, motd and slot cap.
|
||||||
|
import Creeper from "./Creeper.astro";
|
||||||
|
import { site } from "../data/site";
|
||||||
|
interface Props {
|
||||||
|
modded: string;
|
||||||
|
motd: string;
|
||||||
|
upTo: string;
|
||||||
|
}
|
||||||
|
const { modded, motd, upTo } = Astro.props;
|
||||||
|
const { status } = site;
|
||||||
|
---
|
||||||
|
<div class="serverlist">
|
||||||
|
<div class="icon"><Creeper /></div>
|
||||||
|
<div class="meta">
|
||||||
|
<div class="row1">
|
||||||
|
<span class="title">{site.name}</span>
|
||||||
|
<span class="ver">Java {site.mcVersion}</span>
|
||||||
|
</div>
|
||||||
|
<p class="motd">
|
||||||
|
<span class="a">⛏ {modded}</span> · {motd}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="players">
|
||||||
|
<span class="bars" aria-hidden="true"><i /><i /><i /><i /><i /></span>
|
||||||
|
<span>{upTo} <b>{status.slots}</b></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
39
landing/src/data/launcher.example.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"productName": "Ulicraft",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"baseUrl": "https://distribution.ulicraft.net/launcher",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"name": "Ulicraft-1.0.0.exe",
|
||||||
|
"os": "windows",
|
||||||
|
"arch": "x64",
|
||||||
|
"ext": "exe",
|
||||||
|
"size": 90893289,
|
||||||
|
"url": "https://distribution.ulicraft.net/launcher/Ulicraft-1.0.0.exe"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ulicraft-1.0.0.AppImage",
|
||||||
|
"os": "linux",
|
||||||
|
"arch": "x64",
|
||||||
|
"ext": "AppImage",
|
||||||
|
"size": 95722608,
|
||||||
|
"url": "https://distribution.ulicraft.net/launcher/Ulicraft-1.0.0.AppImage"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ulicraft-1.0.0.deb",
|
||||||
|
"os": "linux",
|
||||||
|
"arch": "x64",
|
||||||
|
"ext": "deb",
|
||||||
|
"size": 87751876,
|
||||||
|
"url": "https://distribution.ulicraft.net/launcher/Ulicraft-1.0.0.deb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ulicraft-1.0.0.tar.gz",
|
||||||
|
"os": "linux",
|
||||||
|
"arch": "x64",
|
||||||
|
"ext": "tar.gz",
|
||||||
|
"size": 116347746,
|
||||||
|
"url": "https://distribution.ulicraft.net/launcher/Ulicraft-1.0.0.tar.gz"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
5
landing/src/data/mods.example.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"count": 0,
|
||||||
|
"mods": []
|
||||||
|
}
|
||||||
56
landing/src/data/mods.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// Build-time loader for the auto-generated mod catalogue. tooling/build-modlist.py
|
||||||
|
// writes src/data/mods.json (gitignored, generated) from the distribution's full
|
||||||
|
// client modset; it MAY NOT EXIST on a fresh checkout, so we read it at runtime
|
||||||
|
// with fs (a static `import` would hard-fail the build when absent). Fall back to
|
||||||
|
// the committed .example.json (an empty catalogue); if even that is unreadable,
|
||||||
|
// expose an empty catalogue so the page simply renders no mods and the stat tile
|
||||||
|
// keeps its default. Same pattern as loadLauncherManifest in site.ts.
|
||||||
|
// @ts-ignore — node builtins (no @types/node in this Astro SSG; same reason
|
||||||
|
// `process`/`import.meta.url` reads work at build time, as in site.ts).
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
export type Mod = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
authors: string;
|
||||||
|
description: string;
|
||||||
|
logo: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModCatalogue = {
|
||||||
|
schemaVersion: number;
|
||||||
|
count: number;
|
||||||
|
mods: Mod[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function loadMods(): ModCatalogue {
|
||||||
|
const empty: ModCatalogue = { schemaVersion: 1, count: 0, mods: [] };
|
||||||
|
// Resolve relative to this module first (works when import.meta.url points at
|
||||||
|
// the source dir), then fall back to a cwd-relative path (Astro runs the build
|
||||||
|
// from the landing root, so src/data/* resolves there even when the module has
|
||||||
|
// been bundled to a different location). Prefer the generated mods.json, then
|
||||||
|
// the committed empty mods.example.json.
|
||||||
|
// @ts-ignore — `process` is untyped here (no @types/node), same as site.ts.
|
||||||
|
const cwd: string = typeof process !== "undefined" ? process.cwd() : ".";
|
||||||
|
const candidates: Array<string | URL> = [];
|
||||||
|
for (const name of ["mods.json", "mods.example.json"]) {
|
||||||
|
try {
|
||||||
|
candidates.push(new URL(name, import.meta.url));
|
||||||
|
} catch {
|
||||||
|
// import.meta.url unavailable — skip the module-relative candidate.
|
||||||
|
}
|
||||||
|
candidates.push(`${cwd}/src/data/${name}`);
|
||||||
|
}
|
||||||
|
for (const c of candidates) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(readFileSync(c, "utf8")) as ModCatalogue;
|
||||||
|
if (parsed && Array.isArray(parsed.mods)) return parsed;
|
||||||
|
} catch {
|
||||||
|
// try the next candidate (file missing/unreadable → fall back)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MODS = loadMods();
|
||||||
@@ -1,30 +1,202 @@
|
|||||||
// Single source of truth for the landing page content.
|
// Single source of truth for the landing page's *non-translatable* config:
|
||||||
|
// domains, URLs, theme, launcher files, and the structural shape of the
|
||||||
|
// stat/feature lists. Translatable copy lives in src/i18n/ui.ts.
|
||||||
// BASE_DOMAIN is read at build time; falls back to ulicraft.local.
|
// BASE_DOMAIN is read at build time; falls back to ulicraft.local.
|
||||||
|
// @ts-ignore — node builtins (no @types/node in this Astro SSG; same reason
|
||||||
|
// `process` below is untyped). Used only at build time to read the manifest.
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
const BASE_DOMAIN = process.env.BASE_DOMAIN ?? "ulicraft.local";
|
const BASE_DOMAIN = process.env.BASE_DOMAIN ?? "ulicraft.local";
|
||||||
|
|
||||||
|
// NMSR render mode for avatar tiles. AVATAR_MODE is the ServerCard online row +
|
||||||
|
// account preview (heads); ROSTER_AVATAR_MODE is the Members grid (full body by
|
||||||
|
// default). Read at build time, same as BASE_DOMAIN. e.g. "headiso" | "head" |
|
||||||
|
// "fullbody" | "fullbodyiso".
|
||||||
|
// @ts-ignore — `process` is untyped here (no @types/node), same as BASE_DOMAIN.
|
||||||
|
const AVATAR_MODE = process.env.AVATAR_MODE ?? "headiso";
|
||||||
|
// @ts-ignore — `process` is untyped here (no @types/node).
|
||||||
|
const ROSTER_AVATAR_MODE = process.env.ROSTER_AVATAR_MODE ?? "fullbodyiso";
|
||||||
|
|
||||||
|
// UlicraftLauncher download list. Source of truth is the custom-launcher repo's
|
||||||
|
// build output `launcher.json` (shape: productName/version/baseUrl/files[]),
|
||||||
|
// published to https://distribution.${BASE}/launcher/launcher.json. The release
|
||||||
|
// flow syncs that file into this dir as `launcher.json` (gitignored, generated)
|
||||||
|
// — same pattern as sync-server-mods.sh. It MAY NOT EXIST at build time, so we
|
||||||
|
// read it at runtime with fs (a static `import` would hard-fail the build when
|
||||||
|
// absent). Fall back to the committed `launcher.example.json` (the documented
|
||||||
|
// shape); if even that is unreadable, expose an empty file list so the homepage
|
||||||
|
// simply renders no download buttons. The raw launcher.json is normalized into
|
||||||
|
// the internal shape the page renders against (builds[]).
|
||||||
|
type LauncherBuild = {
|
||||||
|
os: string; // "windows" | "macos" | "linux"
|
||||||
|
arch: string; // "x64" | "arm64" | "universal"
|
||||||
|
format: string; // file extension: "exe" | "dmg" | "AppImage" | "deb" | "tar.gz" | …
|
||||||
|
filename: string;
|
||||||
|
url: string;
|
||||||
|
size?: number;
|
||||||
|
};
|
||||||
|
type LauncherFile = { filename: string; url: string; size?: number };
|
||||||
|
type LauncherManifest = {
|
||||||
|
product: string;
|
||||||
|
version: string;
|
||||||
|
builds: LauncherBuild[];
|
||||||
|
// Not present in launcher.json; the Fjord page degrades gracefully when absent.
|
||||||
|
fjordPack?: LauncherFile;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Raw shape as produced by the custom-launcher build (UlicraftLauncher/dist/launcher.json).
|
||||||
|
type RawLauncherJson = {
|
||||||
|
productName?: string;
|
||||||
|
version?: string;
|
||||||
|
baseUrl?: string;
|
||||||
|
files?: Array<{ name: string; os: string; arch: string; ext: string; url: string; size?: number }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeLauncher(raw: RawLauncherJson): LauncherManifest {
|
||||||
|
return {
|
||||||
|
product: raw.productName ?? "UlicraftLauncher",
|
||||||
|
version: raw.version ?? "0.0.0",
|
||||||
|
builds: (raw.files ?? []).map((f) => ({
|
||||||
|
os: f.os,
|
||||||
|
arch: f.arch,
|
||||||
|
format: f.ext,
|
||||||
|
filename: f.name,
|
||||||
|
url: f.url,
|
||||||
|
size: f.size,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadLauncherManifest(): LauncherManifest {
|
||||||
|
const empty: LauncherManifest = { product: "UlicraftLauncher", version: "0.0.0", builds: [] };
|
||||||
|
// Resolve relative to this module first, then fall back to a cwd-relative path:
|
||||||
|
// Astro bundles this module so import.meta.url may not point at src/data/ at
|
||||||
|
// build time, but the build runs from the landing root, so <cwd>/src/data/*
|
||||||
|
// resolves. Prefer the synced launcher.json, then the committed example.
|
||||||
|
// @ts-ignore — `process` is untyped here (no @types/node).
|
||||||
|
const cwd: string = typeof process !== "undefined" ? process.cwd() : ".";
|
||||||
|
const candidates: Array<string | URL> = [];
|
||||||
|
for (const name of ["launcher.json", "launcher.example.json"]) {
|
||||||
|
try {
|
||||||
|
candidates.push(new URL(name, import.meta.url));
|
||||||
|
} catch {
|
||||||
|
// import.meta.url unavailable — skip the module-relative candidate.
|
||||||
|
}
|
||||||
|
candidates.push(`${cwd}/src/data/${name}`);
|
||||||
|
}
|
||||||
|
for (const c of candidates) {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(readFileSync(c, "utf8")) as RawLauncherJson;
|
||||||
|
if (raw && Array.isArray(raw.files)) return normalizeLauncher(raw);
|
||||||
|
} catch {
|
||||||
|
// try the next candidate (synced file missing → fall back to example)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LAUNCHER_MANIFEST = loadLauncherManifest();
|
||||||
|
|
||||||
|
// REGISTRATION_MODE (invite|open) is read at build time, same .env as Drasl's
|
||||||
|
// render-config.sh. "invite" => the register form must collect an invite code.
|
||||||
|
const REGISTRATION_MODE = process.env.REGISTRATION_MODE ?? "invite";
|
||||||
|
|
||||||
|
// REGISTRATION_SHOW_INVITE (true|false, default false=hidden) ONLY toggles the
|
||||||
|
// visibility of the invite-code field on the register form. The backend invite
|
||||||
|
// gate stays on Drasl's RequireInvite (driven by REGISTRATION_MODE). Decoupled on
|
||||||
|
// purpose: invites are usually admin-minted and the field is hidden by default.
|
||||||
|
// CAVEAT: if REGISTRATION_MODE=invite (backend requires a code) but this is
|
||||||
|
// false, public self-registration can't supply one — set both together.
|
||||||
|
// @ts-ignore — `process` is untyped here (no @types/node).
|
||||||
|
const REGISTRATION_SHOW_INVITE = process.env.REGISTRATION_SHOW_INVITE ?? "false";
|
||||||
|
|
||||||
|
// Minecraft / pack facts, surfaced in a few places.
|
||||||
|
const MC_VERSION = "1.21.1";
|
||||||
|
|
||||||
export const site = {
|
export const site = {
|
||||||
name: "Ulicraft",
|
name: "Ulicraft",
|
||||||
tagline: "LAN-party Minecraft server",
|
|
||||||
baseDomain: BASE_DOMAIN,
|
baseDomain: BASE_DOMAIN,
|
||||||
|
mcVersion: MC_VERSION,
|
||||||
|
|
||||||
// Connect target shown to guests. No port needed — a dnsmasq SRV record
|
// Register form behaviour, driven by REGISTRATION_MODE in .env.
|
||||||
// (_minecraft._tcp.mc.<domain>) points clients at 25565.
|
// true => Drasl RequireInvite = true (backend gate)
|
||||||
serverAddress: `mc.${BASE_DOMAIN}`,
|
// false => open self-registration
|
||||||
|
registrationRequiresInvite: REGISTRATION_MODE === "invite",
|
||||||
|
// Whether the invite-code FIELD is shown on the register form (default false).
|
||||||
|
// Independent of the backend gate above — see REGISTRATION_SHOW_INVITE note.
|
||||||
|
registrationShowInvite: REGISTRATION_SHOW_INVITE === "true",
|
||||||
|
|
||||||
// Drasl auth web UI + authlib-injector API root.
|
// Design knobs (replace the design's in-browser TweaksPanel). Baked at build.
|
||||||
authUrl: `http://auth.${BASE_DOMAIN}`,
|
// mood: "grass" | "nether" | "end"
|
||||||
authlibUrl: `http://auth.${BASE_DOMAIN}/authlib-injector`,
|
// hero: "centered" | "split" | "spotlight"
|
||||||
|
// headFont: "blockblueprint" | "pixelify" | "8bit" | "silkscreen"
|
||||||
|
// dust: floating pixel particles in the hero
|
||||||
|
theme: {
|
||||||
|
mood: "grass",
|
||||||
|
hero: "centered",
|
||||||
|
headFont: "blockblueprint",
|
||||||
|
dust: true,
|
||||||
|
},
|
||||||
|
|
||||||
// packwiz modpack entrypoint.
|
// Connect target shown to guests. No port needed (defaults to 25565).
|
||||||
packwizUrl: `http://packwiz.${BASE_DOMAIN}/pack.toml`,
|
serverAddress: `${BASE_DOMAIN}`,
|
||||||
|
|
||||||
// Caddy local CA root — guests import this to trust the offline asset mirror.
|
// Drasl auth web UI + authlib-injector API root (public HTTPS).
|
||||||
caCertUrl: `http://${BASE_DOMAIN}/ca.crt`,
|
authUrl: `https://auth.${BASE_DOMAIN}`,
|
||||||
|
authlibUrl: `https://auth.${BASE_DOMAIN}/authlib-injector`,
|
||||||
|
// Drasl REST API v2 root — the /register form calls this from the browser.
|
||||||
|
// Requires CORSAllowOrigins to include the apex in drasl config.toml.
|
||||||
|
draslApiUrl: `https://auth.${BASE_DOMAIN}/drasl/api/v2`,
|
||||||
|
|
||||||
// Launcher downloads. Stable filenames are symlinks created by
|
// NMSR avatar renderer root. Player heads come from OUR Drasl skins, not
|
||||||
// tooling/fetch-launcher.sh — keep these in sync with that script.
|
// Mojang/Crafatar — head URL is `${avatarUrl}/headiso/<uuid>?size=64`.
|
||||||
launcher: {
|
avatarUrl: `https://avatar.${BASE_DOMAIN}`,
|
||||||
name: "FjordLauncherUnlocked",
|
|
||||||
|
// NMSR render mode for avatar tiles. Avatar URL = `${avatarUrl}/<mode>/<uuid>?size=N`.
|
||||||
|
// avatarMode → ServerCard online row + account preview (heads)
|
||||||
|
// rosterAvatarMode → PlayerRoster members grid (full body)
|
||||||
|
avatarMode: AVATAR_MODE,
|
||||||
|
rosterAvatarMode: ROSTER_AVATAR_MODE,
|
||||||
|
|
||||||
|
// Uptime Kuma status page (external link in footer).
|
||||||
|
statusUrl: `https://status.${BASE_DOMAIN}`,
|
||||||
|
|
||||||
|
// Live server card (ServerCard.astro). `slots` is the SSG fallback cap shown
|
||||||
|
// before/without JS; live data comes from our SELF-HOSTED mc-status service
|
||||||
|
// (docker/mc-status, mcutil wrapper) reverse-proxied same-origin at the apex
|
||||||
|
// /api/mcstatus/* path — no CORS, no third-party calls. It emits mcstatus.io
|
||||||
|
// v2 JSON, so the URL stays drop-in compatible. Uptime Kuma stays the history
|
||||||
|
// + alerting backend (plan/10).
|
||||||
|
status: {
|
||||||
|
slots: 10,
|
||||||
|
statusApi: `/api/mcstatus/v2/status/java/${BASE_DOMAIN}`,
|
||||||
|
// Registered-players roster (PlayerRoster.astro). Same mc-status service,
|
||||||
|
// same-origin via caddy /api/mcstatus/* → internal /players. Returns a
|
||||||
|
// sanitized [{uuid,name}] of every Drasl player (admin login is server-side
|
||||||
|
// in mc-status; no admin token in the page).
|
||||||
|
rosterApi: `/api/mcstatus/players`,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Feature cards. glyph ∈ shield|diamond|orb|heart; gold = gold pixel fill.
|
||||||
|
// Text (h/p) comes from ui.ts (same order).
|
||||||
|
features: [
|
||||||
|
{ glyph: "diamond", gold: true },
|
||||||
|
{ glyph: "shield", gold: false },
|
||||||
|
{ glyph: "orb", gold: false },
|
||||||
|
{ glyph: "heart", gold: true },
|
||||||
|
],
|
||||||
|
|
||||||
|
// UlicraftLauncher download manifest — feeds the homepage join step 2
|
||||||
|
// per-OS buttons from `builds[]` (and the Fjord page's .mrpack via
|
||||||
|
// `fjordPack`). Synced from the custom-launcher repo; see loader above.
|
||||||
|
launcherManifest: LAUNCHER_MANIFEST,
|
||||||
|
|
||||||
|
// Fjord Launcher downloads (the alternative manual path on /fjord, OFF the
|
||||||
|
// homepage flow). Stable filenames are symlinks created by
|
||||||
|
// tooling/fetch-launcher.sh — keep these in sync with that script. OS names
|
||||||
|
// and hints are technical, kept language-neutral here.
|
||||||
|
fjord: {
|
||||||
|
name: "Fjord Launcher",
|
||||||
base: "/launcher/latest",
|
base: "/launcher/latest",
|
||||||
downloads: [
|
downloads: [
|
||||||
{ os: "Windows", file: "fjord-windows-setup.exe", hint: "x64 installer" },
|
{ os: "Windows", file: "fjord-windows-setup.exe", hint: "x64 installer" },
|
||||||
@@ -33,3 +205,19 @@ export const site = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
// Product / in-game UI literals — never translated (they match what guests see
|
||||||
|
// on screen or type verbatim).
|
||||||
|
export const LITERALS = {
|
||||||
|
authlib: "authlib-injector",
|
||||||
|
multiplayer: "Multiplayer",
|
||||||
|
addServer: "Add Server",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Head-font CSS stacks, keyed by theme.headFont.
|
||||||
|
export const HEAD_FONTS: Record<string, string> = {
|
||||||
|
blockblueprint: "'Block Blueprint', system-ui, sans-serif",
|
||||||
|
pixelify: "'Pixelify Sans', system-ui, sans-serif",
|
||||||
|
"8bit": "'Press Start 2P', monospace",
|
||||||
|
silkscreen: "'Silkscreen', system-ui, sans-serif",
|
||||||
|
};
|
||||||
|
|||||||
724
landing/src/i18n/ui.ts
Normal file
@@ -0,0 +1,724 @@
|
|||||||
|
// Translatable UI copy for the landing page. Structure is identical across
|
||||||
|
// locales; the page (src/pages/[...lang].astro) renders against it.
|
||||||
|
//
|
||||||
|
// Conventions:
|
||||||
|
// - "{name}" is replaced with the launcher name at render time.
|
||||||
|
// - Sentences containing links/kbd/literals are split into parts (a/b/pre/
|
||||||
|
// post/link) so the markup stays in the template, not in the strings.
|
||||||
|
// - Product / in-game literals (authlib-injector, Multiplayer, Add Server)
|
||||||
|
// live in site.ts LITERALS and are never translated.
|
||||||
|
|
||||||
|
export type Lang = "en" | "es" | "eu";
|
||||||
|
export const LANGS: Lang[] = ["en", "es", "eu"];
|
||||||
|
export const LANG_LABEL: Record<Lang, string> = { en: "EN", es: "ES", eu: "EU" };
|
||||||
|
// URL path per locale (es is default, served at root; en moves to /en/).
|
||||||
|
export const LANG_PATH: Record<Lang, string> = { es: "/", en: "/en/", eu: "/eu/" };
|
||||||
|
// Register page path per locale (mirrors LANG_PATH: es at /register).
|
||||||
|
export const LANG_REGISTER_PATH: Record<Lang, string> = {
|
||||||
|
es: "/register",
|
||||||
|
en: "/en/register",
|
||||||
|
eu: "/eu/register",
|
||||||
|
};
|
||||||
|
// Fjord (alternative path) page path per locale (mirrors LANG_REGISTER_PATH).
|
||||||
|
export const LANG_FJORD_PATH: Record<Lang, string> = {
|
||||||
|
es: "/fjord",
|
||||||
|
en: "/en/fjord",
|
||||||
|
eu: "/eu/fjord",
|
||||||
|
};
|
||||||
|
// Account page path per locale (mirrors LANG_REGISTER_PATH).
|
||||||
|
export const LANG_ACCOUNT_PATH: Record<Lang, string> = {
|
||||||
|
es: "/account",
|
||||||
|
en: "/en/account",
|
||||||
|
eu: "/eu/account",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface UI {
|
||||||
|
htmlLang: string;
|
||||||
|
copied: string; // transient copy-button feedback
|
||||||
|
nav: { status: string; members: string; features: string; mods: string; join: string; play: string; register: string; login: string };
|
||||||
|
hero: {
|
||||||
|
eyebrow: string; // "Modded LAN · NeoForge" — version appended at render
|
||||||
|
tagline: string;
|
||||||
|
sub: string;
|
||||||
|
copyIp: string;
|
||||||
|
howToJoin: string;
|
||||||
|
metaModpack: string;
|
||||||
|
};
|
||||||
|
status: {
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
lead: string;
|
||||||
|
modded: string;
|
||||||
|
motd: string;
|
||||||
|
upTo: string;
|
||||||
|
empty: string; // shown on the ServerCard when nobody is online
|
||||||
|
};
|
||||||
|
members: {
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
lead: string;
|
||||||
|
empty: string; // shown when the roster is empty or unreachable
|
||||||
|
};
|
||||||
|
features: {
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
lead: string;
|
||||||
|
items: { h: string; p: string }[];
|
||||||
|
};
|
||||||
|
mods: {
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
lead: string;
|
||||||
|
empty: string; // shown when the catalogue is absent/empty (not yet generated)
|
||||||
|
};
|
||||||
|
join: {
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
lead: string;
|
||||||
|
s1: {
|
||||||
|
kicker: string;
|
||||||
|
h: string;
|
||||||
|
pa: string; // before the register link
|
||||||
|
pb: string; // after it
|
||||||
|
registerLink: string;
|
||||||
|
};
|
||||||
|
s2: { kicker: string; h: string; p: string };
|
||||||
|
};
|
||||||
|
fjord: {
|
||||||
|
navHome: string; // "← Home" link in nav
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
lead: string;
|
||||||
|
s1: { kicker: string; h: string; p: string };
|
||||||
|
s2: {
|
||||||
|
kicker: string;
|
||||||
|
h: string;
|
||||||
|
pa: string; // before the authlib-injector literal
|
||||||
|
pb: string; // after it
|
||||||
|
copy: string;
|
||||||
|
};
|
||||||
|
s3: { kicker: string; h: string; p: string };
|
||||||
|
s4: {
|
||||||
|
kicker: string;
|
||||||
|
h: string;
|
||||||
|
pa: string; // before the Multiplayer/Add Server kbds
|
||||||
|
pb: string; // after them
|
||||||
|
copy: string;
|
||||||
|
};
|
||||||
|
noPack: string; // shown when fjordPack is missing from the manifest
|
||||||
|
};
|
||||||
|
footer: { join: string; status: string; disc: string };
|
||||||
|
register: {
|
||||||
|
navHome: string; // "← Home" link in nav
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
lead: string;
|
||||||
|
usernameLabel: string;
|
||||||
|
usernamePlaceholder: string;
|
||||||
|
passwordLabel: string;
|
||||||
|
passwordPlaceholder: string;
|
||||||
|
inviteLabel: string;
|
||||||
|
invitePlaceholder: string;
|
||||||
|
inviteHint: string; // where to get a code
|
||||||
|
submit: string;
|
||||||
|
submitting: string;
|
||||||
|
haveAccount: string; // "Already have an account?"
|
||||||
|
loginLink: string; // links to the account page
|
||||||
|
successTitle: string;
|
||||||
|
successBody: string; // {name} = player name
|
||||||
|
uuidLabel: string;
|
||||||
|
skinTitle: string;
|
||||||
|
skinLead: string;
|
||||||
|
skinChoose: string;
|
||||||
|
modelLabel: string;
|
||||||
|
modelClassic: string;
|
||||||
|
modelSlim: string;
|
||||||
|
skinUpload: string;
|
||||||
|
skinUploading: string;
|
||||||
|
skinOk: string;
|
||||||
|
finish: string; // proceed to How to Join
|
||||||
|
errFields: string;
|
||||||
|
errSkinType: string;
|
||||||
|
errNetwork: string;
|
||||||
|
};
|
||||||
|
account: {
|
||||||
|
navHome: string; // "← Home" link in nav
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
lead: string;
|
||||||
|
// Login form
|
||||||
|
usernameLabel: string;
|
||||||
|
usernamePlaceholder: string;
|
||||||
|
passwordLabel: string;
|
||||||
|
passwordPlaceholder: string;
|
||||||
|
submit: string;
|
||||||
|
submitting: string;
|
||||||
|
noAccount: string; // "No account yet?"
|
||||||
|
registerLink: string; // links to /register
|
||||||
|
// Skin panel
|
||||||
|
refresh: string; // re-fetch the NMSR avatar
|
||||||
|
playerLabel: string; // "Logged in as"
|
||||||
|
skinTitle: string;
|
||||||
|
skinLead: string; // reuses register.skinLead concept
|
||||||
|
skinChoose: string;
|
||||||
|
modelLabel: string;
|
||||||
|
modelClassic: string;
|
||||||
|
modelSlim: string;
|
||||||
|
skinUpload: string;
|
||||||
|
skinUploading: string;
|
||||||
|
skinOk: string;
|
||||||
|
skinPreviewNote: string; // "NMSR takes ~1 min to update" hint
|
||||||
|
resetSkin: string;
|
||||||
|
resetConfirm: string; // window.confirm text
|
||||||
|
resetOk: string;
|
||||||
|
// Errors
|
||||||
|
errFields: string;
|
||||||
|
errSkinType: string;
|
||||||
|
errNetwork: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ui: Record<Lang, UI> = {
|
||||||
|
en: {
|
||||||
|
htmlLang: "en",
|
||||||
|
copied: "Copied!",
|
||||||
|
nav: { status: "Status", members: "Members", features: "Features", mods: "Mods", join: "How to Join", play: "Play", register: "Register", login: "Account" },
|
||||||
|
hero: {
|
||||||
|
eyebrow: "Modded LAN · NeoForge",
|
||||||
|
tagline: "Kitchen-sink survival, built to outlast the weekend.",
|
||||||
|
sub:
|
||||||
|
"A self-hosted modded server for El valle — a curated tech + magic + " +
|
||||||
|
"exploration pack, your own skins, one-click setup. Grab the address, " +
|
||||||
|
"import the pack, dig in.",
|
||||||
|
copyIp: "Copy IP",
|
||||||
|
howToJoin: "How to Join",
|
||||||
|
metaModpack: "NeoForge modpack",
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
eyebrow: "Server",
|
||||||
|
title: "This is what you'll see.",
|
||||||
|
lead: "Exactly the entry that shows up in your multiplayer list.",
|
||||||
|
modded: "Modded",
|
||||||
|
motd: "NeoForge modpack · Simple Voice Chat · LAN-only",
|
||||||
|
upTo: "up to",
|
||||||
|
empty: "Nobody online",
|
||||||
|
},
|
||||||
|
members: {
|
||||||
|
eyebrow: "El valle",
|
||||||
|
title: "Who's on the server.",
|
||||||
|
lead: "Everyone registered on Ulicraft, with their own self-hosted skin.",
|
||||||
|
empty: "No members yet — be the first to register.",
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
eyebrow: "What makes it Ulicraft",
|
||||||
|
title: "Built for El valle.",
|
||||||
|
lead:
|
||||||
|
"Self-hosted, curated, and persistent — a modded server that respects " +
|
||||||
|
"your time and outlives the LAN.",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
h: "Kitchen-sink pack",
|
||||||
|
p: "~50–100 hand-picked mods — tech, magic, exploration and QoL. NeoForge 1.21.1, curated, no bloat.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
h: "Your identity, self-hosted",
|
||||||
|
p: "Drasl runs accounts and skins on the LAN. No Mojang login needed — your skin and cape just work.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
h: "One-click setup",
|
||||||
|
p: "The Ulicraft Launcher installs and updates the whole modpack from our distribution, so everyone stays in sync.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
h: "Built to last",
|
||||||
|
p: "Not a weekend throwaway — it runs on the homelab with 6-hour world backups. Your base survives.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
mods: {
|
||||||
|
eyebrow: "The pack",
|
||||||
|
title: "Every mod in the pack.",
|
||||||
|
lead: "The full client modset, installed automatically by the launcher. No hunting, no version mismatches.",
|
||||||
|
empty: "Mod list not generated yet — check back soon.",
|
||||||
|
},
|
||||||
|
join: {
|
||||||
|
eyebrow: "How to Join · 2 Steps",
|
||||||
|
title: "From zero to spawning in.",
|
||||||
|
lead: "One-time setup. After this, the launcher keeps you in sync.",
|
||||||
|
s1: {
|
||||||
|
kicker: "Account",
|
||||||
|
h: "Register",
|
||||||
|
pa: "Create your Ulicraft account at",
|
||||||
|
pb: "— one username and password for the server and your skin.",
|
||||||
|
registerLink: "Register",
|
||||||
|
},
|
||||||
|
s2: {
|
||||||
|
kicker: "Launcher",
|
||||||
|
h: "Download {name}",
|
||||||
|
p: "Grab {name}, log in with your Ulicraft credentials, and hit play. Everything else — the modpack, the server address, updates — is handled inside the launcher.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fjord: {
|
||||||
|
navHome: "← Home",
|
||||||
|
eyebrow: "Alternative · Fjord Launcher",
|
||||||
|
title: "Play with Fjord Launcher.",
|
||||||
|
lead: "Prefer a vanilla Prism-based launcher? Set it up manually with Fjord and import the Ulicraft pack.",
|
||||||
|
s1: {
|
||||||
|
kicker: "Launcher",
|
||||||
|
h: "Download Fjord",
|
||||||
|
p: "Grab Fjord Launcher for your system — a Prism-based launcher with authlib-injector support built in.",
|
||||||
|
},
|
||||||
|
s2: {
|
||||||
|
kicker: "Account",
|
||||||
|
h: "Add your account",
|
||||||
|
pa: "In Fjord, add an",
|
||||||
|
pb: "account using this URL, then log in with your Ulicraft credentials.",
|
||||||
|
copy: "Copy",
|
||||||
|
},
|
||||||
|
s3: {
|
||||||
|
kicker: "Modpack",
|
||||||
|
h: "Import the Ulicraft pack",
|
||||||
|
p: "Download the Ulicraft modpack and import it into Fjord as a new instance — it installs the whole pack.",
|
||||||
|
},
|
||||||
|
s4: {
|
||||||
|
kicker: "Connect",
|
||||||
|
h: "Join the server",
|
||||||
|
pa: "Launch the instance, open",
|
||||||
|
pb: ", paste the address, and join.",
|
||||||
|
copy: "Copy IP",
|
||||||
|
},
|
||||||
|
noPack: "The pack download isn't available yet — check back soon.",
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
join: "How to Join",
|
||||||
|
status: "Server status",
|
||||||
|
disc: "Not affiliated with Mojang or Microsoft. Minecraft is a trademark of Mojang AB.",
|
||||||
|
},
|
||||||
|
register: {
|
||||||
|
navHome: "← Home",
|
||||||
|
eyebrow: "Account",
|
||||||
|
title: "Create your Ulicraft account.",
|
||||||
|
lead: "One username and password for the server, your skin, and your cape. Takes a minute.",
|
||||||
|
usernameLabel: "Username",
|
||||||
|
usernamePlaceholder: "your in-game name",
|
||||||
|
passwordLabel: "Password",
|
||||||
|
passwordPlaceholder: "choose a password",
|
||||||
|
inviteLabel: "Invite code",
|
||||||
|
invitePlaceholder: "paste your invite code",
|
||||||
|
inviteHint: "Ask an admin for an invite code.",
|
||||||
|
submit: "Create account",
|
||||||
|
submitting: "Creating…",
|
||||||
|
haveAccount: "Already have an account?",
|
||||||
|
loginLink: "Manage it here",
|
||||||
|
successTitle: "Account created.",
|
||||||
|
successBody: "Welcome, {name}. You can set a skin now, or head straight to How to Join.",
|
||||||
|
uuidLabel: "Player UUID",
|
||||||
|
skinTitle: "Set your skin (optional)",
|
||||||
|
skinLead: "Upload a Minecraft skin PNG. You can always change it later in the account page.",
|
||||||
|
skinChoose: "Choose PNG…",
|
||||||
|
modelLabel: "Model",
|
||||||
|
modelClassic: "Classic",
|
||||||
|
modelSlim: "Slim",
|
||||||
|
skinUpload: "Upload skin",
|
||||||
|
skinUploading: "Uploading…",
|
||||||
|
skinOk: "Skin set!",
|
||||||
|
finish: "Continue to How to Join",
|
||||||
|
errFields: "Fill in every field.",
|
||||||
|
errSkinType: "Pick a PNG image.",
|
||||||
|
errNetwork: "Network error — try again.",
|
||||||
|
},
|
||||||
|
account: {
|
||||||
|
navHome: "← Home",
|
||||||
|
eyebrow: "Account",
|
||||||
|
title: "Manage your skin.",
|
||||||
|
lead: "Log in to upload or reset your Minecraft skin.",
|
||||||
|
usernameLabel: "Username",
|
||||||
|
usernamePlaceholder: "your in-game name",
|
||||||
|
passwordLabel: "Password",
|
||||||
|
passwordPlaceholder: "your password",
|
||||||
|
submit: "Log in",
|
||||||
|
submitting: "Logging in…",
|
||||||
|
noAccount: "No account yet?",
|
||||||
|
registerLink: "Register here",
|
||||||
|
refresh: "Refresh",
|
||||||
|
playerLabel: "Logged in as",
|
||||||
|
skinTitle: "Your skin",
|
||||||
|
skinLead: "Upload a Minecraft skin PNG. The 3D preview below updates instantly; the in-game avatar takes ~1 minute.",
|
||||||
|
skinChoose: "Choose PNG…",
|
||||||
|
modelLabel: "Model",
|
||||||
|
modelClassic: "Classic",
|
||||||
|
modelSlim: "Slim",
|
||||||
|
skinUpload: "Upload skin",
|
||||||
|
skinUploading: "Uploading…",
|
||||||
|
skinOk: "Skin updated!",
|
||||||
|
skinPreviewNote: "Avatar updates in ~1 min.",
|
||||||
|
resetSkin: "Reset to default",
|
||||||
|
resetConfirm: "Reset your skin to the default? This cannot be undone.",
|
||||||
|
resetOk: "Skin reset to default.",
|
||||||
|
errFields: "Fill in every field.",
|
||||||
|
errSkinType: "Pick a PNG image.",
|
||||||
|
errNetwork: "Network error — try again.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
es: {
|
||||||
|
htmlLang: "es",
|
||||||
|
copied: "¡Copiado!",
|
||||||
|
nav: { status: "Estado", members: "Miembros", features: "Características", mods: "Mods", join: "Cómo entrar", play: "Jugar", register: "Registro", login: "Cuenta" },
|
||||||
|
hero: {
|
||||||
|
eyebrow: "LAN con mods · NeoForge",
|
||||||
|
tagline: "Supervivencia todo incluido, para durar más que el finde.",
|
||||||
|
sub:
|
||||||
|
"Un servidor con mods autoalojado para El valle: un pack curado de " +
|
||||||
|
"tecnología, magia y exploración, tus propias skins y configuración en un " +
|
||||||
|
"clic. Copia la dirección, importa el pack y a cavar.",
|
||||||
|
copyIp: "Copiar IP",
|
||||||
|
howToJoin: "Cómo entrar",
|
||||||
|
metaModpack: "Pack NeoForge",
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
eyebrow: "Servidor",
|
||||||
|
title: "Esto es lo que verás.",
|
||||||
|
lead: "La misma entrada que aparece en tu lista de multijugador.",
|
||||||
|
modded: "Con mods",
|
||||||
|
motd: "Pack NeoForge · Simple Voice Chat · Solo LAN",
|
||||||
|
upTo: "hasta",
|
||||||
|
empty: "No hay nadie conectado",
|
||||||
|
},
|
||||||
|
members: {
|
||||||
|
eyebrow: "El valle",
|
||||||
|
title: "Quién está en el servidor.",
|
||||||
|
lead: "Todos los registrados en Ulicraft, cada uno con su skin autoalojada.",
|
||||||
|
empty: "Aún no hay miembros — sé el primero en registrarte.",
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
eyebrow: "Qué hace especial a Ulicraft",
|
||||||
|
title: "Hecho para El valle.",
|
||||||
|
lead:
|
||||||
|
"Autoalojado, curado y persistente: un servidor con mods que respeta tu " +
|
||||||
|
"tiempo y sobrevive a la LAN.",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
h: "Pack todo incluido",
|
||||||
|
p: "~50–100 mods elegidos a mano: tecnología, magia, exploración y QoL. NeoForge 1.21.1, curado y sin relleno.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
h: "Tu identidad, autoalojada",
|
||||||
|
p: "Drasl gestiona cuentas y skins en la LAN. Sin login de Mojang: tu skin y tu capa funcionan sin más.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
h: "Configuración en un clic",
|
||||||
|
p: "El Ulicraft Launcher instala y actualiza todo el modpack desde nuestra distribución, así todos vais sincronizados.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
h: "Hecho para durar",
|
||||||
|
p: "No es de usar y tirar: corre en el homelab con copias del mundo cada 6 horas. Tu base sobrevive.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
mods: {
|
||||||
|
eyebrow: "El pack",
|
||||||
|
title: "Todos los mods del pack.",
|
||||||
|
lead: "El modset completo del cliente, instalado automáticamente por el launcher. Sin búsquedas ni versiones incompatibles.",
|
||||||
|
empty: "La lista de mods aún no está generada — vuelve pronto.",
|
||||||
|
},
|
||||||
|
join: {
|
||||||
|
eyebrow: "Cómo entrar · 2 pasos",
|
||||||
|
title: "De cero a aparecer en el mundo.",
|
||||||
|
lead: "Configuración única. Después, el launcher te mantiene sincronizado.",
|
||||||
|
s1: {
|
||||||
|
kicker: "Cuenta",
|
||||||
|
h: "Regístrate",
|
||||||
|
pa: "Crea tu cuenta de Ulicraft en",
|
||||||
|
pb: "— un usuario y contraseña para el servidor y tu skin.",
|
||||||
|
registerLink: "Registro",
|
||||||
|
},
|
||||||
|
s2: {
|
||||||
|
kicker: "Launcher",
|
||||||
|
h: "Descarga {name}",
|
||||||
|
p: "Coge {name}, inicia sesión con tus credenciales de Ulicraft y dale a jugar. Todo lo demás — el modpack, la dirección del servidor, las actualizaciones — se gestiona dentro del launcher.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fjord: {
|
||||||
|
navHome: "← Inicio",
|
||||||
|
eyebrow: "Alternativa · Fjord Launcher",
|
||||||
|
title: "Juega con Fjord Launcher.",
|
||||||
|
lead: "¿Prefieres un launcher vanilla basado en Prism? Configúralo a mano con Fjord e importa el pack de Ulicraft.",
|
||||||
|
s1: {
|
||||||
|
kicker: "Launcher",
|
||||||
|
h: "Descarga Fjord",
|
||||||
|
p: "Coge Fjord Launcher para tu sistema: un launcher basado en Prism con soporte authlib-injector integrado.",
|
||||||
|
},
|
||||||
|
s2: {
|
||||||
|
kicker: "Cuenta",
|
||||||
|
h: "Añade tu cuenta",
|
||||||
|
pa: "En Fjord, añade una cuenta",
|
||||||
|
pb: "con esta URL y luego inicia sesión con tus credenciales de Ulicraft.",
|
||||||
|
copy: "Copiar",
|
||||||
|
},
|
||||||
|
s3: {
|
||||||
|
kicker: "Modpack",
|
||||||
|
h: "Importa el pack de Ulicraft",
|
||||||
|
p: "Descarga el modpack de Ulicraft e impórtalo en Fjord como instancia nueva: instala todo el pack.",
|
||||||
|
},
|
||||||
|
s4: {
|
||||||
|
kicker: "Conectar",
|
||||||
|
h: "Entra al servidor",
|
||||||
|
pa: "Lanza la instancia, abre",
|
||||||
|
pb: ", pega la dirección y entra.",
|
||||||
|
copy: "Copiar IP",
|
||||||
|
},
|
||||||
|
noPack: "La descarga del pack aún no está disponible — vuelve pronto.",
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
join: "Cómo entrar",
|
||||||
|
status: "Estado del servidor",
|
||||||
|
disc: "No afiliado a Mojang ni Microsoft. Minecraft es una marca de Mojang AB.",
|
||||||
|
},
|
||||||
|
register: {
|
||||||
|
navHome: "← Inicio",
|
||||||
|
eyebrow: "Cuenta",
|
||||||
|
title: "Crea tu cuenta de Ulicraft.",
|
||||||
|
lead: "Un usuario y contraseña para el servidor, tu skin y tu capa. Cosa de un minuto.",
|
||||||
|
usernameLabel: "Usuario",
|
||||||
|
usernamePlaceholder: "tu nombre en el juego",
|
||||||
|
passwordLabel: "Contraseña",
|
||||||
|
passwordPlaceholder: "elige una contraseña",
|
||||||
|
inviteLabel: "Código de invitación",
|
||||||
|
invitePlaceholder: "pega tu código de invitación",
|
||||||
|
inviteHint: "Pide un código de invitación a un admin.",
|
||||||
|
submit: "Crear cuenta",
|
||||||
|
submitting: "Creando…",
|
||||||
|
haveAccount: "¿Ya tienes cuenta?",
|
||||||
|
loginLink: "Gestiónala aquí",
|
||||||
|
successTitle: "Cuenta creada.",
|
||||||
|
successBody: "Bienvenido, {name}. Puedes poner una skin ahora o ir directo a Cómo entrar.",
|
||||||
|
uuidLabel: "UUID del jugador",
|
||||||
|
skinTitle: "Pon tu skin (opcional)",
|
||||||
|
skinLead: "Sube un PNG de skin de Minecraft. Siempre puedes cambiarla luego en tu cuenta.",
|
||||||
|
skinChoose: "Elegir PNG…",
|
||||||
|
modelLabel: "Modelo",
|
||||||
|
modelClassic: "Clásico",
|
||||||
|
modelSlim: "Fino",
|
||||||
|
skinUpload: "Subir skin",
|
||||||
|
skinUploading: "Subiendo…",
|
||||||
|
skinOk: "¡Skin puesta!",
|
||||||
|
finish: "Continuar a Cómo entrar",
|
||||||
|
errFields: "Rellena todos los campos.",
|
||||||
|
errSkinType: "Elige una imagen PNG.",
|
||||||
|
errNetwork: "Error de red: inténtalo de nuevo.",
|
||||||
|
},
|
||||||
|
account: {
|
||||||
|
navHome: "← Inicio",
|
||||||
|
eyebrow: "Cuenta",
|
||||||
|
title: "Gestiona tu skin.",
|
||||||
|
lead: "Inicia sesión para subir o restablecer tu skin de Minecraft.",
|
||||||
|
usernameLabel: "Usuario",
|
||||||
|
usernamePlaceholder: "tu nombre en el juego",
|
||||||
|
passwordLabel: "Contraseña",
|
||||||
|
passwordPlaceholder: "tu contraseña",
|
||||||
|
submit: "Iniciar sesión",
|
||||||
|
submitting: "Entrando…",
|
||||||
|
noAccount: "¿Aún no tienes cuenta?",
|
||||||
|
registerLink: "Regístrate aquí",
|
||||||
|
refresh: "Actualizar",
|
||||||
|
playerLabel: "Sesión iniciada como",
|
||||||
|
skinTitle: "Tu skin",
|
||||||
|
skinLead: "Sube un PNG de skin de Minecraft. La vista previa 3D se actualiza al instante; el avatar en el juego tarda ~1 minuto.",
|
||||||
|
skinChoose: "Elegir PNG…",
|
||||||
|
modelLabel: "Modelo",
|
||||||
|
modelClassic: "Clásico",
|
||||||
|
modelSlim: "Fino",
|
||||||
|
skinUpload: "Subir skin",
|
||||||
|
skinUploading: "Subiendo…",
|
||||||
|
skinOk: "¡Skin actualizada!",
|
||||||
|
skinPreviewNote: "El avatar se actualiza en ~1 min.",
|
||||||
|
resetSkin: "Restablecer skin",
|
||||||
|
resetConfirm: "¿Restablecer tu skin a la predeterminada? Esta acción no se puede deshacer.",
|
||||||
|
resetOk: "Skin restablecida.",
|
||||||
|
errFields: "Rellena todos los campos.",
|
||||||
|
errSkinType: "Elige una imagen PNG.",
|
||||||
|
errNetwork: "Error de red: inténtalo de nuevo.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
eu: {
|
||||||
|
htmlLang: "eu",
|
||||||
|
copied: "Kopiatuta!",
|
||||||
|
nav: { status: "Egoera", members: "Kideak", features: "Ezaugarriak", mods: "Mods", join: "Nola sartu", play: "Jolastu", register: "Erregistroa", login: "Kontua" },
|
||||||
|
hero: {
|
||||||
|
eyebrow: "Mod-dun LANa · NeoForge",
|
||||||
|
tagline: "Mod ugariko biziraupena, irauteko egina.",
|
||||||
|
sub:
|
||||||
|
"El valle-rentzako mod-dun zerbitzari auto-ostatatua: teknologia, magia eta " +
|
||||||
|
"esplorazio pack zaindua, zure azalak eta konfigurazioa klik bakarrean. " +
|
||||||
|
"Hartu helbidea, inportatu packa eta hasi zulatzen.",
|
||||||
|
copyIp: "Kopiatu IPa",
|
||||||
|
howToJoin: "Nola sartu",
|
||||||
|
metaModpack: "NeoForge packa",
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
eyebrow: "Zerbitzaria",
|
||||||
|
title: "Hau ikusiko duzu.",
|
||||||
|
lead: "Zure multijokalari-zerrendan agertzen den sarrera bera.",
|
||||||
|
modded: "Mod-duna",
|
||||||
|
motd: "NeoForge packa · Simple Voice Chat · LAN soilik",
|
||||||
|
upTo: "gehienez",
|
||||||
|
empty: "Inor ez dago konektatuta",
|
||||||
|
},
|
||||||
|
members: {
|
||||||
|
eyebrow: "El valle",
|
||||||
|
title: "Nor dago zerbitzarian.",
|
||||||
|
lead: "Ulicraft-en erregistratutako guztiak, bakoitza bere azal auto-ostatatuarekin.",
|
||||||
|
empty: "Oraindik ez dago kiderik — izan zaitez lehena erregistratzen.",
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
eyebrow: "Zerk egiten du Ulicraft berezi",
|
||||||
|
title: "El valle-rentzat eginda.",
|
||||||
|
lead:
|
||||||
|
"Auto-ostatatua, zaindua eta iraunkorra: zure denbora errespetatzen duen " +
|
||||||
|
"eta LANa gainditzen duen mod-dun zerbitzaria.",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
h: "Pack osoa",
|
||||||
|
p: "Eskuz aukeratutako ~50–100 mod: teknologia, magia, esplorazioa eta QoL. NeoForge 1.21.1, zaindua eta betegarririk gabe.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
h: "Zure nortasuna, auto-ostatatua",
|
||||||
|
p: "Draslek kontuak eta azalak LANean kudeatzen ditu. Mojang loginik gabe: zure azalak eta kapak besterik gabe dabiltza.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
h: "Konfigurazioa klik batean",
|
||||||
|
p: "Ulicraft Launcher-ek modpack osoa instalatu eta eguneratzen du gure banaketatik, denok sinkronizatuta egoteko.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
h: "Irauteko eginda",
|
||||||
|
p: "Ez da behin erabili eta botatzekoa: homelab-ean dabil, munduaren babeskopiak 6 orduro. Zure basea bizirik.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
mods: {
|
||||||
|
eyebrow: "Packa",
|
||||||
|
title: "Packeko mod guztiak.",
|
||||||
|
lead: "Bezeroaren mod-multzo osoa, launcherrak automatikoki instalatua. Bilaketarik gabe, bertsio-gatazkarik gabe.",
|
||||||
|
empty: "Mod zerrenda oraindik ez da sortu — itzuli laster.",
|
||||||
|
},
|
||||||
|
join: {
|
||||||
|
eyebrow: "Nola sartu · 2 urrats",
|
||||||
|
title: "Zerotik mundura agertzera.",
|
||||||
|
lead: "Behin bakarrik konfiguratu. Gero, launcherrak sinkronizatuta mantenduko zaitu.",
|
||||||
|
s1: {
|
||||||
|
kicker: "Kontua",
|
||||||
|
h: "Erregistratu",
|
||||||
|
pa: "Sortu zure Ulicraft kontua hemen:",
|
||||||
|
pb: "— erabiltzaile eta pasahitz bat zerbitzarirako eta zure azalerako.",
|
||||||
|
registerLink: "Erregistroa",
|
||||||
|
},
|
||||||
|
s2: {
|
||||||
|
kicker: "Launcherra",
|
||||||
|
h: "Deskargatu {name}",
|
||||||
|
p: "Hartu {name}, hasi saioa zure Ulicraft kredentzialekin eta sakatu jolastu. Gainerako guztia — modpacka, zerbitzariaren helbidea, eguneraketak — launcherraren barruan kudeatzen da.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fjord: {
|
||||||
|
navHome: "← Hasiera",
|
||||||
|
eyebrow: "Alternatiba · Fjord Launcher",
|
||||||
|
title: "Jokatu Fjord Launcher-ekin.",
|
||||||
|
lead: "Prism-en oinarritutako launcher vanilla nahiago duzu? Konfiguratu eskuz Fjord-ekin eta inportatu Ulicraft packa.",
|
||||||
|
s1: {
|
||||||
|
kicker: "Launcherra",
|
||||||
|
h: "Deskargatu Fjord",
|
||||||
|
p: "Hartu Fjord Launcher zure sistemarako: Prism-en oinarritutako launcherra, authlib-injector euskarriarekin.",
|
||||||
|
},
|
||||||
|
s2: {
|
||||||
|
kicker: "Kontua",
|
||||||
|
h: "Gehitu zure kontua",
|
||||||
|
pa: "Fjord-en, gehitu",
|
||||||
|
pb: "kontu bat URL honekin, eta gero hasi saioa zure Ulicraft kredentzialekin.",
|
||||||
|
copy: "Kopiatu",
|
||||||
|
},
|
||||||
|
s3: {
|
||||||
|
kicker: "Modpacka",
|
||||||
|
h: "Inportatu Ulicraft packa",
|
||||||
|
p: "Deskargatu Ulicraft modpacka eta inportatu Fjord-en instantzia berri gisa: pack osoa instalatzen du.",
|
||||||
|
},
|
||||||
|
s4: {
|
||||||
|
kicker: "Konektatu",
|
||||||
|
h: "Sartu zerbitzarira",
|
||||||
|
pa: "Abiarazi instantzia, ireki",
|
||||||
|
pb: ", itsatsi helbidea eta sartu.",
|
||||||
|
copy: "Kopiatu IPa",
|
||||||
|
},
|
||||||
|
noPack: "Packaren deskarga ez dago oraindik eskuragarri — itzuli laster.",
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
join: "Nola sartu",
|
||||||
|
status: "Zerbitzariaren egoera",
|
||||||
|
disc: "Ez dago Mojang edo Microsoft-ekin lotuta. Minecraft Mojang AB-ren marka da.",
|
||||||
|
},
|
||||||
|
register: {
|
||||||
|
navHome: "← Hasiera",
|
||||||
|
eyebrow: "Kontua",
|
||||||
|
title: "Sortu zure Ulicraft kontua.",
|
||||||
|
lead: "Erabiltzaile eta pasahitz bat zerbitzarirako, zure azalerako eta kaparako. Minutu batean.",
|
||||||
|
usernameLabel: "Erabiltzailea",
|
||||||
|
usernamePlaceholder: "zure jokoko izena",
|
||||||
|
passwordLabel: "Pasahitza",
|
||||||
|
passwordPlaceholder: "aukeratu pasahitz bat",
|
||||||
|
inviteLabel: "Gonbidapen-kodea",
|
||||||
|
invitePlaceholder: "itsatsi zure gonbidapen-kodea",
|
||||||
|
inviteHint: "Eskatu gonbidapen-kode bat admin bati.",
|
||||||
|
submit: "Sortu kontua",
|
||||||
|
submitting: "Sortzen…",
|
||||||
|
haveAccount: "Baduzu kontua?",
|
||||||
|
loginLink: "Kudeatu hemen",
|
||||||
|
successTitle: "Kontua sortuta.",
|
||||||
|
successBody: "Ongi etorri, {name}. Azala orain jar dezakezu, edo zuzenean Nola sartu atalera joan.",
|
||||||
|
uuidLabel: "Jokalariaren UUIDa",
|
||||||
|
skinTitle: "Jarri zure azala (aukerakoa)",
|
||||||
|
skinLead: "Igo Minecraft azal PNG bat. Beti alda dezakezu gero zure kontuan.",
|
||||||
|
skinChoose: "Aukeratu PNGa…",
|
||||||
|
modelLabel: "Eredua",
|
||||||
|
modelClassic: "Klasikoa",
|
||||||
|
modelSlim: "Mehea",
|
||||||
|
skinUpload: "Igo azala",
|
||||||
|
skinUploading: "Igotzen…",
|
||||||
|
skinOk: "Azala jarrita!",
|
||||||
|
finish: "Jarraitu Nola sartura",
|
||||||
|
errFields: "Bete eremu guztiak.",
|
||||||
|
errSkinType: "Aukeratu PNG irudi bat.",
|
||||||
|
errNetwork: "Sare-errorea: saiatu berriro.",
|
||||||
|
},
|
||||||
|
account: {
|
||||||
|
navHome: "← Hasiera",
|
||||||
|
eyebrow: "Kontua",
|
||||||
|
title: "Kudeatu zure azala.",
|
||||||
|
lead: "Hasi saioa zure Minecraft azala igotzeko edo berrezartzeko.",
|
||||||
|
usernameLabel: "Erabiltzailea",
|
||||||
|
usernamePlaceholder: "zure jokoko izena",
|
||||||
|
passwordLabel: "Pasahitza",
|
||||||
|
passwordPlaceholder: "zure pasahitza",
|
||||||
|
submit: "Hasi saioa",
|
||||||
|
submitting: "Sartzen…",
|
||||||
|
noAccount: "Oraindik konturik ez?",
|
||||||
|
registerLink: "Erregistratu hemen",
|
||||||
|
refresh: "Eguneratu",
|
||||||
|
playerLabel: "Saioa hasita:",
|
||||||
|
skinTitle: "Zure azala",
|
||||||
|
skinLead: "Igo Minecraft azal PNG bat. 3D aurrebista berehala eguneratzen da; jokoko avatarra ~1 minutuan.",
|
||||||
|
skinChoose: "Aukeratu PNGa…",
|
||||||
|
modelLabel: "Eredua",
|
||||||
|
modelClassic: "Klasikoa",
|
||||||
|
modelSlim: "Mehea",
|
||||||
|
skinUpload: "Igo azala",
|
||||||
|
skinUploading: "Igotzen…",
|
||||||
|
skinOk: "Azala eguneratuta!",
|
||||||
|
skinPreviewNote: "Avatarra ~1 min barru eguneratzen da.",
|
||||||
|
resetSkin: "Berrezarri azala",
|
||||||
|
resetConfirm: "Zure azala lehenetsi nahi duzu? Ezin da desegin.",
|
||||||
|
resetOk: "Azala berrezarrita.",
|
||||||
|
errFields: "Bete eremu guztiak.",
|
||||||
|
errSkinType: "Aukeratu PNG irudi bat.",
|
||||||
|
errNetwork: "Sare-errorea: saiatu berriro.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
341
landing/src/pages/[...lang].astro
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
---
|
||||||
|
import { site, HEAD_FONTS } from "../data/site";
|
||||||
|
import { ui, LANGS, LANG_LABEL, LANG_PATH, LANG_REGISTER_PATH, LANG_ACCOUNT_PATH, type Lang } from "../i18n/ui";
|
||||||
|
import PixelIcon from "../components/PixelIcon.astro";
|
||||||
|
import ServerCard from "../components/ServerCard.astro";
|
||||||
|
import PlayerRoster from "../components/PlayerRoster.astro";
|
||||||
|
import ModList from "../components/ModList.astro";
|
||||||
|
import "../styles/main.css";
|
||||||
|
|
||||||
|
// One static page per locale: en at "/", es at "/es/", eu at "/eu/".
|
||||||
|
export function getStaticPaths() {
|
||||||
|
return [
|
||||||
|
{ params: { lang: undefined }, props: { locale: "es" as Lang } },
|
||||||
|
{ params: { lang: "en" }, props: { locale: "en" as Lang } },
|
||||||
|
{ params: { lang: "eu" }, props: { locale: "eu" as Lang } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { locale } = Astro.props;
|
||||||
|
const t = ui[locale];
|
||||||
|
const { theme, launcherManifest } = site;
|
||||||
|
const headFont = HEAD_FONTS[theme.headFont] ?? HEAD_FONTS.pixelify;
|
||||||
|
const launcherName = launcherManifest.product;
|
||||||
|
const registerPath = LANG_REGISTER_PATH[locale];
|
||||||
|
const accountPath = LANG_ACCOUNT_PATH[locale];
|
||||||
|
|
||||||
|
// Per-OS download buttons (join step 2) are fetched client-side from the live
|
||||||
|
// launcher.json on page load (no build-time baking, no polling) — see the
|
||||||
|
// script below. We only pass the URL; buttons render into the #launcher-dl
|
||||||
|
// skeleton. URL derives from the distribution subdomain.
|
||||||
|
const launcherJsonUrl = `https://distribution.${site.baseDomain}/launcher/launcher.json`;
|
||||||
|
|
||||||
|
// Build-time floating dust bits (index-derived, no runtime RNG).
|
||||||
|
const dustBits = Array.from({ length: 16 }, (_, i) => {
|
||||||
|
const size = 3 + (i % 3) * 2;
|
||||||
|
return {
|
||||||
|
left: `${(i * 61) % 100}%`,
|
||||||
|
size: `${size}px`,
|
||||||
|
bg: i % 4 === 0 ? "var(--gold)" : "var(--accent)",
|
||||||
|
opacity: 0.18 + (i % 3) * 0.06,
|
||||||
|
anim: `rise ${13 + (i % 7) * 3}s linear ${i * 0.7}s infinite`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
---
|
||||||
|
|
||||||
|
<!doctype html>
|
||||||
|
<html
|
||||||
|
lang={t.htmlLang}
|
||||||
|
data-mood={theme.mood}
|
||||||
|
data-hero={theme.hero}
|
||||||
|
data-head={theme.headFont}
|
||||||
|
style={`--font-head: ${headFont}`}
|
||||||
|
>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>{site.name} — {t.hero.howToJoin}</title>
|
||||||
|
<meta name="description" content={`${site.name}: ${t.hero.tagline}`} />
|
||||||
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
|
<link rel="stylesheet" href="/fonts/fonts.css" />
|
||||||
|
{LANGS.map((l) => <link rel="alternate" hreflang={l} href={LANG_PATH[l]} />)}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- NAV -->
|
||||||
|
<header class="nav">
|
||||||
|
<div class="wrap nav-in">
|
||||||
|
<a class="brand" href="#top">
|
||||||
|
<img class="brand-logo" src="/ulicraft-logo-mini.svg" alt="" width="34" height="34" />
|
||||||
|
<span class="name">ULICRAFT</span>
|
||||||
|
</a>
|
||||||
|
<nav class="nav-links">
|
||||||
|
<a href="#join">{t.nav.join}</a>
|
||||||
|
<a href="#status">{t.nav.status}</a>
|
||||||
|
</nav>
|
||||||
|
<span class="nav-spacer"></span>
|
||||||
|
<div class="lang-switch" aria-label="Language">
|
||||||
|
{LANGS.map((l) => (
|
||||||
|
<a href={LANG_PATH[l]} class:list={[l === locale && "on"]} aria-current={l === locale ? "true" : undefined}>{LANG_LABEL[l]}</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<a class="mc-btn sm" href={registerPath}>{t.nav.register}</a>
|
||||||
|
<a class="mc-btn sm" href={accountPath}>{t.nav.login}</a>
|
||||||
|
<a class="mc-btn primary sm" href="#join">{t.nav.play}</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="top">
|
||||||
|
<!-- HERO -->
|
||||||
|
<section class="hero">
|
||||||
|
{theme.dust && (
|
||||||
|
<div class="dust" aria-hidden="true">
|
||||||
|
{dustBits.map((b) => (
|
||||||
|
<i
|
||||||
|
style={`position:absolute;left:${b.left};bottom:-20px;width:${b.size};height:${b.size};background:${b.bg};opacity:${b.opacity};image-rendering:pixelated;animation:${b.anim}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div class="wrap hero-grid">
|
||||||
|
<div class="hero-logo">
|
||||||
|
<img src="/logo.png" alt={site.name} width="707" height="148" />
|
||||||
|
</div>
|
||||||
|
<div class="hero-copy">
|
||||||
|
<span class="eyebrow">{t.hero.eyebrow} {site.mcVersion}</span>
|
||||||
|
<h1 class="hero-tagline">{t.hero.tagline}</h1>
|
||||||
|
<p class="hero-sub">{t.hero.sub}</p>
|
||||||
|
<div class="hero-ip-row">
|
||||||
|
<a class="mc-btn primary" href="#join">{t.hero.howToJoin}</a>
|
||||||
|
</div>
|
||||||
|
<div class="hero-meta">
|
||||||
|
<span>Java {site.mcVersion}</span>
|
||||||
|
<span class="dot"></span>
|
||||||
|
<span>{t.hero.metaModpack}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hero-status">
|
||||||
|
<ServerCard
|
||||||
|
modded={t.status.modded}
|
||||||
|
motd={t.status.motd}
|
||||||
|
upTo={t.status.upTo}
|
||||||
|
emptyLabel={t.status.empty}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- HOW TO JOIN -->
|
||||||
|
<section id="join" class="pad">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="sec-head reveal">
|
||||||
|
<span class="eyebrow">{t.join.eyebrow}</span>
|
||||||
|
<h2 class="section-title">{t.join.title}</h2>
|
||||||
|
<p class="lead">{t.join.lead}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="steps">
|
||||||
|
<!-- STEP 1 -->
|
||||||
|
<div class="step reveal">
|
||||||
|
<div class="num">1</div>
|
||||||
|
<div class="body">
|
||||||
|
<span class="kicker">{t.join.s1.kicker}</span>
|
||||||
|
<h3>{t.join.s1.h}</h3>
|
||||||
|
<p>
|
||||||
|
{t.join.s1.pa} <a href={registerPath}>{t.join.s1.registerLink}</a> {t.join.s1.pb}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- STEP 2 -->
|
||||||
|
<div class="step reveal">
|
||||||
|
<div class="num">2</div>
|
||||||
|
<div class="body">
|
||||||
|
<span class="kicker">{t.join.s2.kicker}</span>
|
||||||
|
<h3>{t.join.s2.h.replace("{name}", launcherName)}</h3>
|
||||||
|
<p>{t.join.s2.p.replace(/\{name\}/g, launcherName)}</p>
|
||||||
|
<div class="actions" id="launcher-dl" data-src={launcherJsonUrl}>
|
||||||
|
<span class="mc-btn sm dl-skeleton" aria-hidden="true"></span>
|
||||||
|
<span class="mc-btn sm dl-skeleton" aria-hidden="true"></span>
|
||||||
|
<span class="mc-btn sm dl-skeleton" aria-hidden="true"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- STATUS -->
|
||||||
|
<section id="status" class="pad">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="sec-head reveal">
|
||||||
|
<span class="eyebrow">{t.status.eyebrow}</span>
|
||||||
|
<h2 class="section-title">{t.status.title}</h2>
|
||||||
|
<p class="lead">{t.status.lead}</p>
|
||||||
|
</div>
|
||||||
|
<div class="reveal">
|
||||||
|
<ServerCard
|
||||||
|
modded={t.status.modded}
|
||||||
|
motd={t.status.motd}
|
||||||
|
upTo={t.status.upTo}
|
||||||
|
emptyLabel={t.status.empty}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- MEMBERS (registered players roster) -->
|
||||||
|
<section id="members" class="pad" style="background: var(--bg-2)">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="sec-head reveal">
|
||||||
|
<span class="eyebrow">{t.members.eyebrow}</span>
|
||||||
|
<h2 class="section-title">{t.members.title}</h2>
|
||||||
|
<p class="lead">{t.members.lead}</p>
|
||||||
|
</div>
|
||||||
|
<div class="reveal">
|
||||||
|
<PlayerRoster emptyLabel={t.members.empty} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- MODS (installed-pack list, auto-generated) -->
|
||||||
|
<section id="mods" class="pad" style="background: var(--bg-2)">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="sec-head reveal">
|
||||||
|
<span class="eyebrow">{t.mods.eyebrow}</span>
|
||||||
|
<h2 class="section-title">{t.mods.title}</h2>
|
||||||
|
<p class="lead">{t.mods.lead}</p>
|
||||||
|
</div>
|
||||||
|
<div class="reveal">
|
||||||
|
<ModList emptyLabel={t.mods.empty} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- FEATURES -->
|
||||||
|
<section id="features" class="pad">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="sec-head reveal">
|
||||||
|
<span class="eyebrow">{t.features.eyebrow}</span>
|
||||||
|
<h2 class="section-title">{t.features.title}</h2>
|
||||||
|
<p class="lead">{t.features.lead}</p>
|
||||||
|
</div>
|
||||||
|
<div class="feat-split">
|
||||||
|
<div class="feat-grid">
|
||||||
|
{site.features.map((f, i) => (
|
||||||
|
<div class="feat reveal" style={`transition-delay:${(i % 2) * 80}ms`}>
|
||||||
|
<PixelIcon glyph={f.glyph} gold={f.gold} />
|
||||||
|
<div>
|
||||||
|
<h3>{t.features.items[i].h}</h3>
|
||||||
|
<p>{t.features.items[i].p}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div class="feat-aside reveal">
|
||||||
|
<img src="/Goat_JE1_BE1.webp" alt="" width="1200" height="1200" loading="lazy" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- FOOTER -->
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="wrap footer-in">
|
||||||
|
<a class="brand" href="#top">
|
||||||
|
<img class="brand-logo" src="/ulicraft-logo-mini.svg" alt="" width="34" height="34" />
|
||||||
|
<span class="name">ULICRAFT</span>
|
||||||
|
</a>
|
||||||
|
<div class="footer-links">
|
||||||
|
<a class="mc-btn primary sm" href="#join">{t.footer.join}</a>
|
||||||
|
<a class="mc-btn sm" href={site.statusUrl} target="_blank" rel="noopener">{t.footer.status}</a>
|
||||||
|
</div>
|
||||||
|
<p class="disc">
|
||||||
|
{t.footer.disc} {site.name} · {site.baseDomain}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Copy-to-clipboard for every [data-copy] button (HTTP-LAN safe fallback).
|
||||||
|
function copyText(text: string) {
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
const ta = document.createElement("textarea");
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.position = "fixed";
|
||||||
|
ta.style.opacity = "0";
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
try {
|
||||||
|
document.execCommand("copy");
|
||||||
|
} catch {}
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
}
|
||||||
|
document.querySelectorAll<HTMLButtonElement>("button[data-copy]").forEach((btn) => {
|
||||||
|
let timer: number | undefined;
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
copyText(btn.dataset.copy ?? "");
|
||||||
|
const label = btn.dataset.label ?? "Copy";
|
||||||
|
btn.textContent = btn.dataset.copied ?? "Copied!";
|
||||||
|
btn.classList.add("copied");
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = window.setTimeout(() => {
|
||||||
|
btn.textContent = label;
|
||||||
|
btn.classList.remove("copied");
|
||||||
|
}, 1600);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scroll reveal — visible at rest; .anim added when scrolled into view.
|
||||||
|
if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||||
|
const els = [...document.querySelectorAll<HTMLElement>(".reveal")];
|
||||||
|
const io = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const e of entries) {
|
||||||
|
if (e.isIntersecting) {
|
||||||
|
e.target.classList.add("anim");
|
||||||
|
io.unobserve(e.target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: "0px 0px -10% 0px" }
|
||||||
|
);
|
||||||
|
els.forEach((el) => io.observe(el));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Launcher downloads — fetch the live launcher.json once on load (no
|
||||||
|
// polling) and render per-OS buttons into the #launcher-dl skeleton.
|
||||||
|
// launcher.json shape: { files: [{ name, os, arch, ext, url }] }.
|
||||||
|
const dl = document.getElementById("launcher-dl");
|
||||||
|
const src = dl?.dataset.src;
|
||||||
|
if (dl && src) {
|
||||||
|
const OS: Record<string, string> = { windows: "Windows", macos: "macOS", linux: "Linux" };
|
||||||
|
fetch(src, { cache: "no-store" })
|
||||||
|
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
|
||||||
|
.then((data) => {
|
||||||
|
const files = Array.isArray(data?.files) ? data.files : [];
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
for (const f of files) {
|
||||||
|
if (!f?.url) continue;
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.className = "mc-btn sm";
|
||||||
|
a.href = f.url;
|
||||||
|
a.append(`${OS[f.os] ?? f.os ?? "Download"} `);
|
||||||
|
const hint = document.createElement("span");
|
||||||
|
hint.className = "hint";
|
||||||
|
hint.textContent = `· ${[f.arch, f.ext].filter(Boolean).join(" · ")}`;
|
||||||
|
a.append(hint);
|
||||||
|
frag.append(a);
|
||||||
|
}
|
||||||
|
dl.replaceChildren(frag); // empty list → no buttons (skeleton cleared)
|
||||||
|
})
|
||||||
|
.catch(() => dl.replaceChildren()); // unreachable/error → clear skeleton
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
370
landing/src/pages/[...lang]/account.astro
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
---
|
||||||
|
import { site, HEAD_FONTS } from "../../data/site";
|
||||||
|
import {
|
||||||
|
ui,
|
||||||
|
LANGS,
|
||||||
|
LANG_LABEL,
|
||||||
|
LANG_PATH,
|
||||||
|
LANG_ACCOUNT_PATH,
|
||||||
|
LANG_REGISTER_PATH,
|
||||||
|
type Lang,
|
||||||
|
} from "../../i18n/ui";
|
||||||
|
import "../../styles/main.css";
|
||||||
|
|
||||||
|
// One static account page per locale: /account, /es/account, /eu/account.
|
||||||
|
export function getStaticPaths() {
|
||||||
|
return [
|
||||||
|
{ params: { lang: undefined }, props: { locale: "es" as Lang } },
|
||||||
|
{ params: { lang: "en" }, props: { locale: "en" as Lang } },
|
||||||
|
{ params: { lang: "eu" }, props: { locale: "eu" as Lang } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { locale } = Astro.props;
|
||||||
|
const t = ui[locale];
|
||||||
|
const a = t.account;
|
||||||
|
const { theme } = site;
|
||||||
|
const headFont = HEAD_FONTS[theme.headFont] ?? HEAD_FONTS.pixelify;
|
||||||
|
const homePath = LANG_PATH[locale];
|
||||||
|
const registerPath = LANG_REGISTER_PATH[locale];
|
||||||
|
const accountPath = LANG_ACCOUNT_PATH[locale];
|
||||||
|
|
||||||
|
// Strings the client script needs at runtime (kept minimal, passed via define:vars).
|
||||||
|
const clientCfg = {
|
||||||
|
apiUrl: site.draslApiUrl,
|
||||||
|
avatarUrl: site.avatarUrl,
|
||||||
|
avatarMode: site.avatarMode,
|
||||||
|
submit: a.submit,
|
||||||
|
submitting: a.submitting,
|
||||||
|
skinUpload: a.skinUpload,
|
||||||
|
skinUploading: a.skinUploading,
|
||||||
|
skinOk: a.skinOk,
|
||||||
|
refresh: a.refresh,
|
||||||
|
skinPreviewNote: a.skinPreviewNote,
|
||||||
|
resetSkin: a.resetSkin,
|
||||||
|
resetConfirm: a.resetConfirm,
|
||||||
|
resetOk: a.resetOk,
|
||||||
|
errFields: a.errFields,
|
||||||
|
errSkinType: a.errSkinType,
|
||||||
|
errNetwork: a.errNetwork,
|
||||||
|
};
|
||||||
|
---
|
||||||
|
|
||||||
|
<!doctype html>
|
||||||
|
<html
|
||||||
|
lang={t.htmlLang}
|
||||||
|
data-mood={theme.mood}
|
||||||
|
data-head={theme.headFont}
|
||||||
|
style={`--font-head: ${headFont}`}
|
||||||
|
>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>{site.name} — {a.title}</title>
|
||||||
|
<meta name="description" content={a.lead} />
|
||||||
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
|
<link rel="stylesheet" href="/fonts/fonts.css" />
|
||||||
|
{LANGS.map((l) => <link rel="alternate" hreflang={l} href={LANG_ACCOUNT_PATH[l]} />)}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- NAV -->
|
||||||
|
<header class="nav">
|
||||||
|
<div class="wrap nav-in">
|
||||||
|
<a class="brand" href={homePath}>
|
||||||
|
<img class="brand-logo" src="/ulicraft-logo-mini.svg" alt="" width="34" height="34" />
|
||||||
|
<span class="name">ULICRAFT</span>
|
||||||
|
</a>
|
||||||
|
<nav class="nav-links">
|
||||||
|
<a href={homePath}>{a.navHome}</a>
|
||||||
|
</nav>
|
||||||
|
<span class="nav-spacer"></span>
|
||||||
|
<div class="lang-switch" aria-label="Language">
|
||||||
|
{LANGS.map((l) => (
|
||||||
|
<a
|
||||||
|
href={LANG_ACCOUNT_PATH[l]}
|
||||||
|
class:list={[l === locale && "on"]}
|
||||||
|
aria-current={l === locale ? "true" : undefined}
|
||||||
|
>{LANG_LABEL[l]}</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<a class="mc-btn sm" href={registerPath}>{t.nav.register}</a>
|
||||||
|
<a class="mc-btn sm" href={accountPath}>{t.nav.login}</a>
|
||||||
|
<a class="mc-btn primary sm" href={`${homePath}#join`}>{t.nav.play}</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="top">
|
||||||
|
<section class="pad">
|
||||||
|
<div class="reg-wrap">
|
||||||
|
<div class="reg-card">
|
||||||
|
<span class="eyebrow">{a.eyebrow}</span>
|
||||||
|
<h1>{a.title}</h1>
|
||||||
|
<p class="lead">{a.lead}</p>
|
||||||
|
|
||||||
|
<!-- LOGIN (hidden once authenticated) -->
|
||||||
|
<div id="acc-login">
|
||||||
|
<form class="reg-form" id="acc-form" novalidate>
|
||||||
|
<div class="reg-field">
|
||||||
|
<label for="acc-username">{a.usernameLabel}</label>
|
||||||
|
<input class="reg-input" id="acc-username" name="username"
|
||||||
|
type="text" autocomplete="username" placeholder={a.usernamePlaceholder} required />
|
||||||
|
</div>
|
||||||
|
<div class="reg-field">
|
||||||
|
<label for="acc-password">{a.passwordLabel}</label>
|
||||||
|
<input class="reg-input" id="acc-password" name="password"
|
||||||
|
type="password" autocomplete="current-password" placeholder={a.passwordPlaceholder} required />
|
||||||
|
</div>
|
||||||
|
<div class="reg-msg err" id="acc-error" role="alert" hidden></div>
|
||||||
|
<button class="mc-btn primary" id="acc-submit" type="submit">{a.submit}</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="reg-foot">
|
||||||
|
{a.noAccount} <a href={registerPath}>{a.registerLink}</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SKIN PANEL (revealed after login) -->
|
||||||
|
<div class="reg-success" id="acc-panel" hidden>
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow" style="color:var(--accent)">{a.playerLabel}</span>
|
||||||
|
<p class="lead" id="acc-player-name" style="margin-top:6px;font-size:20px;color:var(--text)"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Current avatar preview (NMSR) -->
|
||||||
|
<div id="acc-avatar-wrap" style="display:flex;align-items:flex-start;gap:20px;flex-wrap:wrap">
|
||||||
|
<img id="acc-avatar" src="" alt="avatar" width="96" height="96"
|
||||||
|
style="image-rendering:pixelated;width:96px;height:96px;background:var(--slot)" />
|
||||||
|
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:10px;align-items:flex-start">
|
||||||
|
<button class="mc-btn sm" id="acc-avatar-refresh" type="button">{a.refresh}</button>
|
||||||
|
<p class="reg-skin" style="border:none;padding-top:0;margin:0;color:var(--dim);font-size:13px" id="acc-preview-note"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="reg-skin" id="acc-skin">
|
||||||
|
<div>
|
||||||
|
<h3>{a.skinTitle}</h3>
|
||||||
|
<p class="lead">{a.skinLead}</p>
|
||||||
|
</div>
|
||||||
|
<div class="reg-field">
|
||||||
|
<label class="mc-btn sm" for="acc-skin-file" style="align-self:flex-start">{a.skinChoose}</label>
|
||||||
|
<input id="acc-skin-file" type="file" accept="image/png" hidden />
|
||||||
|
<span class="reg-file-name" id="acc-skin-name"></span>
|
||||||
|
</div>
|
||||||
|
<div class="reg-field">
|
||||||
|
<label>{a.modelLabel}</label>
|
||||||
|
<div class="reg-models">
|
||||||
|
<label><input type="radio" name="skin-model" value="classic" checked /> {a.modelClassic}</label>
|
||||||
|
<label><input type="radio" name="skin-model" value="slim" /> {a.modelSlim}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="reg-msg" id="acc-skin-msg" role="status" hidden></div>
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:12px;align-items:center">
|
||||||
|
<button class="mc-btn primary" id="acc-skin-upload" type="button">{a.skinUpload}</button>
|
||||||
|
<button class="mc-btn" id="acc-skin-reset" type="button" style="color:var(--dim)">{a.resetSkin}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a class="mc-btn sm" href={homePath}>{a.navHome}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- FOOTER -->
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="wrap footer-in">
|
||||||
|
<a class="brand" href={homePath}>
|
||||||
|
<img class="brand-logo" src="/ulicraft-logo-mini.svg" alt="" width="34" height="34" />
|
||||||
|
<span class="name">ULICRAFT</span>
|
||||||
|
</a>
|
||||||
|
<div class="footer-links">
|
||||||
|
<a class="mc-btn sm" href={site.statusUrl} target="_blank" rel="noopener">{t.footer.status}</a>
|
||||||
|
</div>
|
||||||
|
<p class="disc">
|
||||||
|
{t.footer.disc} {site.name} · {site.baseDomain}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script define:vars={{ cfg: clientCfg }}>
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const form = $("acc-form");
|
||||||
|
const loginBox = $("acc-login");
|
||||||
|
const errBox = $("acc-error");
|
||||||
|
const submitBtn = $("acc-submit");
|
||||||
|
const panel = $("acc-panel");
|
||||||
|
const headers = { "Content-Type": "application/json" };
|
||||||
|
|
||||||
|
// Token + player kept in memory only — never stored in localStorage.
|
||||||
|
let apiToken = null;
|
||||||
|
let player = null;
|
||||||
|
|
||||||
|
const showMsg = (box, msg) => {
|
||||||
|
box.textContent = msg;
|
||||||
|
box.hidden = false;
|
||||||
|
};
|
||||||
|
const apiMessage = async (res, fallback) => {
|
||||||
|
try {
|
||||||
|
const data = await res.json();
|
||||||
|
return data && data.message ? data.message : fallback;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Login ----
|
||||||
|
form.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
errBox.hidden = true;
|
||||||
|
const username = $("acc-username").value.trim();
|
||||||
|
const password = $("acc-password").value;
|
||||||
|
if (!username || !password) {
|
||||||
|
errBox.classList.add("err");
|
||||||
|
showMsg(errBox, cfg.errFields);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = cfg.submitting;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${cfg.apiUrl}/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
errBox.classList.add("err");
|
||||||
|
showMsg(errBox, await apiMessage(res, cfg.errNetwork));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
apiToken = data.apiToken;
|
||||||
|
player = data.user.players[0];
|
||||||
|
|
||||||
|
// Show panel — hide the whole login block (form + heading foot).
|
||||||
|
loginBox.hidden = true;
|
||||||
|
$("acc-player-name").textContent = player.name;
|
||||||
|
// Set NMSR avatar
|
||||||
|
const avatarImg = $("acc-avatar");
|
||||||
|
avatarImg.src = `${cfg.avatarUrl}/${cfg.avatarMode}/${player.uuid}?size=96`;
|
||||||
|
avatarImg.alt = player.name;
|
||||||
|
// Show NMSR lag hint
|
||||||
|
$("acc-preview-note").textContent = cfg.skinPreviewNote;
|
||||||
|
panel.hidden = false;
|
||||||
|
} catch {
|
||||||
|
errBox.classList.add("err");
|
||||||
|
showMsg(errBox, cfg.errNetwork);
|
||||||
|
} finally {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = cfg.submit;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Avatar refresh (re-fetch NMSR with a cache-bust) ----
|
||||||
|
$("acc-avatar-refresh").addEventListener("click", () => {
|
||||||
|
if (!player) return;
|
||||||
|
$("acc-avatar").src = `${cfg.avatarUrl}/${cfg.avatarMode}/${player.uuid}?size=96&t=${Date.now()}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Skin upload ----
|
||||||
|
const fileInput = $("acc-skin-file");
|
||||||
|
const fileName = $("acc-skin-name");
|
||||||
|
const skinMsg = $("acc-skin-msg");
|
||||||
|
const skinBtn = $("acc-skin-upload");
|
||||||
|
const resetBtn = $("acc-skin-reset");
|
||||||
|
|
||||||
|
fileInput.addEventListener("change", () => {
|
||||||
|
skinMsg.hidden = true;
|
||||||
|
skinMsg.classList.remove("ok", "err");
|
||||||
|
fileName.textContent = fileInput.files && fileInput.files[0] ? fileInput.files[0].name : "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const readBase64 = (file) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const fr = new FileReader();
|
||||||
|
fr.onload = () => resolve(String(fr.result).split(",")[1]);
|
||||||
|
fr.onerror = reject;
|
||||||
|
fr.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
skinBtn.addEventListener("click", async () => {
|
||||||
|
skinMsg.hidden = true;
|
||||||
|
skinMsg.classList.remove("ok", "err");
|
||||||
|
const file = fileInput.files && fileInput.files[0];
|
||||||
|
if (!file) {
|
||||||
|
skinMsg.classList.add("err");
|
||||||
|
showMsg(skinMsg, cfg.errSkinType);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.type !== "image/png") {
|
||||||
|
skinMsg.classList.add("err");
|
||||||
|
showMsg(skinMsg, cfg.errSkinType);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const model = document.querySelector('input[name="skin-model"]:checked').value;
|
||||||
|
skinBtn.disabled = true;
|
||||||
|
resetBtn.disabled = true;
|
||||||
|
skinBtn.textContent = cfg.skinUploading;
|
||||||
|
try {
|
||||||
|
const skinBase64 = await readBase64(file);
|
||||||
|
const res = await fetch(`${cfg.apiUrl}/players/${player.uuid}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { ...headers, Authorization: `Bearer ${apiToken}` },
|
||||||
|
body: JSON.stringify({ skinBase64, skinModel: model }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
skinMsg.classList.add("err");
|
||||||
|
showMsg(skinMsg, await apiMessage(res, cfg.errNetwork));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Show the local file immediately as instant confirmation (NMSR lags ~1m).
|
||||||
|
const dataUrl = URL.createObjectURL(file);
|
||||||
|
$("acc-avatar").src = dataUrl;
|
||||||
|
skinMsg.classList.add("ok");
|
||||||
|
showMsg(skinMsg, cfg.skinOk);
|
||||||
|
// Clear file picker
|
||||||
|
fileInput.value = "";
|
||||||
|
fileName.textContent = "";
|
||||||
|
} catch {
|
||||||
|
skinMsg.classList.add("err");
|
||||||
|
showMsg(skinMsg, cfg.errNetwork);
|
||||||
|
} finally {
|
||||||
|
skinBtn.disabled = false;
|
||||||
|
resetBtn.disabled = false;
|
||||||
|
skinBtn.textContent = cfg.skinUpload;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Reset to default ----
|
||||||
|
resetBtn.addEventListener("click", async () => {
|
||||||
|
if (!window.confirm(cfg.resetConfirm)) return;
|
||||||
|
skinMsg.hidden = true;
|
||||||
|
skinMsg.classList.remove("ok", "err");
|
||||||
|
skinBtn.disabled = true;
|
||||||
|
resetBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${cfg.apiUrl}/players/${player.uuid}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { ...headers, Authorization: `Bearer ${apiToken}` },
|
||||||
|
body: JSON.stringify({ deleteSkin: true }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
skinMsg.classList.add("err");
|
||||||
|
showMsg(skinMsg, await apiMessage(res, cfg.errNetwork));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Reset preview back to NMSR (will eventually show the default skin).
|
||||||
|
$("acc-avatar").src = `${cfg.avatarUrl}/${cfg.avatarMode}/${player.uuid}?size=96&t=${Date.now()}`;
|
||||||
|
skinMsg.classList.add("ok");
|
||||||
|
showMsg(skinMsg, cfg.resetOk);
|
||||||
|
} catch {
|
||||||
|
skinMsg.classList.add("err");
|
||||||
|
showMsg(skinMsg, cfg.errNetwork);
|
||||||
|
} finally {
|
||||||
|
skinBtn.disabled = false;
|
||||||
|
resetBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
231
landing/src/pages/[...lang]/fjord.astro
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
---
|
||||||
|
import { site, LITERALS, HEAD_FONTS } from "../../data/site";
|
||||||
|
import {
|
||||||
|
ui,
|
||||||
|
LANGS,
|
||||||
|
LANG_LABEL,
|
||||||
|
LANG_PATH,
|
||||||
|
LANG_FJORD_PATH,
|
||||||
|
LANG_REGISTER_PATH,
|
||||||
|
LANG_ACCOUNT_PATH,
|
||||||
|
type Lang,
|
||||||
|
} from "../../i18n/ui";
|
||||||
|
import CopyChip from "../../components/CopyChip.astro";
|
||||||
|
import "../../styles/main.css";
|
||||||
|
|
||||||
|
// One static Fjord page per locale: /fjord, /es/fjord, /eu/fjord. This is the
|
||||||
|
// alternative manual path — intentionally NOT linked from the homepage nav.
|
||||||
|
export function getStaticPaths() {
|
||||||
|
return [
|
||||||
|
{ params: { lang: undefined }, props: { locale: "es" as Lang } },
|
||||||
|
{ params: { lang: "en" }, props: { locale: "en" as Lang } },
|
||||||
|
{ params: { lang: "eu" }, props: { locale: "eu" as Lang } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { locale } = Astro.props;
|
||||||
|
const t = ui[locale];
|
||||||
|
const f = t.fjord;
|
||||||
|
const { theme, fjord } = site;
|
||||||
|
const headFont = HEAD_FONTS[theme.headFont] ?? HEAD_FONTS.pixelify;
|
||||||
|
const homePath = LANG_PATH[locale];
|
||||||
|
const registerPath = LANG_REGISTER_PATH[locale];
|
||||||
|
const accountPath = LANG_ACCOUNT_PATH[locale];
|
||||||
|
const fjordPack = site.launcherManifest.fjordPack;
|
||||||
|
---
|
||||||
|
|
||||||
|
<!doctype html>
|
||||||
|
<html
|
||||||
|
lang={t.htmlLang}
|
||||||
|
data-mood={theme.mood}
|
||||||
|
data-head={theme.headFont}
|
||||||
|
style={`--font-head: ${headFont}`}
|
||||||
|
>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>{site.name} — {f.title}</title>
|
||||||
|
<meta name="description" content={f.lead} />
|
||||||
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
|
<link rel="stylesheet" href="/fonts/fonts.css" />
|
||||||
|
{LANGS.map((l) => <link rel="alternate" hreflang={l} href={LANG_FJORD_PATH[l]} />)}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- NAV -->
|
||||||
|
<header class="nav">
|
||||||
|
<div class="wrap nav-in">
|
||||||
|
<a class="brand" href={homePath}>
|
||||||
|
<img class="brand-logo" src="/ulicraft-logo-mini.svg" alt="" width="34" height="34" />
|
||||||
|
<span class="name">ULICRAFT</span>
|
||||||
|
</a>
|
||||||
|
<nav class="nav-links">
|
||||||
|
<a href={homePath}>{f.navHome}</a>
|
||||||
|
</nav>
|
||||||
|
<span class="nav-spacer"></span>
|
||||||
|
<div class="lang-switch" aria-label="Language">
|
||||||
|
{LANGS.map((l) => (
|
||||||
|
<a
|
||||||
|
href={LANG_FJORD_PATH[l]}
|
||||||
|
class:list={[l === locale && "on"]}
|
||||||
|
aria-current={l === locale ? "true" : undefined}
|
||||||
|
>{LANG_LABEL[l]}</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<a class="mc-btn sm" href={registerPath}>{t.nav.register}</a>
|
||||||
|
<a class="mc-btn sm" href={accountPath}>{t.nav.login}</a>
|
||||||
|
<a class="mc-btn primary sm" href={`${homePath}#join`}>{t.nav.play}</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="top">
|
||||||
|
<section id="join" class="pad">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="sec-head reveal">
|
||||||
|
<span class="eyebrow">{f.eyebrow}</span>
|
||||||
|
<h2 class="section-title">{f.title}</h2>
|
||||||
|
<p class="lead">{f.lead}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="steps">
|
||||||
|
<!-- STEP 1 -->
|
||||||
|
<div class="step reveal">
|
||||||
|
<div class="num">1</div>
|
||||||
|
<div class="body">
|
||||||
|
<span class="kicker">{f.s1.kicker}</span>
|
||||||
|
<h3>{f.s1.h}</h3>
|
||||||
|
<p>{f.s1.p}</p>
|
||||||
|
<div class="actions">
|
||||||
|
{fjord.downloads.map((d) => (
|
||||||
|
<a class="mc-btn sm" href={`${fjord.base}/${d.file}`}>
|
||||||
|
{d.os} <span class="hint">· {d.hint}</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- STEP 2 -->
|
||||||
|
<div class="step reveal">
|
||||||
|
<div class="num">2</div>
|
||||||
|
<div class="body">
|
||||||
|
<span class="kicker">{f.s2.kicker}</span>
|
||||||
|
<h3>{f.s2.h}</h3>
|
||||||
|
<p>
|
||||||
|
{f.s2.pa} <strong style="color:var(--text)">{LITERALS.authlib}</strong> {f.s2.pb}
|
||||||
|
</p>
|
||||||
|
<div class="actions">
|
||||||
|
<CopyChip value={site.authlibUrl} variant="url" label={f.s2.copy} copiedLabel={t.copied} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- STEP 3 -->
|
||||||
|
<div class="step reveal">
|
||||||
|
<div class="num">3</div>
|
||||||
|
<div class="body">
|
||||||
|
<span class="kicker">{f.s3.kicker}</span>
|
||||||
|
<h3>{f.s3.h}</h3>
|
||||||
|
<p>{f.s3.p}</p>
|
||||||
|
<div class="actions">
|
||||||
|
{fjordPack ? (
|
||||||
|
<a class="mc-btn sm" href={fjordPack.url}>
|
||||||
|
{fjordPack.filename} <span class="hint">· .mrpack</span>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<p class="hint">{f.noPack}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- STEP 4 -->
|
||||||
|
<div class="step reveal">
|
||||||
|
<div class="num">4</div>
|
||||||
|
<div class="body">
|
||||||
|
<span class="kicker">{f.s4.kicker}</span>
|
||||||
|
<h3>{f.s4.h}</h3>
|
||||||
|
<p>
|
||||||
|
{f.s4.pa} <kbd>{LITERALS.multiplayer}</kbd> → <kbd>{LITERALS.addServer}</kbd>{f.s4.pb}
|
||||||
|
</p>
|
||||||
|
<div class="actions">
|
||||||
|
<CopyChip value={site.serverAddress} variant="ip" label={f.s4.copy} copiedLabel={t.copied} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- FOOTER -->
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="wrap footer-in">
|
||||||
|
<a class="brand" href={homePath}>
|
||||||
|
<img class="brand-logo" src="/ulicraft-logo-mini.svg" alt="" width="34" height="34" />
|
||||||
|
<span class="name">ULICRAFT</span>
|
||||||
|
</a>
|
||||||
|
<div class="footer-links">
|
||||||
|
<a class="mc-btn primary sm" href={`${homePath}#join`}>{t.footer.join}</a>
|
||||||
|
<a class="mc-btn sm" href={site.statusUrl} target="_blank" rel="noopener">{t.footer.status}</a>
|
||||||
|
</div>
|
||||||
|
<p class="disc">
|
||||||
|
{t.footer.disc} {site.name} · {site.baseDomain}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Copy-to-clipboard for every [data-copy] button (HTTP-LAN safe fallback).
|
||||||
|
// Mirrors the global handler in [...lang].astro (CopyChip depends on it).
|
||||||
|
function copyText(text: string) {
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
const ta = document.createElement("textarea");
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.position = "fixed";
|
||||||
|
ta.style.opacity = "0";
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
try {
|
||||||
|
document.execCommand("copy");
|
||||||
|
} catch {}
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
}
|
||||||
|
document.querySelectorAll<HTMLButtonElement>("button[data-copy]").forEach((btn) => {
|
||||||
|
let timer: number | undefined;
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
copyText(btn.dataset.copy ?? "");
|
||||||
|
const label = btn.dataset.label ?? "Copy";
|
||||||
|
btn.textContent = btn.dataset.copied ?? "Copied!";
|
||||||
|
btn.classList.add("copied");
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = window.setTimeout(() => {
|
||||||
|
btn.textContent = label;
|
||||||
|
btn.classList.remove("copied");
|
||||||
|
}, 1600);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scroll reveal — visible at rest; .anim added when scrolled into view.
|
||||||
|
if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||||
|
const els = [...document.querySelectorAll<HTMLElement>(".reveal")];
|
||||||
|
const io = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const e of entries) {
|
||||||
|
if (e.isIntersecting) {
|
||||||
|
e.target.classList.add("anim");
|
||||||
|
io.unobserve(e.target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: "0px 0px -10% 0px" }
|
||||||
|
);
|
||||||
|
els.forEach((el) => io.observe(el));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
319
landing/src/pages/[...lang]/register.astro
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
---
|
||||||
|
import { site, HEAD_FONTS } from "../../data/site";
|
||||||
|
import {
|
||||||
|
ui,
|
||||||
|
LANGS,
|
||||||
|
LANG_LABEL,
|
||||||
|
LANG_PATH,
|
||||||
|
LANG_REGISTER_PATH,
|
||||||
|
LANG_ACCOUNT_PATH,
|
||||||
|
type Lang,
|
||||||
|
} from "../../i18n/ui";
|
||||||
|
import "../../styles/main.css";
|
||||||
|
|
||||||
|
// One static register page per locale: /register, /es/register, /eu/register.
|
||||||
|
export function getStaticPaths() {
|
||||||
|
return [
|
||||||
|
{ params: { lang: undefined }, props: { locale: "es" as Lang } },
|
||||||
|
{ params: { lang: "en" }, props: { locale: "en" as Lang } },
|
||||||
|
{ params: { lang: "eu" }, props: { locale: "eu" as Lang } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { locale } = Astro.props;
|
||||||
|
const t = ui[locale];
|
||||||
|
const r = t.register;
|
||||||
|
const { theme } = site;
|
||||||
|
const headFont = HEAD_FONTS[theme.headFont] ?? HEAD_FONTS.pixelify;
|
||||||
|
const homePath = LANG_PATH[locale];
|
||||||
|
const registerPath = LANG_REGISTER_PATH[locale];
|
||||||
|
const accountPath = LANG_ACCOUNT_PATH[locale];
|
||||||
|
// Visibility of the invite-code field (default hidden). Independent of the
|
||||||
|
// backend gate (site.registrationRequiresInvite / Drasl RequireInvite).
|
||||||
|
const showInvite = site.registrationShowInvite;
|
||||||
|
|
||||||
|
// Strings the client script needs at runtime (kept minimal, passed via define:vars).
|
||||||
|
const clientCfg = {
|
||||||
|
apiUrl: site.draslApiUrl,
|
||||||
|
showInvite,
|
||||||
|
submit: r.submit,
|
||||||
|
submitting: r.submitting,
|
||||||
|
skinUpload: r.skinUpload,
|
||||||
|
skinUploading: r.skinUploading,
|
||||||
|
skinOk: r.skinOk,
|
||||||
|
successBody: r.successBody,
|
||||||
|
errFields: r.errFields,
|
||||||
|
errSkinType: r.errSkinType,
|
||||||
|
errNetwork: r.errNetwork,
|
||||||
|
};
|
||||||
|
---
|
||||||
|
|
||||||
|
<!doctype html>
|
||||||
|
<html
|
||||||
|
lang={t.htmlLang}
|
||||||
|
data-mood={theme.mood}
|
||||||
|
data-head={theme.headFont}
|
||||||
|
style={`--font-head: ${headFont}`}
|
||||||
|
>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>{site.name} — {r.title}</title>
|
||||||
|
<meta name="description" content={r.lead} />
|
||||||
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
|
<link rel="stylesheet" href="/fonts/fonts.css" />
|
||||||
|
{LANGS.map((l) => <link rel="alternate" hreflang={l} href={LANG_REGISTER_PATH[l]} />)}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- NAV -->
|
||||||
|
<header class="nav">
|
||||||
|
<div class="wrap nav-in">
|
||||||
|
<a class="brand" href={homePath}>
|
||||||
|
<img class="brand-logo" src="/ulicraft-logo-mini.svg" alt="" width="34" height="34" />
|
||||||
|
<span class="name">ULICRAFT</span>
|
||||||
|
</a>
|
||||||
|
<nav class="nav-links">
|
||||||
|
<a href={homePath}>{r.navHome}</a>
|
||||||
|
</nav>
|
||||||
|
<span class="nav-spacer"></span>
|
||||||
|
<div class="lang-switch" aria-label="Language">
|
||||||
|
{LANGS.map((l) => (
|
||||||
|
<a
|
||||||
|
href={LANG_REGISTER_PATH[l]}
|
||||||
|
class:list={[l === locale && "on"]}
|
||||||
|
aria-current={l === locale ? "true" : undefined}
|
||||||
|
>{LANG_LABEL[l]}</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<a class="mc-btn sm" href={registerPath}>{t.nav.register}</a>
|
||||||
|
<a class="mc-btn sm" href={accountPath}>{t.nav.login}</a>
|
||||||
|
<a class="mc-btn primary sm" href={`${homePath}#join`}>{t.nav.play}</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="top">
|
||||||
|
<section class="pad">
|
||||||
|
<div class="reg-wrap">
|
||||||
|
<div class="reg-card">
|
||||||
|
<span class="eyebrow">{r.eyebrow}</span>
|
||||||
|
<h1>{r.title}</h1>
|
||||||
|
<p class="lead">{r.lead}</p>
|
||||||
|
|
||||||
|
<!-- REGISTER FORM -->
|
||||||
|
<form class="reg-form" id="reg-form" novalidate>
|
||||||
|
<div class="reg-field">
|
||||||
|
<label for="reg-username">{r.usernameLabel}</label>
|
||||||
|
<input class="reg-input" id="reg-username" name="username"
|
||||||
|
type="text" autocomplete="username" placeholder={r.usernamePlaceholder} required />
|
||||||
|
</div>
|
||||||
|
<div class="reg-field">
|
||||||
|
<label for="reg-password">{r.passwordLabel}</label>
|
||||||
|
<input class="reg-input" id="reg-password" name="password"
|
||||||
|
type="password" autocomplete="new-password" placeholder={r.passwordPlaceholder} required />
|
||||||
|
</div>
|
||||||
|
{showInvite && (
|
||||||
|
<div class="reg-field">
|
||||||
|
<label for="reg-invite">{r.inviteLabel}</label>
|
||||||
|
<input class="reg-input" id="reg-invite" name="invite"
|
||||||
|
type="text" placeholder={r.invitePlaceholder} required />
|
||||||
|
<span class="hint">{r.inviteHint}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div class="reg-msg err" id="reg-error" role="alert" hidden></div>
|
||||||
|
<button class="mc-btn primary" id="reg-submit" type="submit">{r.submit}</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="reg-foot">
|
||||||
|
{r.haveAccount} <a href={LANG_ACCOUNT_PATH[locale]}>{r.loginLink}</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- SUCCESS + SKIN -->
|
||||||
|
<div class="reg-success" id="reg-success" hidden>
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow" style="color:var(--accent)">{r.successTitle}</span>
|
||||||
|
<p class="lead" id="reg-success-body" style="margin-top:10px"></p>
|
||||||
|
</div>
|
||||||
|
<div class="reg-field">
|
||||||
|
<label>{r.uuidLabel}</label>
|
||||||
|
<div class="reg-uuid" id="reg-uuid"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="reg-skin" id="reg-skin">
|
||||||
|
<div>
|
||||||
|
<h3>{r.skinTitle}</h3>
|
||||||
|
<p class="lead">{r.skinLead}</p>
|
||||||
|
</div>
|
||||||
|
<div class="reg-field">
|
||||||
|
<label class="mc-btn sm" for="reg-skin-file" style="align-self:flex-start">{r.skinChoose}</label>
|
||||||
|
<input id="reg-skin-file" type="file" accept="image/png" hidden />
|
||||||
|
<span class="reg-file-name" id="reg-skin-name"></span>
|
||||||
|
</div>
|
||||||
|
<div class="reg-field">
|
||||||
|
<label>{r.modelLabel}</label>
|
||||||
|
<div class="reg-models">
|
||||||
|
<label><input type="radio" name="skin-model" value="classic" checked /> {r.modelClassic}</label>
|
||||||
|
<label><input type="radio" name="skin-model" value="slim" /> {r.modelSlim}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="reg-msg" id="reg-skin-msg" role="status" hidden></div>
|
||||||
|
<button class="mc-btn" id="reg-skin-upload" type="button">{r.skinUpload}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a class="mc-btn primary" href={`${homePath}#join`}>{r.finish}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- FOOTER -->
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="wrap footer-in">
|
||||||
|
<a class="brand" href={homePath}>
|
||||||
|
<img class="brand-logo" src="/ulicraft-logo-mini.svg" alt="" width="34" height="34" />
|
||||||
|
<span class="name">ULICRAFT</span>
|
||||||
|
</a>
|
||||||
|
<div class="footer-links">
|
||||||
|
<a class="mc-btn sm" href={site.statusUrl} target="_blank" rel="noopener">{t.footer.status}</a>
|
||||||
|
</div>
|
||||||
|
<p class="disc">
|
||||||
|
{t.footer.disc} {site.name} · {site.baseDomain}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script define:vars={{ cfg: clientCfg }}>
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const form = $("reg-form");
|
||||||
|
const errBox = $("reg-error");
|
||||||
|
const submitBtn = $("reg-submit");
|
||||||
|
const successBox = $("reg-success");
|
||||||
|
const headers = { "Content-Type": "application/json" };
|
||||||
|
|
||||||
|
let apiToken = null;
|
||||||
|
let player = null;
|
||||||
|
|
||||||
|
const showErr = (box, msg) => {
|
||||||
|
box.textContent = msg;
|
||||||
|
box.hidden = false;
|
||||||
|
};
|
||||||
|
const apiMessage = async (res, fallback) => {
|
||||||
|
try {
|
||||||
|
const data = await res.json();
|
||||||
|
return data && data.message ? data.message : fallback;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
form.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
errBox.hidden = true;
|
||||||
|
const username = $("reg-username").value.trim();
|
||||||
|
const password = $("reg-password").value;
|
||||||
|
const inviteEl = $("reg-invite");
|
||||||
|
const invite = inviteEl ? inviteEl.value.trim() : null;
|
||||||
|
if (!username || !password || (cfg.showInvite && !invite)) {
|
||||||
|
showErr(errBox, cfg.errFields);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = cfg.submitting;
|
||||||
|
try {
|
||||||
|
const body = { username, password, playerName: username };
|
||||||
|
if (invite) body.inviteCode = invite;
|
||||||
|
let res = await fetch(`${cfg.apiUrl}/users`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
showErr(errBox, await apiMessage(res, cfg.errNetwork));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Log in to get an API token for the (optional) skin upload.
|
||||||
|
res = await fetch(`${cfg.apiUrl}/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
showErr(errBox, await apiMessage(res, cfg.errNetwork));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
apiToken = data.apiToken;
|
||||||
|
player = data.user.players[0];
|
||||||
|
form.hidden = true;
|
||||||
|
$("reg-uuid").textContent = player.uuid;
|
||||||
|
$("reg-success-body").textContent = cfg.successBody.replace("{name}", player.name);
|
||||||
|
successBox.hidden = false;
|
||||||
|
} catch {
|
||||||
|
showErr(errBox, cfg.errNetwork);
|
||||||
|
} finally {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = cfg.submit;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Skin upload ----
|
||||||
|
const fileInput = $("reg-skin-file");
|
||||||
|
const fileName = $("reg-skin-name");
|
||||||
|
const skinMsg = $("reg-skin-msg");
|
||||||
|
const skinBtn = $("reg-skin-upload");
|
||||||
|
|
||||||
|
fileInput.addEventListener("change", () => {
|
||||||
|
skinMsg.hidden = true;
|
||||||
|
fileName.textContent = fileInput.files && fileInput.files[0] ? fileInput.files[0].name : "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const readBase64 = (file) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const fr = new FileReader();
|
||||||
|
fr.onload = () => resolve(String(fr.result).split(",")[1]);
|
||||||
|
fr.onerror = reject;
|
||||||
|
fr.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
skinBtn.addEventListener("click", async () => {
|
||||||
|
skinMsg.hidden = true;
|
||||||
|
skinMsg.classList.remove("ok", "err");
|
||||||
|
const file = fileInput.files && fileInput.files[0];
|
||||||
|
if (!file) {
|
||||||
|
skinMsg.classList.add("err");
|
||||||
|
showErr(skinMsg, cfg.errSkinType);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.type !== "image/png") {
|
||||||
|
skinMsg.classList.add("err");
|
||||||
|
showErr(skinMsg, cfg.errSkinType);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const model = document.querySelector('input[name="skin-model"]:checked').value;
|
||||||
|
skinBtn.disabled = true;
|
||||||
|
skinBtn.textContent = cfg.skinUploading;
|
||||||
|
try {
|
||||||
|
const skinBase64 = await readBase64(file);
|
||||||
|
const res = await fetch(`${cfg.apiUrl}/players/${player.uuid}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { ...headers, Authorization: `Bearer ${apiToken}` },
|
||||||
|
body: JSON.stringify({ skinBase64, skinModel: model }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
skinMsg.classList.add("err");
|
||||||
|
showErr(skinMsg, await apiMessage(res, cfg.errNetwork));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
skinMsg.classList.add("ok");
|
||||||
|
showErr(skinMsg, cfg.skinOk);
|
||||||
|
} catch {
|
||||||
|
skinMsg.classList.add("err");
|
||||||
|
showErr(skinMsg, cfg.errNetwork);
|
||||||
|
} finally {
|
||||||
|
skinBtn.disabled = false;
|
||||||
|
skinBtn.textContent = cfg.skinUpload;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
---
|
|
||||||
import { site } from "../data/site";
|
|
||||||
const { launcher } = site;
|
|
||||||
---
|
|
||||||
|
|
||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<title>{site.name} — join the server</title>
|
|
||||||
<meta name="description" content={`${site.name}: ${site.tagline}`} />
|
|
||||||
<link rel="icon" type="image/png" href="/logo.png" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main>
|
|
||||||
<header class="hero">
|
|
||||||
<img class="logo" src="/logo.png" alt={`${site.name} logo`} width="128" height="128" />
|
|
||||||
<h1>{site.name}</h1>
|
|
||||||
<p class="tagline">{site.tagline}</p>
|
|
||||||
<p class="addr">
|
|
||||||
<span class="addr-label">Server</span>
|
|
||||||
<code id="server-addr">{site.serverAddress}</code>
|
|
||||||
<button class="copy" data-copy={site.serverAddress} type="button">Copy</button>
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<ol class="steps">
|
|
||||||
<li>
|
|
||||||
<h2><span class="num">1</span> Download the launcher</h2>
|
|
||||||
<p>Grab <strong>{launcher.name}</strong> for your system.</p>
|
|
||||||
<div class="downloads">
|
|
||||||
{launcher.downloads.map((d) => (
|
|
||||||
<a class="dl" href={`${launcher.base}/${d.file}`}>
|
|
||||||
<span class="dl-os">{d.os}</span>
|
|
||||||
<span class="dl-hint">{d.hint}</span>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li>
|
|
||||||
<h2><span class="num">2</span> Add your account</h2>
|
|
||||||
<p>In the launcher, add an <strong>authlib-injector</strong> account using this URL:</p>
|
|
||||||
<p>
|
|
||||||
<code>{site.authlibUrl}</code>
|
|
||||||
<button class="copy" data-copy={site.authlibUrl} type="button">Copy</button>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Need credentials? Register at <a href={site.authUrl}>{site.authUrl}</a>, then log in
|
|
||||||
with that username and password.
|
|
||||||
</p>
|
|
||||||
<p class="note">
|
|
||||||
Offline party? Download and trust the
|
|
||||||
<a href={site.caCertUrl}>local CA certificate</a> so the game-file
|
|
||||||
mirror works over HTTPS.
|
|
||||||
</p>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li>
|
|
||||||
<h2><span class="num">3</span> Import the modpack</h2>
|
|
||||||
<p>Add a new instance from this packwiz URL:</p>
|
|
||||||
<p>
|
|
||||||
<code>{site.packwizUrl}</code>
|
|
||||||
<button class="copy" data-copy={site.packwizUrl} type="button">Copy</button>
|
|
||||||
</p>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li>
|
|
||||||
<h2><span class="num">4</span> Connect</h2>
|
|
||||||
<p>Launch the instance, add a server, and join:</p>
|
|
||||||
<p>
|
|
||||||
<code>{site.serverAddress}</code>
|
|
||||||
<button class="copy" data-copy={site.serverAddress} type="button">Copy</button>
|
|
||||||
</p>
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<footer>
|
|
||||||
<p>{site.name} · {site.baseDomain}</p>
|
|
||||||
</footer>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Copy-to-clipboard for every [data-copy] button.
|
|
||||||
document.querySelectorAll<HTMLButtonElement>("button.copy").forEach((btn) => {
|
|
||||||
btn.addEventListener("click", async () => {
|
|
||||||
const text = btn.dataset.copy ?? "";
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(text);
|
|
||||||
const prev = btn.textContent;
|
|
||||||
btn.textContent = "Copied";
|
|
||||||
setTimeout(() => (btn.textContent = prev), 1200);
|
|
||||||
} catch {
|
|
||||||
btn.textContent = "Copy failed";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--stone: #7a7a7a;
|
|
||||||
--bg: #f4f4f2;
|
|
||||||
--card: #ffffff;
|
|
||||||
--ink: #1c1c1c;
|
|
||||||
--muted: #6b6b6b;
|
|
||||||
--line: #e2e2df;
|
|
||||||
--accent: #4f7942;
|
|
||||||
}
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
html { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--ink);
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
|
||||||
main {
|
|
||||||
max-width: 720px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 3rem 1.25rem 4rem;
|
|
||||||
}
|
|
||||||
.hero { text-align: center; margin-bottom: 2.5rem; }
|
|
||||||
.logo {
|
|
||||||
image-rendering: pixelated;
|
|
||||||
border-radius: 12px;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
h1 { font-size: 2.4rem; margin: 0.25rem 0 0; letter-spacing: -0.5px; }
|
|
||||||
.tagline { color: var(--muted); margin: 0.25rem 0 1.25rem; }
|
|
||||||
.addr {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.addr-label {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
code {
|
|
||||||
background: #ededea;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0.2rem 0.5rem;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
.copy {
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
background: var(--card);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0.2rem 0.6rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.copy:hover { border-color: var(--stone); }
|
|
||||||
.steps { list-style: none; padding: 0; margin: 0; display: grid; gap: 1rem; }
|
|
||||||
.steps > li {
|
|
||||||
background: var(--card);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 1.25rem 1.5rem;
|
|
||||||
}
|
|
||||||
.steps h2 { font-size: 1.2rem; margin: 0 0 0.5rem; display: flex; align-items: center; gap: 0.6rem; }
|
|
||||||
.num {
|
|
||||||
display: inline-grid;
|
|
||||||
place-items: center;
|
|
||||||
width: 1.7rem;
|
|
||||||
height: 1.7rem;
|
|
||||||
background: var(--accent);
|
|
||||||
color: #fff;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
.steps p { margin: 0.4rem 0; }
|
|
||||||
.downloads { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.75rem; margin-top: 0.75rem; }
|
|
||||||
.dl {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
text-decoration: none;
|
|
||||||
color: var(--ink);
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 0.8rem 1rem;
|
|
||||||
}
|
|
||||||
.dl:hover { border-color: var(--accent); }
|
|
||||||
.dl-os { font-weight: 600; }
|
|
||||||
.dl-hint { font-size: 0.8rem; color: var(--muted); }
|
|
||||||
footer { text-align: center; color: var(--muted); font-size: 0.85rem; margin-top: 2.5rem; }
|
|
||||||
a { color: var(--accent); }
|
|
||||||
</style>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
728
landing/src/styles/main.css
Normal file
@@ -0,0 +1,728 @@
|
|||||||
|
/* ============================================================
|
||||||
|
ULICRAFT — "Carved from stone"
|
||||||
|
Design system: dark overworld, MC-GUI bevels, pixel type
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* ---- Mood palettes (data-mood on <html>) ---- */
|
||||||
|
:root,
|
||||||
|
:root[data-mood="grass"] {
|
||||||
|
--bg: oklch(0.165 0.014 150);
|
||||||
|
--bg-2: oklch(0.205 0.016 150);
|
||||||
|
--surface: oklch(0.235 0.017 150);
|
||||||
|
--surface-2: oklch(0.285 0.018 150);
|
||||||
|
--slot: oklch(0.185 0.014 150);
|
||||||
|
--bevel-hi: oklch(0.40 0.018 150);
|
||||||
|
--bevel-lo: oklch(0.115 0.012 150);
|
||||||
|
--line: oklch(0.33 0.016 150);
|
||||||
|
|
||||||
|
--accent: oklch(0.72 0.16 145); /* grass */
|
||||||
|
--accent-hi: oklch(0.80 0.15 145);
|
||||||
|
--accent-lo: oklch(0.55 0.15 145);
|
||||||
|
--accent-ink: oklch(0.17 0.05 150); /* text on accent */
|
||||||
|
|
||||||
|
--gold: oklch(0.82 0.135 85);
|
||||||
|
--glow: oklch(0.72 0.16 145 / 0.30);
|
||||||
|
|
||||||
|
--text: oklch(0.95 0.008 110);
|
||||||
|
--muted: oklch(0.72 0.012 145);
|
||||||
|
--dim: oklch(0.55 0.012 145);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-mood="nether"] {
|
||||||
|
--bg: oklch(0.165 0.018 35);
|
||||||
|
--bg-2: oklch(0.205 0.022 32);
|
||||||
|
--surface: oklch(0.235 0.026 32);
|
||||||
|
--surface-2: oklch(0.285 0.030 32);
|
||||||
|
--slot: oklch(0.185 0.020 32);
|
||||||
|
--bevel-hi: oklch(0.42 0.040 35);
|
||||||
|
--bevel-lo: oklch(0.115 0.016 32);
|
||||||
|
--line: oklch(0.34 0.030 32);
|
||||||
|
|
||||||
|
--accent: oklch(0.64 0.19 32); /* nether red */
|
||||||
|
--accent-hi: oklch(0.72 0.18 38);
|
||||||
|
--accent-lo: oklch(0.50 0.17 30);
|
||||||
|
--accent-ink: oklch(0.16 0.04 32);
|
||||||
|
|
||||||
|
--gold: oklch(0.83 0.14 75);
|
||||||
|
--glow: oklch(0.64 0.19 32 / 0.34);
|
||||||
|
|
||||||
|
--text: oklch(0.95 0.010 60);
|
||||||
|
--muted: oklch(0.74 0.020 45);
|
||||||
|
--dim: oklch(0.56 0.020 40);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-mood="end"] {
|
||||||
|
--bg: oklch(0.155 0.018 305);
|
||||||
|
--bg-2: oklch(0.195 0.022 305);
|
||||||
|
--surface: oklch(0.225 0.026 305);
|
||||||
|
--surface-2: oklch(0.275 0.030 305);
|
||||||
|
--slot: oklch(0.175 0.020 305);
|
||||||
|
--bevel-hi: oklch(0.42 0.040 305);
|
||||||
|
--bevel-lo: oklch(0.110 0.016 305);
|
||||||
|
--line: oklch(0.34 0.030 305);
|
||||||
|
|
||||||
|
--accent: oklch(0.74 0.135 175); /* end teal */
|
||||||
|
--accent-hi: oklch(0.82 0.13 175);
|
||||||
|
--accent-lo: oklch(0.58 0.13 178);
|
||||||
|
--accent-ink: oklch(0.16 0.04 200);
|
||||||
|
|
||||||
|
--gold: oklch(0.84 0.13 95);
|
||||||
|
--glow: oklch(0.70 0.16 300 / 0.34);
|
||||||
|
|
||||||
|
--text: oklch(0.96 0.010 300);
|
||||||
|
--muted: oklch(0.76 0.020 300);
|
||||||
|
--dim: oklch(0.58 0.020 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Fonts (switchable head face via --font-head) ---- */
|
||||||
|
:root {
|
||||||
|
--font-head: 'Pixelify Sans', system-ui, sans-serif;
|
||||||
|
--font-body: 'Space Grotesk', system-ui, sans-serif;
|
||||||
|
--font-pixel: 'Press Start 2P', monospace; /* tiny eyebrow labels */
|
||||||
|
--font-mono: 'VT323', monospace; /* IP / terminal */
|
||||||
|
--maxw: 1160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html { scroll-behavior: smooth; }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
html { scroll-behavior: auto; }
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.6;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* faint pixel-grid stone texture over everything */
|
||||||
|
body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
background-image:
|
||||||
|
repeating-linear-gradient(0deg, oklch(1 0 0 / 0.018) 0 1px, transparent 1px 4px),
|
||||||
|
repeating-linear-gradient(90deg, oklch(0 0 0 / 0.06) 0 1px, transparent 1px 4px);
|
||||||
|
background-size: 4px 4px, 4px 4px;
|
||||||
|
mix-blend-mode: overlay;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
img { display: block; max-width: 100%; }
|
||||||
|
|
||||||
|
::selection { background: var(--accent); color: var(--accent-ink); }
|
||||||
|
|
||||||
|
/* ---- Typography helpers ---- */
|
||||||
|
.eyebrow {
|
||||||
|
font-family: var(--font-pixel);
|
||||||
|
font-size: clamp(9px, 1.1vw, 11px);
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: var(--accent);
|
||||||
|
text-transform: uppercase;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
h1, h2, h3 {
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.02;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
font-size: clamp(34px, 5vw, 58px);
|
||||||
|
}
|
||||||
|
.lead { color: var(--muted); }
|
||||||
|
.mono { font-family: var(--font-mono); }
|
||||||
|
|
||||||
|
/* in-copy links (steps, hints) — accent, not browser blue */
|
||||||
|
.step a, .hint a, .hero-sub a { color: var(--accent); text-underline-offset: 2px; }
|
||||||
|
|
||||||
|
/* ---- Layout ---- */
|
||||||
|
.wrap { width: min(var(--maxw), calc(100% - 48px)); margin-inline: auto; }
|
||||||
|
section { position: relative; z-index: 1; }
|
||||||
|
.pad { padding-block: clamp(64px, 9vw, 130px); }
|
||||||
|
.sec-head { max-width: 640px; margin-bottom: 48px; }
|
||||||
|
.sec-head .eyebrow { display: block; margin-bottom: 14px; }
|
||||||
|
.sec-head p { margin: 16px 0 0; font-size: 18px; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
MINECRAFT-STYLE BUTTON
|
||||||
|
============================================================ */
|
||||||
|
.mc-btn {
|
||||||
|
--b: var(--surface-2);
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--b);
|
||||||
|
border: 0;
|
||||||
|
padding: 15px 24px 17px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
box-shadow:
|
||||||
|
inset 2px 2px 0 var(--bevel-hi),
|
||||||
|
inset -2px -2px 0 var(--bevel-lo),
|
||||||
|
0 4px 0 oklch(0 0 0 / 0.5),
|
||||||
|
0 8px 18px oklch(0 0 0 / 0.4);
|
||||||
|
transition: transform .08s ease, filter .12s ease;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
text-shadow: 0 2px 0 oklch(0 0 0 / 0.35);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.mc-btn:hover { filter: brightness(1.12); }
|
||||||
|
.mc-btn:active {
|
||||||
|
transform: translateY(4px);
|
||||||
|
box-shadow:
|
||||||
|
inset 2px 2px 0 var(--bevel-hi),
|
||||||
|
inset -2px -2px 0 var(--bevel-lo),
|
||||||
|
0 0 0 oklch(0 0 0 / 0.5),
|
||||||
|
0 2px 8px oklch(0 0 0 / 0.4);
|
||||||
|
}
|
||||||
|
.mc-btn.primary {
|
||||||
|
--b: var(--accent);
|
||||||
|
--bevel-hi: var(--accent-hi);
|
||||||
|
--bevel-lo: var(--accent-lo);
|
||||||
|
color: var(--accent-ink);
|
||||||
|
text-shadow: 0 2px 0 oklch(1 0 0 / 0.22);
|
||||||
|
}
|
||||||
|
.mc-btn.gold {
|
||||||
|
--b: var(--gold);
|
||||||
|
--bevel-hi: oklch(0.90 0.10 90);
|
||||||
|
--bevel-lo: oklch(0.66 0.13 75);
|
||||||
|
color: oklch(0.20 0.05 80);
|
||||||
|
text-shadow: 0 2px 0 oklch(1 0 0 / 0.25);
|
||||||
|
}
|
||||||
|
.mc-btn.sm { font-size: 14px; padding: 10px 16px 12px; }
|
||||||
|
|
||||||
|
/* Launcher download skeleton (placeholder while launcher.json loads). */
|
||||||
|
.dl-skeleton {
|
||||||
|
min-width: 9.5rem;
|
||||||
|
color: transparent;
|
||||||
|
pointer-events: none;
|
||||||
|
background-image: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
oklch(1 0 0 / 0.04),
|
||||||
|
oklch(1 0 0 / 0.14),
|
||||||
|
oklch(1 0 0 / 0.04)
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: dl-shimmer 1.2s linear infinite;
|
||||||
|
}
|
||||||
|
.dl-skeleton::after { content: "\00a0"; } /* keep height when text is empty */
|
||||||
|
@keyframes dl-shimmer {
|
||||||
|
from { background-position: 200% 0; }
|
||||||
|
to { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Copy-IP chip ---- */
|
||||||
|
.ip-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: stretch;
|
||||||
|
background: var(--slot);
|
||||||
|
box-shadow:
|
||||||
|
inset 2px 2px 0 var(--bevel-lo),
|
||||||
|
inset -2px -2px 0 var(--bevel-hi);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.ip-chip .ip-val {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 18px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--gold);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.ip-chip .ip-val .pin { color: var(--dim); font-size: 22px; }
|
||||||
|
.ip-chip button {
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 14px 18px;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-ink);
|
||||||
|
box-shadow: inset 2px 2px 0 var(--accent-hi), inset -2px -2px 0 var(--accent-lo);
|
||||||
|
transition: filter .12s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ip-chip button:hover { filter: brightness(1.1); }
|
||||||
|
.ip-chip button.copied { background: var(--gold); color: oklch(0.20 0.05 80); }
|
||||||
|
|
||||||
|
/* url variant: long auth URLs — smaller, wraps instead of overflowing */
|
||||||
|
.ip-chip.url { max-width: 100%; }
|
||||||
|
.ip-chip.url .ip-val {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 18px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
word-break: break-all;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.ip-chip.url button { font-size: 14px; padding: 10px 16px; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
NAV
|
||||||
|
============================================================ */
|
||||||
|
.nav {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
background: oklch(0.165 0.014 150 / 0.72);
|
||||||
|
-webkit-backdrop-filter: blur(14px) saturate(140%);
|
||||||
|
backdrop-filter: blur(14px) saturate(140%);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.nav-in {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
height: 68px;
|
||||||
|
}
|
||||||
|
.brand { display: flex; align-items: center; gap: 12px; text-decoration: none; color: var(--text); }
|
||||||
|
.brand-logo { width: 34px; height: 34px; flex-shrink: 0; }
|
||||||
|
.brand .name {
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 22px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.nav-links { display: flex; gap: 6px; margin-left: 12px; }
|
||||||
|
|
||||||
|
/* language switcher */
|
||||||
|
.lang-switch { display: flex; gap: 2px; }
|
||||||
|
.lang-switch a {
|
||||||
|
font-family: var(--font-pixel);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--dim);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 7px 8px;
|
||||||
|
line-height: 1;
|
||||||
|
transition: color .12s ease, background .12s ease;
|
||||||
|
}
|
||||||
|
.lang-switch a:hover { color: var(--text); background: var(--surface); }
|
||||||
|
.lang-switch a.on { color: var(--accent-ink); background: var(--accent); }
|
||||||
|
.nav-links a {
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 8px 12px;
|
||||||
|
transition: color .12s ease, background .12s ease;
|
||||||
|
}
|
||||||
|
.nav-links a:hover { color: var(--text); background: var(--surface); }
|
||||||
|
.nav-spacer { flex: 1; }
|
||||||
|
|
||||||
|
/* ---- Creeper pixel mark ---- */
|
||||||
|
.creeper {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(8, 1fr);
|
||||||
|
grid-template-rows: repeat(8, 1fr);
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: inset 2px 2px 0 var(--accent-hi), inset -2px -2px 0 var(--accent-lo);
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
.creeper i { background: transparent; }
|
||||||
|
.creeper i.f { background: oklch(0.16 0.03 150); }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
HERO
|
||||||
|
============================================================ */
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
padding-top: clamp(48px, 7vw, 90px);
|
||||||
|
padding-bottom: clamp(56px, 8vw, 110px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.hero::before { /* spotlight glow behind logo */
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: -10%;
|
||||||
|
left: 50%;
|
||||||
|
width: 1100px;
|
||||||
|
height: 760px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: radial-gradient(closest-side, var(--glow), transparent 72%);
|
||||||
|
pointer-events: none;
|
||||||
|
filter: blur(8px);
|
||||||
|
}
|
||||||
|
.hero-grid { position: relative; display: grid; gap: clamp(36px, 5vw, 64px); align-items: center; }
|
||||||
|
|
||||||
|
/* layout: centered (default) */
|
||||||
|
:root[data-hero="centered"] .hero-grid { grid-template-columns: 1fr; justify-items: center; text-align: center; }
|
||||||
|
:root[data-hero="centered"] .hero-cta { justify-content: center; }
|
||||||
|
:root[data-hero="centered"] .hero-meta { justify-content: center; }
|
||||||
|
:root[data-hero="centered"] .hero-copy { max-width: 720px; }
|
||||||
|
:root[data-hero="centered"] .hero-status { display: none; }
|
||||||
|
|
||||||
|
/* layout: split (copy left, live panel right) */
|
||||||
|
:root[data-hero="split"] .hero-grid { grid-template-columns: 1.1fr 0.9fr; }
|
||||||
|
:root[data-hero="split"] .hero-logo { max-width: 560px; }
|
||||||
|
:root[data-hero="split"] .hero-logo img { margin-inline: 0; }
|
||||||
|
|
||||||
|
/* layout: spotlight (giant logo, minimal text, centered) */
|
||||||
|
:root[data-hero="spotlight"] .hero-grid { grid-template-columns: 1fr; justify-items: center; text-align: center; }
|
||||||
|
:root[data-hero="spotlight"] .hero-cta { justify-content: center; }
|
||||||
|
:root[data-hero="spotlight"] .hero-meta { justify-content: center; }
|
||||||
|
:root[data-hero="spotlight"] .hero-logo { max-width: 900px; }
|
||||||
|
:root[data-hero="spotlight"] .hero-tagline { font-size: clamp(20px, 2.4vw, 28px); }
|
||||||
|
:root[data-hero="spotlight"] .hero-status { display: none; }
|
||||||
|
:root[data-hero="spotlight"] .hero-features-note { display: none; }
|
||||||
|
|
||||||
|
.hero-logo { width: 100%; max-width: 760px; }
|
||||||
|
.hero-logo img {
|
||||||
|
width: 100%;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
filter: drop-shadow(0 8px 0 oklch(0 0 0 / 0.45)) drop-shadow(0 18px 30px oklch(0 0 0 / 0.55));
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
.hero-copy { display: flex; flex-direction: column; gap: 22px; }
|
||||||
|
.hero-tagline {
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: clamp(24px, 3vw, 38px);
|
||||||
|
line-height: 1.08;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
.hero-tagline .hl { color: var(--accent); }
|
||||||
|
.hero-sub { color: var(--muted); font-size: 18px; max-width: 52ch; margin: 0; }
|
||||||
|
.hero-cta { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; }
|
||||||
|
.hero-meta { display: flex; flex-wrap: wrap; gap: 10px 22px; align-items: center; color: var(--dim); font-size: 14.5px; }
|
||||||
|
.hero-meta .dot { width: 6px; height: 6px; background: var(--dim); }
|
||||||
|
.hero-ip-row { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; }
|
||||||
|
|
||||||
|
/* status pill */
|
||||||
|
.live-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 7px 14px 7px 11px;
|
||||||
|
background: var(--slot);
|
||||||
|
box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.live-dot {
|
||||||
|
width: 9px; height: 9px; background: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--glow);
|
||||||
|
animation: pulse 2.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
|
||||||
|
@media (prefers-reduced-motion: reduce){ .live-dot{ animation:none } }
|
||||||
|
.live-pill b { color: var(--text); font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
SERVER-LIST STATUS PANEL
|
||||||
|
============================================================ */
|
||||||
|
.serverlist {
|
||||||
|
display: flex;
|
||||||
|
gap: 18px;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--slot);
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow:
|
||||||
|
inset 2px 2px 0 var(--bevel-hi),
|
||||||
|
inset -2px -2px 0 var(--bevel-lo),
|
||||||
|
0 8px 24px oklch(0 0 0 / 0.4);
|
||||||
|
}
|
||||||
|
.serverlist .icon {
|
||||||
|
width: 72px; height: 72px; flex-shrink: 0;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
background: var(--bg-2);
|
||||||
|
box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
|
||||||
|
}
|
||||||
|
.serverlist .icon .creeper { width: 48px; height: 48px; }
|
||||||
|
.serverlist .meta { flex: 1; min-width: 0; }
|
||||||
|
.serverlist .row1 { display: flex; align-items: baseline; gap: 12px; flex-wrap: nowrap; }
|
||||||
|
.serverlist .title { font-family: var(--font-head); font-weight: 600; font-size: 22px; white-space: nowrap; }
|
||||||
|
.serverlist .ver { color: var(--dim); font-family: var(--font-mono); font-size: 17px; white-space: nowrap; }
|
||||||
|
.serverlist .motd { margin: 6px 0 0; color: var(--muted); font-size: 15px; }
|
||||||
|
.serverlist .motd .g { color: var(--gold); }
|
||||||
|
.serverlist .motd .a { color: var(--accent); }
|
||||||
|
.serverlist .stat { text-align: right; flex-shrink: 0; }
|
||||||
|
.serverlist .players { font-variant-numeric: tabular-nums; font-size: 15px; color: var(--muted); display:flex; align-items:center; gap:8px; justify-content:flex-end; }
|
||||||
|
.serverlist .players b { color: var(--text); }
|
||||||
|
|
||||||
|
/* signal bars */
|
||||||
|
.bars { display: inline-flex; align-items: flex-end; gap: 2px; height: 16px; }
|
||||||
|
.bars i { width: 4px; background: var(--accent); image-rendering: pixelated; }
|
||||||
|
.bars i:nth-child(1){height:25%}
|
||||||
|
.bars i:nth-child(2){height:45%}
|
||||||
|
.bars i:nth-child(3){height:65%}
|
||||||
|
.bars i:nth-child(4){height:85%}
|
||||||
|
.bars i:nth-child(5){height:100%}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
PLAYER ROSTER (shared avatar tiles)
|
||||||
|
Used by ServerCard.astro (online row) and PlayerRoster.astro
|
||||||
|
(Members grid). A tile is an NMSR avatar + the player name.
|
||||||
|
============================================================ */
|
||||||
|
.roster {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.roster-tile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 8px;
|
||||||
|
background: var(--bg-2);
|
||||||
|
}
|
||||||
|
.roster-tile img {
|
||||||
|
width: auto;
|
||||||
|
height: 230px;
|
||||||
|
object-fit: contain;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
.roster-name {
|
||||||
|
max-width: 100%;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 18px;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.roster-empty { margin: 0; color: var(--dim); font-size: 14px; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
FEATURES
|
||||||
|
============================================================ */
|
||||||
|
.feat-split {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.2fr 1fr;
|
||||||
|
gap: 28px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.feat-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
.feat-aside {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.feat-aside img {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
height: auto;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.feat-split { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
.feat {
|
||||||
|
display: flex;
|
||||||
|
gap: 18px;
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 26px;
|
||||||
|
box-shadow: inset 2px 2px 0 var(--bevel-hi), inset -2px -2px 0 var(--bevel-lo);
|
||||||
|
transition: transform .14s ease, filter .14s ease;
|
||||||
|
}
|
||||||
|
.feat:hover { transform: translateY(-3px); filter: brightness(1.06); }
|
||||||
|
.feat h3 { font-size: 21px; margin-bottom: 8px; }
|
||||||
|
.feat p { margin: 0; color: var(--muted); font-size: 16px; }
|
||||||
|
|
||||||
|
/* pixel icon — plain (no bevel) */
|
||||||
|
.picon {
|
||||||
|
width: 52px; height: 52px; flex-shrink: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
grid-template-rows: repeat(7, 1fr);
|
||||||
|
background: var(--slot);
|
||||||
|
padding: 4px;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
.picon i { background: transparent; }
|
||||||
|
.picon i.on { background: var(--accent); }
|
||||||
|
.picon i.go { background: var(--gold); }
|
||||||
|
/* custom image icon (PixelIcon `img` prop) */
|
||||||
|
.picon-img {
|
||||||
|
width: 52px; height: 52px; flex-shrink: 0;
|
||||||
|
object-fit: contain;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
HOW TO JOIN — steps
|
||||||
|
============================================================ */
|
||||||
|
.steps { display: flex; flex-direction: column; gap: 20px; }
|
||||||
|
.step {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 92px 1fr;
|
||||||
|
gap: 28px;
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 30px;
|
||||||
|
box-shadow: inset 2px 2px 0 var(--bevel-hi), inset -2px -2px 0 var(--bevel-lo);
|
||||||
|
}
|
||||||
|
.step .num {
|
||||||
|
width: 92px; height: 92px;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
background: var(--slot);
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 46px;
|
||||||
|
color: var(--accent);
|
||||||
|
box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
|
||||||
|
}
|
||||||
|
.step .body { padding-top: 2px; }
|
||||||
|
.step .kicker { font-family: var(--font-pixel); font-size: 9px; letter-spacing: 0.1em; color: var(--dim); text-transform: uppercase; }
|
||||||
|
.step h3 { font-size: 26px; margin: 10px 0 12px; }
|
||||||
|
.step p { margin: 0 0 16px; color: var(--muted); font-size: 16.5px; max-width: 64ch; }
|
||||||
|
.step .actions { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
|
||||||
|
.step ul { margin: 0 0 16px; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 9px; }
|
||||||
|
.step ul li { display: flex; gap: 12px; color: var(--muted); font-size: 16px; }
|
||||||
|
.step ul li::before { content: ""; width: 10px; height: 10px; margin-top: 8px; flex-shrink: 0; background: var(--accent); }
|
||||||
|
.step .hint { color: var(--dim); font-size: 14px; }
|
||||||
|
.step kbd {
|
||||||
|
font-family: var(--font-mono); font-size: 18px; line-height: 1;
|
||||||
|
background: var(--bg-2); color: var(--text);
|
||||||
|
padding: 4px 8px; box-shadow: inset 1px 1px 0 var(--bevel-lo), inset -1px -1px 0 var(--bevel-hi);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
FOOTER
|
||||||
|
============================================================ */
|
||||||
|
.footer { border-top: 1px solid var(--line); background: var(--bg-2); }
|
||||||
|
.footer-in { display: flex; flex-wrap: wrap; gap: 32px; align-items: center; justify-content: space-between; padding-block: 40px; }
|
||||||
|
.footer .brand .name { font-size: 20px; }
|
||||||
|
.footer-links { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.footer .disc { color: var(--dim); font-size: 13px; max-width: 46ch; }
|
||||||
|
|
||||||
|
/* ---- floating dust ---- */
|
||||||
|
.dust { position: absolute; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
|
||||||
|
@keyframes rise {
|
||||||
|
0% { transform: translateY(0) translateX(0); opacity: 0; }
|
||||||
|
10% { opacity: 1; }
|
||||||
|
90% { opacity: 1; }
|
||||||
|
100% { transform: translateY(-110vh) translateX(24px); opacity: 0; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) { .dust { display: none; } }
|
||||||
|
.hero-grid { z-index: 1; }
|
||||||
|
|
||||||
|
/* ---- head-font: Press Start 2P is wide/tall — scale down ---- */
|
||||||
|
:root[data-head="8bit"] .section-title { font-size: clamp(20px, 3vw, 38px); line-height: 1.25; }
|
||||||
|
:root[data-head="8bit"] .hero-tagline { font-size: clamp(15px, 2vw, 26px); line-height: 1.4; }
|
||||||
|
:root[data-head="8bit"] .brand .name { font-size: 15px; }
|
||||||
|
:root[data-head="8bit"] .mc-btn { font-size: 12px; }
|
||||||
|
:root[data-head="8bit"] .mc-btn.sm { font-size: 11px; }
|
||||||
|
:root[data-head="8bit"] .step h3 { font-size: 17px; line-height: 1.35; }
|
||||||
|
:root[data-head="8bit"] .step .num { font-size: 30px; }
|
||||||
|
:root[data-head="8bit"] .feat h3 { font-size: 15px; line-height: 1.4; }
|
||||||
|
:root[data-head="8bit"] .serverlist .title { font-size: 16px; }
|
||||||
|
:root[data-head="8bit"] .ip-chip button { font-size: 12px; }
|
||||||
|
:root[data-head="8bit"] .live-pill { font-size: 12px; }
|
||||||
|
|
||||||
|
/* ---- head-font: Silkscreen is small-cap-ish — nudge ---- */
|
||||||
|
:root[data-head="silkscreen"] .section-title { letter-spacing: 0; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
SCROLL REVEAL (fail-safe: visible at rest; animates only when JS adds .anim)
|
||||||
|
============================================================ */
|
||||||
|
.reveal { will-change: opacity, transform; }
|
||||||
|
@keyframes reveal-in {
|
||||||
|
from { opacity: 0; transform: translateY(22px); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.reveal.anim { animation: reveal-in .6s cubic-bezier(.2,.7,.3,1) both; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
REGISTER PAGE
|
||||||
|
============================================================ */
|
||||||
|
.reg-wrap { width: min(560px, calc(100% - 48px)); margin-inline: auto; }
|
||||||
|
.reg-card {
|
||||||
|
background: var(--surface);
|
||||||
|
padding: clamp(24px, 4vw, 40px);
|
||||||
|
box-shadow: inset 2px 2px 0 var(--bevel-hi), inset -2px -2px 0 var(--bevel-lo), 0 8px 24px oklch(0 0 0 / 0.4);
|
||||||
|
}
|
||||||
|
.reg-card .eyebrow { display: block; margin-bottom: 12px; }
|
||||||
|
.reg-card h1 { font-size: clamp(28px, 4vw, 40px); }
|
||||||
|
.reg-card .lead { margin: 14px 0 0; font-size: 17px; }
|
||||||
|
|
||||||
|
.reg-form { display: flex; flex-direction: column; gap: 18px; margin-top: 28px; }
|
||||||
|
.reg-field { display: flex; flex-direction: column; gap: 7px; }
|
||||||
|
.reg-field label {
|
||||||
|
font-family: var(--font-pixel); font-size: 9px; letter-spacing: 0.1em;
|
||||||
|
color: var(--dim); text-transform: uppercase; line-height: 1.8;
|
||||||
|
}
|
||||||
|
.reg-input {
|
||||||
|
font-family: var(--font-body); font-size: 16px; color: var(--text);
|
||||||
|
background: var(--slot); border: 0; padding: 13px 14px;
|
||||||
|
box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
|
||||||
|
}
|
||||||
|
.reg-input::placeholder { color: var(--dim); }
|
||||||
|
.reg-input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||||
|
.reg-field .hint { color: var(--dim); font-size: 13px; }
|
||||||
|
|
||||||
|
.reg-models { display: flex; gap: 10px; }
|
||||||
|
.reg-models label {
|
||||||
|
display: inline-flex; align-items: center; gap: 8px;
|
||||||
|
font-family: var(--font-body); font-size: 15px; letter-spacing: 0; text-transform: none;
|
||||||
|
color: var(--muted); cursor: pointer;
|
||||||
|
background: var(--slot); padding: 9px 14px;
|
||||||
|
box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
|
||||||
|
}
|
||||||
|
.reg-models input { accent-color: var(--accent); }
|
||||||
|
|
||||||
|
.reg-msg { font-size: 15px; padding: 12px 14px; box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi); }
|
||||||
|
.reg-msg[hidden] { display: none; }
|
||||||
|
.reg-msg.err { background: oklch(0.30 0.10 30); color: oklch(0.92 0.04 40); }
|
||||||
|
.reg-msg.ok { background: var(--slot); color: var(--accent); }
|
||||||
|
|
||||||
|
.reg-foot { margin-top: 22px; color: var(--dim); font-size: 14.5px; }
|
||||||
|
.reg-foot a { color: var(--accent); }
|
||||||
|
|
||||||
|
.reg-success[hidden], .reg-skin[hidden] { display: none; }
|
||||||
|
.reg-success { margin-top: 26px; display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.reg-uuid {
|
||||||
|
font-family: var(--font-mono); font-size: 17px; color: var(--gold);
|
||||||
|
background: var(--slot); padding: 8px 12px; word-break: break-all;
|
||||||
|
box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
|
||||||
|
}
|
||||||
|
.reg-skin { margin-top: 8px; display: flex; flex-direction: column; gap: 16px; border-top: 1px solid var(--line); padding-top: 22px; }
|
||||||
|
.reg-skin h3 { font-size: 22px; }
|
||||||
|
.reg-skin .lead { font-size: 15px; }
|
||||||
|
.reg-file-name { color: var(--muted); font-size: 14px; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
RESPONSIVE
|
||||||
|
============================================================ */
|
||||||
|
@media (max-width: 880px) {
|
||||||
|
:root[data-hero="split"] .hero-grid { grid-template-columns: 1fr; }
|
||||||
|
:root[data-hero="split"] .hero-status { display: block; }
|
||||||
|
.feat-grid { grid-template-columns: 1fr; }
|
||||||
|
.nav-links { display: none; }
|
||||||
|
.serverlist { flex-wrap: wrap; }
|
||||||
|
.serverlist .stat { text-align: left; }
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.step { grid-template-columns: 1fr; gap: 18px; }
|
||||||
|
.step .num { width: 64px; height: 64px; font-size: 32px; }
|
||||||
|
.ip-chip .ip-val { font-size: 22px; }
|
||||||
|
}
|
||||||