add page project and update styles

This commit is contained in:
Nikita 2026-09-09 11:51:52 +05:00
parent c6d8e54d89
commit fcea711ddf
44 changed files with 3890 additions and 112 deletions

View file

@ -1,5 +1,8 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import { Cursor } from "@/widgets/cursor";
import { Preloader } from "@/widgets/preloader";
import { ScrollCounter } from "@/widgets/scroll-counter";
import "@/static/globals.css"; import "@/static/globals.css";
const geistSans = Geist({ const geistSans = Geist({
@ -23,7 +26,12 @@ const RootLayout = ({ children }: LayoutProps<"/">) => {
lang="en" lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
> >
<body className="min-h-full flex flex-col">{children}</body> <body className="min-h-full flex flex-col">
<Preloader />
<Cursor />
<ScrollCounter />
{children}
</body>
</html> </html>
); );
}; };

12
src/app/projects/page.tsx Normal file
View file

@ -0,0 +1,12 @@
import { ProjectsPage } from "@/views/projects";
const Projects = () => {
return <ProjectsPage />;
};
export const metadata = {
title: "Проекты",
description: "Все проекты Egorov Nikita",
};
export default Projects;

View file

@ -0,0 +1,109 @@
"use client";
import { useInView } from "@/shared/hooks/useInView";
import styles from "@/static/about/about.module.css";
const techStack = [
{ name: "TypeScript", level: 80 },
{ name: "React / Next.js", level: 85 },
{ name: "JavaScript", level: 90 },
{ name: "CSS / Tailwind", level: 85 },
{ name: "Node.js", level: 70 },
{ name: "Git / GitHub", level: 75 },
{ name: "REST API", level: 80 },
{ name: "Figma", level: 65 },
];
const About = () => {
const [introRef, introInView] = useInView<HTMLDivElement>(0.2);
const [skillsRef, skillsInView] = useInView<HTMLDivElement>(0.3);
const reveal = (visible: boolean) =>
`transition-all duration-700 ease-out will-change-transform ${
visible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-10"
}`;
return (
<section id="about" data-scroll-section className={styles.about}>
<span className={styles.glowA} aria-hidden="true" />
<span className={styles.glowB} aria-hidden="true" />
<span className={styles.ghost} aria-hidden="true">
02
</span>
<div ref={introRef} className="page-container relative z-10">
<p className={`${styles.eyebrow} ${reveal(introInView)}`}>
<span className={styles.rule} aria-hidden="true" />
обо мне
</p>
<h2
className={`${styles.title} ${reveal(introInView)}`}
style={{ transitionDelay: "100ms" }}
>
Разработчик, для которого важны{" "}
<span className={styles.titleAccent}>продуманность</span> и детали
</h2>
<p
className={`${styles.text} ${reveal(introInView)}`}
style={{ transitionDelay: "200ms" }}
>
Привет! Я Никита — веб-разработчик. Строю современные, быстрые и
аккуратные интерфейсы на React и Next.js. Уделяю внимание
производительности, семантике и мелочам, из которых складывается
хорошее впечатление от сайта.
</p>
<dl className={`${styles.stats} ${reveal(introInView)}`} style={{ transitionDelay: "300ms" }}>
<div>
<dt className={styles.statLabel}>опыт</dt>
<dd className={styles.statValue}>
3<span className={styles.statValueAccent}>+</span>
</dd>
</div>
<div>
<dt className={styles.statLabel}>проекты</dt>
<dd className={styles.statValue}>
10<span className={styles.statValueAccent}>+</span>
</dd>
</div>
<div>
<dt className={styles.statLabel}>стек</dt>
<dd className={styles.statValue}>8</dd>
</div>
</dl>
<div
ref={skillsRef}
className={`${styles.skills} ${reveal(skillsInView)}`}
style={{ transitionDelay: "150ms" }}
>
<h3 className={styles.skillsTitle}>Стек и навыки</h3>
<ul className={styles.skillList}>
{techStack.map((item, index) => (
<li key={item.name}>
<p className={styles.skillName}>{item.name}</p>
<div
className={styles.skillBar}
style={{ transitionDelay: `${index * 70}ms` }}
>
<span
className={styles.skillFill}
style={{
width: skillsInView ? `${item.level}%` : "0%",
transitionDelay: `${index * 70}ms`,
}}
/>
</div>
</li>
))}
</ul>
</div>
</div>
</section>
);
};
export default About;

View file

@ -0,0 +1,55 @@
"use client";
import { useRef, type CSSProperties, type MouseEvent } from "react";
import styles from "@/static/hero/flashlight.module.css";
const OFFSCREEN = -260;
type FlashlightTextProps = {
text: string;
};
const FlashlightText = ({ text }: FlashlightTextProps) => {
const ref = useRef<HTMLSpanElement>(null);
const setPosition = (x: number, y: number) => {
const element = ref.current;
if (!element) return;
element.style.setProperty("--fx", `${x}px`);
element.style.setProperty("--fy", `${y}px`);
};
const handleMove = (e: MouseEvent<HTMLSpanElement>) => {
const element = ref.current;
if (!element) return;
const rect = element.getBoundingClientRect();
setPosition(e.clientX - rect.left, e.clientY - rect.top);
element.classList.remove(styles.idle);
};
const handleLeave = () => {
setPosition(OFFSCREEN, OFFSCREEN);
ref.current?.classList.add(styles.idle);
};
return (
<span
ref={ref}
className={styles.root}
onMouseMove={handleMove}
onMouseLeave={handleLeave}
style={{ "--fx": `${OFFSCREEN}px`, "--fy": `${OFFSCREEN}px` } as CSSProperties}
>
<span className={styles.base} aria-hidden="true">
{text}
</span>
<span className={styles.highlight}>{text}</span>
</span>
);
};
export default FlashlightText;

View file

@ -1,20 +1,96 @@
import { sociallinks } from "@/shared/config/social-links/links"; import { sociallinks } from "@/shared/config/social-links/links";
import styles from "@/static/hero/hero.module.css"; import styles from "@/static/hero/hero.module.css";
import FlashlightText from "./FlashlightText";
const socialIcon = (label: string) => {
switch (label.trim().toLowerCase()) {
case "telegram":
return (
<svg
viewBox="0 0 24 24"
width="20"
height="20"
fill="currentColor"
aria-hidden="true"
>
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
</svg>
);
case "github":
return (
<svg
viewBox="0 0 16 16"
width="20"
height="20"
fill="currentColor"
aria-hidden="true"
>
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A5.98 5.98 0 0 0 16 8c0-4.42-3.58-8-8-8z" />
</svg>
);
default:
return null;
}
};
const Hero = () => { const Hero = () => {
return ( return (
<section className={styles.hero}> <section className={styles.hero} data-scroll-section>
<div className="cont-header"> <div className={`cont-header ${styles.inner}`}>
<div className={styles.left}> <div className={styles.left}>
<h1>left</h1> <div className={styles.info}>
<span className={styles.role}>
<span className={styles.dot} />
веб-разработчик
</span>
<h1 className={styles.name} data-cursor-hover>
<FlashlightText text="Egorov Nikita" />
</h1>
<p className={styles.desc}>
Создаю современные, быстрые и продуманные веб-сайты и
веб-приложения под ключ.
</p>
<div className={styles.actions}>
<a href="#projects" className={styles.button}>
Смотреть проекты
</a>
<ul className={styles.socials}>
{sociallinks.map((link) => (
<li key={link.href}>
<a
href={link.href}
target="_blank"
rel="noreferrer"
className={styles.social}
aria-label={link.label}
>
{socialIcon(link.label)}
</a>
</li>
))}
</ul>
</div>
</div>
</div> </div>
<div className={styles.right}> <div className={styles.right}>
<div className={styles.visual}> <div className={styles.visual}>
<span>01</span> <span className={styles.index} aria-hidden="true">
<i>портфолио</i>
</span>
</div> </div>
</div> </div>
</div> </div>
<div className={styles.hint} aria-hidden="true">
листай
<span className={styles.hintLine} />
</div>
</section> </section>
); );
}; };

View file

@ -0,0 +1,75 @@
"use client";
import type { KeyboardEvent, MouseEvent } from "react";
import type { Project } from "@/shared/config/projects/types";
import styles from "@/static/projects/project-card.module.css";
type ProjectCardProps = {
project: Project;
onOpen: (project: Project) => void;
};
const ProjectCard = ({ project, onOpen }: ProjectCardProps) => {
const handleSpotlight = (e: MouseEvent<HTMLElement>) => {
const element = e.currentTarget;
const rect = element.getBoundingClientRect();
element.style.setProperty(
"--mx",
`${((e.clientX - rect.left) / rect.width) * 100}%`
);
element.style.setProperty(
"--my",
`${((e.clientY - rect.top) / rect.height) * 100}%`
);
};
const handleKeyDown = (e: KeyboardEvent<HTMLElement>) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpen(project);
}
};
return (
<article
className={styles.card}
role="button"
tabIndex={0}
data-cursor-hover
aria-haspopup="dialog"
aria-label={`${project.title}: подробнее`}
onClick={() => onOpen(project)}
onKeyDown={handleKeyDown}
onMouseMove={handleSpotlight}
>
<div className={styles.cardIndex}>
<span>{project.id}</span>
<span>{project.year}</span>
</div>
<h3 className={styles.cardTitle}>{project.title}</h3>
<p className={styles.cardDesc}>{project.description}</p>
<ul className={styles.tags}>
{project.tags.map((tag) => (
<li key={tag} className={styles.tag}>
{tag}
</li>
))}
</ul>
<span className={styles.cardLink}>
подробнее
<span className={styles.cardLinkArrow} aria-hidden="true">
↗
</span>
</span>
</article>
);
};
export default ProjectCard;

View file

