site: dawn.gg-style landing at root + port /skin/* for TG bot

- serve new launcher landing site (zern.cc root -> launcher server 1582)
- static assets mounted at /css /js /img
- port GET/HEAD /skin/{filename} from old site with identical headers
- exempt /skin/* + site assets from rate-limit and cache middleware
- Caddy: root domains -> 1582, old site preserved at legacy.zernmc.*
This commit is contained in:
SashegDev
2026-08-20 08:26:00 +00:00
parent 943211314e
commit b4bd76ed35
6 changed files with 1040 additions and 4 deletions
+58 -4
View File
@@ -12,7 +12,8 @@ import json
import structlog
from cachetools import TTLCache
from fastapi import Depends, FastAPI, HTTPException, Request, Response
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol
# Disable httpx debug logging
@@ -46,6 +47,12 @@ BUILDS_DIR = Path("builds")
VERSIONS_DIR = BUILDS_DIR / "versions"
WHITELIST_DIR = Path(__file__).parent / "whitelist"
# Website (landing) static files
SITE_DIR = Path(__file__).parent / "site"
# SkinRestorer skins (shared with TG bot)
SKINS_DIR = Path("/root/python/site/skins")
# Mirror configuration
LAUNCHER_MIRRORS = {
"main": "https://api.zern.cc", # primary
@@ -838,7 +845,7 @@ class CacheControlMiddleware:
path = scope.get("path", "")
# Skip caching for dynamic endpoints
skip_cache = any(p in path for p in ["/api/", "/auth/", "/login", "/launch", "/install"])
skip_cache = any(p in path for p in ["/api/", "/auth/", "/login", "/launch", "/install", "/skin/"])
if skip_cache:
await self.app(scope, receive, send)
return
@@ -953,6 +960,12 @@ app.include_router(admin_router)
app.include_router(friends_router)
app.include_router(playtime_router)
# Static assets for the landing site
for sub in ("css", "js", "img"):
mount_dir = SITE_DIR / sub
mount_dir.mkdir(parents=True, exist_ok=True)
app.mount(f"/{sub}", StaticFiles(directory=mount_dir), name=f"site-{sub}")
# Monkey patch to catch invalid HTTP requests
original_data_received = HttpToolsProtocol.data_received
@@ -990,8 +1003,10 @@ HttpToolsProtocol.data_received = patched_data_received
@app.get("/")
async def root():
"""Root endpoint"""
logger.info("Root endpoint accessed")
"""Root endpoint - serves the launcher landing site"""
index_path = SITE_DIR / "index.html"
if index_path.exists():
return HTMLResponse(index_path.read_text(encoding="utf-8"))
return {
"status": "ok",
"message": "ZernMC Launcher Server is running",
@@ -1000,6 +1015,45 @@ async def root():
}
@app.get("/skin/{filename}")
@app.head("/skin/{filename}")
async def get_skin(filename: str, request: Request):
"""Serve SkinRestorer skins (compatibility with TG bot direct links)"""
if not filename or ".." in filename or "/" in filename or "\\" in filename:
raise HTTPException(status_code=404, detail="Скин не найден")
filepath = SKINS_DIR / filename
if not filepath.is_file():
raise HTTPException(status_code=404, detail="Скин не найден")
try:
content = filepath.read_bytes()
size = len(content)
headers = {
"Content-Length": str(size),
"Content-Type": "image/png",
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0",
"Access-Control-Allow-Origin": "*",
"Content-Encoding": "identity",
}
if request.method == "HEAD":
return Response(status_code=200, headers=headers)
return Response(
content=content,
media_type="image/png",
headers=headers
)
except Exception as e:
logger.error("Skin read error", error=str(e))
raise HTTPException(status_code=500, detail="Ошибка чтения скина")
@app.get("/health")
async def health():
"""Health check endpoint"""
+3
View File
@@ -159,6 +159,9 @@ class LoggingMiddleware(BaseHTTPMiddleware):
# Skip rate limiting for pack file downloads (direct high-speed serving)
if path.startswith("/pack/") and "/file/" in path:
is_file_download = True
elif path.startswith(("/skin/", "/css/", "/js/", "/img/")):
# SkinRestorer skins + landing site assets must not be rate-limited
is_file_download = True
else:
is_file_download = False
+613
View File
@@ -0,0 +1,613 @@
:root {
--accent: #e94560;
--accent-soft: #ff6b6b;
--bg-deep: #07070a;
--bg-surface: #0c0c12;
--bg-card: #16161f;
--text: #eeeef0;
--text-secondary: #88889a;
--border: rgba(255, 255, 255, 0.06);
--font: 'Onest', system-ui, -apple-system, 'Segoe UI', sans-serif;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
font-family: var(--font);
background: var(--bg-deep);
color: var(--text);
line-height: 1.55;
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
}
a { color: inherit; text-decoration: none; }
img { display: block; }
ul { list-style: none; }
::selection { background: var(--accent); color: #fff; }
/* ---------- Nav ---------- */
.nav {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
transition: background .3s ease, border-color .3s ease, backdrop-filter .3s ease;
border-bottom: 1px solid transparent;
}
.nav.scrolled {
background: rgba(7, 7, 10, 0.72);
backdrop-filter: blur(14px);
border-bottom-color: var(--border);
}
.nav-inner {
max-width: 1200px;
margin: 0 auto;
padding: 18px 32px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
}
.nav-brand {
display: flex;
align-items: center;
gap: 10px;
font-weight: 700;
font-size: 18px;
letter-spacing: 0.3px;
}
.nav-brand img { border-radius: 6px; }
.nav-links {
display: flex;
gap: 36px;
font-size: 15px;
font-weight: 500;
color: var(--text-secondary);
}
.nav-links a { position: relative; transition: color .2s ease; }
.nav-links a::after {
content: '';
position: absolute;
left: 0;
bottom: -6px;
width: 0;
height: 2px;
background: var(--accent);
transition: width .25s ease;
}
.nav-links a:hover { color: var(--text); }
.nav-links a:hover::after { width: 100%; }
.nav-cta {
font-size: 14px;
font-weight: 600;
padding: 10px 22px;
border-radius: 8px;
background: var(--accent);
color: #fff;
transition: background .2s ease, transform .15s ease;
}
.nav-cta:hover { background: var(--accent-soft); transform: translateY(-1px); }
/* ---------- Hero ---------- */
.hero {
position: relative;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
overflow: hidden;
padding: 120px 32px 80px;
background:
radial-gradient(1000px 500px at 50% -10%, rgba(233, 69, 96, 0.12), transparent 60%),
var(--bg-deep);
}
.hero-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(255, 255, 255, 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px);
background-size: 64px 64px;
mask-image: radial-gradient(ellipse 70% 60% at 50% 40%, black 20%, transparent 75%);
-webkit-mask-image: radial-gradient(ellipse 70% 60% at 50% 40%, black 20%, transparent 75%);
}
.hero-glow {
position: absolute;
width: 560px;
height: 560px;
left: 50%;
top: 42%;
transform: translate(-50%, -50%);
background: radial-gradient(circle, rgba(233, 69, 96, 0.16), transparent 65%);
filter: blur(20px);
animation: pulse 6s ease-in-out infinite;
pointer-events: none;
}
@keyframes pulse {
0%, 100% { opacity: 0.6; transform: translate(-50%, -50%) scale(1); }
50% { opacity: 1; transform: translate(-50%, -50%) scale(1.12); }
}
.hero-diamond {
position: absolute;
border: 1px solid rgba(233, 69, 96, 0.35);
transform: rotate(45deg);
opacity: 0;
animation: float-in 2s ease forwards;
}
.hero-diamond--1 {
width: 260px; height: 260px;
right: 8%; top: 16%;
animation-delay: .4s;
}
.hero-diamond--2 {
width: 150px; height: 150px;
left: 10%; top: 24%;
border-color: rgba(255, 255, 255, 0.1);
animation-delay: .7s;
}
.hero-diamond--3 {
width: 90px; height: 90px;
left: 24%; bottom: 14%;
animation-delay: 1s;
}
@keyframes float-in {
0% { opacity: 0; transform: rotate(45deg) scale(0.6); }
100% { opacity: 1; transform: rotate(45deg) scale(1); }
}
.hero-inner { position: relative; z-index: 2; max-width: 900px; will-change: transform; }
.hero-kicker {
font-size: 13px;
font-weight: 600;
letter-spacing: 3px;
color: var(--accent);
margin-bottom: 24px;
text-transform: uppercase;
}
.hero-title {
font-size: clamp(52px, 9vw, 108px);
font-weight: 900;
line-height: 0.98;
letter-spacing: -2px;
margin-bottom: 28px;
}
.hero-title-accent {
background: linear-gradient(120deg, var(--accent-soft), var(--accent));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.hero-sub {
font-size: clamp(16px, 2.2vw, 19px);
color: var(--text-secondary);
max-width: 560px;
margin: 0 auto 40px;
font-weight: 300;
}
.hero-actions {
display: flex;
gap: 16px;
justify-content: center;
flex-wrap: wrap;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 15px 30px;
border-radius: 10px;
font-family: inherit;
font-size: 16px;
font-weight: 600;
cursor: pointer;
border: none;
transition: transform .15s ease, box-shadow .2s ease, background .2s ease, border-color .2s ease;
}
.btn:hover { transform: translateY(-2px); }
.btn-primary {
background: var(--accent);
color: #fff;
box-shadow: 0 8px 30px rgba(233, 69, 96, 0.35);
}
.btn-primary:hover {
background: var(--accent-soft);
box-shadow: 0 10px 36px rgba(233, 69, 96, 0.45);
}
.btn-ghost {
background: transparent;
color: var(--text);
border: 1px solid var(--border);
}
.btn-ghost:hover { border-color: rgba(255, 255, 255, 0.18); background: rgba(255, 255, 255, 0.03); }
.btn-block { width: 100%; }
.hero-meta {
margin-top: 26px;
font-size: 13px;
color: var(--text-secondary);
letter-spacing: 0.5px;
}
.scroll-hint {
position: absolute;
bottom: 36px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
z-index: 2;
color: var(--text-secondary);
font-size: 12px;
letter-spacing: 2px;
text-transform: uppercase;
will-change: transform;
}
.scroll-hint-line {
width: 1px;
height: 44px;
background: rgba(255, 255, 255, 0.08);
overflow: hidden;
}
.scroll-hint-line i {
display: block;
width: 1px;
height: 100%;
background: linear-gradient(180deg, var(--accent), transparent);
animation: scroll-line 1.8s ease-in-out infinite;
}
@keyframes scroll-line {
0% { transform: translateY(-100%); }
100% { transform: translateY(100%); }
}
/* ---------- Sections ---------- */
.section {
max-width: 1200px;
margin: 0 auto;
padding: 120px 32px;
position: relative;
}
.section-head { margin-bottom: 64px; }
.section-head--center { text-align: center; }
.section-head--center .section-sub { margin-left: auto; margin-right: auto; }
.section-index {
display: inline-block;
font-size: 13px;
font-weight: 600;
letter-spacing: 2px;
color: var(--accent);
text-transform: uppercase;
margin-bottom: 16px;
}
.section-title {
font-size: clamp(34px, 5vw, 56px);
font-weight: 800;
line-height: 1.05;
letter-spacing: -1px;
margin-bottom: 16px;
}
.accent { color: var(--accent); }
.section-sub {
font-size: 17px;
color: var(--text-secondary);
max-width: 520px;
font-weight: 300;
}
/* ---------- Features ---------- */
.features-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
.feature-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 16px;
padding: 34px 30px;
position: relative;
overflow: hidden;
transition: transform .25s ease, border-color .25s ease, background .25s ease;
}
.feature-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, var(--accent), transparent);
opacity: 0;
transition: opacity .3s ease;
}
.feature-card:hover {
transform: translateY(-4px);
border-color: rgba(233, 69, 96, 0.35);
background: #1a1a25;
}
.feature-card:hover::before { opacity: 1; }
.feature-num {
position: absolute;
top: 26px;
right: 28px;
font-size: 13px;
font-weight: 700;
color: rgba(255, 255, 255, 0.12);
}
.feature-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(233, 69, 96, 0.12);
color: var(--accent);
margin-bottom: 22px;
}
.feature-card h3 {
font-size: 20px;
font-weight: 700;
margin-bottom: 10px;
}
.feature-card p {
font-size: 15px;
color: var(--text-secondary);
font-weight: 300;
}
/* ---------- Download ---------- */
.download {
background:
radial-gradient(800px 400px at 50% 0%, rgba(233, 69, 96, 0.07), transparent 65%),
var(--bg-surface);
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
}
.download-inner { max-width: 720px; margin: 0 auto; }
.download-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 20px;
padding: 36px;
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.4);
}
.download-card-top {
display: flex;
align-items: center;
gap: 20px;
margin-bottom: 28px;
}
.download-card-top img { border-radius: 12px; }
.download-name {
font-size: 22px;
font-weight: 700;
}
.download-desc { font-size: 14px; color: var(--text-secondary); }
.download-mirrors {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin-top: 22px;
}
.mirrors-label {
font-size: 12px;
letter-spacing: 1.5px;
text-transform: uppercase;
color: var(--text-secondary);
margin-right: 6px;
}
.mirror-tag {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
border: 1px solid var(--border);
padding: 6px 12px;
border-radius: 999px;
transition: border-color .2s ease, color .2s ease;
}
.mirror-tag:hover { border-color: rgba(233, 69, 96, 0.4); color: var(--text); }
.download-note {
text-align: center;
margin-top: 28px;
font-size: 14px;
color: var(--text-secondary);
}
/* ---------- News ---------- */
.news-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
.news-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 16px;
padding: 28px;
display: flex;
flex-direction: column;
transition: transform .25s ease, border-color .25s ease;
}
.news-card:hover { transform: translateY(-4px); border-color: rgba(233, 69, 96, 0.35); }
.news-tags { display: flex; gap: 8px; margin-bottom: 16px; }
.news-tag {
font-size: 11px;
font-weight: 600;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--accent);
background: rgba(233, 69, 96, 0.12);
padding: 5px 10px;
border-radius: 6px;
}
.news-tag--version { color: var(--text-secondary); background: rgba(255, 255, 255, 0.06); }
.news-card h3 {
font-size: 18px;
font-weight: 700;
line-height: 1.3;
margin-bottom: 10px;
}
.news-card p {
font-size: 14px;
color: var(--text-secondary);
font-weight: 300;
flex: 1;
}
.news-skeleton {
background: var(--bg-card);
border-radius: 16px;
height: 180px;
border: 1px solid var(--border);
animation: shimmer 1.5s ease-in-out infinite;
}
@keyframes shimmer {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
/* ---------- Footer ---------- */
.footer {
border-top: 1px solid var(--border);
background: var(--bg-deep);
padding: 48px 32px;
}
.footer-inner {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
flex-wrap: wrap;
}
.footer-brand {
display: flex;
align-items: center;
gap: 10px;
font-weight: 700;
}
.footer-brand img { border-radius: 6px; }
.footer-note { font-size: 13px; color: var(--text-secondary); }
/* ---------- Reveal on scroll ---------- */
.reveal {
opacity: 0;
transform: translateY(24px);
transition: opacity .7s ease, transform .7s ease;
}
.reveal.visible {
opacity: 1;
transform: none;
}
/* ---------- Responsive ---------- */
@media (max-width: 1024px) {
.features-grid, .news-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 720px) {
.nav-links { display: none; }
.section { padding: 90px 20px; }
.features-grid, .news-grid { grid-template-columns: 1fr; }
.hero { padding-top: 140px; }
.hero-diamond--1 { right: -10%; }
.download-card { padding: 26px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
html { scroll-behavior: auto; }
}
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#e94560"/>
<stop offset="100%" stop-color="#ff6b6b"/>
</linearGradient>
</defs>
<rect width="256" height="256" rx="64" fill="url(#g)"/>
<path d="M82 128 L128 82 L174 128 L128 174 Z" fill="white" opacity="0.9"/>
</svg>

After

Width:  |  Height:  |  Size: 423 B

+173
View File
@@ -0,0 +1,173 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Zern — Awaken from the cold</title>
<meta name="description" content="Zern — a custom Minecraft launcher. Forge, NeoForge, Fabric. Download and play on ZernMC.">
<link rel="icon" type="image/svg+xml" href="/img/zernmc.svg">
<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=Onest:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<nav class="nav" id="nav">
<div class="nav-inner">
<a class="nav-brand" href="#top">
<img src="/img/zernmc.svg" alt="Zern" width="34" height="34">
<span>Zern</span>
</a>
<ul class="nav-links">
<li><a href="#features">Features</a></li>
<li><a href="#download">Download</a></li>
<li><a href="#news">News</a></li>
</ul>
<a class="nav-cta" href="#download">Play now</a>
</div>
</nav>
<header class="hero" id="top">
<div class="hero-grid" aria-hidden="true"></div>
<div class="hero-glow" aria-hidden="true"></div>
<div class="hero-diamond hero-diamond--1" aria-hidden="true"></div>
<div class="hero-diamond hero-diamond--2" aria-hidden="true"></div>
<div class="hero-diamond hero-diamond--3" aria-hidden="true"></div>
<div class="hero-inner" data-parallax="0.15">
<p class="hero-kicker">CUSTOM MINECRAFT LAUNCHER</p>
<h1 class="hero-title">Awaken from <br><span class="hero-title-accent">the cold.</span></h1>
<p class="hero-sub">Zern brings your favourite modpacks to life. One launcher, every loader, no hassle — built for the ZernMC server and beyond.</p>
<div class="hero-actions">
<a class="btn btn-primary" href="#download">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M4 21h16"/></svg>
Download launcher
</a>
<a class="btn btn-ghost" href="#features">Explore</a>
</div>
<p class="hero-meta">v<span id="hero-version"></span> · <span id="hero-size"></span> · Windows</p>
</div>
<div class="scroll-hint" data-parallax="0.3">
<span>Scroll to explore</span>
<div class="scroll-hint-line"><i></i></div>
</div>
</header>
<main>
<section class="section features" id="features">
<div class="section-head">
<span class="section-index">01 / 05</span>
<h2 class="section-title">Everything <span class="accent">you need.</span></h2>
<p class="section-sub">A launcher that does the heavy lifting, so you can just play.</p>
</div>
<div class="features-grid">
<article class="feature-card">
<span class="feature-num">01</span>
<div class="feature-icon" aria-hidden="true">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z"/></svg>
</div>
<h3>Forge, NeoForge, Fabric</h3>
<p>Every major mod loader handled automatically. Libraries are prefetched and verified, so installs just work — even on slow networks.</p>
</article>
<article class="feature-card">
<span class="feature-num">02</span>
<div class="feature-icon" aria-hidden="true">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/></svg>
</div>
<h3>Dark, fast, native UI</h3>
<p>A lightweight JavaFX interface with a clean dark theme. Starts quick, responds instantly, and stays out of your way.</p>
</article>
<article class="feature-card">
<span class="feature-num">03</span>
<div class="feature-icon" aria-hidden="true">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M5 18H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"/><path d="M9 22v-4"/><path d="M15 22v-4"/></svg>
</div>
<h3>Smart mirrors & proxy</h3>
<p>Downloads route through fast mirrors with automatic failover. Blocked or throttled? The proxy takes over so you never stall at 80%.</p>
</article>
<article class="feature-card">
<span class="feature-num">04</span>
<div class="feature-icon" aria-hidden="true">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a10 10 0 0 0-9 14l2 5 5-2A10 10 0 1 0 12 2z"/><path d="M8.5 12h.01"/><path d="M12 12h.01"/><path d="M15.5 12h.01"/></svg>
</div>
<h3>News & server status</h3>
<p>Changelogs, events and updates stream straight into the launcher. You always know what's new before you hit play.</p>
</article>
<article class="feature-card">
<span class="feature-num">05</span>
<div class="feature-icon" aria-hidden="true">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</div>
<h3>One-click pack install</h3>
<p>Pick a pack, hit install. Version manifests, vanilla assets and libraries are pulled and verified automatically. Simple.</p>
</article>
</div>
</section>
<section class="section download" id="download">
<div class="download-inner">
<div class="section-head section-head--center">
<span class="section-index">02 / 05</span>
<h2 class="section-title">Get <span class="accent">Zern.</span></h2>
<p class="section-sub">Download the launcher for Windows and join the ZernMC server.</p>
</div>
<div class="download-card">
<div class="download-card-top">
<img src="/img/zernmc.svg" alt="" width="64" height="64">
<div>
<h3 class="download-name">Zern Launcher <span id="download-version"></span></h3>
<p class="download-desc">Windows · <span id="download-size"></span></p>
</div>
</div>
<a class="btn btn-primary btn-block" id="download-btn" href="/launcher/download/latest">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M4 21h16"/></svg>
Download latest
</a>
<div class="download-mirrors" id="mirrors">
<span class="mirrors-label">Mirrors</span>
</div>
</div>
<p class="download-note">No paywalls, no ads. Just a launcher. System requirements: Windows 10+, 4 GB RAM.</p>
</div>
</section>
<section class="section news" id="news">
<div class="section-head">
<span class="section-index">03 / 05</span>
<h2 class="section-title">What's <span class="accent">new.</span></h2>
<p class="section-sub">Latest changelogs and updates from the Zern team.</p>
</div>
<div class="news-grid" id="news-grid">
<div class="news-skeleton"></div>
<div class="news-skeleton"></div>
<div class="news-skeleton"></div>
</div>
</section>
</main>
<footer class="footer">
<div class="footer-inner">
<div class="footer-brand">
<img src="/img/zernmc.svg" alt="Zern" width="28" height="28">
<span>ZernMC</span>
</div>
<p class="footer-note">© <span id="year">2026</span> ZernMC. Not affiliated with Mojang or Microsoft. Minecraft is a trademark of Mojang AB.</p>
</div>
</footer>
<script src="/js/main.js"></script>
</body>
</html>
+183
View File
@@ -0,0 +1,183 @@
(() => {
'use strict';
const API = ''; // same origin (root -> launcher server)
function fmtSize(bytes) {
if (!bytes) return '—';
const units = ['B', 'KB', 'MB', 'GB'];
let i = 0;
let n = bytes;
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
return n.toFixed(1) + ' ' + units[i];
}
async function loadLauncherInfo() {
try {
const res = await fetch(API + '/launcher/info');
if (!res.ok) return;
const data = await res.json();
const version = data.current_version || '—';
const heroVersion = document.getElementById('hero-version');
const heroSize = document.getElementById('hero-size');
const dlVersion = document.getElementById('download-version');
const dlSize = document.getElementById('download-size');
const dlBtn = document.getElementById('download-btn');
if (heroVersion) heroVersion.textContent = version;
if (dlVersion) dlVersion.textContent = version;
if (data.files && data.files.zips && data.files.zips.length) {
const latest = data.files.zips[0];
if (heroSize) heroSize.textContent = fmtSize(latest.size);
if (dlSize) dlSize.textContent = fmtSize(latest.size);
}
if (dlBtn && data.new_format && data.new_format.download_url) {
dlBtn.href = API + data.new_format.download_url;
}
} catch (e) {
console.warn('[site] failed to load launcher info', e);
}
}
async function loadMirrors() {
try {
const res = await fetch(API + '/launcher/mirrors');
if (!res.ok) return;
const data = await res.json();
const container = document.getElementById('mirrors');
if (!container || !data.mirrors || !data.mirrors.length) return;
const tags = data.mirrors.slice(0, 5).map((m) => {
const a = document.createElement('a');
a.className = 'mirror-tag';
a.href = (m.url || '') + '/launcher/download/latest';
a.textContent = m.name || m;
return a;
});
tags.forEach((t) => container.appendChild(t));
} catch (e) {
console.warn('[site] failed to load mirrors', e);
}
}
function stripMarkup(text) {
if (!text) return '';
return text
.replace(/\[[^\]]*photo=[^\]]*\]/g, '')
.replace(/\[[^\]]*\]/g, '')
.replace(/\*\*\*/g, '')
.replace(/\*\*/g, '')
.replace(/[*_~`#]/g, '')
.replace(/\n+/g, ' ')
.trim();
}
async function loadNews() {
const grid = document.getElementById('news-grid');
if (!grid) return;
try {
const res = await fetch(API + '/news');
if (!res.ok) throw new Error('news fetch failed');
const data = await res.json();
const news = (data.news || []).slice(0, 6);
if (!news.length) { grid.innerHTML = '<p class="section-sub">No news yet.</p>'; return; }
grid.innerHTML = '';
news.forEach((n) => {
const card = document.createElement('article');
card.className = 'news-card';
const tags = document.createElement('div');
tags.className = 'news-tags';
if (n.type) {
const t = document.createElement('span');
t.className = 'news-tag';
t.textContent = n.type;
tags.appendChild(t);
}
if (n.version) {
const v = document.createElement('span');
v.className = 'news-tag news-tag--version';
v.textContent = n.version;
tags.appendChild(v);
}
const title = document.createElement('h3');
title.textContent = n.title || 'Update';
const body = document.createElement('p');
const text = stripMarkup(n.body);
body.textContent = text.length > 220 ? text.slice(0, 220) + '…' : text;
card.appendChild(tags);
card.appendChild(title);
card.appendChild(body);
grid.appendChild(card);
});
} catch (e) {
console.warn('[site] failed to load news', e);
grid.innerHTML = '<p class="section-sub">News is temporarily unavailable.</p>';
}
}
function initNav() {
const nav = document.getElementById('nav');
const onScroll = () => nav.classList.toggle('scrolled', window.scrollY > 24);
window.addEventListener('scroll', onScroll, { passive: true });
onScroll();
}
function initParallax() {
const els = document.querySelectorAll('[data-parallax]');
if (!els.length) return;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const onScroll = () => {
const y = window.scrollY;
if (y > window.innerHeight) return;
els.forEach((el) => {
const speed = parseFloat(el.dataset.parallax);
el.style.transform = 'translateY(' + (y * speed) + 'px)';
});
};
window.addEventListener('scroll', onScroll, { passive: true });
onScroll();
}
function initReveal() {
const els = document.querySelectorAll('.section-head, .feature-card, .download-card, .news-card');
if (!('IntersectionObserver' in window)) {
els.forEach((el) => el.classList.add('visible'));
return;
}
const io = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
io.unobserve(entry.target);
}
});
}, { threshold: 0.12 });
els.forEach((el) => { el.classList.add('reveal'); io.observe(el); });
}
function initYear() {
const el = document.getElementById('year');
if (el) el.textContent = new Date().getFullYear();
}
document.addEventListener('DOMContentLoaded', () => {
loadLauncherInfo();
loadMirrors();
loadNews();
initNav();
initParallax();
initReveal();
initYear();
});
})();