mirror of
https://github.com/artemium428/428th-website-2026.git
synced 2026-11-14 08:56:20 +00:00
470 lines
16 KiB
JavaScript
470 lines
16 KiB
JavaScript
// ---------- SHARED UTILITIES ----------
|
|
function readCssPx(name, fallback, el = document.documentElement){
|
|
const value = getComputedStyle(el).getPropertyValue(name).trim();
|
|
const parsed = parseFloat(value);
|
|
return Number.isFinite(parsed) ? parsed : fallback;
|
|
}
|
|
|
|
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
|
|
// ---------- ЯЗЫК (переключатель = ссылки на статические URL) ----------
|
|
function initLangSwitch(){
|
|
const root = document.getElementById('langSwitch');
|
|
if (!root) return;
|
|
|
|
root.querySelectorAll('a[data-set-lang]').forEach(link => {
|
|
link.addEventListener('click', () => {
|
|
const lang = link.getAttribute('data-set-lang');
|
|
if (lang === 'ru' || lang === 'en' || lang === 'ua') {
|
|
localStorage.setItem('lang', lang);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// ---------- NAV MAGNET (как ghost / EclipseButton) ----------
|
|
function initNavMagnet(){
|
|
const canHover = window.matchMedia('(hover: hover) and (pointer: fine)').matches;
|
|
if (prefersReducedMotion || !canHover) return;
|
|
|
|
document.querySelectorAll('.main-nav a:not(.nav-cta)').forEach(link => {
|
|
let raf = 0;
|
|
let tx = 0;
|
|
let ty = 0;
|
|
let ts = 1;
|
|
let cx = 0;
|
|
let cy = 0;
|
|
let cs = 1;
|
|
|
|
function tick(){
|
|
cx += (tx - cx) * 0.18;
|
|
cy += (ty - cy) * 0.18;
|
|
cs += (ts - cs) * 0.18;
|
|
link.style.transform = `translate3d(${cx}px, ${cy}px, 0) scale(${cs})`;
|
|
const done = Math.abs(tx - cx) + Math.abs(ty - cy) + Math.abs(ts - cs) < 0.04;
|
|
raf = done ? 0 : requestAnimationFrame(tick);
|
|
}
|
|
|
|
function schedule(){
|
|
if (!raf) raf = requestAnimationFrame(tick);
|
|
}
|
|
|
|
link.addEventListener('mousemove', (e) => {
|
|
const rect = link.getBoundingClientRect();
|
|
tx = (e.clientX - rect.left - rect.width / 2) * 0.15;
|
|
ty = (e.clientY - rect.top - rect.height / 2) * 0.15;
|
|
ts = 1.02;
|
|
schedule();
|
|
});
|
|
|
|
link.addEventListener('mouseleave', () => {
|
|
tx = 0;
|
|
ty = 0;
|
|
ts = 1;
|
|
schedule();
|
|
});
|
|
});
|
|
}
|
|
|
|
// ---------- HERO LOGO SCROLL ANIMATION ----------
|
|
function initHeroScroll(config){
|
|
const {
|
|
section,
|
|
stage,
|
|
brand,
|
|
brandInner,
|
|
mobileAnimClass,
|
|
scrollDrivenClass,
|
|
brandScaleAnimation,
|
|
syncFit,
|
|
brandGapFromSection = false,
|
|
clampMobileScale = null,
|
|
clearSectionVars = null,
|
|
setMobileScaleVar = false,
|
|
subtractRunwayOnMobile = true
|
|
} = config;
|
|
|
|
const siteHeader = document.querySelector('.site-header');
|
|
const mainNav = document.querySelector('.main-nav');
|
|
|
|
let cachedBaseHeight = 0;
|
|
let cachedBrandGap = 32;
|
|
let cachedMaxScale = 1.9;
|
|
let cachedMaxBrandHeight = 0;
|
|
let cachedMinBrandHeight = 0;
|
|
let cachedNavHeight = 0;
|
|
let heroRunway = 0;
|
|
let heroStartY = 0;
|
|
let heroResizeTimer = 0;
|
|
let heroIsMobile = false;
|
|
let heroUseScrollTimeline = false;
|
|
let lastHeroScrollStep = -1;
|
|
let postHeroLift = null;
|
|
|
|
const heroRoot = document.documentElement;
|
|
|
|
function clearHeroAnimationVars(){
|
|
heroRoot.style.removeProperty('--hero-scale');
|
|
heroRoot.style.removeProperty('--hero-brand-height');
|
|
heroRoot.style.removeProperty('--hero-stage-height');
|
|
heroRoot.style.removeProperty('--hero-brand-max-h');
|
|
heroRoot.style.removeProperty('--hero-brand-min-h');
|
|
heroRoot.style.removeProperty('--hero-nav-shift');
|
|
heroRoot.style.removeProperty('--hero-nav-shift-max');
|
|
heroRoot.style.removeProperty('--hero-scroll-start');
|
|
heroRoot.style.removeProperty('--hero-scroll-end');
|
|
if (brandInner){
|
|
brandInner.style.removeProperty('animation-range');
|
|
brandInner.style.removeProperty('animation-timeline');
|
|
brandInner.style.removeProperty('animation');
|
|
}
|
|
if (mainNav){
|
|
mainNav.style.removeProperty('animation-range');
|
|
mainNav.style.removeProperty('animation-timeline');
|
|
mainNav.style.removeProperty('animation');
|
|
mainNav.style.removeProperty('transform');
|
|
}
|
|
if (postHeroLift){
|
|
postHeroLift.style.removeProperty('animation-range');
|
|
postHeroLift.style.removeProperty('animation-timeline');
|
|
postHeroLift.style.removeProperty('animation');
|
|
postHeroLift.style.removeProperty('transform');
|
|
delete postHeroLift.dataset.heroLiftExited;
|
|
}
|
|
if (clearSectionVars) clearSectionVars(section);
|
|
}
|
|
|
|
function ensurePostHeroLift(){
|
|
if (postHeroLift?.isConnected) return postHeroLift;
|
|
postHeroLift = section?.parentElement?.querySelector(':scope > .post-hero-lift') || null;
|
|
return postHeroLift;
|
|
}
|
|
|
|
function applyHeroScrollAnimations(range){
|
|
brandInner.style.animation = `${brandScaleAnimation} ease-out both`;
|
|
brandInner.style.animationTimeline = 'scroll(root block)';
|
|
brandInner.style.animationRange = range;
|
|
mainNav.style.animation = 'hero-nav-lift ease-out both';
|
|
mainNav.style.animationTimeline = 'scroll(root block)';
|
|
mainNav.style.animationRange = range;
|
|
ensurePostHeroLift();
|
|
if (postHeroLift){
|
|
postHeroLift.style.animation = 'hero-content-lift ease-out both';
|
|
postHeroLift.style.animationTimeline = 'scroll(root block)';
|
|
postHeroLift.style.animationRange = range;
|
|
postHeroLift.style.removeProperty('transform');
|
|
delete postHeroLift.dataset.heroLiftExited;
|
|
}
|
|
}
|
|
|
|
function setHeroMobileMode(enabled){
|
|
if (!section) return;
|
|
section.classList.toggle(mobileAnimClass, enabled);
|
|
section.classList.toggle(scrollDrivenClass, enabled && heroUseScrollTimeline);
|
|
if (enabled) ensurePostHeroLift();
|
|
}
|
|
|
|
function getHeroScrollRunwayMultiplier(){
|
|
return readCssPx('--hero-scroll-runway-multiplier', 1);
|
|
}
|
|
|
|
function getHeroEffectiveRunway(){
|
|
return heroRunway * getHeroScrollRunwayMultiplier();
|
|
}
|
|
|
|
function getHeroAnimationRange(){
|
|
return `${heroStartY}px ${heroStartY + getHeroEffectiveRunway()}px`;
|
|
}
|
|
|
|
function easeOutCubic(t){
|
|
return 1 - (1 - t) ** 3;
|
|
}
|
|
|
|
function getHeroScrollProgress(){
|
|
return Math.min(Math.max(window.scrollY - heroStartY, 0), getHeroEffectiveRunway());
|
|
}
|
|
|
|
function updateHeroContentLift(){
|
|
if (!heroIsMobile || prefersReducedMotion || !section){
|
|
if (postHeroLift) postHeroLift.style.removeProperty('transform');
|
|
return;
|
|
}
|
|
|
|
ensurePostHeroLift();
|
|
if (!postHeroLift) return;
|
|
|
|
if (heroUseScrollTimeline){
|
|
const exited = section.getBoundingClientRect().bottom <= 0;
|
|
if (exited){
|
|
if (postHeroLift.dataset.heroLiftExited !== '1'){
|
|
postHeroLift.style.animation = 'none';
|
|
postHeroLift.style.transform = 'translate3d(0, 0, 0)';
|
|
postHeroLift.dataset.heroLiftExited = '1';
|
|
}
|
|
return;
|
|
}
|
|
if (postHeroLift.dataset.heroLiftExited === '1'){
|
|
delete postHeroLift.dataset.heroLiftExited;
|
|
const range = getHeroAnimationRange();
|
|
postHeroLift.style.animation = 'hero-content-lift ease-out both';
|
|
postHeroLift.style.animationTimeline = 'scroll(root block)';
|
|
postHeroLift.style.animationRange = range;
|
|
postHeroLift.style.removeProperty('transform');
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (section.getBoundingClientRect().bottom <= 0){
|
|
postHeroLift.style.transform = 'translate3d(0, 0, 0)';
|
|
return;
|
|
}
|
|
const navShift = getComputedStyle(heroRoot).getPropertyValue('--hero-nav-shift').trim() || '0px';
|
|
postHeroLift.style.transform = `translate3d(0, ${navShift}, 0)`;
|
|
}
|
|
|
|
function isHeroMobileViewport(){
|
|
return window.innerWidth < 600;
|
|
}
|
|
|
|
function getHeroMaxScale(){
|
|
const mobile = window.innerWidth < 600;
|
|
const scope = section || document.documentElement;
|
|
return readCssPx(mobile ? '--hero-max-scale-mobile' : '--hero-max-scale', mobile ? 1.6 : 2.2, scope);
|
|
}
|
|
|
|
function syncSiteHeaderHeight(){
|
|
if (!siteHeader) return;
|
|
const height = siteHeader.getBoundingClientRect().height;
|
|
document.documentElement.style.setProperty('--site-header-height', `${Math.ceil(height)}px`);
|
|
}
|
|
|
|
function getHeroBrandHeight(scale){
|
|
return cachedBaseHeight * scale + cachedBrandGap;
|
|
}
|
|
|
|
function getHeroStickyTop(){
|
|
return readCssPx('--site-header-height', 57) + readCssPx('--hero-stage-offset', 80);
|
|
}
|
|
|
|
function measureHero(){
|
|
syncSiteHeaderHeight();
|
|
heroIsMobile = isHeroMobileViewport();
|
|
lastHeroScrollStep = -1;
|
|
|
|
if (!brandInner || !brand || !section || !stage || prefersReducedMotion){
|
|
heroUseScrollTimeline = false;
|
|
setHeroMobileMode(false);
|
|
clearHeroAnimationVars();
|
|
syncFit();
|
|
return;
|
|
}
|
|
|
|
const gapScope = brandGapFromSection ? section : document.documentElement;
|
|
cachedBrandGap = readCssPx('--hero-brand-gap', 32, gapScope);
|
|
cachedMaxScale = getHeroMaxScale();
|
|
clearHeroAnimationVars();
|
|
brandInner.style.removeProperty('transform');
|
|
|
|
syncFit();
|
|
cachedBaseHeight = brandInner.offsetHeight;
|
|
if (heroIsMobile){
|
|
if (clampMobileScale){
|
|
cachedMaxScale = clampMobileScale(cachedMaxScale, brand, brandInner, section);
|
|
}
|
|
cachedMaxBrandHeight = getHeroBrandHeight(cachedMaxScale);
|
|
cachedMinBrandHeight = getHeroBrandHeight(1);
|
|
} else {
|
|
cachedMaxBrandHeight = getHeroBrandHeight(1);
|
|
cachedMinBrandHeight = getHeroBrandHeight(1 / cachedMaxScale);
|
|
}
|
|
cachedNavHeight = mainNav ? mainNav.offsetHeight : 0;
|
|
heroRunway = Math.max(cachedMaxBrandHeight - cachedMinBrandHeight, 0);
|
|
heroStartY = stage.getBoundingClientRect().top + window.scrollY - getHeroStickyTop();
|
|
|
|
heroUseScrollTimeline = heroIsMobile
|
|
&& typeof CSS !== 'undefined'
|
|
&& CSS.supports('animation-timeline', 'scroll()');
|
|
|
|
if (heroIsMobile){
|
|
setHeroMobileMode(true);
|
|
if (setMobileScaleVar){
|
|
section.style.setProperty('--hero-max-scale-mobile', cachedMaxScale.toFixed(4));
|
|
}
|
|
heroRoot.style.setProperty('--hero-brand-max-h', `${cachedMaxBrandHeight}px`);
|
|
heroRoot.style.setProperty('--hero-brand-min-h', `${cachedMinBrandHeight}px`);
|
|
heroRoot.style.setProperty('--hero-brand-height', `${cachedMaxBrandHeight}px`);
|
|
heroRoot.style.setProperty('--hero-stage-height', `${cachedMaxBrandHeight + cachedNavHeight}px`);
|
|
heroRoot.style.setProperty('--hero-nav-shift-max', `${-(cachedMaxBrandHeight - cachedMinBrandHeight)}px`);
|
|
if (heroUseScrollTimeline){
|
|
applyHeroScrollAnimations(getHeroAnimationRange());
|
|
}
|
|
} else {
|
|
setHeroMobileMode(false);
|
|
}
|
|
|
|
updateHeroBrand();
|
|
updateHeroContentLift();
|
|
}
|
|
|
|
function updateHeroBrand(){
|
|
if (!brandInner || !brand || !stage || prefersReducedMotion || cachedBaseHeight <= 0) return;
|
|
if (heroUseScrollTimeline) return;
|
|
|
|
const scrolled = getHeroScrollProgress();
|
|
const effectiveRunway = getHeroEffectiveRunway();
|
|
const linearProgress = effectiveRunway > 0 ? scrolled / effectiveRunway : 1;
|
|
const progress = easeOutCubic(Math.min(linearProgress, 1));
|
|
|
|
if (heroIsMobile){
|
|
const scale = cachedMaxScale - progress * (cachedMaxScale - 1);
|
|
const brandHeight = cachedBaseHeight * scale + cachedBrandGap;
|
|
const navShift = cachedMaxBrandHeight - brandHeight;
|
|
|
|
heroRoot.style.setProperty('--hero-scale', scale.toFixed(4));
|
|
heroRoot.style.setProperty('--hero-nav-shift', `${-navShift}px`);
|
|
updateHeroContentLift();
|
|
return;
|
|
}
|
|
|
|
if (scrolled === lastHeroScrollStep) return;
|
|
lastHeroScrollStep = scrolled;
|
|
|
|
const brandHeight = cachedMaxBrandHeight - progress * (cachedMaxBrandHeight - cachedMinBrandHeight);
|
|
const scale = (brandHeight - cachedBrandGap) / cachedBaseHeight;
|
|
|
|
heroRoot.style.setProperty('--hero-scale', scale.toFixed(4));
|
|
heroRoot.style.setProperty('--hero-brand-height', `${brandHeight}px`);
|
|
heroRoot.style.setProperty('--hero-stage-height', `${brandHeight + cachedNavHeight}px`);
|
|
}
|
|
|
|
let heroScrollRaf = 0;
|
|
let heroLastScrollY = -1;
|
|
|
|
function runHeroScrollFrame(){
|
|
heroScrollRaf = 0;
|
|
const scrollY = window.scrollY;
|
|
|
|
if (heroIsMobile) updateHeroContentLift();
|
|
if (!heroUseScrollTimeline) updateHeroBrand();
|
|
|
|
if (scrollY !== heroLastScrollY){
|
|
heroLastScrollY = scrollY;
|
|
scheduleHeroScrollFrame();
|
|
}
|
|
}
|
|
|
|
function scheduleHeroScrollFrame(){
|
|
if (heroScrollRaf) return;
|
|
heroScrollRaf = requestAnimationFrame(runHeroScrollFrame);
|
|
}
|
|
|
|
function onHeroScroll(){
|
|
scheduleHeroScrollFrame();
|
|
}
|
|
|
|
function initHeroAnimation(){
|
|
measureHero();
|
|
}
|
|
|
|
function scheduleHeroMeasure(){
|
|
clearTimeout(heroResizeTimer);
|
|
heroResizeTimer = setTimeout(initHeroAnimation, 120);
|
|
}
|
|
|
|
function getNavScrollOffset(){
|
|
return readCssPx('--site-header-height', 57) + 24;
|
|
}
|
|
|
|
function getSectionScrollInset(target){
|
|
const paddingTop = parseFloat(getComputedStyle(target).paddingTop);
|
|
return Number.isFinite(paddingTop) ? paddingTop : 0;
|
|
}
|
|
|
|
function scrollToSection(target){
|
|
const offset = getNavScrollOffset();
|
|
let top = target.getBoundingClientRect().top + window.scrollY - offset + getSectionScrollInset(target);
|
|
const belowHero = section
|
|
&& (section.compareDocumentPosition(target) & Node.DOCUMENT_POSITION_FOLLOWING);
|
|
const canSubtractRunway = subtractRunwayOnMobile || !heroIsMobile;
|
|
if (belowHero && heroRunway > 0 && canSubtractRunway && window.scrollY < heroStartY + heroRunway){
|
|
top -= heroRunway;
|
|
}
|
|
|
|
window.scrollTo({
|
|
top: Math.max(top, 0),
|
|
behavior: prefersReducedMotion ? 'auto' : 'smooth'
|
|
});
|
|
}
|
|
|
|
window.addEventListener('scroll', onHeroScroll, { passive: true });
|
|
window.addEventListener('resize', scheduleHeroMeasure);
|
|
window.addEventListener('load', initHeroAnimation);
|
|
|
|
if (brandInner && window.ResizeObserver){
|
|
const heroResizeObserver = new ResizeObserver(scheduleHeroMeasure);
|
|
heroResizeObserver.observe(brandInner);
|
|
}
|
|
|
|
if (siteHeader && window.ResizeObserver){
|
|
const headerResizeObserver = new ResizeObserver(scheduleHeroMeasure);
|
|
headerResizeObserver.observe(siteHeader);
|
|
}
|
|
|
|
initHeroAnimation();
|
|
|
|
document.querySelectorAll('.main-nav a[href^="#"]').forEach(link => {
|
|
link.addEventListener('click', e => {
|
|
const target = document.querySelector(link.getAttribute('href'));
|
|
if (!target) return;
|
|
|
|
e.preventDefault();
|
|
scrollToSection(target);
|
|
});
|
|
});
|
|
}
|
|
|
|
// ---------- REVEAL ON SCROLL ----------
|
|
function initReveal(){
|
|
const observer = new IntersectionObserver((entries) => {
|
|
entries.forEach(entry => {
|
|
if (entry.isIntersecting){
|
|
entry.target.classList.add('visible');
|
|
observer.unobserve(entry.target);
|
|
}
|
|
});
|
|
}, { threshold: 0.15 });
|
|
|
|
document.querySelectorAll('.reveal').forEach(el => observer.observe(el));
|
|
}
|
|
|
|
// ---------- МОДАЛКА + ФОРМА ----------
|
|
function initContactForm(){
|
|
const modal = document.getElementById('contactModal');
|
|
document.getElementById('openContactForm').addEventListener('click', () => modal.classList.add('open'));
|
|
document.getElementById('closeContactForm').addEventListener('click', () => modal.classList.remove('open'));
|
|
modal.addEventListener('click', e => { if (e.target === modal) modal.classList.remove('open'); });
|
|
|
|
const form = document.getElementById('contactForm');
|
|
const status = document.getElementById('formStatus');
|
|
|
|
form.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
status.textContent = form.dataset.statusLoading || '...';
|
|
|
|
const data = Object.fromEntries(new FormData(form).entries());
|
|
|
|
try {
|
|
const res = await fetch('/api/contact', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data)
|
|
});
|
|
if (!res.ok) throw new Error('request_failed');
|
|
status.textContent = form.dataset.statusSuccess || '✓';
|
|
form.reset();
|
|
setTimeout(() => modal.classList.remove('open'), 1200);
|
|
} catch (err) {
|
|
status.textContent = form.dataset.statusError || 'Error';
|
|
}
|
|
});
|
|
}
|