@ -0,0 +1,73 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useInView } from "@/shared/hooks/useInView";
import { projects } from "@/shared/config/projects/projects";
import type { Project } from "@/shared/config/projects/types";
import { ProjectModal } from "@/widgets/project-modal";
import styles from "@/static/projects/projects.module.css";
import ProjectCard from "./ProjectCard";
const preview = projects.slice(0, 6);
const ProjectsSection = () => {
const [gridRef, gridInView] = useInView<HTMLDivElement>(0.12);
const [selected, setSelected] = useState<Project | null>(null);
const reveal = (visible: boolean) =>
`transition-all duration-700 ease-out will-change-transform ${
visible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
}`;
return (
<section id="projects" className={styles.projects} data-scroll-section>
<span className={styles.glowA} aria-hidden="true" />
<span className={styles.glowB} aria-hidden="true" />
<span className={styles.ghost} aria-hidden="true">
03
</span>
<div className="page-container relative z-10">
<div className={styles.head}>
<div>
<p className={styles.eyebrow}>
<span className={styles.rule} aria-hidden="true" />
работы
</p>
<h2 className={styles.title}>
Избранные <span className={styles.titleAccent}>проекты</span>
</h2>
</div>
<Link href="/projects" className={styles.viewAll}>
все проекты
<span className={styles.viewAllArrow} aria-hidden="true">
↗
</span>
</Link>
</div>
<div ref={gridRef} className={styles.grid}>
{preview.map((project, index) => (
<div
key={project.id}
className={reveal(gridInView)}
style={{ transitionDelay: `${index * 80}ms` }}
>
<ProjectCard project={project} onOpen={setSelected} />
</div>
))}
</div>
</div>
<ProjectModal project={selected} onClose={() => setSelected(null)} />
</section>
);
};
export default ProjectsSection;

View file

@ -0,0 +1,43 @@
import styles from "@/static/sponsors/sponsors.module.css";
const sponsors = [
{ name: "Nova", className: styles.nova },
{ name: "LUMINA", className: styles.lumina },
{ name: "Vertex", className: styles.vertex },
{ name: "orbital", className: styles.orbit },
{ name: "MONOLITH", className: styles.monolith },
{ name: "Aster Studio", className: styles.aster },
{ name: "Kite", className: styles.kite },
{ name: "PULSE", className: styles.pulse },
];
const List = ({ hidden = false }: { hidden?: boolean }) => (
<ul className={styles.group} aria-hidden={hidden || undefined}>
{sponsors.map((sponsor) => (
<li key={sponsor.name} className={styles.item}>
<span className={`${styles.word} ${sponsor.className}`}>
{sponsor.name}
</span>
</li>
))}
</ul>
);
const Sponsors = () => {
return (
<section className={styles.section} aria-label="Партнёры и бренды">
<div className="page-container">
<p className={styles.label}>С ними я работал</p>
</div>
<div className={styles.ticker}>
<div className={styles.track}>
<List />
<List hidden />
</div>
</div>
</section>
);
};
export default Sponsors;

View file

@ -0,0 +1,93 @@
"use client";
import { useInView } from "@/shared/hooks/useInView";
import styles from "@/static/testimonials/testimonials.module.css";
const testimonials = [
{
quote:
"Никита сделал лендинг, который реально приносит лиды. Быстро, аккуратно, без лишней воды в коммуникации.",
name: "Алексей Орлов",
role: "Основатель финтех-стартапа",
},
{
quote:
"Код понятный, страницы летают, а правки после сдачи вносить легко. Работать с ним — одно удовольствие.",
name: "Мария Соколова",
role: "Продакт-менеджер",
},
{
quote:
"За 2 недели собрал нам интернет-магазин под ключ: каталог, оплата, админка. Всё чётко и в срок.",
name: "Дмитрий Волков",
role: "Владелец бренда одежды",
},
];
const initials = (name: string) =>
name
.split(" ")
.map((part) => part[0])
.join("");
const Testimonials = () => {
const [gridRef, gridInView] = useInView<HTMLDivElement>(0.15);
const reveal = (visible: boolean) =>
`transition-all duration-700 ease-out will-change-transform ${
visible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
}`;
return (
<section className={styles.testimonials} data-scroll-section>
<span className={styles.dots} aria-hidden="true" />
<span className={styles.glow} aria-hidden="true" />
<span className={styles.ghost} aria-hidden="true">
04
</span>
<div className="page-container relative z-10">
<p className={styles.eyebrow}>
<span className={styles.rule} aria-hidden="true" />
отзывы
</p>
<h2 className={styles.title}>
Что обо мне <span className={styles.titleAccent}>говорят</span>
</h2>
<div ref={gridRef} className={styles.grid}>
{testimonials.map((item, index) => (
<article
key={item.name}
className={`${styles.card} ${reveal(gridInView)}`}
style={{ transitionDelay: `${index * 90}ms` }}
>
<p className={styles.quote} aria-hidden="true">
&ldquo;
</p>
<p className={styles.stars} aria-label="Оценка 5 из 5">
★★★★★
</p>
<p className={styles.text}>{item.quote}</p>
<footer className={styles.author}>
<span className={styles.avatar} aria-hidden="true">
{initials(item.name)}
</span>
<span>
<span className={styles.authorName}>{item.name}</span>
<span className={styles.authorRole}>{item.role}</span>
</span>
</footer>
</article>
))}
</div>
</div>
</section>
);
};
export default Testimonials;

View file

@ -3,10 +3,14 @@ import type { NavigationItem } from "./types";
export const navigation: NavigationItem[] = [ export const navigation: NavigationItem[] = [
{ {
label: "Обо мне", label: "Обо мне",
href: "#about", href: "/#about",
}, },
{ {
label: "Проекты", label: "Проекты",
href: "#projects", href: "/projects",
},
{
label: "Контакты",
href: "/#contact",
}, },
]; ];

View file

@ -0,0 +1,157 @@
import type { Project } from "./types";
export const projects: Project[] = [
{
id: "01",
title: "Fintech Landing",
description:
"Быстрый лендинг финтех-стартапа: анимации, формы заявки и стабильная производительность.",
year: "2025",
tags: ["Next.js", "TypeScript", "Tailwind"],
details:
"Одностраничный сайт для финтех-стартапа: продуманная структура, анимации появления, формы заявок и интеграция с метриками. Упор на скорость загрузки и рост конверсии.",
features: [
"Лендинг со скоростью загрузки < 1 с",
"Анимированные блоки и формы заявки",
"Интеграция с Яндекс.Метрикой и аналитикой",
"Адаптив под все устройства",
],
code: "https://github.com/autli5",
},
{
id: "02",
title: "Маркетплейс",
description:
"Сервис для продажи и покупки товаров: каталог, корзина, оплата и личный кабинет.",
year: "2025",
tags: ["Next.js", "PostgreSQL", "Prisma"],
details:
"Маркетплейс с ролями покупателя и продавца: каталог с фильтрами, корзина, онлайн-оплата, личный кабинет и панель продавца для управления товарами.",
features: [
"Каталог с фильтрами и поиском",
"Корзина и онлайн-оплата",
"Личный кабинет покупателя",
"Панель продавца с загрузкой товаров",
],
code: "https://github.com/autli5",
},
{
id: "03",
title: "SaaS Dashboard",
description:
"Аналитика в реальном времени: графики, отчёты и экспорт данных для бизнеса.",
year: "2024",
tags: ["React", "TypeScript", "Recharts"],
details:
"Дашборд для SaaS-продукта: витрина метрик в реальном времени, интерактивные графики, построение отчётов и экспорт в CSV. Тёмная тема и кастомизация виджетов.",
features: [
"Графики в реальном времени",
"Конструктор отчётов и экспорт в CSV",
"Пользовательские виджеты и тёмная тема",
"Ролевой доступ к данным",
],
code: "https://github.com/autli5",
},
{
id: "04",
title: "Корпоративный сайт",
description:
"Сайт компании на headless CMS с мультиязычностью и редакторским контролем.",
year: "2024",
tags: ["Next.js", "CMS", "Editorial"],
details:
"Корпоративный сайт на headless CMS: редакторы сами публикуют новости и страницы, есть мультиязычность, SEO-разметка и политика конфиденциальности.",
features: [
"Управление контентом через CMS",
"Мультиязычность (RU / EN)",
"SEO-семантика и Open Graph",
"Блог с постраничной навигацией",
],
code: "https://github.com/autli5",
},
{
id: "05",
title: "Доставка · PWA",
description:
"Веб-приложение доставки с офлайн-режимом, трекингом заказов и push-уведомлениями.",
year: "2024",
tags: ["React", "Node.js", "PWA"],
details:
"PWA-приложение службы доставки: офлайн-работа, установка на устройство, трекинг заказов в реальном времени и push-уведомления о статусе.",
features: [
"Офлайн-режим через service worker",
"Push-уведомления и установка на устройство",
"Трекинг заказа в реальном времени",
"Корзина и быстрое оформление",
],
code: "https://github.com/autli5",
},
{
id: "06",
title: "Магазин одежды",
description:
"Интернет-магазин с каталогом, умными фильтрами и онлайн-оплатой.",
year: "2023",
tags: ["Next.js", "Tailwind", "Stripe"],
details:
"Интернет-магазин одежды: каталог с фильтрами по размерам и сезонам, быстрый просмотр товара, онлайн-оплата через Stripe и уведомления о заказах.",
features: [
"Фильтры и сортировка каталога",
"Онлайн-оплата через Stripe",
"Модальные карточки товара",
"Админка для управления остатками",
],
code: "https://github.com/autli5",
},
{
id: "07",
title: "Криптовалютный кошелёк",
description:
"Web-кошелёк с отслеживанием баланса, курсов и историей транзакций.",
year: "2023",
tags: ["React", "TypeScript", "Web3"],
details:
"Клиент для криптовалютного кошелька: подключение через Web3, отслеживание баланса и курсов, история транзакций и переводы между адресами.",
features: [
"Подключение кошелька через Web3",
"Мониторинг баланса и курсов",
"История и статусы транзакций",
"Переводы между адресами",
],
code: "https://github.com/autli5",
},
{
id: "08",
title: "Агрегатор новостей",
description:
"Лента новостей из нескольких источников с фильтрами и поиском.",
year: "2023",
tags: ["Next.js", "TypeScript", "RSS"],
details:
"Агрегатор новостей: сбор ленты из RSS-источников, категории, полнотекстовый поиск, сохранение статей и тёмная тема.",
features: [
"Сбор ленты из нескольких RSS-источников",
"Фильтры по категориям и поиск",
"Сохранение в «избранное»",
"Тёмная тема",
],
code: "https://github.com/autli5",
},
{
id: "09",
title: "Лендинг кофейни",
description:
"Атмосферный лендинг кофейни с меню, галереей и бронированием столика.",
year: "2022",
tags: ["Astro", "GSAP", "Jamstack"],
details:
"Лендинг кофейни: галерея с лайтбоксом, меню с фильтрами, форма бронирования столика и плавные scroll-анимации на GSAP. Генерация статики — максимальная скорость.",
features: [
"Меню с фильтрацией по категориям",
"Галерея с лайтбоксом",
"Форма бронирования столика",
"Scroll-анимации на GSAP",
],
code: "https://github.com/autli5",
},
];

