site: add server section, highlights carousel, OBT/Season content

- server status cards (online/players/version) via /api/status mcstatus ping
- highlights carousel ported from old site (/highlights/ static media)
- OBT (war in Zarya, open beta event server) + Season (Prologue, Create:
  Aeronautics, year-long) sections
- nav updated, sections renumbered 01-06
This commit is contained in:
SashegDev
2026-08-20 09:15:14 +00:00
parent 1aefca32f2
commit aaecf7e395
20 changed files with 465 additions and 7 deletions
+46
View File
@@ -53,6 +53,9 @@ SITE_DIR = Path(__file__).parent / "site"
# SkinRestorer skins (shared with TG bot)
SKINS_DIR = Path("/root/python/site/skins")
# Minecraft server status (same host the TG bot pings)
MC_STATUS_HOST = os.environ.get("MC_STATUS_HOST", "mc.zernmc.ru")
# Mirror configuration
LAUNCHER_MIRRORS = {
"main": "https://api.zern.cc", # primary
@@ -966,6 +969,11 @@ for sub in ("css", "js", "img"):
mount_dir.mkdir(parents=True, exist_ok=True)
app.mount(f"/{sub}", StaticFiles(directory=mount_dir), name=f"site-{sub}")
# Server highlights media (screenshots / clips for the landing carousel)
HIGHLIGHTS_DIR = SITE_DIR / "highlights"
HIGHLIGHTS_DIR.mkdir(parents=True, exist_ok=True)
app.mount("/highlights", StaticFiles(directory=HIGHLIGHTS_DIR), name="site-highlights")
# Monkey patch to catch invalid HTTP requests
original_data_received = HttpToolsProtocol.data_received
@@ -1054,6 +1062,44 @@ async def get_skin(filename: str, request: Request):
raise HTTPException(status_code=500, detail="Ошибка чтения скина")
@app.get("/api/status")
async def api_server_status():
"""Live Minecraft server status via mcstatus ping (same host as TG bot)"""
try:
from mcstatus import JavaServer
server = JavaServer.lookup(f"{MC_STATUS_HOST}")
status = await server.async_status()
players_list = []
try:
query = await server.async_query()
if query.players.names:
players_list = query.players.names
elif query.players.online > 0 and query.players.sample:
players_list = [p.name for p in query.players.sample]
except Exception:
if status.players.sample:
players_list = [p.name for p in status.players.sample]
version = getattr(status.version, "name", None) or "unknown"
motd = status.description
return {
"online": True,
"players_online": status.players.online,
"players_max": status.players.max,
"players_list": ", ".join(players_list) if players_list else "никого нет",
"players": players_list,
"motd": motd,
"version": version if isinstance(version, str) else str(version),
"updated": datetime.now().strftime("%H:%M:%S")
}
except asyncio.TimeoutError:
return {"online": False, "error": "Таймаут подключения к серверу", "updated": datetime.now().strftime("%H:%M:%S")}
except Exception as e:
return {"online": False, "error": f"{type(e).__name__}: {e}", "updated": datetime.now().strftime("%H:%M:%S")}
@app.get("/health")
async def health():
"""Health check endpoint"""
+1 -1
View File
@@ -159,7 +159,7 @@ 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/")):
elif path.startswith(("/skin/", "/css/", "/js/", "/img/", "/highlights/")):
# SkinRestorer skins + landing site assets must not be rate-limited
is_file_download = True
else:
+208 -2
View File
@@ -346,6 +346,209 @@ ul { list-style: none; }
font-weight: 300;
}
/* ---------- Server status ---------- */
.server {
background:
radial-gradient(800px 400px at 15% 10%, rgba(233, 69, 96, 0.08), transparent 60%),
radial-gradient(800px 400px at 85% 90%, rgba(233, 69, 96, 0.05), transparent 60%);
}
.server-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
.server-card {
background: rgba(22, 22, 31, 0.85);
backdrop-filter: blur(16px);
border: 1px solid var(--border);
border-radius: 16px;
padding: 30px 28px;
transition: transform .25s ease, border-color .25s ease;
}
.server-card:hover { transform: translateY(-4px); border-color: rgba(233, 69, 96, 0.35); }
.server-card-label {
font-size: 12px;
font-weight: 600;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--text-secondary);
margin-bottom: 14px;
}
.server-card-value {
font-size: 26px;
font-weight: 800;
line-height: 1.1;
display: flex;
align-items: center;
gap: 10px;
}
.server-card-sub { font-size: 13px; color: var(--text-secondary); margin-top: 10px; }
.server-season { color: var(--text-secondary); font-size: 18px; font-weight: 600; }
.status-dot {
width: 12px;
height: 12px;
border-radius: 50%;
flex: none;
display: inline-block;
}
.status-dot--online { background: #22c55e; box-shadow: 0 0 16px rgba(34, 197, 94, 0.8); animation: pulse 2s infinite; }
.status-dot--offline { background: #ef4444; box-shadow: 0 0 16px rgba(239, 68, 68, 0.8); }
.status-dot--loading { background: var(--text-secondary); animation: pulse 1.2s infinite; }
.status-text--online { color: #22c55e; }
.status-text--offline { color: #ef4444; }
/* ---------- Highlights carousel ---------- */
.carousel {
position: relative;
border-radius: 20px;
overflow: hidden;
background: var(--bg-card);
border: 1px solid var(--border);
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.35);
}
.carousel-track {
display: flex;
transition: transform .6s cubic-bezier(0.4, 0, 0.2, 1);
}
.carousel-slide {
flex: 0 0 100%;
min-width: 100%;
position: relative;
background: #000;
}
.carousel-slide img,
.carousel-slide video {
width: 100%;
height: auto;
max-height: 640px;
object-fit: contain;
display: block;
}
.carousel-caption {
position: absolute;
left: 20px;
bottom: 20px;
background: rgba(7, 7, 10, 0.7);
backdrop-filter: blur(10px);
border: 1px solid var(--border);
color: var(--text);
font-size: 13px;
padding: 8px 14px;
border-radius: 8px;
}
.carousel-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 5;
width: 52px;
height: 52px;
border-radius: 50%;
border: 1px solid var(--border);
background: rgba(7, 7, 10, 0.6);
backdrop-filter: blur(8px);
color: var(--text);
font-size: 28px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background .2s ease, transform .15s ease, color .2s ease;
}
.carousel-btn:hover { background: var(--accent); color: #fff; }
.carousel-btn--prev { left: 16px; }
.carousel-btn--next { right: 16px; }
.carousel-btn:active { transform: translateY(-50%) scale(0.92); }
.carousel-dots {
display: flex;
justify-content: center;
gap: 8px;
padding: 18px 0;
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 5;
}
.carousel-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.25);
cursor: pointer;
transition: background .2s ease, transform .2s ease;
}
.carousel-dot.active { background: var(--accent); transform: scale(1.3); }
/* ---------- Season theme grid ---------- */
.theme {
background: var(--bg-surface);
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
}
.theme-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
.theme-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 16px;
padding: 30px;
text-align: center;
transition: transform .25s ease, border-color .25s ease, background .25s ease;
}
.theme-card:hover {
transform: translateY(-4px);
border-color: rgba(233, 69, 96, 0.35);
background: #1a1a25;
}
.theme-tag {
display: inline-block;
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;
margin-bottom: 16px;
}
.theme-card h3 { font-size: 20px; font-weight: 700; margin-bottom: 10px; }
.theme-card p { font-size: 14px; color: var(--text-secondary); font-weight: 300; }
/* ---------- Features ---------- */
.features-grid {
@@ -595,16 +798,19 @@ ul { list-style: none; }
/* ---------- Responsive ---------- */
@media (max-width: 1024px) {
.features-grid, .news-grid { grid-template-columns: repeat(2, 1fr); }
.features-grid, .news-grid, .theme-grid { grid-template-columns: repeat(2, 1fr); }
.server-stats { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 720px) {
.nav-links { display: none; }
.section { padding: 90px 20px; }
.features-grid, .news-grid { grid-template-columns: 1fr; }
.features-grid, .news-grid, .theme-grid { grid-template-columns: 1fr; }
.server-stats { grid-template-columns: 1fr; }
.hero { padding-top: 140px; }
.hero-diamond--1 { right: -10%; }
.download-card { padding: 26px; }
.carousel-btn { width: 42px; height: 42px; font-size: 22px; }
}
@media (prefers-reduced-motion: reduce) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

+88 -3
View File
@@ -20,6 +20,9 @@
<span>Zern</span>
</a>
<ul class="nav-links">
<li><a href="#server">Server</a></li>
<li><a href="#highlights">Highlights</a></li>
<li><a href="#theme">Season</a></li>
<li><a href="#features">Features</a></li>
<li><a href="#download">Download</a></li>
<li><a href="#news">News</a></li>
@@ -58,9 +61,50 @@
<main>
<section class="section server" id="server">
<div class="section-head">
<span class="section-index">01 / 06</span>
<h2 class="section-title">The server is <span class="accent">alive.</span></h2>
<p class="section-sub">ZernMC is a custom Minecraft server with its own lore, hand-written plugins and a living community. This is what's happening right now.</p>
</div>
<div class="server-stats">
<div class="server-card">
<div class="server-card-label">Status</div>
<div class="server-card-value" id="srv-status"><span class="status-dot status-dot--loading"></span> Checking…</div>
<div class="server-card-sub" id="srv-version">1.20.1 / OBТ</div>
</div>
<div class="server-card">
<div class="server-card-label">Players online</div>
<div class="server-card-value" id="srv-players">— / —</div>
<div class="server-card-sub">mc.zernmc.ru</div>
</div>
<div class="server-card">
<div class="server-card-label">Mode</div>
<div class="server-card-value">Survival <span class="server-season">· Season Prologue</span></div>
<div class="server-card-sub">1.21.1 NeoForge · Create: Aeronautics</div>
</div>
</div>
</section>
<section class="section highlights" id="highlights">
<div class="section-head">
<span class="section-index">02 / 06</span>
<h2 class="section-title">Server <span class="accent">highlights.</span></h2>
<p class="section-sub">Moments captured on the ZernMC server — wars, builds and chaos.</p>
</div>
<div class="carousel">
<div class="carousel-track" id="carousel"></div>
<button class="carousel-btn carousel-btn--prev" id="carouselPrev" aria-label="Previous"></button>
<button class="carousel-btn carousel-btn--next" id="carouselNext" aria-label="Next"></button>
<div class="carousel-dots" id="carouselDots"></div>
</div>
</section>
<section class="section features" id="features">
<div class="section-head">
<span class="section-index">01 / 05</span>
<span class="section-index">03 / 06</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>
@@ -113,10 +157,51 @@
</div>
</section>
<section class="section theme" id="theme">
<div class="section-head section-head--center">
<span class="section-index">04 / 06</span>
<h2 class="section-title">What's inside <span class="accent">the season.</span></h2>
<p class="section-sub">Prologue — a modded season built around Create: Aeronautics, war and survival. OBT is the testing ground for the whole idea.</p>
</div>
<div class="theme-grid">
<article class="theme-card">
<span class="theme-tag">OBT · 1.20.1 Forge</span>
<h3>War in Zarya</h3>
<p>Two-day event set in the city of Zarya: the Fenix group attacks, Lastochka defends, and civilians pick a side — or rise up. Military rules apply.</p>
</article>
<article class="theme-card">
<span class="theme-tag">OBT · test stage</span>
<h3>Open beta server</h3>
<p>The beta is an event server: city battles, an atomic-reactor city-life scenario and rule sets that will shape the real season.</p>
</article>
<article class="theme-card">
<span class="theme-tag">Season · 1.21.1 NeoForge</span>
<h3>Ships in the sky</h3>
<p>Create: Aeronautics gives you airships, submarines and flying contraptions. Trains, diesel engines, big cannons and a nuclear reactor to master.</p>
</article>
<article class="theme-card">
<span class="theme-tag">Season · Prologue</span>
<h3>A year-long run</h3>
<p>Each season is a marathon, not a sprint. Start this chapter from day one and shape it with the community.</p>
</article>
<article class="theme-card">
<span class="theme-tag">Server</span>
<h3>Own lore & plugins</h3>
<p>Hand-written plugins, events written for the server story, and systems the launcher integrates with out of the box.</p>
</article>
<article class="theme-card">
<span class="theme-tag">Community</span>
<h3>Voice & friends</h3>
<p>Simple Voice Chat in-game, a friends system in the launcher and a Telegram community that runs the show — decisions are voted on by players.</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>
<span class="section-index">05 / 06</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>
@@ -144,7 +229,7 @@
<section class="section news" id="news">
<div class="section-head">
<span class="section-index">03 / 05</span>
<span class="section-index">06 / 06</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>
+122 -1
View File
@@ -63,6 +63,124 @@
}
}
async function loadServerStatus() {
const statusEl = document.getElementById('srv-status');
const playersEl = document.getElementById('srv-players');
const versionEl = document.getElementById('srv-version');
const dot = document.querySelector('#srv-status .status-dot');
if (!statusEl) return;
try {
const res = await fetch(API + '/api/status');
const data = await res.json();
if (data.online) {
if (dot) dot.className = 'status-dot status-dot--online';
statusEl.innerHTML = '<span class="status-dot status-dot--online"></span><span class="status-text--online">Online</span>';
if (playersEl) playersEl.textContent = `${data.players_online} / ${data.players_max}`;
if (versionEl && data.version) versionEl.textContent = data.version;
} else {
if (dot) dot.className = 'status-dot status-dot--offline';
statusEl.innerHTML = '<span class="status-dot status-dot--offline"></span><span class="status-text--offline">Offline</span>';
if (playersEl) playersEl.textContent = '— / —';
}
} catch (e) {
if (dot) dot.className = 'status-dot status-dot--offline';
statusEl.innerHTML = '<span class="status-dot status-dot--offline"></span><span>Unavailable</span>';
}
}
const HIGHLIGHTS = [
{ file: '1.jpg', type: 'image', caption: 'Zern' },
{ file: '2.mp4', type: 'video', caption: null },
{ file: '3.jpg', type: 'image', caption: 'Это честно не я' },
{ file: '4.mp4', type: 'video', caption: null },
{ file: '5.jpg', type: 'image', caption: 'Спавн ТЦ (старое)' },
{ file: '6.jpg', type: 'image', caption: 'Спавн склад (старое)' },
{ file: '7.jpg', type: 'image', caption: null },
{ file: '8.jpg', type: 'image', caption: null },
{ file: '9.jpg', type: 'image', caption: null },
{ file: '10.jpg', type: 'image', caption: null },
{ file: '11.jpg', type: 'image', caption: null },
{ file: '12.jpg', type: 'image', caption: null },
{ file: '13.jpg', type: 'image', caption: null },
{ file: '14.jpg', type: 'image', caption: null },
{ file: '15.jpg', type: 'image', caption: null },
];
function initCarousel() {
const track = document.getElementById('carousel');
const dotsWrap = document.getElementById('carouselDots');
const prev = document.getElementById('carouselPrev');
const next = document.getElementById('carouselNext');
if (!track) return;
HIGHLIGHTS.forEach((h, i) => {
const slide = document.createElement('div');
slide.className = 'carousel-slide';
if (h.type === 'video') {
const vid = document.createElement('video');
vid.src = '/highlights/' + h.file;
vid.controls = true;
vid.autoplay = true;
vid.loop = true;
vid.muted = true;
vid.playsInline = true;
slide.appendChild(vid);
} else {
const img = document.createElement('img');
img.src = '/highlights/' + h.file;
img.alt = h.caption || 'Zern highlight';
img.loading = 'lazy';
slide.appendChild(img);
}
if (h.caption) {
const cap = document.createElement('span');
cap.className = 'carousel-caption';
cap.textContent = h.caption;
slide.appendChild(cap);
}
track.appendChild(slide);
const dot = document.createElement('span');
dot.className = 'carousel-dot' + (i === 0 ? ' active' : '');
dot.addEventListener('click', () => goto(i));
dotsWrap.appendChild(dot);
});
let index = 0;
let timer = null;
function goto(i) {
index = (i + HIGHLIGHTS.length) % HIGHLIGHTS.length;
track.style.transform = 'translateX(-' + index * 100 + '%)';
dotsWrap.querySelectorAll('.carousel-dot').forEach((d, k) => {
d.classList.toggle('active', k === index);
});
}
function nextSlide() { goto(index + 1); }
function prevSlide() { goto(index - 1); }
function start() {
stop();
timer = setInterval(nextSlide, 6500);
}
function stop() {
if (timer) { clearInterval(timer); timer = null; }
}
if (prev) prev.addEventListener('click', () => { prevSlide(); start(); });
if (next) next.addEventListener('click', () => { nextSlide(); start(); });
track.addEventListener('mouseenter', stop);
track.addEventListener('mouseleave', start);
start();
}
function stripMarkup(text) {
if (!text) return '';
return text
@@ -150,7 +268,7 @@
}
function initReveal() {
const els = document.querySelectorAll('.section-head, .feature-card, .download-card, .news-card');
const els = document.querySelectorAll('.section-head, .feature-card, .download-card, .news-card, .server-card, .theme-card, .carousel');
if (!('IntersectionObserver' in window)) {
els.forEach((el) => el.classList.add('visible'));
return;
@@ -175,6 +293,9 @@
loadLauncherInfo();
loadMirrors();
loadNews();
loadServerStatus();
setInterval(loadServerStatus, 15000);
initCarousel();
initNav();
initParallax();
initReveal();