Files
Maxim Dolgolyov 3898080f04 fix(features): админ открывает отключённые модули — пейдж-гейты уважают admin-override
Причина бага «из админа конструктор симуляций редиректит на дашборд»: у sim-builder.html
свой пейдж-гейт, который при feature_sim_builder=false уводил на /dashboard НЕЗАВИСИМО от
роли (мой прошлый admin-override был только в hideDisabledFeatures, а этот гейт его не знал).

Тот же недочёт нашёлся ещё у 3 страниц с собственным фича-редиректом (на /403):
collection.html, knowledge-map.html, red-book.html. Во все 4 добавил обход для админа
(админ управляет модулями → видит и открывает всё, даже отключённое) — согласно правилу
admin-override. Поведение для ученика/учителя не изменилось.

node --check инлайна всех 4 страниц — OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:59:51 +03:00

1513 lines
86 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Красная книга РБ — LearnSpace</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=Unbounded:wght@400;600;700;900&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="/css/ls.css"/>
<style>
/* ── RB tokens ──────────────────────────────────────────────────────── */
:root {
--rb-bg: #0a1a0d;
--rb-surface: #111d13;
--rb-border: #1e3523;
--rb-cr: #ef4444;
--rb-en: #f97316;
--rb-vu: #eab308;
--rb-nt: #22c55e;
--rb-lc: #3b82f6;
--rb-accent: #4ade80;
--rb-text: #e2f5e8;
--rb-muted: #6b9a74;
--sb-width: 240px;
}
/* ── Layout override for dark RB theme ─────────────────────────────── */
body { background: var(--rb-bg); color: var(--rb-text); font-family: 'Manrope', sans-serif; }
.app-layout { background: var(--rb-bg); }
.sb-content { padding: 0; }
/* ── RB Sidebar ─────────────────────────────────────────────────────── */
.app-layout > .sidebar { background: var(--rb-surface) !important; border-right: 1px solid var(--rb-border) !important; }
.sb-brand { border-bottom: 1px solid var(--rb-border); padding: 16px 12px 12px; }
.sb-link, a.sb-link, button.sb-link { color: var(--rb-muted) !important; }
.sb-link:hover { background: rgba(74,222,128,.08) !important; color: var(--rb-text) !important; }
.sb-link.active { background: rgba(74,222,128,.14) !important; color: var(--rb-accent) !important; font-weight: 700; }
.sb-link.active::before { background: var(--rb-accent) !important; }
.sb-toggle { color: var(--rb-muted) !important; border-color: var(--rb-border) !important; background: transparent !important; }
.sb-toggle:hover { background: rgba(74,222,128,.12) !important; color: var(--rb-accent) !important; border-color: var(--rb-accent) !important; }
.nav-user-chip { border-color: var(--rb-border) !important; }
.nav-user-chip:hover { background: rgba(74,222,128,.08) !important; }
.nav-avatar { background: linear-gradient(135deg, #166534, #15803d) !important; }
.nav-user-name { color: var(--rb-muted) !important; }
/* brand */
.rb-brand-link { display: flex; align-items: center; gap: 9px; text-decoration: none; flex: 1; min-width: 0; overflow: hidden; }
.rb-brand-icon { font-size: 24px; line-height: 1; flex-shrink: 0; }
.rb-brand-text { font-family: 'Unbounded', sans-serif; font-size: 10px; font-weight: 700; color: var(--rb-accent); line-height: 1.4; white-space: nowrap; }
/* section label */
.rb-sb-section { font-size: 9px; font-weight: 800; letter-spacing: 2px; text-transform: uppercase; color: var(--rb-muted); padding: 14px 12px 4px; margin: 0; opacity: 0.55; }
.app-layout.sb-collapsed .rb-sb-section { display: none; }
/* divider */
.rb-sb-divider { border: none; border-top: 1px solid var(--rb-border); margin: 8px 4px 8px; }
/* back link */
.rb-back-link { opacity: 0.65; font-size: 0.82rem !important; }
.rb-back-link:hover { opacity: 1 !important; }
/* ── Hero ────────────────────────────────────────────────────────────── */
.rb-hero {
position: relative;
height: 100vh;
min-height: 600px;
overflow: hidden;
display: flex; align-items: center; justify-content: center;
}
#hero-canvas {
position: absolute; inset: 0; width: 100%; height: 100%;
}
.hero-overlay {
position: absolute; inset: 0;
background: linear-gradient(to bottom, transparent 30%, var(--rb-bg) 100%);
pointer-events: none;
}
.hero-content {
position: relative; z-index: 2;
text-align: center; padding: 0 24px;
}
.hero-badge {
display: inline-block;
background: rgba(239,68,68,.15);
border: 1px solid rgba(239,68,68,.4);
color: #fca5a5;
font-size: 11px; font-weight: 700; letter-spacing: 2px; text-transform: uppercase;
padding: 5px 14px; border-radius: 20px; margin-bottom: 20px;
}
.hero-title {
font-family: 'Unbounded', sans-serif;
font-size: clamp(28px, 5vw, 64px); font-weight: 900; line-height: 1.1;
color: #fff; margin: 0 0 16px;
}
.hero-title span { color: var(--rb-accent); }
.hero-subtitle {
font-size: 16px; color: var(--rb-muted); margin: 0 0 36px;
max-width: 500px; margin-left: auto; margin-right: auto;
}
.hero-btns { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; }
.btn-rb-primary {
display: inline-flex; align-items: center; gap: 8px;
background: var(--rb-accent); color: #0a1a0d;
font-weight: 700; font-size: 14px; padding: 12px 24px;
border: none; border-radius: 12px; cursor: pointer;
text-decoration: none; transition: all .2s;
}
.btn-rb-primary:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(74,222,128,.3); }
.btn-rb-outline {
display: inline-flex; align-items: center; gap: 8px;
background: transparent; color: var(--rb-accent);
font-weight: 600; font-size: 14px; padding: 12px 24px;
border: 1.5px solid var(--rb-accent); border-radius: 12px; cursor: pointer;
text-decoration: none; transition: all .2s;
}
.btn-rb-outline:hover { background: rgba(74,222,128,.1); }
.hero-scroll {
position: absolute; bottom: 32px; left: 50%; transform: translateX(-50%);
z-index: 3; text-align: center; cursor: pointer;
}
.hero-scroll-arrow {
width: 24px; height: 24px; border-right: 2px solid var(--rb-accent);
border-bottom: 2px solid var(--rb-accent); transform: rotate(45deg);
margin: 0 auto; animation: scrollBounce 1.5s infinite;
}
@keyframes scrollBounce {
0%,100% { transform: rotate(45deg) translateY(0); }
50% { transform: rotate(45deg) translateY(6px); }
}
/* ── Main content ────────────────────────────────────────────────────── */
.rb-main { padding: 40px 32px 80px; max-width: 1400px; margin: 0 auto; }
/* ── Stats bar ───────────────────────────────────────────────────────── */
.stats-bar {
display: flex; gap: 16px; flex-wrap: wrap;
background: var(--rb-surface); border: 1px solid var(--rb-border);
border-radius: 16px; padding: 20px 28px; margin-bottom: 32px;
align-items: center;
}
.stat-chip {
display: flex; align-items: center; gap: 8px;
font-size: 13px; font-weight: 600;
}
.stat-dot {
width: 10px; height: 10px; border-radius: 50%;
flex-shrink: 0;
}
.stat-chip .n { font-size: 20px; font-weight: 700; }
.sep { width: 1px; height: 32px; background: var(--rb-border); }
.collection-progress { margin-left: auto; min-width: 200px; }
.progress-label { font-size: 12px; color: var(--rb-muted); margin-bottom: 6px; display: flex; justify-content: space-between; }
.progress-bar-wrap { height: 6px; background: var(--rb-border); border-radius: 3px; overflow: hidden; }
.progress-bar-fill { height: 100%; background: linear-gradient(90deg, #22c55e, #4ade80); border-radius: 3px; transition: width .6s; }
/* ── Two-column layout: map + gallery ───────────────────────────────── */
.rb-layout { display: grid; grid-template-columns: 320px 1fr; gap: 28px; align-items: start; }
@media (max-width: 900px) { .rb-layout { grid-template-columns: 1fr; } }
/* ── Map panel ───────────────────────────────────────────────────────── */
.map-panel {
background: var(--rb-surface); border: 1px solid var(--rb-border);
border-radius: 16px; padding: 20px; position: sticky; top: 24px;
}
.map-panel h3 { font-size: 14px; color: var(--rb-muted); margin: 0 0 16px; font-weight: 600; letter-spacing: .5px; text-transform: uppercase; }
.by-map {
width: 100%; aspect-ratio: 1.2;
position: relative;
}
.by-map svg { width: 100%; height: 100%; }
.region-path {
fill: #1e3523; stroke: #2d5238; stroke-width: 1;
cursor: pointer; transition: fill .2s;
}
.region-path:hover, .region-path.active { fill: #2d6a3f; }
.region-path.selected { fill: #166534; stroke: var(--rb-accent); stroke-width: 1.5; }
.region-tooltip {
position: absolute; background: #0d2210; border: 1px solid var(--rb-border);
color: var(--rb-text); font-size: 12px; padding: 8px 12px; border-radius: 8px;
pointer-events: none; white-space: nowrap; z-index: 10;
opacity: 0; transition: opacity .15s;
}
.map-legend { display: flex; gap: 12px; margin-top: 14px; flex-wrap: wrap; }
.legend-item { display: flex; align-items: center; gap: 5px; font-size: 11px; color: var(--rb-muted); }
.legend-dot { width: 8px; height: 8px; border-radius: 50%; }
/* ── Filters ─────────────────────────────────────────────────────────── */
.filters-bar {
display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 20px; align-items: center;
}
.filter-chip {
display: inline-flex; align-items: center; gap: 6px;
background: var(--rb-surface); border: 1px solid var(--rb-border);
color: var(--rb-muted); font-size: 12px; font-weight: 600;
padding: 6px 14px; border-radius: 20px; cursor: pointer;
transition: all .15s; white-space: nowrap;
}
.filter-chip:hover { border-color: var(--rb-accent); color: var(--rb-text); }
.filter-chip.active { background: rgba(74,222,128,.15); border-color: var(--rb-accent); color: var(--rb-accent); }
.filter-chip .chip-icon { font-size: 14px; }
.cat-chip { font-weight: 700; }
.cat-chip.CR { color: var(--rb-cr); border-color: rgba(239,68,68,.3); }
.cat-chip.CR.active { background: rgba(239,68,68,.15); }
.cat-chip.EN { color: var(--rb-en); border-color: rgba(249,115,22,.3); }
.cat-chip.EN.active { background: rgba(249,115,22,.15); }
.cat-chip.VU { color: var(--rb-vu); border-color: rgba(234,179,8,.3); }
.cat-chip.VU.active { background: rgba(234,179,8,.15); }
.cat-chip.NT { color: var(--rb-nt); border-color: rgba(34,197,94,.3); }
.cat-chip.NT.active { background: rgba(34,197,94,.15); }
.search-wrap { position: relative; flex: 1; min-width: 180px; }
.search-wrap input {
width: 100%; background: var(--rb-surface); border: 1px solid var(--rb-border);
color: var(--rb-text); font-size: 13px; padding: 8px 12px 8px 36px;
border-radius: 20px; outline: none; font-family: inherit;
}
.search-wrap input:focus { border-color: var(--rb-accent); }
.search-wrap input::placeholder { color: var(--rb-muted); }
.search-icon { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--rb-muted); font-size: 14px; }
/* ── Species grid ────────────────────────────────────────────────────── */
.species-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
}
.species-card {
background: var(--rb-surface); border: 1px solid var(--rb-border);
border-radius: 14px; overflow: hidden; cursor: pointer;
transition: all .2s; position: relative;
animation: cardIn .4s both;
animation-delay: calc(var(--i, 0) * 40ms);
}
@keyframes cardIn {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
.species-card:hover { transform: translateY(-4px); border-color: var(--rb-accent); box-shadow: 0 8px 24px rgba(0,0,0,.4); }
.species-card.locked { opacity: .7; }
.card-photo {
width: 100%; aspect-ratio: 1;
background: #1a2e1d;
display: flex; align-items: center; justify-content: center;
font-size: 48px; position: relative; overflow: hidden;
}
.card-photo img { width: 100%; height: 100%; object-fit: cover; }
.card-photo .card-icon { font-size: 48px; }
.card-lock {
position: absolute; inset: 0;
background: rgba(0,0,0,.55);
display: flex; align-items: center; justify-content: center;
font-size: 24px;
}
.card-body { padding: 12px; }
.card-name { font-size: 13px; font-weight: 700; color: var(--rb-text); margin: 0 0 2px; line-height: 1.3; }
.card-lat { font-size: 10px; color: var(--rb-muted); font-style: italic; margin: 0 0 8px; }
.card-footer { display: flex; align-items: center; justify-content: space-between; }
.cat-badge {
font-size: 10px; font-weight: 800; letter-spacing: .5px;
padding: 2px 8px; border-radius: 8px;
}
.cat-badge.CR { background: rgba(239,68,68,.2); color: var(--rb-cr); }
.cat-badge.EN { background: rgba(249,115,22,.2); color: var(--rb-en); }
.cat-badge.VU { background: rgba(234,179,8,.2); color: var(--rb-vu); }
.cat-badge.NT { background: rgba(34,197,94,.2); color: var(--rb-nt); }
.cat-badge.LC { background: rgba(59,130,246,.2); color: var(--rb-lc); }
.card-group-icon { font-size: 16px; }
/* ── Modal ───────────────────────────────────────────────────────────── */
.rb-modal-backdrop {
position: fixed; inset: 0; background: rgba(0,0,0,.8);
z-index: 1000; display: none; align-items: center; justify-content: center;
padding: 20px; backdrop-filter: blur(4px);
}
.rb-modal-backdrop.open { display: flex; }
.rb-modal {
background: var(--rb-surface); border: 1px solid var(--rb-border);
border-radius: 20px; width: 100%; max-width: 820px; max-height: 90vh;
overflow: hidden; display: flex; flex-direction: column;
animation: modalIn .25s both;
}
@keyframes modalIn {
from { opacity: 0; transform: scale(.95); }
to { opacity: 1; transform: scale(1); }
}
.modal-top {
display: grid; grid-template-columns: 280px 1fr;
min-height: 280px;
}
@media (max-width: 600px) { .modal-top { grid-template-columns: 1fr; } }
.modal-visual {
background: #0d2210; position: relative;
display: flex; align-items: center; justify-content: center;
min-height: 200px;
}
.modal-visual canvas { width: 100%; height: 100%; position: absolute; inset: 0; }
.modal-visual .modal-icon { font-size: 72px; position: relative; z-index: 1; }
.modal-header { padding: 24px 24px 16px; }
.modal-cat-badge {
display: inline-block; font-size: 11px; font-weight: 800;
letter-spacing: 1px; padding: 3px 12px; border-radius: 10px; margin-bottom: 10px;
}
.modal-name { font-family: 'Unbounded', sans-serif; font-size: 20px; font-weight: 800; margin: 0 0 4px; color: #fff; }
.modal-be { font-size: 13px; color: var(--rb-muted); font-style: italic; margin: 0 0 4px; }
.modal-lat { font-size: 12px; color: var(--rb-muted); font-style: italic; margin: 0 0 14px; }
.modal-meta { display: flex; gap: 12px; flex-wrap: wrap; }
.meta-pill {
display: flex; align-items: center; gap: 5px;
background: rgba(255,255,255,.05); border: 1px solid var(--rb-border);
font-size: 11px; color: var(--rb-muted); padding: 4px 10px; border-radius: 8px;
}
.modal-body { flex: 1; overflow-y: auto; padding: 0 24px 24px; }
.modal-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--rb-border); margin-bottom: 20px; padding-top: 16px; }
.modal-tab {
padding: 8px 16px; font-size: 13px; font-weight: 600; color: var(--rb-muted);
cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px;
transition: all .15s;
}
.modal-tab.active { color: var(--rb-accent); border-color: var(--rb-accent); }
.modal-tab-content { display: none; }
.modal-tab-content.active { display: block; }
.modal-desc { font-size: 14px; line-height: 1.8; color: #c8e6ce; }
.fact-box {
background: rgba(74,222,128,.08); border-left: 3px solid var(--rb-accent);
padding: 12px 16px; border-radius: 0 8px 8px 0; margin-top: 16px;
}
.fact-box p { margin: 0; font-size: 13px; color: var(--rb-accent); line-height: 1.6; }
.threats-list { list-style: none; padding: 0; margin: 0; }
.threats-list li {
display: flex; align-items: flex-start; gap: 10px;
padding: 10px 0; border-bottom: 1px solid var(--rb-border); font-size: 13px; color: #c8e6ce;
}
.threats-list li:last-child { border-bottom: none; }
.threat-icon { color: var(--rb-cr); flex-shrink: 0; margin-top: 1px; }
.regions-grid { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
.region-badge {
background: rgba(74,222,128,.1); border: 1px solid var(--rb-border);
color: var(--rb-accent); font-size: 11px; font-weight: 700;
padding: 4px 12px; border-radius: 8px;
}
.food-web-section { margin-top: 16px; }
.fw-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: var(--rb-muted); margin: 0 0 8px; }
.fw-list { display: flex; gap: 8px; flex-wrap: wrap; }
.fw-chip {
display: flex; align-items: center; gap: 6px;
background: rgba(255,255,255,.05); border: 1px solid var(--rb-border);
font-size: 12px; padding: 4px 10px; border-radius: 8px; cursor: pointer;
}
.fw-chip:hover { border-color: var(--rb-accent); color: var(--rb-accent); }
.trend-chart { width: 100%; height: 120px; position: relative; margin-top: 16px; }
.trend-svg { width: 100%; height: 100%; }
.modal-footer {
padding: 16px 24px;
border-top: 1px solid var(--rb-border);
display: flex; gap: 12px; align-items: center;
}
.btn-collect {
flex: 1; background: var(--rb-accent); color: #0a1a0d;
font-weight: 700; font-size: 14px; padding: 12px;
border: none; border-radius: 10px; cursor: pointer; transition: all .2s;
display: flex; align-items: center; justify-content: center; gap: 8px;
}
.btn-collect:hover { filter: brightness(1.1); }
.btn-collect.already { background: var(--rb-border); color: var(--rb-muted); cursor: default; }
.btn-close-modal {
background: transparent; border: 1px solid var(--rb-border);
color: var(--rb-muted); padding: 12px 20px; border-radius: 10px;
cursor: pointer; font-size: 13px; font-weight: 600;
}
.btn-close-modal:hover { border-color: var(--rb-muted); color: var(--rb-text); }
.modal-xp-badge {
background: rgba(234,179,8,.15); border: 1px solid rgba(234,179,8,.3);
color: #fde047; font-size: 12px; font-weight: 700; padding: 6px 14px; border-radius: 8px;
}
/* ── Toast notifications ─────────────────────────────────────────────── */
#rb-toasts {
position: fixed; bottom: 24px; right: 24px; z-index: 9999;
display: flex; flex-direction: column; gap: 10px; pointer-events: none;
}
.rb-toast {
display: flex; align-items: center; gap: 10px;
background: #0d2210; border: 1px solid var(--rb-border);
color: var(--rb-text); font-size: 13px; font-weight: 600;
padding: 12px 18px; border-radius: 12px;
box-shadow: 0 8px 24px rgba(0,0,0,.5);
pointer-events: auto;
animation: toastIn .3s both;
}
.rb-toast.success { border-color: rgba(74,222,128,.4); }
.rb-toast.error { border-color: rgba(239,68,68,.4); color: #fca5a5; }
.rb-toast.info { border-color: rgba(59,130,246,.4); }
.rb-toast.out { animation: toastOut .3s both; }
@keyframes toastIn { from { opacity:0; transform:translateX(20px); } to { opacity:1; transform:translateX(0); } }
@keyframes toastOut { from { opacity:1; transform:translateX(0); } to { opacity:0; transform:translateX(20px); } }
/* ── Empty state ─────────────────────────────────────────────────────── */
.rb-empty {
grid-column: 1/-1; text-align: center; padding: 60px 20px;
color: var(--rb-muted);
}
.rb-empty .empty-icon { font-size: 48px; margin-bottom: 12px; }
.rb-empty p { font-size: 14px; }
/* ── Quests section ──────────────────────────────────────────────────── */
.quests-section { margin-bottom: 28px; }
.quests-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.quests-header h3 { font-family: 'Unbounded', sans-serif; font-size: 15px; font-weight: 700; margin: 0; }
.quests-header .q-sub { font-size: 11px; color: var(--rb-muted); margin-left: 8px; }
.quests-row { display: flex; gap: 12px; overflow-x: auto; padding-bottom: 4px; }
.quest-card {
flex-shrink: 0; width: 240px;
background: var(--rb-surface); border: 1px solid var(--rb-border);
border-radius: 14px; padding: 16px; cursor: pointer;
transition: all .2s; position: relative; overflow: hidden;
}
.quest-card:hover { border-color: var(--rb-accent); transform: translateY(-2px); }
.quest-card.completed { border-color: rgba(74,222,128,.35); opacity: .8; }
.quest-card::before {
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px;
background: linear-gradient(90deg, var(--qcolor, #4ade80), transparent);
}
.quest-icon { font-size: 24px; margin-bottom: 8px; display: block; }
.quest-title { font-size: 13px; font-weight: 700; color: #fff; margin: 0 0 4px; }
.quest-desc { font-size: 11px; color: var(--rb-muted); margin: 0 0 12px; line-height: 1.5; }
.quest-progress-bar { height: 4px; background: var(--rb-border); border-radius: 2px; overflow: hidden; margin-bottom: 6px; }
.quest-progress-fill { height: 100%; border-radius: 2px; transition: width .5s; }
.quest-progress-text { display: flex; justify-content: space-between; font-size: 10px; color: var(--rb-muted); }
.quest-xp-badge { display: inline-block; background: rgba(234,179,8,.15); border: 1px solid rgba(234,179,8,.3); color: #fde047; font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 6px; }
.quest-done-badge { display: inline-block; background: rgba(74,222,128,.15); border: 1px solid rgba(74,222,128,.3); color: var(--rb-accent); font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 6px; }
/* Quest drawer */
.quest-drawer-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,.6); z-index: 800; display: none; }
.quest-drawer-backdrop.open { display: block; }
.quest-drawer {
position: fixed; right: 0; top: 0; bottom: 0; width: 380px;
background: var(--rb-surface); border-left: 1px solid var(--rb-border);
z-index: 801; overflow-y: auto; padding: 28px 24px;
transform: translateX(100%); transition: transform .25s;
}
.quest-drawer-backdrop.open .quest-drawer { transform: translateX(0); }
.quest-drawer h3 { font-family: 'Unbounded', sans-serif; font-size: 16px; font-weight: 800; margin: 0 0 6px; }
.quest-drawer p { font-size: 13px; color: var(--rb-muted); margin: 0 0 20px; line-height: 1.6; }
.quest-species-list { display: flex; flex-direction: column; gap: 8px; }
.qsp-row {
display: flex; align-items: center; gap: 10px;
background: rgba(255,255,255,.03); border: 1px solid var(--rb-border);
border-radius: 10px; padding: 10px 12px; cursor: pointer; transition: border-color .15s;
}
.qsp-row:hover { border-color: var(--rb-accent); }
.qsp-row.done { opacity: .55; }
.qsp-icon { font-size: 20px; }
.qsp-name { font-size: 13px; font-weight: 600; flex: 1; }
.qsp-check { color: var(--rb-accent); font-size: 16px; }
.qsp-lock { color: var(--rb-muted); font-size: 14px; }
.drawer-close-btn {
background: transparent; border: 1px solid var(--rb-border);
color: var(--rb-muted); padding: 8px 18px; border-radius: 8px;
cursor: pointer; font-size: 12px; font-weight: 600; margin-top: 20px;
}
.drawer-start-btn {
background: var(--rb-accent); color: #0a1a0d;
font-weight: 700; font-size: 13px; padding: 10px 20px;
border: none; border-radius: 10px; cursor: pointer; margin-top: 16px; width: 100%;
}
/* ── Daily card ──────────────────────────────────────────────────────── */
.daily-card {
background: linear-gradient(135deg, #0d2210, #1a3a20);
border: 1px solid var(--rb-border); border-radius: 16px;
padding: 20px 24px; margin-bottom: 28px;
display: flex; gap: 20px; align-items: center; cursor: pointer;
transition: all .2s;
}
.daily-card:hover { border-color: var(--rb-accent); }
.daily-badge-wrap { flex-shrink: 0; }
.daily-badge {
background: rgba(234,179,8,.15); border: 1px solid rgba(234,179,8,.3);
color: #fde047; font-size: 10px; font-weight: 800; letter-spacing: 1.5px;
text-transform: uppercase; padding: 4px 10px; border-radius: 8px; display: inline-block; margin-bottom: 8px;
}
.daily-icon { font-size: 40px; display: block; }
.daily-info { flex: 1; min-width: 0; }
.daily-info h4 { margin: 0 0 4px; font-size: 15px; font-weight: 700; color: #fff; }
.daily-info p { margin: 0; font-size: 12px; color: var(--rb-muted); line-height: 1.5; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.daily-xp { flex-shrink: 0; background: rgba(234,179,8,.15); border: 1px solid rgba(234,179,8,.3); color: #fde047; font-weight: 700; font-size: 13px; padding: 8px 16px; border-radius: 10px; }
/* ── Sightings feed ──────────────────────────────────────────────────── */
.sight-item {
background: rgba(255,255,255,.03); border: 1px solid var(--rb-border);
border-radius: 10px; padding: 12px;
}
.sight-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.sight-user { font-size: 12px; font-weight: 700; color: var(--rb-text); }
.sight-date { font-size: 11px; color: var(--rb-muted); margin-left: auto; }
.sight-region { font-size: 11px; background: rgba(74,222,128,.1); color: var(--rb-accent); padding: 2px 8px; border-radius: 6px; }
.sight-desc { font-size: 13px; color: #c8e6ce; line-height: 1.5; }
.sight-verified { font-size: 10px; background: rgba(59,130,246,.15); color: #60a5fa; padding: 2px 8px; border-radius: 6px; }
/* ── Section header ──────────────────────────────────────────────────── */
.section-header {
display: flex; align-items: center; gap: 12px; margin-bottom: 20px;
}
.section-header h2 { font-family: 'Unbounded', sans-serif; font-size: 18px; font-weight: 700; margin: 0; }
.section-header .count { background: var(--rb-border); color: var(--rb-muted); font-size: 12px; font-weight: 700; padding: 2px 10px; border-radius: 10px; }
</style>
</head>
<body>
<div class="app-layout" id="app">
<!-- Red Book sidebar -->
<nav class="sidebar">
<div class="sb-brand">
<a href="/red-book.html" class="rb-brand-link">
<span class="rb-brand-icon"><svg class="ic" viewBox="0 0 24 24"><path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10z"/><path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12"/></svg></span>
<span class="sb-lbl rb-brand-text">Красная<br>книга РБ</span>
</a>
<button class="sb-toggle" onclick="toggleSidebar()" title="Свернуть меню"><i data-lucide="panel-left-close"></i></button>
</div>
<nav class="sb-nav">
<p class="rb-sb-section">РАЗДЕЛЫ</p>
<a href="/red-book.html" class="sb-link active"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Каталог видов</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<a href="/collection-rb.html" class="sb-link"><i data-lucide="star" class="sb-icon"></i><span class="sb-lbl">Моя коллекция</span></a>
<a href="/red-book-ecosystem.html" class="sb-link"><i data-lucide="git-fork" class="sb-icon"></i><span class="sb-lbl">Пищевые сети</span></a>
<a href="/red-book-biomes.html" class="sb-link"><i data-lucide="trees" class="sb-icon"></i><span class="sb-lbl">Биомы</span></a>
<a href="/red-book-games.html" class="sb-link"><i data-lucide="gamepad-2" class="sb-icon"></i><span class="sb-lbl">Игры</span></a>
<hr class="rb-sb-divider">
<a href="/dashboard.html" class="sb-link rb-back-link"><i data-lucide="chevron-left" class="sb-icon"></i><span class="sb-lbl">Назад</span></a>
<a href="/profile.html" class="sb-link"><i data-lucide="user" class="sb-icon"></i><span class="sb-lbl">Профиль</span></a>
</nav>
<div class="sb-footer">
<div class="nav-user-chip" onclick="location.href='/profile.html'">
<div class="nav-avatar" id="nav-avatar">LS</div>
<span class="nav-user-name" id="nav-user"></span>
</div>
</div>
</nav>
<main class="sb-content">
<!-- HERO -->
<section class="rb-hero">
<canvas id="hero-canvas"></canvas>
<div class="hero-overlay"></div>
<div class="hero-content">
<div class="hero-badge">Республика Беларусь · Официальный список</div>
<h1 class="hero-title">Красная книга<br><span>Беларуси</span></h1>
<p class="hero-subtitle"><span id="hero-threatened"></span> видов под угрозой. Исследуйте, открывайте, защищайте.</p>
<div class="hero-btns">
<a href="#gallery" class="btn-rb-primary" onclick="scrollToGallery()"><svg class="ic" viewBox="0 0 24 24"><path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10z"/><path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12"/></svg> Исследовать виды</a>
</div>
</div>
<div class="hero-scroll" onclick="scrollToGallery()">
<div class="hero-scroll-arrow"></div>
</div>
</section>
<!-- MAIN -->
<div class="rb-main" id="gallery">
<!-- Daily species -->
<div class="daily-card" id="daily-card" onclick="openDailySpecies()">
<div class="daily-badge-wrap">
<span class="daily-badge">Вид дня</span>
<span class="daily-icon" id="daily-icon"><svg class="ic" viewBox="0 0 24 24"><path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10z"/><path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12"/></svg></span>
</div>
<div class="daily-info">
<h4 id="daily-name">Загрузка...</h4>
<p id="daily-fact"></p>
</div>
<div class="daily-xp">+20 XP</div>
</div>
<!-- Quests -->
<div class="quests-section" id="quests-section" style="display:none">
<div class="quests-header">
<div>
<h3><svg class="ic" viewBox="0 0 24 24"><polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6"/><line x1="8" y1="2" x2="8" y2="18"/><line x1="16" y1="6" x2="16" y2="22"/></svg> Квесты<span class="q-sub" id="quests-sub"></span></h3>
</div>
<a href="javascript:void(0)" onclick="openQuestDrawer(null)" style="font-size:12px;color:var(--rb-muted);text-decoration:none;">Все квесты <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg></a>
</div>
<div class="quests-row" id="quests-row">
<!-- filled by JS -->
</div>
</div>
<!-- Stats -->
<div class="stats-bar">
<div class="stat-chip"><div class="stat-dot" style="background:var(--rb-cr)"></div><span class="n" id="stat-cr"></span> CR</div>
<div class="sep"></div>
<div class="stat-chip"><div class="stat-dot" style="background:var(--rb-en)"></div><span class="n" id="stat-en"></span> EN</div>
<div class="sep"></div>
<div class="stat-chip"><div class="stat-dot" style="background:var(--rb-vu)"></div><span class="n" id="stat-vu"></span> VU</div>
<div class="sep"></div>
<div class="stat-chip"><div class="stat-dot" style="background:var(--rb-nt)"></div><span class="n" id="stat-nt"></span> NT</div>
<div class="sep"></div>
<div class="stat-chip" style="color:var(--rb-muted)">Всего <span class="n" id="stat-total"></span></div>
<div class="collection-progress" id="collection-progress-wrap">
<div class="progress-label"><span>Ваша коллекция</span><span id="progress-text">0 / 0</span></div>
<div class="progress-bar-wrap"><div class="progress-bar-fill" id="progress-fill" style="width:0%"></div></div>
</div>
</div>
<!-- Two-column layout -->
<div class="rb-layout">
<!-- MAP PANEL -->
<div class="map-panel">
<h3>Ареал по регионам</h3>
<div class="by-map">
<!-- Simplified SVG map of Belarus regions -->
<svg viewBox="0 0 300 260" xmlns="http://www.w3.org/2000/svg" id="belarus-svg">
<!-- Витебская -->
<path class="region-path" data-region="vitebsk" data-name="Витебская область"
d="M60,20 L180,20 L200,30 L200,90 L160,100 L140,95 L100,100 L60,90 Z"/>
<!-- Минская -->
<path class="region-path" data-region="minsk" data-name="Минская область"
d="M60,90 L100,100 L140,95 L160,100 L170,130 L150,155 L100,160 L60,150 L50,120 Z"/>
<!-- Гродненская -->
<path class="region-path" data-region="grodno" data-name="Гродненская область"
d="M10,80 L60,90 L50,120 L60,150 L30,170 L10,150 L5,110 Z"/>
<!-- Брестская -->
<path class="region-path" data-region="brest" data-name="Брестская область"
d="M10,150 L30,170 L60,150 L100,160 L90,210 L60,230 L20,220 L5,180 Z"/>
<!-- Гомельская -->
<path class="region-path" data-region="gomel" data-name="Гомельская область"
d="M100,160 L150,155 L200,165 L240,190 L230,240 L160,250 L90,240 L90,210 Z"/>
<!-- Могилёвская -->
<path class="region-path" data-region="mogilev" data-name="Могилёвская область"
d="M160,100 L200,90 L250,100 L260,140 L240,190 L200,165 L150,155 L170,130 Z"/>
</svg>
<div class="region-tooltip" id="region-tooltip"></div>
</div>
<div class="map-legend">
<div class="legend-item"><div class="legend-dot" style="background:#2d6a3f"></div>Выбранный</div>
<div class="legend-item"><div class="legend-dot" style="background:#1e3523"></div>Без фильтра</div>
</div>
<!-- Region species count labels filled by JS -->
<div id="region-stats" style="margin-top:16px; display:flex; flex-direction:column; gap:6px;"></div>
</div>
<!-- GALLERY -->
<div>
<!-- Groups filter -->
<div class="filters-bar" id="groups-filter"></div>
<!-- Category + search filter -->
<div class="filters-bar">
<div class="filter-chip cat-chip CR" data-cat="CR" onclick="toggleCat('CR')">CR</div>
<div class="filter-chip cat-chip EN" data-cat="EN" onclick="toggleCat('EN')">EN</div>
<div class="filter-chip cat-chip VU" data-cat="VU" onclick="toggleCat('VU')">VU</div>
<div class="filter-chip cat-chip NT" data-cat="NT" onclick="toggleCat('NT')">NT</div>
<div class="search-wrap">
<span class="search-icon"><svg class="ic" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span>
<input type="text" id="search-input" placeholder="Поиск по названию..." oninput="debouncedSearch()"/>
</div>
</div>
<div class="section-header">
<h2>Виды</h2>
<span class="count" id="species-count"></span>
<button onclick="openRandomSpecies()" style="margin-left:auto;display:inline-flex;align-items:center;gap:6px;background:transparent;border:1px solid var(--rb-border);color:var(--rb-muted);font-size:12px;font-weight:600;padding:6px 14px;border-radius:20px;cursor:pointer;transition:all .15s;" onmouseover="this.style.borderColor='var(--rb-accent)';this.style.color='var(--rb-accent)'" onmouseout="this.style.borderColor='var(--rb-border)';this.style.color='var(--rb-muted)'"><svg class="ic" viewBox="0 0 24 24"><rect x="2" y="2" width="20" height="20" rx="5"/><path d="M16 8h.01M8 8h.01M8 16h.01M16 16h.01M12 12h.01"/></svg> Случайный вид</button>
</div>
<div class="species-grid" id="species-grid">
<!-- filled by JS -->
</div>
</div>
</div>
</div>
</main>
</div>
<!-- TOASTS -->
<div id="rb-toasts"></div>
<!-- QUEST DRAWER -->
<div class="quest-drawer-backdrop" id="quest-drawer-bd" onclick="closeQuestDrawer(event)">
<div class="quest-drawer" id="quest-drawer">
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:6px;">
<span id="qd-icon" style="font-size:32px"><svg class="ic" viewBox="0 0 24 24"><polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6"/><line x1="8" y1="2" x2="8" y2="18"/><line x1="16" y1="6" x2="16" y2="22"/></svg></span>
<span id="qd-status-badge"></span>
</div>
<h3 id="qd-title"></h3>
<p id="qd-desc"></p>
<div style="display:flex;gap:10px;margin-bottom:20px;flex-wrap:wrap;" id="qd-meta"></div>
<div id="quest-species-list" class="quest-species-list"></div>
<button class="drawer-start-btn" id="qd-start-btn" onclick="startCurrentQuest()" style="display:none"><svg class="ic" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg> Начать квест</button>
<button class="drawer-close-btn" onclick="closeQuestDrawer()">Закрыть</button>
</div>
</div>
<!-- MODAL -->
<div class="rb-modal-backdrop" id="modal-backdrop" onclick="closeModalOnBackdrop(event)">
<div class="rb-modal" id="rb-modal">
<div class="modal-top">
<div class="modal-visual" id="modal-visual">
<span class="modal-icon" id="modal-icon"><svg class="ic" viewBox="0 0 24 24"><path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10z"/><path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12"/></svg></span>
</div>
<div class="modal-header">
<div class="modal-cat-badge" id="modal-cat-badge">VU</div>
<h2 class="modal-name" id="modal-name"></h2>
<p class="modal-be" id="modal-be"></p>
<p class="modal-lat" id="modal-lat"></p>
<div class="modal-meta" id="modal-meta"></div>
</div>
</div>
<div class="modal-body">
<div class="modal-tabs">
<div class="modal-tab active" onclick="switchTab('desc')">Описание</div>
<div class="modal-tab" onclick="switchTab('threats')">Угрозы</div>
<div class="modal-tab" onclick="switchTab('area')">Ареал</div>
<div class="modal-tab" onclick="switchTab('web')">Пищевая сеть</div>
<div class="modal-tab" onclick="switchTab('trend')">Динамика</div>
<div class="modal-tab" onclick="switchTab('sightings')"><svg class="ic" viewBox="0 0 24 24"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg> Наблюдения</div>
</div>
<div class="modal-tab-content active" id="tab-desc">
<p class="modal-desc" id="modal-desc"></p>
<div class="fact-box"><p id="modal-fact"></p></div>
</div>
<div class="modal-tab-content" id="tab-threats">
<ul class="threats-list" id="modal-threats"></ul>
<div style="margin-top:16px; font-size:13px; color:var(--rb-muted);" id="modal-conservation"></div>
<div style="margin-top:12px; font-size:13px; color:var(--rb-muted);" id="modal-where"></div>
</div>
<div class="modal-tab-content" id="tab-area">
<p style="font-size:13px; color:var(--rb-muted); margin-bottom:12px;">Регионы присутствия:</p>
<div class="regions-grid" id="modal-regions"></div>
</div>
<div class="modal-tab-content" id="tab-web">
<div class="food-web-section" id="modal-fw-prey"></div>
<div class="food-web-section" id="modal-fw-pred" style="margin-top:16px;"></div>
</div>
<div class="modal-tab-content" id="tab-trend">
<div class="trend-chart">
<svg class="trend-svg" id="trend-svg" viewBox="0 0 400 120" preserveAspectRatio="none"></svg>
</div>
<p style="font-size:11px; color:var(--rb-muted); margin-top:8px;" id="trend-source"></p>
</div>
<div class="modal-tab-content" id="tab-sightings">
<!-- Sighting form -->
<div style="background:rgba(74,222,128,.06);border:1px solid var(--rb-border);border-radius:12px;padding:16px;margin-bottom:16px;">
<p style="font-size:13px;font-weight:700;margin:0 0 10px;color:#fff;">Добавить наблюдение</p>
<select id="sight-region" style="width:100%;background:var(--rb-surface);border:1px solid var(--rb-border);color:var(--rb-text);font-size:13px;padding:8px 12px;border-radius:8px;outline:none;margin-bottom:8px;font-family:inherit;">
<option value="">— Регион (необязательно) —</option>
<option value="vitebsk">Витебская</option>
<option value="minsk">Минская</option>
<option value="grodno">Гродненская</option>
<option value="brest">Брестская</option>
<option value="gomel">Гомельская</option>
<option value="mogilev">Могилёвская</option>
</select>
<textarea id="sight-desc" placeholder="Описание наблюдения..." style="width:100%;background:var(--rb-surface);border:1px solid var(--rb-border);color:var(--rb-text);font-size:13px;padding:8px 12px;border-radius:8px;outline:none;resize:vertical;min-height:60px;font-family:inherit;box-sizing:border-box;margin-bottom:8px;"></textarea>
<button onclick="submitSighting()" style="background:var(--rb-accent);color:#0a1a0d;font-weight:700;font-size:13px;padding:8px 18px;border:none;border-radius:8px;cursor:pointer;">Отправить</button>
</div>
<!-- Sightings feed -->
<div id="sightings-feed" style="display:flex;flex-direction:column;gap:10px;"></div>
</div>
</div>
<div class="modal-footer">
<button class="btn-collect" id="btn-collect" onclick="collectCurrent()"><svg class="ic" viewBox="0 0 24 24"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/></svg> Открыть (+XP)</button>
<span class="modal-xp-badge" id="modal-xp-badge">+30 XP</span>
<button class="btn-close-modal" onclick="closeModal()">Закрыть</button>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.149.0/build/three.min.js"></script>
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/mobile.js"></script>
<script>
/* ══════════════════════════════════════════════════════════════════════════
State
══════════════════════════════════════════════════════════════════════════ */
let state = {
groups: [], species: [], mapData: [],
filters: { group_id: null, category: null, region: null, q: '' },
current: null, dailySpecies: null,
collectedIds: new Set(),
};
const REGION_NAMES = {
vitebsk: 'Витебская', minsk: 'Минская', grodno: 'Гродненская',
brest: 'Брестская', gomel: 'Гомельская', mogilev: 'Могилёвская',
};
const XP_MAP = { CR: 50, EN: 40, VU: 30, NT: 20, LC: 10 };
/* ══════════════════════════════════════════════════════════════════════════
Init
══════════════════════════════════════════════════════════════════════════ */
async function init() {
lucide.createIcons();
const feats = await LS.loadFeatures().catch(() => ({}));
if (feats.red_book === false && LS.getUser()?.role !== 'admin') { window.location.replace('/403'); return; }
LS.hideDisabledFeatures?.();
// Auth (sidebar)
const user = LS.getUser?.() || null;
if (user) {
document.getElementById('nav-user').textContent = user.name?.split(' ')[0] || 'Профиль';
LS.renderNavAvatar(document.getElementById('nav-avatar'), user);
}
// Sidebar collapse
if (localStorage.getItem('ls_sb_collapsed') === '1') {
document.getElementById('app').classList.add('sb-collapsed');
}
// Load data in parallel
const [groups, statsRes, mapData, daily] = await Promise.all([
LS.get('/api/red-book/groups').catch(() => []),
LS.get('/api/red-book/stats').catch(() => ({})),
LS.get('/api/red-book/map-data').catch(() => []),
LS.get('/api/red-book/daily').catch(() => null),
]);
state.groups = groups;
state.mapData = mapData;
state.dailySpecies = daily;
// Stats bar
document.getElementById('stat-cr').textContent = statsRes.cr || 0;
document.getElementById('stat-en').textContent = statsRes.en || 0;
document.getElementById('stat-vu').textContent = statsRes.vu || 0;
document.getElementById('hero-threatened').textContent = (statsRes.cr || 0) + (statsRes.en || 0) + (statsRes.vu || 0);
document.getElementById('stat-nt').textContent = statsRes.nt || 0;
document.getElementById('stat-total').textContent = statsRes.total || 0;
const collected = statsRes.collected || 0;
const total = statsRes.total || 1;
document.getElementById('progress-text').textContent = `${collected} / ${total}`;
document.getElementById('progress-fill').style.width = `${Math.round(collected/total*100)}%`;
// Daily card
if (daily) {
document.getElementById('daily-icon').innerHTML = daily.icon || '<svg class="ic" viewBox="0 0 24 24"><path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10z"/><path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12"/></svg>';
document.getElementById('daily-name').textContent = daily.name_ru;
document.getElementById('daily-fact').textContent = daily.interesting_fact || daily.description?.slice(0,100)+'…';
}
// Groups filter
renderGroupsFilter();
// Map
renderMap();
// Load species
await loadSpecies();
// Load quests after species so state.collectedIds is ready
loadQuests();
// Three.js hero
initHero();
// Deep-link: ?species=ID
const deepSpecies = new URLSearchParams(location.search).get('species');
if (deepSpecies) openSpecies(Number(deepSpecies));
}
/* ══════════════════════════════════════════════════════════════════════════
Groups filter
══════════════════════════════════════════════════════════════════════════ */
function renderGroupsFilter() {
const el = document.getElementById('groups-filter');
el.innerHTML = `<div class="filter-chip ${!state.filters.group_id?'active':''}" onclick="setGroup(null)">Все группы</div>`;
state.groups.forEach(g => {
el.innerHTML += `<div class="filter-chip ${state.filters.group_id===g.id?'active':''}" onclick="setGroup(${g.id})">
<span class="chip-icon">${g.icon}</span><span class="sb-lbl">${g.name_ru}</span>
<span style="opacity:.5">${g.n}</span>
</div>`;
});
}
function setGroup(id) {
state.filters.group_id = id;
renderGroupsFilter();
loadSpecies();
}
/* ══════════════════════════════════════════════════════════════════════════
Category filter
══════════════════════════════════════════════════════════════════════════ */
function toggleCat(cat) {
state.filters.category = state.filters.category === cat ? null : cat;
document.querySelectorAll('.cat-chip').forEach(el => {
el.classList.toggle('active', el.dataset.cat === state.filters.category);
});
loadSpecies();
}
/* ══════════════════════════════════════════════════════════════════════════
Search
══════════════════════════════════════════════════════════════════════════ */
let searchTimer;
function debouncedSearch() {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
state.filters.q = document.getElementById('search-input').value.trim();
loadSpecies();
}, 300);
}
/* ══════════════════════════════════════════════════════════════════════════
Load & render species
══════════════════════════════════════════════════════════════════════════ */
async function loadSpecies() {
const p = new URLSearchParams();
if (state.filters.group_id) p.set('group_id', state.filters.group_id);
if (state.filters.category) p.set('category', state.filters.category);
if (state.filters.region) p.set('region', state.filters.region);
if (state.filters.q) p.set('q', state.filters.q);
p.set('limit', '100');
const data = await LS.get(`/api/red-book/species?${p}`).catch(() => ({ species: [] }));
state.species = data.species || [];
// update collection set
state.collectedIds = new Set(state.species.filter(s => s.collected).map(s => s.id));
renderGrid();
renderQuestCards();
}
function renderGrid() {
const grid = document.getElementById('species-grid');
const sp = state.species;
document.getElementById('species-count').textContent = sp.length;
if (!sp.length) {
grid.innerHTML = `<div class="rb-empty"><div class="empty-icon"><svg class="ic" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></div><p>Ничего не найдено. Измените фильтры.</p></div>`;
return;
}
grid.innerHTML = sp.map((s, i) => {
const locked = !s.collected;
return `<div class="species-card ${locked?'locked':''}" style="--i:${i%20}" onclick="openSpecies(${s.id})">
<div class="card-photo">
${s.photo_url ? `<img src="${s.photo_url}" alt="${s.name_ru}" loading="lazy"/>` : `<span class="card-icon">${s.group_icon||'<svg class="ic" viewBox="0 0 24 24"><path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10z"/><path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12"/></svg>'}</span>`}
${locked ? '<div class="card-lock"><svg class="ic" viewBox="0 0 24 24"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></div>' : ''}
</div>
<div class="card-body">
<div class="card-name">${s.name_ru}</div>
<div class="card-lat">${s.name_lat || ''}</div>
<div class="card-footer">
<span class="cat-badge ${s.category}">${s.category}</span>
<span class="card-group-icon">${s.group_icon||''}</span>
</div>
</div>
</div>`;
}).join('');
}
/* ══════════════════════════════════════════════════════════════════════════
Map
══════════════════════════════════════════════════════════════════════════ */
function renderMap() {
const regionMap = {};
state.mapData.forEach(r => regionMap[r.region_code] = r);
document.querySelectorAll('.region-path').forEach(path => {
const code = path.dataset.region;
const info = regionMap[code] || { total: 0, cr: 0, en: 0 };
path.dataset.total = info.total;
path.dataset.cr = info.cr;
path.dataset.en = info.en;
// Color intensity by total species
const intensity = Math.min(info.total / 30, 1);
const g = Math.round(26 + intensity * 60);
path.style.fill = `rgb(13,${g},16)`;
const tooltip = document.getElementById('region-tooltip');
path.addEventListener('mouseenter', e => {
tooltip.style.opacity = '1';
tooltip.innerHTML = `<b>${path.dataset.name}</b><br>Видов: ${info.total} · CR:${info.cr} EN:${info.en}`;
});
path.addEventListener('mousemove', e => {
const rect = path.closest('.by-map').getBoundingClientRect();
tooltip.style.left = (e.clientX - rect.left + 8) + 'px';
tooltip.style.top = (e.clientY - rect.top - 30) + 'px';
});
path.addEventListener('mouseleave', () => tooltip.style.opacity = '0');
path.addEventListener('click', () => toggleRegion(code, path));
});
// Region stats sidebar
const statsEl = document.getElementById('region-stats');
statsEl.innerHTML = state.mapData.map(r =>
`<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--rb-muted)">
<span>${REGION_NAMES[r.region_code]||r.region_code}</span>
<span style="font-weight:700;color:var(--rb-text)">${r.total}</span>
</div>`
).join('');
}
function toggleRegion(code, path) {
if (state.filters.region === code) {
state.filters.region = null;
document.querySelectorAll('.region-path').forEach(p => p.classList.remove('selected'));
} else {
state.filters.region = code;
document.querySelectorAll('.region-path').forEach(p => p.classList.remove('selected'));
path.classList.add('selected');
}
loadSpecies();
}
/* ══════════════════════════════════════════════════════════════════════════
Open species modal
══════════════════════════════════════════════════════════════════════════ */
async function openSpecies(id) {
const sp = await LS.get(`/api/red-book/species/${id}`).catch(() => null);
if (!sp) return;
state.current = sp;
// visual
document.getElementById('modal-icon').innerHTML = sp.group_icon || '<svg class="ic" viewBox="0 0 24 24"><path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10z"/><path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12"/></svg>';
// category badge
const badge = document.getElementById('modal-cat-badge');
const catColors = { CR:'#ef4444', EN:'#f97316', VU:'#eab308', NT:'#22c55e', LC:'#3b82f6' };
badge.textContent = `${sp.category} · КК РБ ${sp.by_category}`;
badge.style.background = (catColors[sp.category] || '#22c55e') + '22';
badge.style.color = catColors[sp.category] || '#22c55e';
badge.style.border = `1px solid ${catColors[sp.category] || '#22c55e'}55`;
document.getElementById('modal-name').textContent = sp.name_ru;
document.getElementById('modal-be').textContent = sp.name_be || '';
document.getElementById('modal-lat').textContent = sp.name_lat || '';
const MONTH_ABBR = ['Янв','Фев','Мар','Апр','Май','Июн','Июл','Авг','Сен','Окт','Ноя','Дек'];
const seasons = sp.season_active ? JSON.parse(sp.season_active || '[]') : [];
const seasonHtml = seasons.length
? `<div class="meta-pill" title="Сезон активности"><svg class="ic" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg> ${seasons.map(m => MONTH_ABBR[parseInt(m)-1]).join(' · ')}</div>`
: '';
document.getElementById('modal-meta').innerHTML = [
sp.habitat_name ? `<div class="meta-pill">${sp.habitat_type === 'forest' ? '<svg class="ic" viewBox="0 0 24 24"><path d="M17 14 12 3 7 14"/><path d="M4 20 8 11h8l4 9"/><line x1="12" y1="20" x2="12" y2="22"/></svg>' : sp.habitat_type === 'wetland' ? '<svg class="ic" viewBox="0 0 24 24"><path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10z"/><path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12"/></svg>' : sp.habitat_type === 'river' ? '<svg class="ic" viewBox="0 0 24 24"><path d="M2 6c.6.5 1.2 1 2.5 1C7 7 7 5 9.5 5c2.6 0 2.4 2 5 2 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1M2 12c.6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 2.6 0 2.4 2 5 2 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1M2 18c.6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 2.6 0 2.4 2 5 2 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1"/></svg>' : '<svg class="ic" viewBox="0 0 24 24"><path d="M7 20h10"/><path d="M10 20c5.5-2.5.8-6.4 3-10"/><path d="M9.5 9.4c1.1.8 1.8 2.2 2.3 3.7-2 .4-3.5.4-4.8-.3-1.2-.6-2.3-1.9-3-4.2 2.8-.5 4.4 0 5.5.8z"/><path d="M14.1 6a7 7 0 0 1 1.5 4.7c-1.7 1.3-3 1.4-4 1.1a3 3 0 0 1-1.8-1.9c.8-2 2.3-3.3 4.3-3.9z"/></svg>'} ${sp.habitat_name}</div>` : '',
sp.group_name ? `<div class="meta-pill">${sp.group_icon} ${sp.group_name}</div>` : '',
sp.biomass_kg ? `<div class="meta-pill"><svg class="ic" viewBox="0 0 24 24"><path d="m16 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1z"/><path d="m2 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1z"/><path d="M7 21h10M12 3v18M3 7h2c2 0 5-1 7-2 2 1 5 2 7 2h2"/></svg> ${sp.biomass_kg >= 1 ? sp.biomass_kg + ' кг' : (sp.biomass_kg*1000).toFixed(0) + ' г'}</div>` : '',
seasonHtml,
].join('');
// tabs content
document.getElementById('modal-desc').textContent = sp.description || '';
document.getElementById('modal-fact').textContent = sp.interesting_fact || '';
// threats
const threats = sp.threats || [];
document.getElementById('modal-threats').innerHTML = threats.map(t =>
`<li><span class="threat-icon"><svg class="ic" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></span>${t}</li>`
).join('') || '<li><span class="threat-icon">—</span>Данные отсутствуют</li>';
document.getElementById('modal-conservation').innerHTML = sp.conservation ? '<svg class="ic" viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg> ' + sp.conservation : '';
document.getElementById('modal-where').innerHTML = sp.where_to_see ? '<svg class="ic" viewBox="0 0 24 24"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg> ' + sp.where_to_see : '';
// regions
document.getElementById('modal-regions').innerHTML = (sp.regions || []).map(r =>
`<span class="region-badge">${REGION_NAMES[r] || r}</span>`
).join('') || '<span style="color:var(--rb-muted)">Данные отсутствуют</span>';
// food web
const preyEl = document.getElementById('modal-fw-prey');
if (sp.prey?.length) {
preyEl.innerHTML = `<p class="fw-label">Охотится на</p><div class="fw-list">${sp.prey.map(p => `<div class="fw-chip" onclick="closeModal();openSpecies(${p.id})">${p.group_icon||''}${p.name_ru}</div>`).join('')}</div>`;
} else { preyEl.innerHTML = ''; }
const predEl = document.getElementById('modal-fw-pred');
if (sp.predators?.length) {
predEl.innerHTML = `<p class="fw-label">Хищники</p><div class="fw-list">${sp.predators.map(p => `<div class="fw-chip" onclick="closeModal();openSpecies(${p.id})">${p.group_icon||''}${p.name_ru}</div>`).join('')}</div>`;
} else { predEl.innerHTML = ''; }
// trend chart
renderTrendChart(sp.population_trend || sp.population_data || []);
// collect button
const btnCollect = document.getElementById('btn-collect');
const xp = XP_MAP[sp.category] || 20;
document.getElementById('modal-xp-badge').textContent = `+${xp} XP`;
if (sp.collected || state.collectedIds.has(sp.id)) {
btnCollect.className = 'btn-collect already';
btnCollect.innerHTML = '<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Уже в коллекции';
btnCollect.disabled = true;
} else {
btnCollect.className = 'btn-collect';
btnCollect.innerHTML = `<svg class="ic" viewBox="0 0 24 24"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/></svg> Открыть (+${xp} XP)`;
btnCollect.disabled = false;
}
// Switch to first tab
switchTab('desc');
document.getElementById('modal-backdrop').classList.add('open');
}
function renderTrendChart(data) {
const svg = document.getElementById('trend-svg');
const pts = Array.isArray(data) ? data : [];
if (!pts.length) { svg.innerHTML = '<text x="200" y="60" text-anchor="middle" fill="#6b9a74" font-size="12">Нет данных</text>'; return; }
const years = pts.map(p => p.year || p.year);
const counts = pts.map(p => p.count || p.count_estimate || 0);
const minY = Math.min(...counts), maxY = Math.max(...counts) || 1;
const minX = Math.min(...years), maxX = Math.max(...years) || minX + 1;
const W = 400, H = 100, PAD = 20;
const px = y => PAD + (y - minX) / (maxX - minX) * (W - PAD*2);
const py = c => (H - PAD) - (c - minY) / (maxY - minY) * (H - PAD*2);
const polyline = pts.map((p,i) => `${px(p.year||years[i])},${py(p.count||p.count_estimate||0)}`).join(' ');
const fill = pts.map((p,i) => `${px(p.year||years[i])},${py(p.count||p.count_estimate||0)}`).join(' ')
+ ` ${px(maxX)},${H-PAD} ${px(minX)},${H-PAD}`;
const color = counts[counts.length-1] > counts[0] ? '#4ade80' : '#ef4444';
svg.innerHTML = `
<polygon points="${fill}" fill="${color}22"/>
<polyline points="${polyline}" fill="none" stroke="${color}" stroke-width="2"/>
${pts.map((p,i) => `<circle cx="${px(p.year||years[i])}" cy="${py(p.count||p.count_estimate||0)}" r="3" fill="${color}"/>
<text x="${px(p.year||years[i])}" y="${H}" fill="#6b9a74" font-size="9" text-anchor="middle">${p.year||years[i]}</text>
`).join('')}
`;
document.getElementById('trend-source').textContent = pts[pts.length-1]?.source ? `Источник: ${pts[pts.length-1].source}` : '';
}
/* ══════════════════════════════════════════════════════════════════════════
Toast
══════════════════════════════════════════════════════════════════════════ */
function showToast(msg, type = 'success', duration = 3000) {
const wrap = document.getElementById('rb-toasts');
const t = document.createElement('div');
t.className = `rb-toast ${type}`;
t.innerHTML = msg;
wrap.appendChild(t);
setTimeout(() => {
t.classList.add('out');
t.addEventListener('animationend', () => t.remove());
}, duration);
}
/* ══════════════════════════════════════════════════════════════════════════
Random species
══════════════════════════════════════════════════════════════════════════ */
function openRandomSpecies() {
const sp = state.species;
if (!sp.length) return;
const s = sp[Math.floor(Math.random() * sp.length)];
openSpecies(s.id);
}
async function collectCurrent() {
if (!state.current) return;
const sp = state.current;
if (sp.collected || state.collectedIds.has(sp.id)) return;
const token = LS.getToken?.() || localStorage.getItem('ls_token');
if (!token) { showToast('Войдите в систему, чтобы открывать виды', 'error'); return; }
const res = await LS.post(`/api/red-book/species/${sp.id}/collect`, { method: 'explore' }).catch(e => ({ error: e.message }));
if (res.error && !res.collected) { showToast(res.error, 'error'); return; }
state.collectedIds.add(sp.id);
sp.collected = true;
const btn = document.getElementById('btn-collect');
btn.className = 'btn-collect already';
btn.innerHTML = `<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Открыто! +${res.xp_earned || 20} XP`;
btn.disabled = true;
showToast(`<svg class="ic" viewBox="0 0 24 24"><path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10z"/><path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12"/></svg> ${sp.name_ru} добавлен в коллекцию! +${res.xp_earned || 20} XP`, 'success');
// Server-side completed quests
(res.completed_quests || []).forEach(q => {
showToast(`<svg class="ic" viewBox="0 0 24 24"><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/><path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/><path d="M18 2H6v7a6 6 0 0 0 12 0V2z"/></svg> Квест выполнен: «${q.title}»! +${q.xp_reward} XP`, 'success', 5000);
});
if (res.completed_quests?.length) await loadQuests();
else checkQuestProgress();
// Update grid card
loadSpecies();
}
function openDailySpecies() {
if (state.dailySpecies?.id) openSpecies(state.dailySpecies.id);
}
/* ══════════════════════════════════════════════════════════════════════════
Quests
══════════════════════════════════════════════════════════════════════════ */
let questState = { quests: [], drawerQuest: null };
const QUEST_ICONS = ['<svg class="ic" viewBox="0 0 24 24"><polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6"/><line x1="8" y1="2" x2="8" y2="18"/><line x1="16" y1="6" x2="16" y2="22"/></svg>','<svg class="ic" viewBox="0 0 24 24"><path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10z"/><path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12"/></svg>','<svg class="ic" viewBox="0 0 24 24"><path d="M16 7h.01"/><path d="M3.4 18H12a8 8 0 0 0 8-8V7a4 4 0 0 0-7.28-2.3L2 20"/><path d="m20 7 2 .5-2 .5"/><path d="M10 18v3M14 17.75v3.25"/><path d="M7 18a6 6 0 0 0 3.84-10.61"/></svg>','<svg class="ic" viewBox="0 0 24 24"><path d="M12 22c-4 0-8-2-10-6 0-4 2-8 5-10l2 4 2-2 1 3 2-4c3 2 5 6 5 10-2 4-6 5-7 5z"/></svg>','<svg class="ic" viewBox="0 0 24 24"><path d="M2 6c.6.5 1.2 1 2.5 1C7 7 7 5 9.5 5c2.6 0 2.4 2 5 2 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1M2 12c.6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 2.6 0 2.4 2 5 2 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1M2 18c.6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 2.6 0 2.4 2 5 2 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1"/></svg>','<svg class="ic" viewBox="0 0 24 24"><path d="M17 14 12 3 7 14"/><path d="M4 20 8 11h8l4 9"/><line x1="12" y1="20" x2="12" y2="22"/></svg>','<svg class="ic" viewBox="0 0 24 24"><path d="M4.5 13.5C4 18.538 7.582 21 12 21s8-2.462 8-5.5"/><path d="M4.5 13.5c-1.62-2.808-.16-6.83 3.5-8.5M19.5 13.5c1.62-2.808.16-6.83-3.5-8.5"/><circle cx="8" cy="8" r="2"/><circle cx="16" cy="8" r="2"/></svg>','<svg class="ic" viewBox="0 0 24 24"><path d="M17 9c-2 1-3 3-3 5v4M7 9c2 1 3 3 3 5v4M5 9C2 9 2 7 2 6s1-3 3-3h14c2 0 3 2 3 3s0 3-3 3M7 21h10"/></svg>'];
const QUEST_COLORS = ['#4ade80','#f97316','#3b82f6','#a855f7','#22d3ee','#eab308'];
async function loadQuests() {
const quests = await LS.get('/api/red-book/quests').catch(() => []);
questState.quests = quests;
const section = document.getElementById('quests-section');
if (!quests.length) { section.style.display = 'none'; return; }
section.style.display = 'block';
const active = quests.filter(q => q.user_status === 'active').length;
const completed = quests.filter(q => q.user_status === 'completed').length;
document.getElementById('quests-sub').textContent = ` · ${completed}/${quests.length} выполнено`;
renderQuestCards();
}
function renderQuestCards() {
const row = document.getElementById('quests-row');
const qs = questState.quests;
row.innerHTML = qs.map((q, i) => {
const ids = q.species_ids || [];
const collectedInQ = ids.filter(id => state.collectedIds.has(id)).length;
const pct = ids.length ? Math.round(collectedInQ / ids.length * 100) : 0;
const color = QUEST_COLORS[i % QUEST_COLORS.length];
const icon = QUEST_ICONS[i % QUEST_ICONS.length];
const done = q.user_status === 'completed';
return `<div class="quest-card ${done?'completed':''}" style="--qcolor:${color}" onclick="openQuestDrawer(${q.id})">
<span class="quest-icon">${icon}</span>
<div class="quest-title">${q.title}</div>
<div class="quest-desc">${q.description}</div>
<div class="quest-progress-bar"><div class="quest-progress-fill" style="width:${pct}%;background:${color}"></div></div>
<div class="quest-progress-text">
<span>${collectedInQ} / ${ids.length} видов</span>
${done ? '<span class="quest-done-badge"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Выполнен</span>' : `<span class="quest-xp-badge">+${q.xp_reward} XP</span>`}
</div>
</div>`;
}).join('');
}
function openQuestDrawer(questId) {
const q = questId ? questState.quests.find(q => q.id === questId) : questState.quests[0];
if (!q) return;
questState.drawerQuest = q;
const ids = q.species_ids || [];
const i = questState.quests.indexOf(q);
const color = QUEST_COLORS[i % QUEST_COLORS.length];
const icon = QUEST_ICONS[i % QUEST_ICONS.length];
document.getElementById('qd-icon').innerHTML = icon;
document.getElementById('qd-title').textContent = q.title;
document.getElementById('qd-desc').textContent = q.description;
const done = q.user_status === 'completed';
const statusBadge = document.getElementById('qd-status-badge');
statusBadge.innerHTML = done
? '<span style="background:rgba(74,222,128,.15);border:1px solid rgba(74,222,128,.3);color:#4ade80;font-size:11px;font-weight:700;padding:3px 10px;border-radius:8px"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Выполнен</span>'
: q.user_status === 'active'
? '<span style="background:rgba(59,130,246,.15);border:1px solid rgba(59,130,246,.3);color:#60a5fa;font-size:11px;font-weight:700;padding:3px 10px;border-radius:8px">В прогрессе</span>'
: '';
document.getElementById('qd-meta').innerHTML = `
<div style="background:rgba(234,179,8,.15);border:1px solid rgba(234,179,8,.3);color:#fde047;font-size:11px;font-weight:700;padding:4px 12px;border-radius:8px">+${q.xp_reward} XP</div>
<div style="background:rgba(255,255,255,.05);border:1px solid var(--rb-border);font-size:11px;color:var(--rb-muted);padding:4px 12px;border-radius:8px">${ids.length} видов</div>
`;
const startBtn = document.getElementById('qd-start-btn');
startBtn.style.display = (q.user_status === 'locked' || q.user_status === 'unknown') ? 'block' : 'none';
// Species list
const listEl = document.getElementById('quest-species-list');
if (ids.length) {
// Try to match with known species
const speciesMap = {};
state.species.forEach(s => speciesMap[s.id] = s);
listEl.innerHTML = ids.map(id => {
const sp = speciesMap[id];
const done = state.collectedIds.has(id);
return `<div class="qsp-row ${done?'done':''}" onclick="closeQuestDrawer();openSpecies(${id})">
<span class="qsp-icon">${sp?.group_icon || '<svg class="ic" viewBox="0 0 24 24"><path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10z"/><path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12"/></svg>'}</span>
<span class="qsp-name">${sp?.name_ru || `Вид #${id}`}</span>
${done ? '<span class="qsp-check"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg></span>' : '<span class="qsp-lock"><svg class="ic" viewBox="0 0 24 24"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>'}
</div>`;
}).join('');
} else {
listEl.innerHTML = '<p style="color:var(--rb-muted);font-size:13px">Нет видов в квесте</p>';
}
document.getElementById('quest-drawer-bd').classList.add('open');
}
function closeQuestDrawer(e) {
if (e && e.target !== document.getElementById('quest-drawer-bd')) return;
document.getElementById('quest-drawer-bd').classList.remove('open');
questState.drawerQuest = null;
}
async function startCurrentQuest() {
const q = questState.drawerQuest;
if (!q) return;
await LS.post(`/api/red-book/quests/${q.id}/start`, {}).catch(() => {});
q.user_status = 'active';
document.getElementById('qd-start-btn').style.display = 'none';
const _badge = document.getElementById('qd-status-badge');
if (_badge) _badge.innerHTML = '<span style="background:rgba(59,130,246,.15);border:1px solid rgba(59,130,246,.3);color:#60a5fa;font-size:11px;font-weight:700;padding:3px 10px;border-radius:8px">В прогрессе</span>';
renderQuestCards();
showToast('<svg class="ic" viewBox="0 0 24 24"><polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6"/><line x1="8" y1="2" x2="8" y2="18"/><line x1="16" y1="6" x2="16" y2="22"/></svg> Квест начат!', 'success');
}
/* ══════════════════════════════════════════════════════════════════════════
Sightings
══════════════════════════════════════════════════════════════════════════ */
const REGION_LABELS = {
vitebsk: 'Витебская', minsk: 'Минская', grodno: 'Гродненская',
brest: 'Брестская', gomel: 'Гомельская', mogilev: 'Могилёвская',
};
async function loadSightings(speciesId) {
const feed = document.getElementById('sightings-feed');
feed.innerHTML = '<p style="color:var(--rb-muted);font-size:13px">Загрузка...</p>';
const list = await LS.get(`/api/red-book/sightings?species_id=${speciesId}`).catch(() => []);
if (!list.length) {
feed.innerHTML = '<p style="color:var(--rb-muted);font-size:13px">Наблюдений пока нет. Будьте первым!</p>';
return;
}
const fmt = dt => new Date(dt).toLocaleDateString('ru', { day:'2-digit', month:'short', year:'numeric' });
feed.innerHTML = list.map(s => `
<div class="sight-item">
<div class="sight-header">
<span class="sight-user"><svg class="ic" viewBox="0 0 24 24"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg> ${s.user_name}</span>
${s.region_code ? `<span class="sight-region">${REGION_LABELS[s.region_code] || s.region_code}</span>` : ''}
${s.confirmed_by_teacher ? '<span class="sight-verified"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Подтверждено</span>' : ''}
<span class="sight-date">${fmt(s.created_at)}</span>
</div>
${s.description ? `<div class="sight-desc">${s.description}</div>` : ''}
</div>`).join('');
}
async function submitSighting() {
if (!state.current) return;
const token = LS.getToken?.() || localStorage.getItem('ls_token');
if (!token) { showToast('Войдите в систему', 'error'); return; }
const region = document.getElementById('sight-region').value;
const desc = document.getElementById('sight-desc').value.trim();
const res = await LS.post('/api/red-book/sightings', {
species_id: state.current.id,
region_code: region,
description: desc,
}).catch(e => ({ error: e.message }));
if (res.error) { showToast(res.error, 'error'); return; }
document.getElementById('sight-desc').value = '';
document.getElementById('sight-region').value = '';
showToast('<svg class="ic" viewBox="0 0 24 24"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg> Наблюдение добавлено!', 'success');
loadSightings(state.current.id);
}
function checkQuestProgress() {
questState.quests.forEach(q => {
if (q.user_status !== 'active') return;
const ids = q.species_ids || [];
const done = ids.every(id => state.collectedIds.has(id));
if (done && q.user_status !== 'completed') {
q.user_status = 'completed';
showToast(`<svg class="ic" viewBox="0 0 24 24"><polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6"/><line x1="8" y1="2" x2="8" y2="18"/><line x1="16" y1="6" x2="16" y2="22"/></svg> Квест выполнен: «${q.title}»! +${q.xp_reward} XP`, 'success', 5000);
}
});
renderQuestCards();
}
/* ══════════════════════════════════════════════════════════════════════════
Modal tabs
══════════════════════════════════════════════════════════════════════════ */
function switchTab(name) {
document.querySelectorAll('.modal-tab').forEach((t,i) => {
const tabNames = ['desc','threats','area','web','trend','sightings'];
t.classList.toggle('active', tabNames[i] === name);
});
document.querySelectorAll('.modal-tab-content').forEach(c => {
c.classList.toggle('active', c.id === 'tab-'+name);
});
if (name === 'sightings' && state.current) loadSightings(state.current.id);
}
function closeModal() {
document.getElementById('modal-backdrop').classList.remove('open');
state.current = null;
}
function closeModalOnBackdrop(e) {
if (e.target === document.getElementById('modal-backdrop')) closeModal();
}
/* ══════════════════════════════════════════════════════════════════════════
Scroll helper
══════════════════════════════════════════════════════════════════════════ */
function scrollToGallery() {
document.getElementById('gallery').scrollIntoView({ behavior: 'smooth' });
}
/* ══════════════════════════════════════════════════════════════════════════
Sidebar toggle
══════════════════════════════════════════════════════════════════════════ */
function toggleSidebar() {
const app = document.getElementById('app');
app.classList.toggle('sb-collapsed');
localStorage.setItem('ls_sb_collapsed', app.classList.contains('sb-collapsed') ? '1' : '0');
lucide.createIcons();
}
/* ══════════════════════════════════════════════════════════════════════════
Three.js Hero Forest Scene
══════════════════════════════════════════════════════════════════════════ */
function initHero() {
const canvas = document.getElementById('hero-canvas');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setClearColor(0x0a1a0d);
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x071209, 0.035);
const camera = new THREE.PerspectiveCamera(60, canvas.clientWidth / canvas.clientHeight, 0.1, 200);
camera.position.set(0, 2, 18);
// Resize
function onResize() {
const w = canvas.parentElement.clientWidth, h = canvas.parentElement.clientHeight;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
window.addEventListener('resize', onResize);
onResize();
// Lighting
const ambient = new THREE.AmbientLight(0x0d2e10, 2);
scene.add(ambient);
const sun = new THREE.DirectionalLight(0x88ffaa, 1.5);
sun.position.set(5, 10, 5);
scene.add(sun);
const fill = new THREE.PointLight(0x0066ff, 0.3, 50);
fill.position.set(-10, 5, 0);
scene.add(fill);
// Ground
const groundGeo = new THREE.PlaneGeometry(80, 80, 1, 1);
const groundMat = new THREE.MeshLambertMaterial({ color: 0x071a09 });
const ground = new THREE.Mesh(groundGeo, groundMat);
ground.rotation.x = -Math.PI / 2;
scene.add(ground);
// Trees
function makeTree(x, z, scale) {
const g = new THREE.Group();
const trunkH = (0.8 + Math.random() * 0.4) * scale;
const trunk = new THREE.Mesh(
new THREE.CylinderGeometry(0.06*scale, 0.1*scale, trunkH, 6),
new THREE.MeshLambertMaterial({ color: 0x3b2010 })
);
trunk.position.y = trunkH / 2;
g.add(trunk);
const levels = 3 + Math.floor(Math.random() * 2);
for (let i = 0; i < levels; i++) {
const r = (0.7 - i * 0.15) * scale;
const h = (0.7 - i * 0.08) * scale;
const col = new THREE.Color().setHSL(0.33, 0.6, 0.1 + i * 0.04);
const cone = new THREE.Mesh(
new THREE.ConeGeometry(r, h, 7),
new THREE.MeshLambertMaterial({ color: col })
);
cone.position.y = trunkH + h * 0.4 + i * h * 0.55;
g.add(cone);
}
g.position.set(x, 0, z);
g.rotation.y = Math.random() * Math.PI * 2;
return g;
}
for (let i = 0; i < 80; i++) {
const angle = Math.random() * Math.PI * 2;
const radius = 6 + Math.random() * 25;
const scale = 0.6 + Math.random() * 1.4;
scene.add(makeTree(Math.cos(angle) * radius, Math.sin(angle) * radius, scale));
}
// Firefly particles
const COUNT = 300;
const positions = new Float32Array(COUNT * 3);
const phases = new Float32Array(COUNT);
for (let i = 0; i < COUNT; i++) {
positions[i*3] = (Math.random() - 0.5) * 40;
positions[i*3+1] = Math.random() * 8;
positions[i*3+2] = (Math.random() - 0.5) * 40;
phases[i] = Math.random() * Math.PI * 2;
}
const pgeo = new THREE.BufferGeometry();
pgeo.setAttribute('position', new THREE.BufferAttribute(positions.slice(), 3));
const pmat = new THREE.PointsMaterial({ color: 0x88ffaa, size: 0.12, transparent: true, opacity: 0.6 });
const particles = new THREE.Points(pgeo, pmat);
scene.add(particles);
// Animate
let t = 0;
function animate() {
requestAnimationFrame(animate);
t += 0.008;
// Slow camera drift
camera.position.x = Math.sin(t * 0.15) * 2;
camera.position.y = 2 + Math.sin(t * 0.1) * 0.3;
camera.lookAt(0, 3, 0);
// Firefly flicker + drift
const pos = pgeo.attributes.position;
for (let i = 0; i < COUNT; i++) {
pos.array[i*3+1] += Math.sin(t * 2 + phases[i]) * 0.003;
pos.array[i*3] += Math.cos(t + phases[i] * 0.5) * 0.002;
}
pos.needsUpdate = true;
pmat.opacity = 0.4 + Math.sin(t * 3) * 0.2;
renderer.render(scene, camera);
}
animate();
}
init();
</script>
</body>
</html>