View file

@ -0,0 +1,11 @@
export type Project = {
id: string;
title: string;
description: string;
year: string;
tags: string[];
details: string;
features: string[];
demo?: string;
code?: string;
};

View file

@ -0,0 +1,27 @@
import { useEffect, useRef, useState } from "react";
export const useInView = <T extends HTMLElement>(threshold = 0.2) => {
const ref = useRef<T>(null);
const [inView, setInView] = useState(false);
useEffect(() => {
const element = ref.current;
if (!element) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setInView(true);
observer.disconnect();
}
},
{ threshold }
);
observer.observe(element);
return () => observer.disconnect();
}, [threshold]);
return [ref, inView] as const;
};

View file

@ -0,0 +1,230 @@
/* =========================
ABOUT
========================= */
.about {
position: relative;
overflow: hidden;
padding-block: var(--section-y);
}
/* decorative glows */
.glowA,
.glowB {
position: absolute;
border-radius: 50%;
filter: blur(96px);
pointer-events: none;
}
.glowA {
top: 18%;
left: -180px;
width: 420px;
height: 420px;
background: rgba(255, 255, 255, 0.05);
}
.glowB {
right: -220px;
bottom: 8%;
width: 520px;
height: 520px;
background: rgba(255, 255, 255, 0.04);
}
/* big decorative ghost number behind the section */
.ghost {
position: absolute;
top: 40px;
right: 4vw;
z-index: 0;
font-size: clamp(160px, 24vw, 460px);
font-weight: 800;
line-height: 1;
letter-spacing: -0.05em;
color: transparent;
-webkit-text-stroke: 1px rgba(255, 255, 255, 0.05);
pointer-events: none;
user-select: none;
}
/* =========================
CONTENT
========================= */
.eyebrow {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
color: var(--color-muted);
font-size: var(--fs-overline);
font-weight: 500;
letter-spacing: var(--ls-overline);
text-transform: uppercase;
}
.rule {
width: var(--space-lg);
height: 1px;
background: var(--color-muted);
}
.title {
margin-top: var(--space-md);
max-width: 24em;
font-size: var(--fs-display);
font-weight: 700;
line-height: var(--lh-tight);
letter-spacing: -0.02em;
}
.titleAccent {
color: var(--color-faint);
}
.text {
margin-top: var(--space-md);
max-width: var(--text-measure);
color: var(--color-muted);
font-size: var(--fs-body);
line-height: var(--lh-body);
}
/* =========================
STATS
========================= */
.stats {
display: flex;
flex-wrap: wrap;
gap: var(--space-xl);
margin-block: var(--space-xl) 0;
padding-top: var(--space-md);
border-top: 1px solid var(--color-line);
}
.statLabel {
color: var(--color-faint);
font-size: var(--fs-small);
text-transform: uppercase;
letter-spacing: 0.14em;
}
.statValue {
margin-top: var(--space-xs);
font-size: var(--fs-title);
font-weight: 700;
line-height: 1;
}
.statValueAccent {
color: var(--color-faint);
}
/* =========================
SKILLS
========================= */
.skills {
margin-top: var(--space-2xl);
}
.skillsTitle {
color: var(--color-faint);
font-size: var(--fs-overline);
font-weight: 500;
letter-spacing: var(--ls-overline);
text-transform: uppercase;
}
.skillList {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-lg);
margin: 0;
padding: var(--space-md) 0 0;
list-style: none;
}
.skillName {
color: var(--color-text);
font-size: var(--fs-body);
font-weight: 600;
}
.skillBar {
height: 3px;
margin-top: var(--space-xs);
border-radius: 100px;
background: var(--color-line);
overflow: hidden;
}
.skillFill {
display: block;
height: 100%;
border-radius: 100px;
background: var(--color-text);
transition: width 1s cubic-bezier(0.22, 1, 0.36, 1);
}
/* =========================
MOBILE
========================= */
@media (max-width: 768px) {
.ghost {
top: 24px;
right: -30px;
}
}
@media (max-width: 640px) {
.skillList {
grid-template-columns: 1fr;
gap: var(--space-md);
}
.stats {
gap: var(--space-lg);
}
}

View file

@ -0,0 +1,114 @@
/* =========================
CUSTOM CURSOR
========================= */
.cursor {
position: fixed;
inset: 0;
z-index: 10000;
pointer-events: none;
opacity: 0;
transition: opacity 0.35s ease;
}
.visible {
opacity: 1;
}
.dot {
position: absolute;
top: 0;
left: 0;
width: 8px;
height: 8px;
margin: -4px 0 0 -4px;
border-radius: 50%;
background: #fff;
transform: translate3d(-100px, -100px, 0);
transition:
width 0.25s ease,
height 0.25s ease,
margin 0.25s ease,
opacity 0.25s ease,
background 0.25s ease;
}
.ring {
position: absolute;
top: 0;
left: 0;
width: 40px;
height: 40px;
margin: -20px 0 0 -20px;
border: 1px solid rgba(255, 255, 255, 0.4);
border-radius: 50%;
transform: translate3d(-100px, -100px, 0);
transition:
width 0.25s cubic-bezier(0.22, 1, 0.36, 1),
height 0.25s cubic-bezier(0.22, 1, 0.36, 1),
margin 0.25s cubic-bezier(0.22, 1, 0.36, 1),
border-color 0.25s ease,
background 0.25s ease;
}
/* =========================
HOVERING INTERACTIVE
========================= */
.hovering .ring {
width: 64px;
height: 64px;
margin: -32px 0 0 -32px;
border-color: rgba(255, 255, 255, 0.9);
background: rgba(255, 255, 255, 0.07);
}
.hovering .dot {
width: 6px;
height: 6px;
margin: -3px 0 0 -3px;
opacity: 0;
}
/* =========================
PRESSED
========================= */
.pressed .ring {
width: 30px;
height: 30px;
margin: -15px 0 0 -15px;
border-color: #fff;
background: #fff;
}
.pressed .dot {
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.ring {
transition: none;
}
}

View file

@ -0,0 +1,211 @@
/* =========================
FOOTER
========================= */
.footer {
position: relative;
overflow: hidden;
border-top: 1px solid var(--color-line);
padding-block: var(--space-2xl) var(--space-lg);
}
.watermark {
position: absolute;
left: 50%;
bottom: -0.34em;
z-index: 0;
transform: translateX(-50%);
font-size: clamp(120px, 22vw, 360px);
font-weight: 800;
line-height: 1;
letter-spacing: -0.06em;
color: transparent;
-webkit-text-stroke: 1px rgba(255, 255, 255, 0.06);
white-space: nowrap;
pointer-events: none;
user-select: none;
}
/* =========================
CTA
========================= */
.cta {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: var(--space-md);
}
.eyebrow {
color: var(--color-muted);
font-size: var(--fs-overline);
font-weight: 500;
letter-spacing: var(--ls-overline);
text-transform: uppercase;
}
.title {
max-width: 18em;
font-size: var(--fs-display);
font-weight: 800;
line-height: var(--lh-tight);
letter-spacing: -0.03em;
}
.titleAccent {
color: var(--color-faint);
}
.text {
max-width: var(--text-measure);
color: var(--color-muted);
font-size: var(--fs-body);
line-height: var(--lh-body);
}
.actions {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: var(--space-md);
margin-top: var(--space-xs);
}
.button,
.buttonGhost {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-sm) var(--space-lg);
border-radius: 100px;
font-size: var(--fs-body);
font-weight: 600;
text-decoration: none;
transition:
transform 0.3s ease,
box-shadow 0.3s ease,
background 0.3s ease,
color 0.3s ease,
border-color 0.3s ease;
}
.button {
background: #fff;
color: #000;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 18px 40px rgba(255, 255, 255, 0.16);
}
.buttonGhost {
border: 1px solid rgba(255, 255, 255, 0.2);
background: transparent;
color: var(--color-text);
}
.buttonGhost:hover {
border-color: rgba(255, 255, 255, 0.5);
background: rgba(255, 255, 255, 0.06);
transform: translateY(-2px);
}
/* =========================
BOTTOM ROW
========================= */
.bottom {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-md);
margin-top: var(--space-2xl);
padding-top: var(--space-md);
border-top: 1px solid var(--color-line);
}
.wordmark {
font-size: var(--fs-logo);
font-weight: 800;
line-height: 1;
letter-spacing: -0.06em;
}
.nav ul {
display: flex;
align-items: center;
gap: var(--space-md);
margin: 0;
padding: 0;
list-style: none;
}
.nav a {
color: var(--color-muted);
font-size: var(--fs-small);
text-decoration: none;
transition: color 0.25s ease;
}
.nav a:hover {
color: var(--color-text);
}
.copy {
color: var(--color-faint);
font-size: var(--fs-caption);
}
@media (max-width: 640px) {
.bottom {
justify-content: center;
text-align: center;
}
}

View file

