Compare commits
75 Commits
6e91749f59
...
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 |
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.
|
||||
125
.env.example
@@ -1,29 +1,112 @@
|
||||
# Copy to .env and fill in real values
|
||||
|
||||
# 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
|
||||
# won't resolve through a normal unicast DNS server.
|
||||
BASE_DOMAIN=ulicraft.lan
|
||||
# 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
|
||||
# DNS is handled OUTSIDE this repo — point ${BASE_DOMAIN} and every subdomain
|
||||
# (auth. avatar. status. distribution. www.) at the host running nginx.
|
||||
BASE_DOMAIN=ulicraft.net
|
||||
|
||||
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:
|
||||
# CF_API_KEY=your-curseforge-api-key
|
||||
|
||||
# ── Blessing Skin auth variant (docker-compose.blessingskin.yml) ──────
|
||||
# Only needed when running the Blessing Skin override instead of Drasl.
|
||||
BS_DB_NAME=blessingskin
|
||||
BS_DB_USER=blessingskin
|
||||
BS_DB_PASSWORD=change-me-bs-db
|
||||
BS_DB_ROOT_PASSWORD=change-me-bs-root
|
||||
# Generate once and paste here so it survives container recreates:
|
||||
# docker run --rm azusamikan/blessing-skin-server-docker:latest php artisan key:generate --show
|
||||
BS_APP_KEY=base64:replace-with-generated-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
|
||||
|
||||
36
.gitignore
vendored
@@ -5,16 +5,17 @@ runtime/authlib-injector.jar
|
||||
|
||||
# rendered configs (from tooling/render-config.sh)
|
||||
drasl/config/config.toml
|
||||
dnsmasq/dnsmasq.conf
|
||||
# packwiz pack files rendered from pack/**/*.tmpl by tooling/render-pack.sh
|
||||
# (domain-dependent build output); source of truth is the .tmpl + custom/ jars.
|
||||
pack/mods/*.pw.toml
|
||||
pack/index.toml
|
||||
pack/CustomSkinLoader/CustomSkinLoader.json
|
||||
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
|
||||
mirror/
|
||||
pack/mods-files/
|
||||
# server mod subset synced from the distribution repo by
|
||||
# tooling/sync-server-mods.sh (source of truth is mods-sides.json + $DIST jars)
|
||||
server-mods/
|
||||
|
||||
# vendored binaries
|
||||
launcher/
|
||||
|
||||
# landing page: generated build output + deps
|
||||
www/
|
||||
@@ -22,5 +23,18 @@ landing/node_modules/
|
||||
landing/.astro/
|
||||
landing/dist/
|
||||
|
||||
# exported Caddy CA (per-deploy artifact)
|
||||
caddy/caddy-root-ca.crt
|
||||
# launcher download list: the custom-launcher build output launcher.json,
|
||||
# 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
|
||||
> persistent auth/skin infrastructure designed to outlive the LAN weekend
|
||||
> and integrate with the existing homelab.
|
||||
> Self-hosted modded Minecraft (NeoForge 1.21.1) with self-hosted auth/skins
|
||||
> (Drasl), a distribution-fed modpack, avatar rendering, a guest landing page, and uptime
|
||||
> 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
|
||||
|
||||
- **Players**: 4–10 friends, LAN-based
|
||||
- **Players**: 4–10 friends
|
||||
- **Modpack vibe**: Kitchen-sink (tech + magic + exploration + QoL)
|
||||
- **Pack approach**: Manually curated (~50–100 mods), NOT a forked existing pack
|
||||
- **Minecraft version**: 1.21.1
|
||||
- **Mod loader**: NeoForge (NOT classic Forge — see Decisions below)
|
||||
- **Network**: LAN party context, but stack is persistent (lives past the weekend)
|
||||
- **Auth**: Self-hosted Yggdrasil-compatible (Drasl) + Keycloak OIDC
|
||||
- **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`.
|
||||
- **Minecraft version**: 1.21.1, **NeoForge** (not classic Forge)
|
||||
- **Auth/skins**: Drasl (Yggdrasil-compatible), **password login** — no OIDC
|
||||
- **DNS**: handled OUTSIDE this repo; point `${BASE_DOMAIN}` + subdomains at the host
|
||||
- **Persistence horizon**: long-term service, not disposable
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
LAN segment
|
||||
│
|
||||
├── AdGuard Home → resolves drasl.home.local, keycloak.home.local
|
||||
│ 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
|
||||
One unified `docker-compose.yml`. The host's own nginx terminates TLS (Let's
|
||||
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
|
||||
via caddy's `mcnet` aliases (`auth.`).
|
||||
|
||||
Clients (LAN party guests):
|
||||
Prism Launcher → authlib-injector account pointing at Drasl
|
||||
→ joins minecraft:25565
|
||||
```
|
||||
Internet ── host nginx (TLS, LE certs, HSTS) ──► caddy (127.0.0.1:8880)
|
||||
│ 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.
|
||||
2. Clicks "Register with Keycloak" → redirected to Keycloak login.
|
||||
3. Logs in with homelab Keycloak credentials.
|
||||
4. Returned to Drasl, picks a player name, uploads a skin.
|
||||
5. Drasl shows them a "Minecraft Token" on their profile page.
|
||||
6. In Prism Launcher: Settings → Accounts → Add authlib-injector account
|
||||
with URL `http://drasl.home.local:25585/authlib-injector` and the
|
||||
Minecraft Token as password.
|
||||
7. Joins Minecraft server. Server validates session against Drasl (via
|
||||
authlib-injector JVM agent), Drasl confirms, player enters with their skin.
|
||||
## Key Decisions
|
||||
|
||||
- **NeoForge over Forge** — the 1.21.x kitchen-sink ecosystem is overwhelmingly
|
||||
NeoForge; the Forge team migrated. `itzg/minecraft-server` via `TYPE=NEOFORGE`.
|
||||
- **Drasl over Ely.by / vanilla offline** — self-hosted Go service, Yggdrasil
|
||||
drop-in, skins + capes, actively maintained. Run with **password login**
|
||||
(no OIDC/Keycloak in this stack).
|
||||
- **Manual curation** (user choice, flagged expensive) — if it gets painful,
|
||||
fork-and-trim Leaking Kitchen Sink is still on the table (closest vibe match).
|
||||
- **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
|
||||
|
||||
### Minecraft 1.21+ secure profile incompatibility
|
||||
- Server: must set `enforce-secure-profile=false` (compose: `ENFORCE_SECURE_PROFILE=FALSE`)
|
||||
- Drasl: must set `SignPublicKeys = false`
|
||||
- These are linked. The Drasl docs explicitly warn: *"Mixed authentication does
|
||||
not work with `SignPublicKeys = true` on Minecraft 1.21+."*
|
||||
### Minecraft 1.21+ secure profile
|
||||
- Server: `ENFORCE_SECURE_PROFILE=FALSE` (`enforce-secure-profile=false`)
|
||||
- Drasl: `SignPublicKeys = false`
|
||||
- Linked. Drasl docs: *"Mixed authentication does not work with
|
||||
`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
|
||||
- Syntax: `-javaagent:/path/to/authlib-injector.jar=<yggdrasil-api-url>`
|
||||
- The URL after `=` is the **API root**, which for Drasl is
|
||||
`<BaseURL>/authlib-injector`.
|
||||
- **Must match between server and client.** If server uses
|
||||
`http://drasl.home.local:25585/authlib-injector` and client uses anything
|
||||
else (e.g. an IP), session validation fails silently with "Invalid session".
|
||||
- Download from: https://github.com/yushijinhun/authlib-injector/releases
|
||||
- Mount into the itzg container 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.
|
||||
- Syntax: `-javaagent:/extras/authlib-injector.jar=<yggdrasil-api-url>`
|
||||
- URL after `=` is the **API root** = `<BaseURL>/authlib-injector`. For this
|
||||
stack: `http://auth.${BASE_DOMAIN}/authlib-injector` (internal, over mcnet).
|
||||
- **Must match between server and client** (clients use the public
|
||||
`https://auth.${BASE_DOMAIN}/authlib-injector`), else session validation fails
|
||||
silently with "Invalid session".
|
||||
- Releases: https://github.com/yushijinhun/authlib-injector — jar lives in
|
||||
`runtime/` (mounted at `/extras/authlib-injector.jar`).
|
||||
|
||||
### NeoForge version pinning
|
||||
Mods are picky about exact NeoForge minor versions. The compose currently
|
||||
pins `NEOFORGE_VERSION: "21.1.209"` as a placeholder. **Verify against
|
||||
https://projects.neoforged.net/neoforged/neoforge before deploying.** Don't
|
||||
use `"latest"` for a stable server.
|
||||
Mods are picky about exact minor versions. Compose pins `NEOFORGE_VERSION:
|
||||
"21.1.233"` — **must match the distribution's NeoForge** (`distribution.json`),
|
||||
since mods are built against it (e.g. ferritecore≥218, farmersdelight≥219).
|
||||
**Verify against https://projects.neoforged.net/neoforged/neoforge before
|
||||
deploying.** Never `"latest"` for a stable server.
|
||||
|
||||
### Drasl username vs player name
|
||||
- **Drasl username** = OIDC user's email (used for web UI login)
|
||||
- **Player name** = `preferred_username` from OIDC, or user-chosen if
|
||||
`AllowChoosingPlayerName = true`
|
||||
- Keycloak users must have email set. Verify the realm's email-as-username
|
||||
setting and the `preferred_username` mapper are configured.
|
||||
### Mod source + server filtering
|
||||
The full modset lives in the distribution repo (`$DISTRIBUTION_WEB_ROOT/servers/
|
||||
ulicraft-1.21.1/forgemods/required/*.jar`), managed by Nebula. Clients get
|
||||
everything via `distribution.json`. The server runs a **filtered subset**:
|
||||
`tooling/classify-mods.py` reads each jar's `neoforge.mods.toml` with tomllib and
|
||||
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
|
||||
Prefer **Modrinth over CurseForge** for itzg auto-download. CurseForge
|
||||
requires an API key (`CF_API_KEY`) and has been historically flakier with
|
||||
the itzg image. Use CF only for mods that are exclusive to it.
|
||||
### Filestash (files.) — four traps
|
||||
Full list in `plan/20-files.md`; the ones that cost real time:
|
||||
- **`general.host` is a BARE HOSTNAME** (`files.${BASE_DOMAIN}`, no scheme) +
|
||||
`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
|
||||
|
||||
The working compose file is at `./docker-compose.yml`. Key environment vars
|
||||
documented inline. Adjacent files:
|
||||
|
||||
```
|
||||
.
|
||||
├── 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
|
||||
```
|
||||
**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
|
||||
(`curl /api/config` → `origin`) and re-read `docker compose logs filestash` —
|
||||
the broken redirect announced itself there (`msg=Redirecting to http://https://`)
|
||||
while every other check passed.
|
||||
|
||||
### Memory sizing
|
||||
With ~50–100 mods and 8 concurrent players on 1.21.1:
|
||||
- `INIT_MEMORY: 4G`, `MAX_MEMORY: 10G` is a safe starting point
|
||||
- Use `USE_AIKAR_FLAGS: TRUE` for the well-known GC tuning
|
||||
- Monitor with `mc-monitor` (itzg makes one) if you want metrics
|
||||
~50–100 mods, 8 players, 1.21.1: `INIT_MEMORY: 4G`, `MAX_MEMORY: 10G`,
|
||||
`USE_AIKAR_FLAGS: TRUE`.
|
||||
|
||||
### Backups
|
||||
`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.
|
||||
## Config (.env)
|
||||
|
||||
## 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**
|
||||
- Client type: `OpenID Connect`
|
||||
- Client ID: `drasl`
|
||||
- Name: `Drasl Minecraft Auth`
|
||||
|
||||
2. **Capability config**
|
||||
- Client authentication: **ON** (confidential client)
|
||||
- Authentication flow: **Standard flow** only
|
||||
- Disable: Direct access grants, Implicit, Service accounts
|
||||
|
||||
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
|
||||
Launcher: **FjordLauncherUnlocked** (Prism fork, first-class authlib-injector),
|
||||
mirrored at apex `/launcher/`. Per guest:
|
||||
1. Download from `https://${BASE_DOMAIN}` → `/launcher/`.
|
||||
2. Add an authlib-injector account, URL
|
||||
`https://auth.${BASE_DOMAIN}/authlib-injector` (register/login at
|
||||
`https://auth.${BASE_DOMAIN}`).
|
||||
3. The launcher installs the modpack from the distribution (`distribution.json`).
|
||||
(packwiz and the `pack.` service/subdomain have been fully removed.)
|
||||
4. Connect to `${BASE_DOMAIN}` (`:25565`).
|
||||
|
||||
## 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:
|
||||
- Performance (Embeddium/Sodium-equivalent for NeoForge, FerriteCore, ModernFix)
|
||||
- Tech/automation (Mekanism, Create, Immersive Engineering, AE2)
|
||||
- Magic (Ars Nouveau, Botania, Iron's Spells 'n Spellbooks)
|
||||
- Storage (Sophisticated Storage/Backpacks, Functional Storage)
|
||||
- Exploration (YUNG's structures, Repurposed Structures, biome packs)
|
||||
- QoL (JEI, Jade, JEI Resources, Inventory Profiles Next, AppleSkin)
|
||||
- World gen (Tectonic, Terralith — check NeoForge 1.21.1 compat)
|
||||
- Compat glue (Polymorph for recipe conflicts)
|
||||
- Map (XaeroMinimap + Xaero World Map)
|
||||
- Voice (Simple Voice Chat — port 24454/udp already in compose)
|
||||
- [ ] **Decide on dimension/server-side performance mods** (C2ME, Lithium-equivalent)
|
||||
- [ ] **Reverse proxy + local TLS** for Drasl (Caddy + step-ca recommended,
|
||||
given the existing homelab)
|
||||
- [ ] **DNS record** in AdGuard for `drasl.home.local` → Docker host LAN IP
|
||||
- [ ] **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
|
||||
- [ ] **WS6 — Mod shortlist + list component (`plan/17-mod-shortlist.md`).**
|
||||
Light gap doc: current 76-jar pack is vanilla+ QoL/perf; **empty** on tech &
|
||||
magic, thin worldgen, no map; orphan deps `ponderjs`/`Necronomicon`. Landing
|
||||
mod-list component = auto-generated `mods.json` + extracted logos from the
|
||||
distribution forgemods (`tooling/build-modlist.py`), flat alphabetical, pretty
|
||||
names, no categories/links; feeds the "50+" stat tile.
|
||||
- [ ] Server-side perf mods (C2ME, etc.).
|
||||
- [x] **Filestash file share** (`files.`, 2026-07-14) — live in prod. One shared
|
||||
htpasswd login, writable, rendered `:ro` config. See `plan/20-files.md`.
|
||||
Follow-ups: add the Uptime Kuma HTTP monitor for `files.`; the share is
|
||||
**outside mc-backup** and unquota'd (accepted — eyeballed).
|
||||
- [ ] **Verify NeoForge version** against current stable before first deploy.
|
||||
- [x] **Bootstrap Drasl admin** account (`admin`, 2026-06-10). Key is
|
||||
`DefaultAdmins` (not `DefaultAdminUsernames`); first admin needs an invite-flip.
|
||||
See `plan/03-drasl.md` gotchas.
|
||||
|
||||
## Reference URLs
|
||||
|
||||
- Drasl repo: https://github.com/unmojang/drasl
|
||||
- Drasl configuration docs: https://github.com/unmojang/drasl/blob/master/doc/configuration.md
|
||||
- Drasl recipes (example configs): https://github.com/unmojang/drasl/blob/master/doc/recipes.md
|
||||
- Drasl: https://github.com/unmojang/drasl — config docs:
|
||||
https://github.com/unmojang/drasl/blob/master/doc/configuration.md
|
||||
- authlib-injector: https://github.com/yushijinhun/authlib-injector
|
||||
- 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
|
||||
- NeoForge versions: https://projects.neoforged.net/neoforged/neoforge
|
||||
- Prism Launcher: https://prismlauncher.org/
|
||||
- Reference modpacks for inspiration (NOT forking):
|
||||
- ATM10 (Modrinth/CurseForge — ~500 mods, NeoForge 1.21.1)
|
||||
- Leaking Kitchen Sink (CurseForge — trimmed ~150 mods, NeoForge 1.21.1)
|
||||
- NMSR: https://github.com/NickAcPT/nmsr-rs
|
||||
- Uptime Kuma: https://github.com/louislam/uptime-kuma
|
||||
- FjordLauncherUnlocked: https://github.com/hero-persson/FjordLauncherUnlocked
|
||||
- Reference packs (inspiration, NOT forking): ATM10, Leaking Kitchen Sink.
|
||||
|
||||
## Communication Preferences (carry-over from past sessions)
|
||||
## Communication Preferences (carry-over)
|
||||
|
||||
- Concise and direct
|
||||
- Diagnose and resolve without demanding detailed reproduction steps
|
||||
- Prefer iterative single-change updates over large rewrites
|
||||
- Cautious scripts that halt on ambiguity rather than proceeding silently
|
||||
- Avoid over-engineered responses when a simple answer suffices
|
||||
- Spanish or English fine; user is comfortable in both
|
||||
- Concise and direct; diagnose + resolve without demanding repro steps.
|
||||
- Prefer iterative single-change updates over large rewrites.
|
||||
- Cautious scripts that halt on ambiguity rather than proceeding silently.
|
||||
- Avoid over-engineering when a simple answer suffices.
|
||||
- Spanish or English both fine.
|
||||
|
||||
267
README.md
@@ -1,111 +1,232 @@
|
||||
# Ulicraft Server
|
||||
|
||||
Self-hosted modded Minecraft (NeoForge 1.21.1) for a LAN party. Runs **online**
|
||||
(internet present) or **fully air-gapped** (offline). Self-hosted auth/skins
|
||||
(Drasl), a packwiz modpack, a guest landing page, and a transparent mirror of
|
||||
Mojang/launcher/NeoForge assets so guest laptops need no internet.
|
||||
Self-hosted modded Minecraft (NeoForge 1.21.1) with self-hosted auth/skins
|
||||
(Drasl), a distribution-fed modpack, avatar rendering, a guest landing page, and
|
||||
uptime monitoring. One unified `docker-compose.yml` runs the whole stack behind
|
||||
the host's nginx + Let's Encrypt.
|
||||
|
||||
## Stack
|
||||
|
||||
One compose file, one `docker compose up -d`:
|
||||
|
||||
| Service | Image | Role |
|
||||
|---|---|---|
|
||||
| `minecraft` | itzg/minecraft-server | NeoForge 1.21.1 server, `:25565` |
|
||||
| `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 |
|
||||
|
||||
Optional layers: `avahi` (mDNS `.local`), `dnsmasq` (turnkey DNS) — only if you
|
||||
don't run your own DNS.
|
||||
Vhosts (all proxied through the host nginx → caddy):
|
||||
|
||||
Config lives in `.env`: `BASE_DOMAIN` (default `ulicraft.lan`), `HOST_LAN_IP`,
|
||||
`RCON_PASSWORD`, `ENABLE_MDNS`. Services are subdomains: `auth.`, `packwiz.`,
|
||||
`mc.`, plus the apex landing page.
|
||||
- apex `${BASE_DOMAIN}` — landing page + `/launcher/` downloads + `/api/mcstatus/*`
|
||||
- `auth.` — Drasl
|
||||
- `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
|
||||
tooling/dns-records.sh online # service names only (internet present)
|
||||
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`.
|
||||
Handled **outside this repo**. Point `${BASE_DOMAIN}` and every subdomain
|
||||
(`auth. avatar. status. files. distribution. www.`) at the host running nginx.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Docker + Docker Compose; for prep also `pnpm`, `packwiz`, `jq`, `curl`,
|
||||
`envsubst`, `sha1sum`.
|
||||
Docker + Docker Compose; for prep also `pnpm`, `jq`, `curl`, `envsubst`.
|
||||
|
||||
## Launch
|
||||
|
||||
```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,
|
||||
# mirror mods+assets, fetch launcher +
|
||||
# authlib, pre-bake the server volume
|
||||
# One-time / on change — render configs + build content:
|
||||
tooling/render-config.sh # drasl + nmsr configs from *.tmpl
|
||||
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
|
||||
tooling/build-stack.sh --up airgap # offline; full asset mirror on :443 (default)
|
||||
tooling/build-stack.sh --up core # base only (debugging)
|
||||
docker compose up -d --build # first run installs NeoForge + mods, builds nmsr
|
||||
docker compose down # stop
|
||||
```
|
||||
|
||||
`--up` prints the DNS records to add for that mode. Down:
|
||||
`docker compose -f docker-compose.yml -f docker-compose.static.yml -f docker-compose.mirror.yml down`
|
||||
First boot: itzg installs NeoForge + the synced server mods into the `mc_data` volume;
|
||||
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)
|
||||
|
||||
1. Open `http://ulicraft.lan`, download FjordLauncherUnlocked for your OS.
|
||||
2. Add an **authlib-injector** account, URL `http://auth.ulicraft.lan/authlib-injector`
|
||||
(register/login at `http://auth.ulicraft.lan`).
|
||||
3. **Air-gap only:** trust the local CA — download `http://ulicraft.lan/ca.crt`
|
||||
and add it to your OS trust store (so the HTTPS asset mirror is trusted).
|
||||
4. Import the pack: `http://packwiz.ulicraft.lan/pack.toml`.
|
||||
5. Connect to **`mc.ulicraft.lan`** (no port needed).
|
||||
1. Open `https://${BASE_DOMAIN}`, download FjordLauncherUnlocked for your OS.
|
||||
2. Add an **authlib-injector** account, URL
|
||||
`https://auth.${BASE_DOMAIN}/authlib-injector` (register/login at
|
||||
`https://auth.${BASE_DOMAIN}`).
|
||||
3. The launcher installs the modpack from the distribution automatically.
|
||||
4. Connect to **`${BASE_DOMAIN}`** (Minecraft, `:25565`).
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
docker-compose.yml # core: drasl, minecraft, mc-backup, caddy
|
||||
.static.yml .mirror.yml # online/airgap layers
|
||||
.avahi.yml .dnsmasq.yml # optional DNS layers
|
||||
caddy/Caddyfile + conf.d/*.caddy # ingress vhost snippets (core/static/mirror)
|
||||
docker-compose.yml # the whole stack (one file)
|
||||
caddy/Caddyfile + conf.d/*.caddy # ingress vhost snippets
|
||||
# (core/static/distribution/avatar/status)
|
||||
drasl/config/config.toml.tmpl # auth config (rendered)
|
||||
dnsmasq/dnsmasq.conf.tmpl # optional DNS config (rendered)
|
||||
docker/avahi/ # mDNS responder image
|
||||
pack/ # packwiz modpack source
|
||||
nmsr/config.toml.tmpl # avatar renderer config, Drasl-backed (rendered)
|
||||
docker/nmsr/ # built-from-source NMSR image
|
||||
landing/ # Astro site → www/
|
||||
tooling/ # build-stack, render-config, dns-records,
|
||||
# mirror-mods, mirror-airgap, fetch-launcher
|
||||
mirror/ # vendored jars + launcher + asset mirror (gitignored)
|
||||
plan/ # full design docs (00–12)
|
||||
launcher/ # FjordLauncher assets (gitignored)
|
||||
server-mods/ # filtered server mod jars (synced, gitignored)
|
||||
tooling/ # render-config, sync-server-mods, render-nginx,
|
||||
# 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.
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
# Base Caddy config. Vhosts live in conf.d/*.caddy snippets that are mounted
|
||||
# in selectively so the stack can run with or without the static site / mirror:
|
||||
# conf.d/00-core.caddy auth + packwiz (always — core ingress)
|
||||
# conf.d/10-static.caddy apex landing + launcher downloads (static toggle)
|
||||
# conf.d/20-mirror.caddy air-gap upstream mirror (tls internal) (mirror toggle)
|
||||
# auto_https disable_redirects: http:// sites stay plaintext on :80; the mirror
|
||||
# snippet's hosts still get internal TLS on :443 when that snippet is present.
|
||||
# Base Caddy config. Vhosts live in conf.d/*.caddy snippets:
|
||||
# conf.d/00-core.caddy auth
|
||||
# conf.d/10-static.caddy apex landing + launcher downloads
|
||||
# conf.d/30-distribution.caddy static site from another repo
|
||||
# conf.d/40-avatar.caddy nmsr skin/avatar renderer
|
||||
# conf.d/50-status.caddy uptime-kuma status page
|
||||
# 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
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
# Core ingress — Blessing Skin variant. Replaces 00-core.caddy when the
|
||||
# docker-compose.blessingskin.yml override is in play (mounted at the same
|
||||
# target path). auth.* now points at Blessing Skin instead of Drasl.
|
||||
#
|
||||
# Blessing Skin serves BOTH its web UI (/) and the Yggdrasil API (/api/yggdrasil)
|
||||
# on the same vhost, so a single reverse_proxy covers everything.
|
||||
http://auth.{$BASE_DOMAIN} {
|
||||
reverse_proxy blessing-skin:80
|
||||
}
|
||||
|
||||
http://pack.{$BASE_DOMAIN} {
|
||||
root * /srv/pack
|
||||
file_server browse
|
||||
}
|
||||
@@ -1,9 +1,4 @@
|
||||
# Core ingress — always mounted. Drasl auth + packwiz pack metadata.
|
||||
# Core ingress — always mounted. Drasl auth.
|
||||
http://auth.{$BASE_DOMAIN} {
|
||||
reverse_proxy drasl:25585
|
||||
}
|
||||
|
||||
http://pack.{$BASE_DOMAIN} {
|
||||
root * /srv/pack
|
||||
file_server browse
|
||||
}
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
# Static toggle — apex landing page + launcher downloads.
|
||||
# Mounted only when the stack runs with static files (build-stack.sh up static|full).
|
||||
# Apex — landing page (/srv/www) + launcher downloads (/srv/launcher).
|
||||
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
|
||||
# the read-only /srv/www. handle_path strips the /launcher prefix.
|
||||
handle_path /launcher/* {
|
||||
root * /srv/launcher
|
||||
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 {
|
||||
root * /srv/www
|
||||
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,103 +0,0 @@
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
# Blessing Skin auth variant — use INSTEAD of Drasl.
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
# Layer this over the base file:
|
||||
#
|
||||
# docker compose -f docker-compose.yml -f docker-compose.blessingskin.yml up -d
|
||||
#
|
||||
# What it does vs the base stack:
|
||||
# - drasl → left running but UNUSED + unreachable (Caddy no longer
|
||||
# routes to it; it has no host port). Compose merges
|
||||
# depends_on, so it can't be removed from an override —
|
||||
# idling it is the clean option. `docker compose ... stop
|
||||
# drasl` after up if you want it down.
|
||||
# - caddy → auth.* now reverse-proxies blessing-skin:80
|
||||
# - mariadb → new; Blessing Skin's datastore (no SQLite support)
|
||||
# - blessing-skin → new; PHP skin server + yggdrasil-api plugin
|
||||
# - minecraft → authlib-injector now points at /api/yggdrasil,
|
||||
# ONLINE_MODE=TRUE (real session validation against BSS)
|
||||
#
|
||||
# First-run is a WEB WIZARD — see plan/03b-blessing-skin.md. The yggdrasil-api
|
||||
# plugin must be installed + enabled from the BSS admin panel before the
|
||||
# /api/yggdrasil endpoint (and thus Minecraft login) works.
|
||||
#
|
||||
# DB creds + APP_KEY come from .env (see .env.example: BS_* vars).
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
|
||||
services:
|
||||
caddy:
|
||||
# Swap the core ingress conf for the Blessing Skin one. Compose MERGES
|
||||
# volumes by container path, so mounting the BSS conf at the SAME target
|
||||
# (/etc/caddy/conf.d/00-core.caddy) shadows the base 00-core.caddy; all
|
||||
# other base mounts (pack, custom, static, ca) are kept automatically.
|
||||
volumes:
|
||||
- ./caddy/conf.d/00-core-blessingskin.caddy:/etc/caddy/conf.d/00-core.caddy:ro
|
||||
depends_on:
|
||||
- blessing-skin
|
||||
|
||||
mariadb:
|
||||
image: mariadb:11
|
||||
container_name: mariadb
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MARIADB_DATABASE: ${BS_DB_NAME}
|
||||
MARIADB_USER: ${BS_DB_USER}
|
||||
MARIADB_PASSWORD: ${BS_DB_PASSWORD}
|
||||
MARIADB_ROOT_PASSWORD: ${BS_DB_ROOT_PASSWORD}
|
||||
TZ: "Europe/Madrid"
|
||||
volumes:
|
||||
- bs_db:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
networks:
|
||||
- mcnet
|
||||
|
||||
blessing-skin:
|
||||
image: azusamikan/blessing-skin-server-docker:latest
|
||||
container_name: blessing-skin
|
||||
restart: unless-stopped
|
||||
# Internal-only — reached via Caddy at auth.${BASE_DOMAIN}.
|
||||
environment:
|
||||
APP_URL: "http://auth.${BASE_DOMAIN}"
|
||||
# Persist APP_KEY across recreates (else sessions/encryption break).
|
||||
# Generate once: docker run --rm azusamikan/blessing-skin-server-docker:latest php artisan key:generate --show
|
||||
APP_KEY: ${BS_APP_KEY}
|
||||
DB_DRIVER: "mysql"
|
||||
DB_HOST: "mariadb"
|
||||
DB_PORT: "3306"
|
||||
DB_DATABASE: ${BS_DB_NAME}
|
||||
DB_USERNAME: ${BS_DB_USER}
|
||||
DB_PASSWORD: ${BS_DB_PASSWORD}
|
||||
TZ: "Europe/Madrid"
|
||||
volumes:
|
||||
- bs_storage:/app/storage # uploaded skins/capes + textures
|
||||
- bs_plugins:/app/plugins # yggdrasil-api plugin lives here
|
||||
depends_on:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- mcnet
|
||||
|
||||
minecraft:
|
||||
environment:
|
||||
# authlib-injector API root for Blessing Skin's yggdrasil-api plugin
|
||||
# is <site>/api/yggdrasil (NOT /authlib-injector like Drasl).
|
||||
JVM_OPTS: "-javaagent:/extras/authlib-injector.jar=http://auth.${BASE_DOMAIN}/api/yggdrasil"
|
||||
# authlib-injector needs real auth: online-mode TRUE so the server
|
||||
# validates sessions against Blessing Skin. (Drasl variant used FALSE.)
|
||||
ONLINE_MODE: "TRUE"
|
||||
# Keep FALSE for safety on 1.21+. The yggdrasil-api plugin DOES sign
|
||||
# profile keys, so you MAY flip this to TRUE if signed chat is wanted.
|
||||
ENFORCE_SECURE_PROFILE: "FALSE"
|
||||
# Merged with the base depends_on (drasl, caddy); just adds blessing-skin so
|
||||
# Minecraft starts after the skin server is up.
|
||||
depends_on:
|
||||
- blessing-skin
|
||||
|
||||
volumes:
|
||||
bs_db:
|
||||
bs_storage:
|
||||
bs_plugins:
|
||||
@@ -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,55 +1,31 @@
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
# Ulicraft LAN-party stack — Minecraft 1.21.1 NeoForge + Drasl auth
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
# Single source of config: BASE_DOMAIN + HOST_LAN_IP in .env.
|
||||
# DNS is provided by YOUR party DNS server — see tooling/dns-records.sh for the
|
||||
# records to add (online vs airgap). A turnkey dnsmasq is available as an
|
||||
# optional layer: docker-compose.dnsmasq.yml.
|
||||
# - caddy is the only HTTP ingress; holds mcnet aliases so the stack
|
||||
# resolves auth.${BASE_DOMAIN} / packwiz.${BASE_DOMAIN} internally too
|
||||
# - drasl handles auth (password login; NO Keycloak/OIDC in this stack)
|
||||
# - packwiz pack is served by caddy at packwiz.${BASE_DOMAIN}
|
||||
# - minecraft installs mods via PACKWIZ_URL, authlib-injector -> auth.
|
||||
# Single, unified compose. One file holds the whole stack:
|
||||
# drasl auth + skins (Yggdrasil), internal only
|
||||
# minecraft the server (itzg/NEOFORGE), mods from ./server-mods volume
|
||||
# mc-backup world backups every 6h via RCON
|
||||
# caddy internal ingress — routes auth./apex/launcher/avatar.
|
||||
# /distribution. by Host header (conf.d/*.caddy)
|
||||
# nmsr skin/avatar renderer at avatar.${BASE_DOMAIN}
|
||||
# uptime-kuma status monitoring at status.${BASE_DOMAIN}
|
||||
#
|
||||
# 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:
|
||||
# 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
|
||||
- ./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
|
||||
- ./pack:/srv/pack:ro
|
||||
# Custom (locally-hosted) mod jars live OUTSIDE ./pack so packwiz refresh
|
||||
# doesn't index them as direct files — they're external download targets
|
||||
# referenced by pack/mods/*.pw.toml. Served at pack.${BASE_DOMAIN}/custom/.
|
||||
- ./custom:/srv/pack/custom: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}"
|
||||
- "pack.${BASE_DOMAIN}"
|
||||
drasl:
|
||||
image: unmojang/drasl:latest
|
||||
container_name: drasl
|
||||
restart: unless-stopped
|
||||
# Internal-only: no published host port. Reached via Caddy at
|
||||
# auth.${BASE_DOMAIN} (Caddy holds the network alias on mcnet).
|
||||
# Internal-only: no published host port. Reached via caddy at
|
||||
# auth.${BASE_DOMAIN} (caddy holds the network alias on mcnet).
|
||||
volumes:
|
||||
- ./drasl/config:/etc/drasl:ro
|
||||
- drasl_state:/var/lib/drasl
|
||||
@@ -72,7 +48,9 @@ services:
|
||||
TYPE: "NEOFORGE"
|
||||
VERSION: "1.21.1"
|
||||
# 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 ────────────────────────────────────
|
||||
INIT_MEMORY: "4G"
|
||||
@@ -80,23 +58,37 @@ services:
|
||||
USE_AIKAR_FLAGS: "TRUE"
|
||||
JVM_XX_OPTS: "-XX:+UseG1GC"
|
||||
|
||||
# ── Auth: offline mode + authlib-injector pointing at Drasl ─
|
||||
ONLINE_MODE: "FALSE"
|
||||
# mount authlib-injector.jar at /extras/authlib-injector.jar
|
||||
JVM_OPTS: "-javaagent:/extras/authlib-injector.jar=http://auth.${BASE_DOMAIN}/authlib-injector"
|
||||
# ── Auth: ONLINE mode validated against Drasl via authlib-injector ─
|
||||
# MUST be true: authlib-injector redirects the normal online-auth
|
||||
# handshake from Mojang to Drasl. online-mode=false skips the handshake
|
||||
# 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 ───────────────────────────────────────────
|
||||
DIFFICULTY: "normal"
|
||||
MODE: "survival"
|
||||
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"
|
||||
VIEW_DISTANCE: "10"
|
||||
SIMULATION_DISTANCE: "8"
|
||||
ENFORCE_SECURE_PROFILE: "FALSE" # required for authlib-injector on 1.21+
|
||||
ALLOW_FLIGHT: "TRUE" # many tech mods need this
|
||||
|
||||
# ── Mod source: packwiz pack served by caddy ────────────────
|
||||
PACKWIZ_URL: "http://pack.${BASE_DOMAIN}/pack.toml"
|
||||
# ── Mod source: filtered subset of the distribution modset ──
|
||||
# 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 ───────────────────────────────────
|
||||
ENABLE_RCON: "TRUE"
|
||||
@@ -106,14 +98,29 @@ services:
|
||||
TZ: "Europe/Madrid"
|
||||
volumes:
|
||||
- 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:
|
||||
- drasl
|
||||
- caddy # packwiz pack is served by caddy
|
||||
- drasl # authlib (auth.) must resolve at boot
|
||||
# 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:
|
||||
- mcnet
|
||||
|
||||
# Optional: backups
|
||||
mc-backup:
|
||||
image: itzg/mc-backup
|
||||
container_name: mc-backup
|
||||
@@ -132,9 +139,129 @@ services:
|
||||
networks:
|
||||
- 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:
|
||||
drasl_state:
|
||||
mc_data:
|
||||
kuma_data:
|
||||
filestash_state:
|
||||
|
||||
networks:
|
||||
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).
|
||||
# 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.
|
||||
|
||||
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}"
|
||||
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.
|
||||
AllowPasswordLogin = true
|
||||
@@ -18,5 +22,29 @@ SignPublicKeys = false
|
||||
# Free-text owner label shown in the web UI (a string, not a table).
|
||||
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).
|
||||
# [[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>
|
||||