Files
SREBOT/web/views/timeline.ejs
T
NotSoToothless 732730829d feakyyy (#1300)
2026-06-04 13:55:14 -07:00

702 lines
30 KiB
Plaintext

<!DOCTYPE html>
<html lang="<%= lang %>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Timeline - <%= botName %></title>
<meta name="description" content="The history of <%= botName %>, from a Discord side project to a War Thunder Squadron Battles companion used across the community.">
<meta name="theme-color" content="#90EE90">
<link rel="icon" type="image/png" href="/images/transparent_toothlessssss.png">
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
<link rel="preload" href="/Fonts/symbols_skyquake.ttf" as="font" type="font/ttf" crossorigin="anonymous">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<!-- Tailwind CSS -->
<link rel="stylesheet" href="/css/output.css">
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" media="print" onload="this.media='all'">
<style>
@font-face {
font-family: 'skyquakesymbols';
src: url('/Fonts/symbols_skyquake.ttf') format('truetype');
font-display: swap;
}
* { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; }
body {
background: #1b1b1b;
min-height: 100vh;
}
.gradient-text {
background: linear-gradient(135deg, #F5F5DC 0%, #90EE90 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.text-accent { color: #F5F5DC; }
.text-muted { color: #90EE90; }
/* Serpentine timeline */
.timeline {
position: relative;
}
/* SVG that holds the flowing connector line, drawn behind the cards */
.timeline-line {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 0;
overflow: visible;
}
.timeline-line .track {
fill: none;
stroke: rgba(144, 238, 144, 0.10);
stroke-width: 4;
stroke-linecap: round;
stroke-linejoin: round;
}
.timeline-line .progress {
fill: none;
stroke: url(#timelineGradient);
stroke-width: 4;
stroke-linecap: round;
stroke-linejoin: round;
filter: drop-shadow(0 0 6px rgba(144, 238, 144, 0.45));
/* dash values are set by JS once we know the path length */
transition: stroke-dashoffset 0.05s linear;
}
.timeline-line .comet {
fill: #F5F5DC;
filter: drop-shadow(0 0 10px rgba(245, 245, 220, 0.9)) drop-shadow(0 0 18px rgba(144, 238, 144, 0.6));
}
/* The grid of milestone nodes. Default = single column (mobile). */
.timeline-grid {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 1fr;
gap: 3rem 2rem;
}
@media (min-width: 1024px) {
.timeline-grid {
grid-template-columns: repeat(3, 1fr);
gap: 6rem 2.5rem;
}
/* The line snakes 3 by 3: row 1 left to right, row 2 right to left,
row 3 left to right, so the connector never crosses itself. */
.timeline-node:nth-child(1) { grid-area: 1 / 1; }
.timeline-node:nth-child(2) { grid-area: 1 / 2; }
.timeline-node:nth-child(3) { grid-area: 1 / 3; }
.timeline-node:nth-child(4) { grid-area: 2 / 3; }
.timeline-node:nth-child(5) { grid-area: 2 / 2; }
.timeline-node:nth-child(6) { grid-area: 2 / 1; }
.timeline-node:nth-child(7) { grid-area: 3 / 1; }
.timeline-node:nth-child(8) { grid-area: 3 / 2; }
.timeline-node:nth-child(9) { grid-area: 3 / 3; }
}
.timeline-card {
position: relative;
background: linear-gradient(135deg, rgba(62, 78, 62, 0.22) 0%, rgba(44, 44, 44, 0.22) 100%);
border: 1px solid rgba(245, 245, 220, 0.08);
backdrop-filter: blur(12px);
border-radius: 1rem;
padding: 1.75rem;
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1), border-color 0.35s ease, box-shadow 0.35s ease;
}
.timeline-card:hover {
transform: translateY(-4px);
border-color: rgba(144, 238, 144, 0.35);
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.35);
}
/* Node marker: the dot that the line threads through */
.timeline-marker {
position: absolute;
top: -1.4rem;
left: 1.75rem;
width: 2.6rem;
height: 2.6rem;
border-radius: 9999px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
color: #1b1b1b;
background: linear-gradient(135deg, #F5F5DC 0%, #90EE90 100%);
box-shadow: 0 0 0 5px rgba(27, 27, 27, 1), 0 0 18px rgba(144, 238, 144, 0.45);
}
.timeline-index {
font-family: 'skyquakesymbols', 'Inter', monospace;
font-size: 0.7rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: rgba(144, 238, 144, 0.7);
}
.timeline-date {
color: #90EE90;
font-weight: 700;
font-size: 0.95rem;
}
.timeline-title {
color: #F5F5DC;
font-weight: 700;
font-size: 1.35rem;
line-height: 1.25;
}
.timeline-desc {
color: rgba(255, 255, 255, 0.78);
line-height: 1.65;
font-size: 0.95rem;
}
/* Reveal animation, staggered as the line draws past each node */
.timeline-node {
opacity: 0;
transform: translateY(28px);
position: relative;
z-index: 2;
transition: opacity 0.6s ease, transform 0.6s cubic-bezier(0.2, 0.9, 0.25, 1);
}
.timeline-node.is-visible {
opacity: 1;
transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) {
.timeline-node { opacity: 1; transform: none; transition: none; }
.timeline-line .progress { transition: none; }
}
</style>
</head>
<body class="text-white antialiased">
<%- include('partials/nav', { activePage: 'timeline' }) %>
<!-- Header -->
<section class="pt-32 pb-10 lg:pt-40 lg:pb-14">
<div class="max-w-[1400px] mx-auto px-6 lg:px-8 text-center">
<p class="text-muted text-sm font-semibold tracking-[0.25em] uppercase mb-4">Our Story</p>
<h1 class="text-4xl lg:text-6xl font-extrabold mb-5 gradient-text">The Timeline</h1>
<p class="text-lg text-white/70 max-w-2xl mx-auto">
Our history, the milestones we passed along the way, and the giants whose
shoulders helped us climb higher.
</p>
</div>
</section>
<!-- Timeline -->
<section class="pb-28">
<div class="max-w-[1200px] mx-auto px-6 lg:px-12">
<div class="timeline" id="timeline">
<!-- Flowing connector line (drawn by JS through the node markers) -->
<svg class="timeline-line" id="timelineLine" preserveAspectRatio="none" aria-hidden="true">
<defs>
<linearGradient id="timelineGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#90EE90"/>
<stop offset="100%" stop-color="#F5F5DC"/>
</linearGradient>
</defs>
<path class="track" id="timelineTrack" d=""></path>
<path class="progress" id="timelineProgress" d=""></path>
<circle class="comet" id="timelineComet" r="6" cx="0" cy="0" style="opacity:0"></circle>
</svg>
<div class="timeline-grid">
<!--
EDITABLE STARTER CONTENT: swap the dates / copy below for the real
project history. The serpentine line auto-routes through every
.timeline-node in order, so you can add or remove milestones freely.
-->
<article class="timeline-node">
<div class="timeline-card">
<div class="timeline-marker"><i class="fas fa-seedling"></i></div>
<div class="pt-3">
<div class="flex items-center justify-between mb-2">
<span class="timeline-index">01 · Origin</span>
<span class="timeline-date">June 2024</span>
</div>
<h3 class="timeline-title mb-3">Born as SNLK SQB BOT</h3>
<p class="timeline-desc">
Commissioned by ImApollo and first launched as
<strong class="text-accent">SNLK SQB BOT</strong>. The earliest build is running
within a week, with match data still entered by hand. Within weeks, the prototype
is already being shared with other servers.
</p>
</div>
</div>
</article>
<article class="timeline-node">
<div class="timeline-card">
<div class="timeline-marker"><i class="fas fa-tag"></i></div>
<div class="pt-3">
<div class="flex items-center justify-between mb-2">
<span class="timeline-index">02 · Rebrand</span>
<span class="timeline-date">October 2024</span>
</div>
<h3 class="timeline-title mb-3">Becoming SREBOT</h3>
<p class="timeline-desc">
The project outgrows its original name. SNLK SQB BOT becomes
<strong class="text-accent">SREBOT</strong>, the identity it still carries today.
</p>
</div>
</div>
</article>
<article class="timeline-node">
<div class="timeline-card">
<div class="timeline-marker"><i class="fas fa-file-code"></i></div>
<div class="pt-3">
<div class="flex items-center justify-between mb-2">
<span class="timeline-index">03 · Parser</span>
<span class="timeline-date">January 2025</span>
</div>
<h3 class="timeline-title mb-3">Frovy Opens the Door</h3>
<p class="timeline-desc">
Frovy shows us the first parser and gives SREBOT a real path toward automatic
scoreboards. He had also figured out how to request clandata and receive
immediate point updates, even if he kept the method from us for months.
</p>
</div>
</div>
</article>
<article class="timeline-node">
<div class="timeline-card">
<div class="timeline-marker"><i class="fas fa-robot"></i></div>
<div class="pt-3">
<div class="flex items-center justify-between mb-2">
<span class="timeline-index">04 · Logs</span>
<span class="timeline-date">March 13, 2025</span>
</div>
<h3 class="timeline-title mb-3">Dagor &amp; The First Logs</h3>
<p class="timeline-desc">
Dagor is introduced and the second parser is integrated, a much more robust one
that SREBOT still uses today. The logging pipeline matures around the parser work,
making scoreboards more informative.
</p>
</div>
</div>
</article>
<article class="timeline-node">
<div class="timeline-card">
<div class="timeline-marker"><i class="fas fa-fire"></i></div>
<div class="pt-3">
<div class="flex items-center justify-between mb-2">
<span class="timeline-index">05 · Momentum</span>
<span class="timeline-date">July 2025</span>
</div>
<h3 class="timeline-title mb-3">Word Gets Around</h3>
<p class="timeline-desc">
Squadrons start to take notice. The bot picks up real momentum and grows
in popularity as more communities bring it into their servers.
</p>
</div>
</div>
</article>
<article class="timeline-node">
<div class="timeline-card">
<div class="timeline-marker"><i class="fas fa-laptop-code"></i></div>
<div class="pt-3">
<div class="flex items-center justify-between mb-2">
<span class="timeline-index">06 · Website</span>
<span class="timeline-date">August 2025</span>
</div>
<h3 class="timeline-title mb-3">The Site Goes Live</h3>
<p class="timeline-desc">
Friendly competition with the Boris bot pushes the developers to raise
their game. The SREBOT website goes live, turning stats, scoreboards, and
replays into a browser experience players can explore.
</p>
</div>
</div>
</article>
<article class="timeline-node">
<div class="timeline-card">
<div class="timeline-marker"><i class="fas fa-handshake"></i></div>
<div class="pt-3">
<div class="flex items-center justify-between mb-2">
<span class="timeline-index">07 · Partnership</span>
<span class="timeline-date">January 2026</span>
</div>
<h3 class="timeline-title mb-3">Teaming Up with Spectra</h3>
<p class="timeline-desc">
The sheer volume of requests to Gaijin forces a change of approach. SREBOT
consolidates and partners with Spectra to receive games directly, instead of
downloading and parsing every match on its own.
</p>
</div>
</div>
</article>
<article class="timeline-node">
<div class="timeline-card">
<div class="timeline-marker"><i class="fas fa-coins"></i></div>
<div class="pt-3">
<div class="flex items-center justify-between mb-2">
<span class="timeline-index">08 · Monetization</span>
<span class="timeline-date">April 2026</span>
</div>
<h3 class="timeline-title mb-3">Fueling the Future</h3>
<p class="timeline-desc">
Monetization brings in real income for the first time, funding better
servers and helping new features ship faster.
</p>
</div>
</div>
</article>
<article class="timeline-node">
<div class="timeline-card">
<div class="timeline-marker"><i class="fas fa-location-dot"></i></div>
<div class="pt-3">
<div class="flex items-center justify-between mb-2">
<span class="timeline-index">09 · Today</span>
<span class="timeline-date">Now</span>
</div>
<h3 class="timeline-title mb-3">Where We Are Now</h3>
<p class="timeline-desc">
Automated parsing through Spectra, funded development, and a growing
community of squadrons define the project today. The next chapter is already
underway.
</p>
</div>
</div>
</article>
</div>
</div>
<div class="text-center mt-20">
<a href="/" class="btn-primary px-8 py-4 rounded-xl text-base font-bold inline-flex items-center"
style="background: linear-gradient(135deg, #F5F5DC 0%, #E8E8D0 100%); color:#1E1E1E;">
<i class="fas fa-home mr-3"></i><%= t('common.backToHome') %>
</a>
</div>
</div>
</section>
<!-- Footer -->
<%- include('partials/footer') %>
<script>
window.__lang = '<%= lang %>';
window.__i18n = <%- localeJson %>;
window.__t = function(key) {
var parts = key.split('.'), obj = window.__i18n;
for (var i = 0; i < parts.length; i++) { obj = obj && obj[parts[i]]; }
return obj !== undefined ? obj : key;
};
window.switchLanguage = function(lang) {
var next = lang || (document.documentElement.lang === 'en' ? 'ru' : 'en');
if (next === document.documentElement.lang) return;
document.cookie = 'lang=' + next + ';path=/;max-age=31536000;SameSite=Lax';
window.location.reload();
};
</script>
<script src="/js/main.js?v=3"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/ScrollTrigger.min.js"></script>
<!-- Serpentine timeline renderer -->
<script>
(function () {
var timeline = document.getElementById('timeline');
var svg = document.getElementById('timelineLine');
var track = document.getElementById('timelineTrack');
var progress = document.getElementById('timelineProgress');
var comet = document.getElementById('timelineComet');
if (!timeline || !svg) return;
var nodes = Array.prototype.slice.call(timeline.querySelectorAll('.timeline-node'));
var reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var useGsap = !!(window.gsap && window.ScrollTrigger);
var drawProgress = 0; // 0 to 1, how much of the line is drawn
var targetDrawProgress = 0;
var pathLength = 0;
var nodeProgresses = [];
var nodeRevealed = [];
var state = { progress: 0 };
var lineTrigger = null;
var nodeTriggers = [];
var scrollNormalized = false;
// Build a rounded-corner path that threads through a list of points.
function roundedPath(pts, radius) {
if (pts.length < 2) return '';
var d = 'M ' + pts[0].x.toFixed(1) + ' ' + pts[0].y.toFixed(1);
for (var i = 1; i < pts.length - 1; i++) {
var p0 = pts[i - 1], p1 = pts[i], p2 = pts[i + 1];
var v1 = unit(p1, p0), v2 = unit(p1, p2);
var r1 = Math.min(radius, dist(p0, p1) / 2);
var r2 = Math.min(radius, dist(p2, p1) / 2);
var a = { x: p1.x + v1.x * r1, y: p1.y + v1.y * r1 };
var b = { x: p1.x + v2.x * r2, y: p1.y + v2.y * r2 };
d += ' L ' + a.x.toFixed(1) + ' ' + a.y.toFixed(1);
d += ' Q ' + p1.x.toFixed(1) + ' ' + p1.y.toFixed(1) + ' ' + b.x.toFixed(1) + ' ' + b.y.toFixed(1);
}
var last = pts[pts.length - 1];
d += ' L ' + last.x.toFixed(1) + ' ' + last.y.toFixed(1);
return d;
}
function unit(from, to) {
var dx = to.x - from.x, dy = to.y - from.y, len = Math.hypot(dx, dy) || 1;
return { x: dx / len, y: dy / len };
}
function dist(a, b) { return Math.hypot(a.x - b.x, a.y - b.y); }
function build() {
var box = timeline.getBoundingClientRect();
svg.setAttribute('viewBox', '0 0 ' + box.width + ' ' + box.height);
// Anchor point of each node = centre of its circular marker.
var pts = nodes.map(function (node) {
var marker = node.querySelector('.timeline-marker');
var r = (marker || node).getBoundingClientRect();
return { x: r.left - box.left + r.width / 2, y: r.top - box.top + r.height / 2 };
});
var d = roundedPath(pts, 36);
track.setAttribute('d', d);
progress.setAttribute('d', d);
pathLength = progress.getTotalLength();
progress.style.strokeDasharray = pathLength;
progress.style.strokeDashoffset = pathLength * (1 - drawProgress);
nodeProgresses = nodes.map(function (node, index) {
return nodePathProgress(node, index, pathLength);
});
updateComet(pathLength);
return { pts: pts, len: pathLength };
}
function updateComet(len) {
if (reduceMotion || drawProgress <= 0 || drawProgress >= 1) {
comet.style.opacity = 0;
return;
}
var pt = progress.getPointAtLength(len * drawProgress);
comet.setAttribute('cx', pt.x);
comet.setAttribute('cy', pt.y);
comet.style.opacity = 1;
}
// Find roughly how far along the path a point sits (coarse sampling).
var _samples = null;
function lengthAtPoint(target, len) {
if (!_samples) {
_samples = [];
var steps = 120;
for (var i = 0; i <= steps; i++) {
var l = len * (i / steps);
var p = progress.getPointAtLength(l);
_samples.push({ l: l, x: p.x, y: p.y });
}
}
var best = 0, bestD = Infinity;
for (var j = 0; j < _samples.length; j++) {
var dd = Math.hypot(_samples[j].x - target.x, _samples[j].y - target.y);
if (dd < bestD) { bestD = dd; best = _samples[j].l; }
}
return best;
}
function nodePathProgress(node, index, len) {
if (index === nodes.length - 1) return 1;
var marker = node.querySelector('.timeline-marker');
var markerRect = (marker || node).getBoundingClientRect();
var box = timeline.getBoundingClientRect();
var pt = {
x: markerRect.left - box.left + markerRect.width / 2,
y: markerRect.top - box.top + markerRect.height / 2
};
return lengthAtPoint(pt, len) / len;
}
function revealReachedNodes(len) {
nodes.forEach(function (node, index) {
if (nodeRevealed[index]) return;
if (drawProgress >= (nodeProgresses[index] || 0) - 0.012) {
nodeRevealed[index] = true;
node.classList.add('is-visible');
if (useGsap) {
gsap.to(node, {
autoAlpha: 1,
y: 0,
scale: 1,
duration: 0.7,
ease: 'power3.out',
overwrite: true
});
}
}
});
}
function renderProgress() {
drawProgress = state.progress;
progress.style.strokeDashoffset = pathLength * (1 - drawProgress);
updateComet(pathLength);
revealReachedNodes(pathLength);
if (drawProgress >= 1) {
comet.style.opacity = 0;
nodes.forEach(function (n, index) {
nodeRevealed[index] = true;
n.classList.add('is-visible');
if (useGsap) gsap.set(n, { autoAlpha: 1, y: 0, scale: 1 });
});
}
}
function advanceTo(value, duration) {
var next = Math.max(targetDrawProgress, value);
targetDrawProgress = Math.min(1, Math.max(0, next));
if (targetDrawProgress <= state.progress + 0.001) return;
if (!useGsap) {
state.progress = targetDrawProgress;
renderProgress();
return;
}
gsap.to(state, {
progress: targetDrawProgress,
duration: duration || 0.85,
ease: 'power3.out',
overwrite: true,
onUpdate: renderProgress
});
}
function destroyScrollTriggers() {
if (lineTrigger) {
lineTrigger.kill();
lineTrigger = null;
}
nodeTriggers.forEach(function (trigger) { trigger.kill(); });
nodeTriggers = [];
}
function createScrollTriggers() {
if (!useGsap) return;
gsap.registerPlugin(ScrollTrigger);
if (!scrollNormalized) {
try {
ScrollTrigger.normalizeScroll({
allowNestedScroll: true,
lockAxis: false,
momentum: function (self) {
return Math.min(2.4, Math.abs(self.velocityY) / 1100);
}
});
} catch (e) {
ScrollTrigger.normalizeScroll(true);
}
scrollNormalized = true;
}
destroyScrollTriggers();
nodes.forEach(function (node, index) {
gsap.set(node, nodeRevealed[index]
? { autoAlpha: 1, y: 0, scale: 1, transformOrigin: '50% 50%' }
: { autoAlpha: 0, y: 34, scale: 0.985, transformOrigin: '50% 50%' });
});
lineTrigger = ScrollTrigger.create({
trigger: timeline,
start: 'top 82%',
endTrigger: nodes[nodes.length - 1] || timeline,
end: 'top 82%',
scrub: 0.75,
invalidateOnRefresh: true,
onUpdate: function (self) {
advanceTo(self.progress, 0.45);
}
});
nodes.forEach(function (node, index) {
nodeTriggers.push(ScrollTrigger.create({
trigger: node,
start: 'top 82%',
once: true,
onEnter: function () {
advanceTo(nodeProgresses[index] || 0, 0.85);
}
}));
});
}
function init() {
build();
if (reduceMotion) {
drawProgress = 1;
state.progress = 1;
progress.style.strokeDashoffset = 0;
nodes.forEach(function (n) { n.classList.add('is-visible'); });
return;
}
if (useGsap) {
createScrollTriggers();
renderProgress();
ScrollTrigger.refresh();
} else {
advanceTo(1, 0);
}
}
// Recompute geometry on resize (layout flips between 1 and 3 columns).
var resizeTimer;
window.addEventListener('resize', function () {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(function () {
_samples = null;
build();
if (!reduceMotion && useGsap) {
createScrollTriggers();
ScrollTrigger.refresh();
}
}, 120);
});
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
// Re-measure after fonts/icons settle so markers are positioned correctly.
window.addEventListener('load', function () {
_samples = null;
build();
if (!reduceMotion && useGsap) {
createScrollTriggers();
ScrollTrigger.refresh();
}
});
})();
</script>
</body>
</html>