@ -1,3 +1,15 @@
@import "tailwindcss";
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* =========================
FONTS
========================= */
@font-face { @font-face {
font-family: "Montserrat"; font-family: "Montserrat";
src: url("/fonts/Montserrat/Montserrat-VariableFont_wght.ttf") format("truetype"); src: url("/fonts/Montserrat/Montserrat-VariableFont_wght.ttf") format("truetype");
@ -14,27 +26,136 @@
font-display: swap; font-display: swap;
} }
/* =========================
DESIGN TOKENS
========================= */
:root { :root {
/* Font */ /* Font */
--font-main: "Montserrat", sans-serif; --font-main: "Montserrat", sans-serif;
/* Color */
--color-bg: #000;
--color-text: #fff;
--color-muted: rgba(255, 255, 255, 0.55);
--color-faint: rgba(255, 255, 255, 0.38);
--color-line: rgba(255, 255, 255, 0.1);
/* Layout container */
--container-max: 1280px;
--container-padding: clamp(20px, 4vw, 64px);
--container-gap: clamp(24px, 3vw, 64px);
/* Spacing scale */
--space-xs: 8px;
--space-sm: 16px;
--space-md: 24px;
--space-lg: 40px;
--space-xl: 64px;
--space-2xl: 96px;
--section-y: clamp(72px, 9vw, 128px);
/* Text measure */
--text-measure: 62ch;
/* Type scale */
--fs-base: 16px;
--fs-hero: clamp(44px, 7vw, 108px);
--fs-display: clamp(28px, 3.6vw, 46px);
--fs-title: clamp(18px, 1.6vw, 24px);
--fs-logo: clamp(26px, 2vw, 38px);
--fs-body: clamp(15px, 1.05vw, 17px);
--fs-small: 14px;
--fs-caption: 12px;
--fs-overline: 13px;
--fs-nav: clamp(15px, 1vw, 19px);
/* Line heights & letter-spacing */
--lh-tight: 1.05;
--lh-body: 1.6;
--ls-overline: 0.28em;
}
/* =========================
BASE
========================= */
html {
scroll-behavior: smooth;
} }
html, html,
body { body {
font-family: var(--font-main); font-family: var(--font-main);
font-size: var(--fs-base);
background-color: var(--color-bg); background-color: var(--color-bg);
color: var(--color-text); color: var(--color-text);
font-size: var(--fz-normal); line-height: var(--lh-body);
} }
h1, a {
color: #fff; h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: 700;
color: var(--color-text);
}
a {
color: var(--color-text);
}
/* Custom cursor: hide native cursor on fine-pointer devices */
@media (hover: hover) and (pointer: fine) {
html.has-cursor,
html.has-cursor * {
cursor: none !important;
}
}
/* Lock scroll while the preloader is visible */
html.no-scroll,
html.no-scroll body {
overflow: hidden;
}
/* =========================
HIDE NATIVE SCROLLBAR
(прокрутка остаётся — wheel/трекпад)
========================= */
html {
scrollbar-width: none;
-ms-overflow-style: none;
}
html::-webkit-scrollbar,
body::-webkit-scrollbar {
display: none;
}
/* =========================
LAYOUT GRID — единственный
source of truth для ширины
и горизонтальных отступов
========================= */
.page-container {
width: 100%;
max-width: var(--container-max);
margin-inline: auto;
padding-inline: var(--container-padding);
} }
.cont-header { .cont-header {
width: 100%; width: 100%;
margin: 0 auto; max-width: var(--container-max);
padding: 0 var(--container-padding, 40px); margin-inline: auto;
padding-inline: var(--container-padding);
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: var(--container-gap);
} }

View file

@ -1,16 +1,25 @@
/* =========================
HEADER
========================= */
.header { .header {
position: relative; position: sticky;
top: 0;
display: flex; display: flex;
justify-content: center;
z-index: 1000; z-index: 1000;
width: 100%; width: 100%;
height: 90px; height: 90px;
color: #fff; color: var(--color-text);
padding: var(--container-padding); background: rgba(0, 0, 0, 0.85);
border-bottom: 1px solid #181818; backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-bottom: 1px solid var(--color-line);
} }
/* ========================= /* =========================
@ -25,11 +34,11 @@
.logo { .logo {
display: inline-block; display: inline-block;
color: #fff; color: var(--color-text);
font-size: clamp(28px, 2vw, 38px); font-size: var(--fs-logo);
line-height: 1;
font-weight: 700; font-weight: 700;
line-height: 1;
letter-spacing: -0.06em; letter-spacing: -0.06em;
text-decoration: none; text-decoration: none;
@ -41,7 +50,6 @@
opacity: 0.65; opacity: 0.65;
} }
/* ========================= /* =========================
RIGHT RIGHT
========================= */ ========================= */
@ -49,11 +57,9 @@
.right { .right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-md);
gap: clamp(24px, 3vw, 55px);
} }
/* ========================= /* =========================
DESKTOP NAV DESKTOP NAV
========================= */ ========================= */
@ -62,7 +68,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: clamp(24px, 2.8vw, 55px); gap: var(--space-lg);
margin: 0; margin: 0;
padding: 0; padding: 0;
@ -82,9 +88,9 @@
padding: 8px 0; padding: 8px 0;
color: #777; color: var(--color-muted);
font-size: clamp(15px, 1vw, 20px); font-size: var(--fs-nav);
font-weight: 500; font-weight: 500;
letter-spacing: -0.01em; letter-spacing: -0.01em;
@ -103,20 +109,19 @@
width: 0; width: 0;
height: 1px; height: 1px;
background: #fff; background: var(--color-text);
transition: width 0.3s ease; transition: width 0.3s ease;
} }
.desktopNav a:hover { .desktopNav a:hover {
color: #fff; color: var(--color-text);
} }
.desktopNav a:hover::after { .desktopNav a:hover::after {
width: 100%; width: 100%;
} }
/* ========================= /* =========================
HAMBURGER HAMBURGER
========================= */ ========================= */
@ -147,7 +152,7 @@
border-radius: 10px; border-radius: 10px;
background: #fff; background: var(--color-text);
transition: transition:
transform 0.3s ease, transform 0.3s ease,
@ -167,6 +172,18 @@
bottom: 0; bottom: 0;
} }
.hamburger.active span:nth-child(1) {
transform: translateY(11px) rotate(45deg);
}
.hamburger.active span:nth-child(2) {
opacity: 0;
transform: translateX(10px);
}
.hamburger.active span:nth-child(3) {
transform: translateY(-11px) rotate(-45deg);
}
/* ========================= /* =========================
MOBILE MENU MOBILE MENU
@ -180,9 +197,9 @@
width: 100%; width: 100%;
height: calc(100vh - 90px); height: calc(100vh - 90px);
padding: 60px var(--container-padding); padding: var(--space-2xl) var(--container-padding);
background: #000; background: var(--color-bg);
transform: translateX(100%); transform: translateX(100%);
@ -199,7 +216,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 30px; gap: var(--space-md);
margin: 0; margin: 0;
padding: 0; padding: 0;
@ -215,12 +232,11 @@
.mobileMenu a { .mobileMenu a {
display: inline-block; display: inline-block;
color: #fff; color: var(--color-text);
font-size: clamp(30px, 6vw, 52px);
line-height: 1;
font-size: var(--fs-display);
font-weight: 600; font-weight: 600;
line-height: 1;
letter-spacing: -0.04em; letter-spacing: -0.04em;
text-decoration: none; text-decoration: none;
@ -229,10 +245,9 @@
} }
.mobileMenu a:hover { .mobileMenu a:hover {
color: #777; color: var(--color-muted);
} }
/* ========================= /* =========================
LARGE SCREENS LARGE SCREENS
========================= */ ========================= */
@ -241,21 +256,8 @@
.header { .header {
height: 110px; height: 110px;
} }
.logo {
font-size: 42px;
}
.desktopNav a {
font-size: 20px;
}
.desktopNav ul {
gap: 50px;
}
} }
/* ========================= /* =========================
TABLET TABLET
========================= */ ========================= */
@ -265,20 +267,11 @@
height: 80px; height: 80px;
} }
.logo {
font-size: 28px;
}
.desktopNav ul { .desktopNav ul {
gap: 24px; gap: var(--space-md);
}
.desktopNav a {
font-size: 15px;
} }
} }
/* ========================= /* =========================
MOBILE MOBILE
========================= */ ========================= */
@ -300,13 +293,8 @@
top: 70px; top: 70px;
height: calc(100vh - 70px); height: calc(100vh - 70px);
} }
.right {
gap: 0;
}
} }
/* ========================= /* =========================
SMALL MOBILE SMALL MOBILE
========================= */ ========================= */
@ -329,14 +317,6 @@
top: 64px; top: 64px;
height: calc(100vh - 64px); height: calc(100vh - 64px);
padding-top: 45px; padding-top: var(--space-lg);
}
.mobileMenu ul {
gap: 26px;
}
.mobileMenu a {
font-size: 32px;
} }
} }

View file

@ -0,0 +1,64 @@
/* =========================
FLASHLIGHT TEXT
========================= */
.root {
position: relative;
display: inline-block;
}
.base {
display: block;
color: rgba(255, 255, 255, 0.14);
}
.highlight {
position: absolute;
inset: 0;
display: block;
color: #fff;
pointer-events: none;
text-shadow: 0 0 26px rgba(255, 255, 255, 0.55);
transition: opacity 0.35s ease;
-webkit-mask-image: radial-gradient(
circle 170px at var(--fx, -260px) var(--fy, -260px),
#000 0%,
rgba(0, 0, 0, 0.9) 25%,
rgba(0, 0, 0, 0.35) 55%,
transparent 78%
);
mask-image: radial-gradient(
circle 170px at var(--fx, -260px) var(--fy, -260px),
#000 0%,
rgba(0, 0, 0, 0.9) 25%,
rgba(0, 0, 0, 0.35) 55%,
transparent 78%
);
}
/* свет гаснет, когда курсор покидает текст */
.root.idle .highlight {
opacity: 0;
}
/* =========================
TOUCH DEVICES
========================= */
@media (hover: none), (pointer: coarse) {
.base {
color: rgba(255, 255, 255, 0.92);
}
.highlight {
display: none;
}
}

View file

@ -1,10 +1,413 @@
/* =========================
HERO
========================= */
.hero { .hero {
background-color: aquamarine; position: relative;
height: calc(100vh - 90px);
display: flex;
align-items: center;
min-height: calc(100vh - 90px);
overflow: hidden;
} }
.right { /* subtle grid */
width: 49%; .hero::before {
height: 90vh; content: "";
background-color: red;
position: absolute;
inset: 0;
z-index: 0;
background-image:
linear-gradient(rgba(255, 255, 255, 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px);
background-size: 72px 72px;
-webkit-mask-image: radial-gradient(
ellipse 70% 90% at 30% 55%,
#000 0%,
transparent 70%
);
mask-image: radial-gradient(
ellipse 70% 90% at 30% 55%,
#000 0%,
transparent 70%
);
pointer-events: none;
}
/* ambient glow */
.hero::after {
content: "";
position: absolute;
top: -22%;
right: -12%;
width: 58vw;
height: 58vw;
border-radius: 50%;
background: radial-gradient(circle, rgba(255, 255, 255, 0.07) 0%, transparent 60%);
pointer-events: none;
}
.inner {
position: relative;
z-index: 1;
}
/* scroll hint at the bottom */
.hint {
position: absolute;
left: 50%;
bottom: var(--space-md);
z-index: 1;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-xs);
color: var(--color-faint);
font-size: var(--fs-caption);
font-weight: 500;
letter-spacing: 0.3em;
text-transform: uppercase;
pointer-events: none;
}
.hintLine {
display: block;
width: 1px;
height: 36px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.6), transparent);
animation: hintFloat 2.2s ease-in-out infinite;
}
@keyframes hintFloat {
0% {
transform: scaleY(0.4);
transform-origin: top;
}
50% {
transform: scaleY(1);
transform-origin: top;
}
100% {
transform: scaleY(0.4);
transform-origin: top;
}
}
.left {
flex: 1;
min-width: 0;
}
.info {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-md);
padding: var(--space-xl) 0;
}
/* =========================
ROLE BADGE
========================= */
.role {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: 10px 18px;
border: 1px solid var(--color-line);
border-radius: 100px;
background: rgba(255, 255, 255, 0.03);
color: var(--color-muted);
font-size: var(--fs-overline);
font-weight: 500;
letter-spacing: var(--ls-overline);
text-transform: uppercase;
}
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #fff;
box-shadow:
0 0 0 4px rgba(255, 255, 255, 0.12),
0 0 18px rgba(255, 255, 255, 0.9);
}
/* =========================
NAME
========================= */
.name {
margin: 0;
font-size: var(--fs-hero);
font-weight: 800;
line-height: var(--lh-tight);
letter-spacing: -0.04em;
}
/* =========================
DESCRIPTION
========================= */
.desc {
max-width: var(--text-measure);
color: var(--color-muted);
font-size: var(--fs-body);
line-height: var(--lh-body);
}
/* =========================
ACTIONS
========================= */
.actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-md);
margin-top: var(--space-xs);
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--space-sm) var(--space-lg);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 100px;
background: transparent;
color: var(--color-text);
font-size: var(--fs-body);
font-weight: 600;
letter-spacing: 0.04em;
text-decoration: none;
transition:
background 0.3s ease,
color 0.3s ease,
box-shadow 0.3s ease,
transform 0.3s ease;
}
.button:hover {
background: #fff;
color: #000;
box-shadow: 0 18px 40px rgba(255, 255, 255, 0.18);
transform: translateY(-2px);
}
/* =========================
SOCIALS
========================= */
.socials {
display: flex;
gap: var(--space-xs);
margin: 0;
padding: 0;
list-style: none;
}
.social {
display: inline-flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 50%;
color: rgba(255, 255, 255, 0.75);
text-decoration: none;
transition:
background 0.3s ease,
color 0.3s ease,
border-color 0.3s ease,
transform 0.3s ease;
}
.social:hover {
border-color: rgba(255, 255, 255, 0.5);
background: rgba(255, 255, 255, 0.06);
color: #fff;
transform: translateY(-2px);
}
/* =========================
RIGHT VISUAL
========================= */
.right {
width: min(30vw, 400px);
flex-shrink: 0;
}
@media (max-width: 1200px) {
.right {
width: min(28vw, 340px);
}
}
.visual {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
aspect-ratio: 1;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 50%;
overflow: hidden;
background: radial-gradient(circle at 35% 30%, rgba(255, 255, 255, 0.08) 0%, transparent 55%);
}
.visual::before {
content: "";
position: absolute;
inset: 8%;
border: 1px dashed rgba(255, 255, 255, 0.12);
border-radius: 50%;
animation: spin 30s linear infinite;
}
.index {
position: relative;
display: inline-flex;
color: var(--color-faint);
font-size: var(--fs-caption);
font-weight: 500;
letter-spacing: var(--ls-overline);
text-transform: uppercase;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* =========================
TABLET
========================= */
@media (min-width: 1600px) {
.hero {
min-height: calc(100vh - 110px);
}
}
@media (max-width: 1024px) {
.hero {
min-height: calc(100vh - 80px);
}
}
@media (max-width: 900px) {
.inner {
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 56px;
}
.right {
width: min(60vw, 300px);
align-self: center;
}
}
/* =========================
MOBILE
========================= */
@media (max-width: 768px) {
.hero {
min-height: calc(100vh - 70px);
}
.hint {
display: none;
}
.right {
display: none;
}
}
@media (max-width: 480px) {
.hero {
min-height: calc(100vh - 64px);
}
}
@media (prefers-reduced-motion: reduce) {
.visual::before {
animation: none;
}
} }

View file

@ -0,0 +1,117 @@
/* =========================
PRELOADER
========================= */
.preloader {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
background: #000;
transform: translateY(0);
transition:
transform 0.7s cubic-bezier(0.76, 0, 0.24, 1),
opacity 0.5s ease;
}
.done {
transform: translateY(-100%);
}
.content {
display: flex;
flex-direction: column;
align-items: center;
gap: 28px;
width: min(420px, calc(100% - 80px));
}
.logo {
display: flex;
align-items: baseline;
color: var(--color-text);
font-size: var(--fs-display);
font-weight: 800;
letter-spacing: -0.06em;
line-height: 1;
}
.accent {
color: transparent;
-webkit-text-stroke: 1px rgba(255, 255, 255, 0.7);
margin-left: 2px;
}
.bar {
position: relative;
width: 100%;
height: 1px;
background: rgba(255, 255, 255, 0.15);
}
.barFill {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: #fff;
box-shadow: 0 0 12px rgba(255, 255, 255, 0.8);
transition: width 0.15s linear;
}
.meta {
display: flex;
justify-content: space-between;
width: 100%;
color: rgba(255, 255, 255, 0.45);
font-size: var(--fs-caption);
letter-spacing: 0.14em;
text-transform: uppercase;
}
.percent {
color: rgba(255, 255, 255, 0.9);
}
/* =========================
PULSING HINT
========================= */
@media (prefers-reduced-motion: no-preference) {
.logo {
animation: pulse 1.4s ease-in-out infinite;
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.55;
}
}

View file

@ -0,0 +1,281 @@
/* =========================
PROJECT MODAL
========================= */
.overlay {
position: fixed;
inset: 0;
z-index: 9990;
display: flex;
align-items: center;
justify-content: center;
padding: var(--container-padding);
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
animation: backdropIn 0.3s ease;
}
.panel {
width: min(560px, 100%);
max-height: min(78vh, 720px);
border: 1px solid var(--color-line);
border-radius: 24px;
background: #0a0a0a;
color: var(--color-text);
overflow: hidden;
animation: modalIn 0.35s cubic-bezier(0.22, 1, 0.36, 1);
}
.panel:focus {
outline: none;
}
@keyframes modalIn {
from {
opacity: 0;
transform: translateY(14px) scale(0.97);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes backdropIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* =========================
CONTENT
========================= */
.wrap {
display: flex;
flex-direction: column;
gap: var(--space-md);
max-height: inherit;
padding: var(--space-lg);
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.wrap::-webkit-scrollbar {
display: none;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
}
.meta {
color: var(--color-faint);
font-size: var(--fs-caption);
letter-spacing: 0.14em;
text-transform: uppercase;
}
.close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
flex-shrink: 0;
border: 1px solid var(--color-line);
border-radius: 50%;
background: transparent;
color: var(--color-text);
font-size: 20px;
line-height: 1;
cursor: pointer;
transition:
border-color 0.25s ease,
background 0.25s ease;
}
.close:hover {
border-color: rgba(255, 255, 255, 0.4);
background: rgba(255, 255, 255, 0.06);
}
.title {
margin: 0;
font-size: var(--fs-title);
font-weight: 700;
letter-spacing: -0.02em;
}
.details {
color: var(--color-muted);
font-size: var(--fs-body);
line-height: var(--lh-body);
}
.metaList {
display: flex;
flex-direction: column;
gap: var(--space-xs);
margin: 0;
padding: var(--space-sm) 0 0;
border-top: 1px solid var(--color-line);
list-style: none;
}
.metaList li {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-md);
font-size: var(--fs-small);
}
.metaList span {
color: var(--color-faint);
}
.metaList strong {
font-weight: 600;
text-align: right;
}
.subheading {
margin: 0;
color: var(--color-faint);
font-size: var(--fs-overline);
letter-spacing: var(--ls-overline);
text-transform: uppercase;
}
.features {
display: flex;
flex-direction: column;
gap: var(--space-xs);
margin: 0;
padding: 0;
color: var(--color-muted);
font-size: var(--fs-body);
list-style: none;
}
.features li {
position: relative;
padding-left: 18px;
}
.features li::before {
content: "—";
position: absolute;
left: 0;
color: var(--color-faint);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
padding-top: var(--space-sm);
border-top: 1px solid var(--color-line);
}
.actionButton,
.actionGhost {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: 10px 18px;
border-radius: 100px;
font-size: var(--fs-small);
font-weight: 600;
text-decoration: none;
transition:
transform 0.25s ease,
background 0.25s ease,
border-color 0.25s ease;
}
.actionButton {
background: #fff;
color: #000;
}
.actionButton:hover {
transform: translateY(-1px);
}
.actionGhost {
border: 1px solid rgba(255, 255, 255, 0.2);
color: var(--color-text);
}
.actionGhost:hover {
border-color: rgba(255, 255, 255, 0.5);
background: rgba(255, 255, 255, 0.06);
}

View file

@ -0,0 +1,172 @@
/* =========================
PROJECT CARD
========================= */
.card {
position: relative;
display: flex;
flex-direction: column;
gap: var(--space-sm);
height: 100%;
padding: var(--space-lg);
border: 1px solid var(--color-line);
border-radius: 20px;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.045),
rgba(255, 255, 255, 0.012)
);
overflow: hidden;
cursor: pointer;
transition:
border-color 0.35s ease,
background 0.35s ease,
transform 0.35s cubic-bezier(0.22, 1, 0.36, 1);
}
.card::before {
content: "";
position: absolute;
inset: -40%;
opacity: 0;
background: radial-gradient(
circle at var(--mx, 50%) var(--my, 0%),
rgba(255, 255, 255, 0.09),
transparent 45%
);
pointer-events: none;
transition: opacity 0.5s ease;
}
.card:hover,
.card:focus-visible {
border-color: rgba(255, 255, 255, 0.22);
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.06),
rgba(255, 255, 255, 0.015)
);
transform: translateY(-4px);
}
.card:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.4);
outline-offset: 2px;
}
.card:hover::before {
opacity: 1;
}
.cardIndex {
display: flex;
justify-content: space-between;
color: var(--color-faint);
font-size: var(--fs-caption);
letter-spacing: 0.14em;
text-transform: uppercase;
}
.cardTitle {
position: relative;
margin: 0;
margin-top: var(--space-sm);
font-size: var(--fs-title);
font-weight: 700;
letter-spacing: -0.02em;
}
.cardDesc {
position: relative;
margin: 0;
color: var(--color-muted);
font-size: var(--fs-body);
line-height: var(--lh-body);
}
.tags {
position: relative;
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
margin: 0;
margin-top: auto;
padding: var(--space-md) 0 0;
list-style: none;
}
.tag {
padding: 5px 12px;
border: 1px solid var(--color-line);
border-radius: 100px;
color: var(--color-muted);
font-size: var(--fs-caption);
}
.cardLink {
position: relative;
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: var(--space-sm);
color: var(--color-text);
font-size: var(--fs-caption);
letter-spacing: 0.14em;
text-transform: uppercase;
opacity: 0.7;
transition: opacity 0.3s ease;
}
.card:hover .cardLink,
.card:focus-visible .cardLink {
opacity: 1;
}
.cardLinkArrow {
display: inline-block;
transition: transform 0.3s ease;
}
.card:hover .cardLinkArrow,
.card:focus-visible .cardLinkArrow {
transform: translate(3px, -3px);
}

View file

@ -0,0 +1,134 @@
/* =========================
PROJECTS PAGE
========================= */
.projectsPage {
position: relative;
overflow: hidden;
padding-block: var(--section-y);
}
.glow {
position: absolute;
top: 6%;
right: -180px;
width: 480px;
height: 480px;
border-radius: 50%;
filter: blur(96px);
background: rgba(255, 255, 255, 0.05);
pointer-events: none;
}
.ghost {
position: absolute;
top: 40px;
left: 1vw;
z-index: 0;
font-size: clamp(160px, 24vw, 460px);
font-weight: 800;
line-height: 1;
letter-spacing: -0.05em;
color: transparent;
-webkit-text-stroke: 1px rgba(255, 255, 255, 0.05);
pointer-events: none;
user-select: none;
}
/* =========================
HEAD
========================= */
.head {
position: relative;
z-index: 1;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
color: var(--color-muted);
font-size: var(--fs-overline);
font-weight: 500;
letter-spacing: var(--ls-overline);
text-transform: uppercase;
}
.rule {
width: var(--space-lg);
height: 1px;
background: var(--color-muted);
}
.title {
margin-top: var(--space-md);
font-size: var(--fs-display);
font-weight: 700;
line-height: var(--lh-tight);
letter-spacing: -0.02em;
}
.titleAccent {
color: var(--color-faint);
}
.count {
margin-top: var(--space-xs);
color: var(--color-faint);
font-size: var(--fs-body);
}
/* =========================
GRID
========================= */
.grid {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-md);
margin-top: var(--space-xl);
}
/* =========================
RESPONSIVE
========================= */
@media (max-width: 1024px) {
.grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 640px) {
.grid {
grid-template-columns: 1fr;
}
.ghost {
left: -24px;
}
}

View file

@ -0,0 +1,187 @@
/* =========================
PROJECTS
========================= */
.projects {
position: relative;
overflow: hidden;
padding-block: var(--section-y);
}
.glowA,
.glowB {
position: absolute;
border-radius: 50%;
filter: blur(96px);
pointer-events: none;
}
.glowA {
top: 10%;
right: -180px;
width: 460px;
height: 460px;
background: rgba(255, 255, 255, 0.05);
}
.glowB {
bottom: 0;
left: -220px;
width: 520px;
height: 520px;
background: rgba(255, 255, 255, 0.04);
}
.ghost {
position: absolute;
top: 30px;
left: 1vw;
z-index: 0;
font-size: clamp(160px, 24vw, 460px);
font-weight: 800;
line-height: 1;
letter-spacing: -0.05em;
color: transparent;
-webkit-text-stroke: 1px rgba(255, 255, 255, 0.05);
pointer-events: none;
user-select: none;
}
/* =========================
HEAD
========================= */
.head {
position: relative;
z-index: 1;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: var(--space-md);
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
color: var(--color-muted);
font-size: var(--fs-overline);
font-weight: 500;
letter-spacing: var(--ls-overline);
text-transform: uppercase;
}
.rule {
width: var(--space-lg);
height: 1px;
background: var(--color-muted);
}
.title {
margin-top: var(--space-md);
max-width: 24em;
font-size: var(--fs-display);
font-weight: 700;
line-height: var(--lh-tight);
letter-spacing: -0.02em;
}
.titleAccent {
color: var(--color-faint);
}
.viewAll {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding-bottom: 4px;
border-bottom: 1px solid transparent;
color: var(--color-muted);
font-size: var(--fs-body);
font-weight: 500;
text-decoration: none;
transition:
color 0.3s ease,
border-color 0.3s ease;
}
.viewAll:hover {
color: var(--color-text);
border-color: currentColor;
}
.viewAllArrow {
display: inline-block;
transition: transform 0.3s ease;
}
.viewAll:hover .viewAllArrow {
transform: translate(3px, -3px);
}
/* =========================
GRID
========================= */
.grid {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-md);
margin-top: var(--space-xl);
}
/* =========================
RESPONSIVE
========================= */
@media (max-width: 1024px) {
.grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 640px) {
.grid {
grid-template-columns: 1fr;
}
.head {
flex-direction: column;
align-items: flex-start;
gap: var(--space-sm);
}
}

View file

@ -0,0 +1,184 @@
/* =========================
SPONSORS / MARQUEE
========================= */
.section {
padding-top: var(--space-2xl);
}
.label {
margin-bottom: var(--space-lg);
text-align: center;
color: var(--color-faint);
font-size: var(--fs-overline);
font-weight: 500;
letter-spacing: var(--ls-overline);
text-transform: uppercase;
}
.ticker {
position: relative;
overflow: hidden;
border-block: 1px solid var(--color-line);
padding-block: var(--space-md);
}
/* мягкое затухание по краям */
.ticker::before,
.ticker::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
z-index: 1;
width: clamp(24px, 5vw, 80px);
pointer-events: none;
}
.ticker::before {
left: 0;
background: linear-gradient(90deg, #000, transparent);
}
.ticker::after {
right: 0;
background: linear-gradient(270deg, #000, transparent);
}
.track {
display: flex;
width: max-content;
animation: marquee 34s linear infinite;
}
.track:hover {
animation-play-state: paused;
}
.group {
display: flex;
align-items: center;
margin: 0;
padding: 0;
list-style: none;
}
.item {
display: flex;
align-items: center;
gap: var(--space-md);
padding-inline: var(--space-lg);
}
.item::after {
content: "◆";
color: rgba(255, 255, 255, 0.28);
font-size: 10px;
}
.word {
color: rgba(255, 255, 255, 0.55);
font-size: clamp(18px, 1.8vw, 26px);
font-weight: 700;
white-space: nowrap;
}
/* разные «начертания» брендов */
.nova {
font-style: italic;
font-weight: 800;
color: rgba(255, 255, 255, 0.75);
}
.lumina {
font-weight: 300;
letter-spacing: 0.5em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.4);
margin-right: -0.5em;
}
.vertex {
font-weight: 900;
letter-spacing: 0.12em;
color: rgba(255, 255, 255, 0.7);
}
.orbit {
font-style: italic;
font-weight: 400;
color: rgba(255, 255, 255, 0.45);
}
.monolith {
font-weight: 700;
letter-spacing: 0.3em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.5);
}
.aster {
font-weight: 600;
color: rgba(255, 255, 255, 0.65);
}
.kite {
font-weight: 800;
text-transform: lowercase;
color: rgba(255, 255, 255, 0.55);
}
.pulse {
font-weight: 500;
letter-spacing: 0.22em;
text-transform: uppercase;
color: #fff;
}
@keyframes marquee {
to {
transform: translateX(-50%);
}
}
@media (max-width: 640px) {
.item {
padding-inline: var(--space-md);
}
}
@media (prefers-reduced-motion: reduce) {
.track {
animation: none;
}
}

View file

@ -0,0 +1,254 @@
/* =========================
TESTIMONIALS
========================= */
.testimonials {
position: relative;
overflow: hidden;
padding-block: var(--section-y);
}
/* точечная текстура по фоновой сетке */
.dots {
position: absolute;
inset: 0;
z-index: 0;
background-image: radial-gradient(
rgba(255, 255, 255, 0.06) 1px,
transparent 1.6px
);
background-size: 26px 26px;
-webkit-mask-image: radial-gradient(
ellipse 62% 72% at 50% 45%,
#000 0%,
transparent 78%
);
mask-image: radial-gradient(
ellipse 62% 72% at 50% 45%,
#000 0%,
transparent 78%
);
pointer-events: none;
}
.glow {
position: absolute;
top: 30%;
left: -160px;
width: 440px;
height: 440px;
border-radius: 50%;
filter: blur(96px);
background: rgba(255, 255, 255, 0.04);
pointer-events: none;
}
.ghost {
position: absolute;
top: 24px;
right: 2vw;
z-index: 0;
font-size: clamp(160px, 24vw, 460px);
font-weight: 800;
line-height: 1;
letter-spacing: -0.05em;
color: transparent;
-webkit-text-stroke: 1px rgba(255, 255, 255, 0.05);
pointer-events: none;
user-select: none;
}
/* =========================
HEAD
========================= */
.eyebrow {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
color: var(--color-muted);
font-size: var(--fs-overline);
font-weight: 500;
letter-spacing: var(--ls-overline);
text-transform: uppercase;
}
.rule {
width: var(--space-lg);
height: 1px;
background: var(--color-muted);
}
.title {
margin-top: var(--space-md);
max-width: 24em;
font-size: var(--fs-display);
font-weight: 700;
line-height: var(--lh-tight);
letter-spacing: -0.02em;
}
.titleAccent {
color: var(--color-faint);
}
/* =========================
GRID
========================= */
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-md);
margin-top: var(--space-xl);
}
.card {
position: relative;
display: flex;
flex-direction: column;
gap: var(--space-sm);
padding: var(--space-lg);
border: 1px solid var(--color-line);
border-radius: 20px;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.04),
rgba(255, 255, 255, 0.012)
);
transition:
border-color 0.3s ease,
transform 0.3s cubic-bezier(0.22, 1, 0.36, 1);
}
.card:hover {
border-color: rgba(255, 255, 255, 0.22);
transform: translateY(-4px);
}
.quote {
color: var(--color-faint);
font-size: clamp(44px, 4vw, 60px);
font-weight: 800;
line-height: 0.55;
}
.stars {
margin-top: var(--space-xs);
color: #fff;
font-size: var(--fs-small);
letter-spacing: 4px;
}
.text {
flex: 1;
color: var(--color-muted);
font-size: var(--fs-body);
line-height: var(--lh-body);
}
.author {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-top: var(--space-sm);
padding-top: var(--space-sm);
border-top: 1px solid var(--color-line);
}
.avatar {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
flex-shrink: 0;
border: 1px solid var(--color-line);
border-radius: 50%;
background: rgba(255, 255, 255, 0.06);
color: var(--color-text);
font-size: var(--fs-small);
font-weight: 700;
}
.authorName {
display: block;
font-size: var(--fs-small);
font-weight: 600;
}
.authorRole {
display: block;
margin-top: 2px;
color: var(--color-faint);
font-size: var(--fs-caption);
}
/* =========================
RESPONSIVE
========================= */
@media (max-width: 1024px) {
.grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 640px) {
.grid {
grid-template-columns: 1fr;
}
.ghost {
right: -30px;
}
}

View file

@ -1,13 +1,29 @@
import { Header } from "@/widgets/header"; import { Header } from "@/widgets/header";
import { Footer } from "@/widgets/footer";
import Hero from "@/components/hero/hero"; import Hero from "@/components/hero/hero";
import About from "@/components/about/about";
import Sponsors from "@/components/sponsors/sponsors";
import Testimonials from "@/components/testimonials/testimonials";
import Projects from "@/components/projects/projects";
const HomePage = () => { const HomePage = () => {
return ( return (
<> <>
<Header /> <Header />
<Hero /> <main className="relative flex flex-col">
<Hero />
<About />
<Sponsors />
<Testimonials />
<Projects />
</main>
<Footer />
</> </>
); );
}; };

View file

@ -0,0 +1 @@
export { default as ProjectsPage } from "./ui/ProjectsPage";

View file

@ -0,0 +1,73 @@
"use client";
import { useState } from "react";
import { Header } from "@/widgets/header";
import { Footer } from "@/widgets/footer";
import { useInView } from "@/shared/hooks/useInView";
import { projects } from "@/shared/config/projects/projects";
import type { Project } from "@/shared/config/projects/types";
import { ProjectModal } from "@/widgets/project-modal";
import ProjectCard from "@/components/projects/ProjectCard";
import styles from "@/static/projects/projects-page.module.css";
const ProjectsPage = () => {
const [selected, setSelected] = useState<Project | null>(null);
const [headRef, headInView] = useInView<HTMLDivElement>(0.4);
const [gridRef, gridInView] = useInView<HTMLDivElement>(0.08);
const reveal = (visible: boolean) =>
`transition-all duration-700 ease-out will-change-transform ${
visible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
}`;
return (
<>
<Header />
<main className="relative flex flex-col">
<section className={styles.projectsPage} data-scroll-section>
<span className={styles.glow} aria-hidden="true" />
<span className={styles.ghost} aria-hidden="true">
{String(projects.length).padStart(2, "0")}
</span>
<div className="page-container relative z-10">
<div ref={headRef} className={styles.head}>
<p className={`${styles.eyebrow} ${reveal(headInView)}`}>
<span className={styles.rule} aria-hidden="true" />
проекты
</p>
<h1 className={`${styles.title} ${reveal(headInView)}`}>
Все <span className={styles.titleAccent}>работы</span>
</h1>
<p className={`${styles.count} ${reveal(headInView)}`}>
{String(projects.length).padStart(2, "0")} проектов под ключ
</p>
</div>
<div ref={gridRef} className={styles.grid}>
{projects.map((project, index) => (
<div
key={project.id}
className={reveal(gridInView)}
style={{ transitionDelay: `${index * 60}ms` }}
>
<ProjectCard project={project} onOpen={setSelected} />
</div>
))}
</div>
</div>
<ProjectModal project={selected} onClose={() => setSelected(null)} />
</section>
</main>
<Footer />
</>
);
};
export default ProjectsPage;

View file

@ -0,0 +1 @@
export { default as Cursor } from "./ui/Cursor";

View file

@ -0,0 +1,97 @@
"use client";
import { useEffect, useRef, useState } from "react";
import styles from "@/static/cursor/cursor.module.css";
const Cursor = () => {
const dotRef = useRef<HTMLDivElement>(null);
const ringRef = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
const [hovering, setHovering] = useState(false);
const [pressed, setPressed] = useState(false);
useEffect(() => {
const finePointer = window.matchMedia("(pointer: fine)").matches;
if (!finePointer) return;
document.documentElement.classList.add("has-cursor");
let raf = 0;
let mouseX = window.innerWidth / 2;
let mouseY = window.innerHeight / 2;
let ringX = mouseX;
let ringY = mouseY;
const onPointerMove = (e: PointerEvent) => {
mouseX = e.clientX;
mouseY = e.clientY;
if (dotRef.current) {
dotRef.current.style.transform = `translate3d(${mouseX}px, ${mouseY}px, 0)`;
}
setVisible(true);
};
const onPointerOver = (e: PointerEvent) => {
const target = e.target as HTMLElement | null;
const interactive = target?.closest(
"a, button, [role='button'], input, select, textarea, [data-cursor-hover]"
);
setHovering(Boolean(interactive));
};
const onPointerDown = () => setPressed(true);
const onPointerUp = () => setPressed(false);
const loop = () => {
ringX += (mouseX - ringX) * 0.18;
ringY += (mouseY - ringY) * 0.18;
if (ringRef.current) {
ringRef.current.style.transform = `translate3d(${ringX}px, ${ringY}px, 0)`;
}
raf = requestAnimationFrame(loop);
};
window.addEventListener("pointermove", onPointerMove, { passive: true });
window.addEventListener("pointerover", onPointerOver, { passive: true });
window.addEventListener("pointerdown", onPointerDown);
window.addEventListener("pointerup", onPointerUp);
raf = requestAnimationFrame(loop);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerover", onPointerOver);
window.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("pointerup", onPointerUp);
document.documentElement.classList.remove("has-cursor");
};
}, []);
const className = [
styles.cursor,
visible && styles.visible,
hovering && styles.hovering,
pressed && styles.pressed,
]
.filter(Boolean)
.join(" ");
return (
<div className={className} aria-hidden="true">
<div ref={dotRef} className={styles.dot} />
<div ref={ringRef} className={styles.ring} />
</div>
);
};
export default Cursor;

View file

@ -0,0 +1 @@
export { default as Footer } from "./ui/Footer";

View file

@ -0,0 +1,81 @@
import Link from "next/link";
import { navigation } from "@/shared/config/navigation/navigation";
import { sociallinks } from "@/shared/config/social-links/links";
import styles from "@/static/footer/footer.module.css";
const Footer = () => {
const telegram = sociallinks.find(
(link) => link.label.toLowerCase() === "telegram"
);
const github = sociallinks.find(
(link) => link.label.toLowerCase() === "github"
);
return (
<footer id="contact" className={styles.footer} data-scroll-section>
<span className={styles.watermark} aria-hidden="true">
Autli
</span>
<div className="page-container">
<div className={styles.cta}>
<p className={styles.eyebrow}>контакты</p>
<h2 className={styles.title}>
Есть проект?
<br />
<span className={styles.titleAccent}>Напиши мне</span>
</h2>
<p className={styles.text}>
Отвечаю обычно в течение дня. Расскажи, что хочешь сделать, — я
помогу с идеей, оценкой и реализацией.
</p>
<div className={styles.actions}>
<a
href={telegram?.href}
target="_blank"
rel="noreferrer"
className={styles.button}
>
Написать в Telegram
<span aria-hidden="true">↗</span>
</a>
<a
href={github?.href}
target="_blank"
rel="noreferrer"
className={styles.buttonGhost}
>
GitHub
<span aria-hidden="true">↗</span>
</a>
</div>
</div>
<div className={styles.bottom}>
<span className={styles.wordmark}>Autli</span>
<nav className={styles.nav} aria-label="Навигация в подвале">
<ul>
{navigation.map((item) => (
<li key={item.href}>
<Link href={item.href}>{item.label}</Link>
</li>
))}
</ul>
</nav>
<p className={styles.copy}>
© {new Date().getFullYear()} Egorov Nikita
</p>
</div>
</div>
</footer>
);
};
export default Footer;

View file

@ -1,14 +1,45 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { navigation } from "@/shared/config/navigation/navigation"; import { navigation } from "@/shared/config/navigation/navigation";
import styles from "@/static/header/header.module.css"; import styles from "@/static/header/header.module.css";
const Header = () => { const Header = () => {
const [open, setOpen] = useState(false);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
const onResize = () => {
if (window.innerWidth > 768) setOpen(false);
};
document.documentElement.classList.toggle("no-scroll", open);
window.addEventListener("keydown", onKeyDown);
window.addEventListener("resize", onResize);
return () => {
document.documentElement.classList.remove("no-scroll");
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("resize", onResize);
};
}, [open]);
const close = () => setOpen(false);
return ( return (
<header className={styles.header}> <header className={styles.header}>
<div className="cont-header"> <div className="cont-header">
<div className={styles.left}> <div className={styles.left}>
<a href="/" className={styles.logo}> <Link href="/" className={styles.logo} onClick={close}>
Autli Autli
</a> </Link>
</div> </div>
<div className={styles.right}> <div className={styles.right}>
@ -16,39 +47,19 @@ const Header = () => {
<ul> <ul>
{navigation.map((item) => ( {navigation.map((item) => (
<li key={item.href}> <li key={item.href}>
<a href={item.href}> <Link href={item.href}>{item.label}</Link>
{item.label}
</a>
</li> </li>
))} ))}
</ul> </ul>
</nav> </nav>
{/* Переключатель языка */}
<a
href="/en/"
className={styles["language-toggle"]}
aria-label="Переключить язык"
>
EN
</a>
{/* Переключатель темы */}
<button
className={styles["theme-toggle"]}
id="theme-toggle"
type="button"
aria-label="Переключить тему"
>
<span className={styles["theme-icon"]}>☾</span>
</button>
{/* Гамбургер */} {/* Гамбургер */}
<button <button
className={styles.hamburger} className={`${styles.hamburger} ${open ? styles.active : ""}`}
type="button" type="button"
aria-label="Открыть меню" aria-label={open ? "Закрыть меню" : "Открыть меню"}
aria-expanded="false" aria-expanded={open}
onClick={() => setOpen((value) => !value)}
> >
<span /> <span />
<span /> <span />
@ -57,13 +68,16 @@ const Header = () => {
</div> </div>
</div> </div>
<nav className={styles.mobileMenu} aria-label="Мобильная навигация"> <nav
className={`${styles.mobileMenu} ${open ? styles.active : ""}`}
aria-label="Мобильная навигация"
>
<ul> <ul>
{navigation.map((item) => ( {navigation.map((item) => (
<li key={item.href}> <li key={item.href}>
<a href={item.href}> <Link href={item.href} onClick={close}>
{item.label} {item.label}
</a> </Link>
</li> </li>
))} ))}
</ul> </ul>

View file

@ -0,0 +1 @@
export { default as Preloader } from "./ui/Preloader";

View file

@ -0,0 +1,87 @@
"use client";
import { useEffect, useState } from "react";
import styles from "@/static/preloader/preloader.module.css";
const Preloader = () => {
const [progress, setProgress] = useState(0);
const [done, setDone] = useState(false);
const [hidden, setHidden] = useState(false);
useEffect(() => {
document.documentElement.classList.add("no-scroll");
const duration = 1500;
const start = performance.now();
let raf = 0;
const tick = (now: number) => {
const value = Math.min(100, Math.round(((now - start) / duration) * 100));
setProgress(value);
if (value < 100) {
raf = requestAnimationFrame(tick);
}
};
raf = requestAnimationFrame(tick);
const finish = () => {
cancelAnimationFrame(raf);
setProgress(100);
document.documentElement.classList.remove("no-scroll");
window.setTimeout(() => setDone(true), 300);
};
if (document.readyState === "complete") {
window.setTimeout(finish, duration + 200);
} else {
window.addEventListener("load", finish);
}
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("load", finish);
document.documentElement.classList.remove("no-scroll");
};
}, []);
useEffect(() => {
if (!done) return;
const timer = window.setTimeout(() => setHidden(true), 700);
return () => window.clearTimeout(timer);
}, [done]);
if (hidden) return null;
const className = [styles.preloader, done && styles.done]
.filter(Boolean)
.join(" ");
return (
<div className={className} aria-hidden="true">
<div className={styles.content}>
<p className={styles.logo}>
Autli<span className={styles.accent}>.</span>
</p>
<div className={styles.bar}>
<span
className={styles.barFill}
style={{ width: `${progress}%` }}
/>
</div>
<div className={styles.meta}>
<span className={styles.status}>загрузка</span>
<span className={styles.percent}>{progress}%</span>
</div>
</div>
</div>
);
};
export default Preloader;

View file

@ -0,0 +1 @@
export { default as ProjectModal } from "./ui/ProjectModal";

View file

@ -0,0 +1,114 @@
"use client";
import { useEffect, useRef } from "react";
import type { Project } from "@/shared/config/projects/types";
import styles from "@/static/project-modal/project-modal.module.css";
type ProjectModalProps = {
project: Project | null;
onClose: () => void;
};
const ProjectModal = ({ project, onClose }: ProjectModalProps) => {
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!project) return;
document.documentElement.classList.add("no-scroll");
panelRef.current?.focus();
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKeyDown);
return () => {
document.documentElement.classList.remove("no-scroll");
window.removeEventListener("keydown", onKeyDown);
};
}, [project, onClose]);
if (!project) return null;
return (
<div
className={styles.overlay}
role="dialog"
aria-modal="true"
aria-label={`Проект ${project.title}`}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div ref={panelRef} className={styles.panel} tabIndex={-1}>
<div className={styles.wrap}>
<header className={styles.head}>
<span className={styles.meta}>Проект {project.id}</span>
<button
type="button"
className={styles.close}
aria-label="Закрыть"
onClick={onClose}
>
×
</button>
</header>
<h3 className={styles.title}>{project.title}</h3>
<p className={styles.details}>{project.details}</p>
<ul className={styles.metaList}>
<li>
<span>Год</span>
<strong>{project.year}</strong>
</li>
<li>
<span>Стек</span>
<strong>{project.tags.join(" · ")}</strong>
</li>
</ul>
<h4 className={styles.subheading}>Что внутри</h4>
<ul className={styles.features}>
{project.features.map((feature) => (
<li key={feature}>{feature}</li>
))}
</ul>
<footer className={styles.actions}>
{project.demo ? (
<a
href={project.demo}
target="_blank"
rel="noreferrer"
className={styles.actionButton}
>
Живая версия
<span aria-hidden="true">↗</span>
</a>
) : null}
{project.code ? (
<a
href={project.code}
target="_blank"
rel="noreferrer"
className={styles.actionGhost}
>
Код проекта
<span aria-hidden="true">↗</span>
</a>
) : null}
</footer>
</div>
</div>
</div>
);
};
export default ProjectModal;

View file

@ -0,0 +1 @@
export { default as ScrollCounter } from "./ui/ScrollCounter";

View file

@ -0,0 +1,95 @@
"use client";
import { useEffect, useRef, useState } from "react";
const DIGITS = Array.from({ length: 10 }, (_, i) => 9 - i);
const DigitWheel = ({ value }: { value: number }) => (
<span
className="inline-block h-[1em] w-[0.62em] overflow-hidden leading-none tabular-nums"
aria-hidden="true"
>
<span
className="flex flex-col transition-transform duration-500 ease-[cubic-bezier(0.65,0,0.35,1)]"
style={{ transform: `translateY(${-(9 - value)}em)` }}
>
{DIGITS.map((digit) => (
<span
key={digit}
className="flex h-[1em] w-[0.62em] items-center justify-center"
>
{digit}
</span>
))}
</span>
</span>
);
const ScrollCounter = () => {
const [index, setIndex] = useState(0);
const [total, setTotal] = useState(0);
const totalRef = useRef(0);
useEffect(() => {
const sections = Array.from(
document.querySelectorAll("[data-scroll-section]")
);
if (!sections.length) return;
const observer = new IntersectionObserver(
(entries) => {
if (totalRef.current !== sections.length) {
totalRef.current = sections.length;
setTotal(sections.length);
}
for (const entry of entries) {
if (entry.isIntersecting) {
setIndex(Math.max(0, sections.indexOf(entry.target)));
}
}
},
{ rootMargin: "-45% 0px -45% 0px", threshold: 0 }
);
sections.forEach((section) => observer.observe(section));
return () => observer.disconnect();
}, []);
const current = String(index + 1).padStart(2, "0");
const totalText = String(total).padStart(2, "0");
const progress = total > 1 ? (index / (total - 1)) * 100 : 0;
return (
<div
className="pointer-events-none fixed right-[clamp(16px,2vw,32px)] top-1/2 z-40 hidden -translate-y-1/2 flex-col items-center gap-3 lg:flex"
aria-hidden="true"
>
{/* rolling digits: предыдущая цифра уходит вниз,
следующая появляется сверху */}
<div className="flex items-baseline text-2xl font-semibold leading-none text-white/80">
<DigitWheel value={Number(current[0])} />
<DigitWheel value={Number(current[1])} />
<span className="ml-1 text-[11px] font-medium text-white/30">
/ {totalText}
</span>
</div>
{/* vertical progress hairline (позиция зависит от числа секций) */}
<span className="relative block h-14 w-px overflow-hidden bg-white/10">
<span
className="absolute left-0 top-0 block h-1/2 w-full bg-white/70 transition-[top] duration-500 ease-out"
style={{ top: `${progress * 0.5}%` }}
/>
</span>
<span className="text-[10px] font-medium uppercase tracking-[0.3em] text-white/30">
scroll
</span>
</div>
);
};
export default ScrollCounter;