add page project and update styles
This commit is contained in:
parent
c6d8e54d89
commit
fcea711ddf
44 changed files with 3890 additions and 112 deletions
BIN
public/fonts/Montserrat/Montserrat-Italic-VariableFont_wght.ttf
Normal file
BIN
public/fonts/Montserrat/Montserrat-Italic-VariableFont_wght.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Montserrat/Montserrat-VariableFont_wght.ttf
Normal file
BIN
public/fonts/Montserrat/Montserrat-VariableFont_wght.ttf
Normal file
Binary file not shown.
|
|
@ -1,5 +1,8 @@
|
|||
import type { Metadata } from "next";
|
||||
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";
|
||||
|
||||
const geistSans = Geist({
|
||||
|
|
@ -23,7 +26,12 @@ const RootLayout = ({ children }: LayoutProps<"/">) => {
|
|||
lang="en"
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
12
src/app/projects/page.tsx
Normal file
12
src/app/projects/page.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { ProjectsPage } from "@/views/projects";
|
||||
|
||||
const Projects = () => {
|
||||
return <ProjectsPage />;
|
||||
};
|
||||
|
||||
export const metadata = {
|
||||
title: "Проекты",
|
||||
description: "Все проекты Egorov Nikita",
|
||||
};
|
||||
|
||||
export default Projects;
|
||||
109
src/components/about/about.tsx
Normal file
109
src/components/about/about.tsx
Normal 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;
|
||||
55
src/components/hero/FlashlightText.tsx
Normal file
55
src/components/hero/FlashlightText.tsx
Normal 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;
|
||||
|
|
@ -1,20 +1,96 @@
|
|||
import { sociallinks } from "@/shared/config/social-links/links";
|
||||
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 = () => {
|
||||
return (
|
||||
<section className={styles.hero}>
|
||||
<div className="cont-header">
|
||||
<section className={styles.hero} data-scroll-section>
|
||||
<div className={`cont-header ${styles.inner}`}>
|
||||
<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 className={styles.right}>
|
||||
<div className={styles.visual}>
|
||||
<span>01</span>
|
||||
<span className={styles.index} aria-hidden="true">
|
||||
<i>портфолио</i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.hint} aria-hidden="true">
|
||||
листай
|
||||
<span className={styles.hintLine} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
75
src/components/projects/ProjectCard.tsx
Normal file
75
src/components/projects/ProjectCard.tsx
Normal 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;
|
||||
73
src/components/projects/projects.tsx
Normal file
73
src/components/projects/projects.tsx
Normal 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;
|
||||
43
src/components/sponsors/sponsors.tsx
Normal file
43
src/components/sponsors/sponsors.tsx
Normal 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;
|
||||
93
src/components/testimonials/testimonials.tsx
Normal file
93
src/components/testimonials/testimonials.tsx
Normal 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">
|
||||
“
|
||||
</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;
|
||||
|
|
@ -3,10 +3,14 @@ import type { NavigationItem } from "./types";
|
|||
export const navigation: NavigationItem[] = [
|
||||
{
|
||||
label: "Обо мне",
|
||||
href: "#about",
|
||||
href: "/#about",
|
||||
},
|
||||
{
|
||||
label: "Проекты",
|
||||
href: "#projects",
|
||||
href: "/projects",
|
||||
},
|
||||
{
|
||||
label: "Контакты",
|
||||
href: "/#contact",
|
||||
},
|
||||
];
|
||||
157
src/shared/config/projects/projects.ts
Normal file
157
src/shared/config/projects/projects.ts
Normal 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",
|
||||
},
|
||||
];
|
||||
11
src/shared/config/projects/types.ts
Normal file
11
src/shared/config/projects/types.ts
Normal 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;
|
||||
};
|
||||
27
src/shared/hooks/useInView.ts
Normal file
27
src/shared/hooks/useInView.ts
Normal 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;
|
||||
};
|
||||
230
src/static/about/about.module.css
Normal file
230
src/static/about/about.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
114
src/static/cursor/cursor.module.css
Normal file
114
src/static/cursor/cursor.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
211
src/static/footer/footer.module.css
Normal file
211
src/static/footer/footer.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,15 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* =========================
|
||||
FONTS
|
||||
========================= */
|
||||
|
||||
@font-face {
|
||||
font-family: "Montserrat";
|
||||
src: url("/fonts/Montserrat/Montserrat-VariableFont_wght.ttf") format("truetype");
|
||||
|
|
@ -14,27 +26,136 @@
|
|||
font-display: swap;
|
||||
}
|
||||
|
||||
/* =========================
|
||||
DESIGN TOKENS
|
||||
========================= */
|
||||
|
||||
:root {
|
||||
/* Font */
|
||||
--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,
|
||||
body {
|
||||
font-family: var(--font-main);
|
||||
font-size: var(--fs-base);
|
||||
background-color: var(--color-bg);
|
||||
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 {
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--container-padding, 40px);
|
||||
max-width: var(--container-max);
|
||||
margin-inline: auto;
|
||||
padding-inline: var(--container-padding);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--container-gap);
|
||||
}
|
||||
|
|
@ -1,16 +1,25 @@
|
|||
/* =========================
|
||||
HEADER
|
||||
========================= */
|
||||
|
||||
.header {
|
||||
position: relative;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
|
||||
width: 100%;
|
||||
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 {
|
||||
display: inline-block;
|
||||
|
||||
color: #fff;
|
||||
color: var(--color-text);
|
||||
|
||||
font-size: clamp(28px, 2vw, 38px);
|
||||
line-height: 1;
|
||||
font-size: var(--fs-logo);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.06em;
|
||||
|
||||
text-decoration: none;
|
||||
|
|
@ -41,7 +50,6 @@
|
|||
opacity: 0.65;
|
||||
}
|
||||
|
||||
|
||||
/* =========================
|
||||
RIGHT
|
||||
========================= */
|
||||
|
|
@ -49,11 +57,9 @@
|
|||
.right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
gap: clamp(24px, 3vw, 55px);
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
|
||||
/* =========================
|
||||
DESKTOP NAV
|
||||
========================= */
|
||||
|
|
@ -62,7 +68,7 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
gap: clamp(24px, 2.8vw, 55px);
|
||||
gap: var(--space-lg);
|
||||
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
|
@ -82,9 +88,9 @@
|
|||
|
||||
padding: 8px 0;
|
||||
|
||||
color: #777;
|
||||
color: var(--color-muted);
|
||||
|
||||
font-size: clamp(15px, 1vw, 20px);
|
||||
font-size: var(--fs-nav);
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.01em;
|
||||
|
||||
|
|
@ -103,20 +109,19 @@
|
|||
width: 0;
|
||||
height: 1px;
|
||||
|
||||
background: #fff;
|
||||
background: var(--color-text);
|
||||
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.desktopNav a:hover {
|
||||
color: #fff;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.desktopNav a:hover::after {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
/* =========================
|
||||
HAMBURGER
|
||||
========================= */
|
||||
|
|
@ -147,7 +152,7 @@
|
|||
|
||||
border-radius: 10px;
|
||||
|
||||
background: #fff;
|
||||
background: var(--color-text);
|
||||
|
||||
transition:
|
||||
transform 0.3s ease,
|
||||
|
|
@ -167,6 +172,18 @@
|
|||
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
|
||||
|
|
@ -180,9 +197,9 @@
|
|||
width: 100%;
|
||||
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%);
|
||||
|
||||
|
|
@ -199,7 +216,7 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
gap: 30px;
|
||||
gap: var(--space-md);
|
||||
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
|
@ -215,12 +232,11 @@
|
|||
.mobileMenu a {
|
||||
display: inline-block;
|
||||
|
||||
color: #fff;
|
||||
|
||||
font-size: clamp(30px, 6vw, 52px);
|
||||
line-height: 1;
|
||||
color: var(--color-text);
|
||||
|
||||
font-size: var(--fs-display);
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04em;
|
||||
|
||||
text-decoration: none;
|
||||
|
|
@ -229,10 +245,9 @@
|
|||
}
|
||||
|
||||
.mobileMenu a:hover {
|
||||
color: #777;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
|
||||
/* =========================
|
||||
LARGE SCREENS
|
||||
========================= */
|
||||
|
|
@ -241,21 +256,8 @@
|
|||
.header {
|
||||
height: 110px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 42px;
|
||||
}
|
||||
|
||||
.desktopNav a {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.desktopNav ul {
|
||||
gap: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* =========================
|
||||
TABLET
|
||||
========================= */
|
||||
|
|
@ -265,20 +267,11 @@
|
|||
height: 80px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.desktopNav ul {
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.desktopNav a {
|
||||
font-size: 15px;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* =========================
|
||||
MOBILE
|
||||
========================= */
|
||||
|
|
@ -300,13 +293,8 @@
|
|||
top: 70px;
|
||||
height: calc(100vh - 70px);
|
||||
}
|
||||
|
||||
.right {
|
||||
gap: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* =========================
|
||||
SMALL MOBILE
|
||||
========================= */
|
||||
|
|
@ -329,14 +317,6 @@
|
|||
top: 64px;
|
||||
height: calc(100vh - 64px);
|
||||
|
||||
padding-top: 45px;
|
||||
}
|
||||
|
||||
.mobileMenu ul {
|
||||
gap: 26px;
|
||||
}
|
||||
|
||||
.mobileMenu a {
|
||||
font-size: 32px;
|
||||
padding-top: var(--space-lg);
|
||||
}
|
||||
}
|
||||
64
src/static/hero/flashlight.module.css
Normal file
64
src/static/hero/flashlight.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,413 @@
|
|||
/* =========================
|
||||
HERO
|
||||
========================= */
|
||||
|
||||
.hero {
|
||||
background-color: aquamarine;
|
||||
height: calc(100vh - 90px);
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
min-height: calc(100vh - 90px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* subtle grid */
|
||||
.hero::before {
|
||||
content: "";
|
||||
|
||||
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: 49%;
|
||||
height: 90vh;
|
||||
background-color: red;
|
||||
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;
|
||||
}
|
||||
}
|
||||
117
src/static/preloader/preloader.module.css
Normal file
117
src/static/preloader/preloader.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
281
src/static/project-modal/project-modal.module.css
Normal file
281
src/static/project-modal/project-modal.module.css
Normal 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);
|
||||
}
|
||||
172
src/static/projects/project-card.module.css
Normal file
172
src/static/projects/project-card.module.css
Normal 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);
|
||||
}
|
||||
134
src/static/projects/projects-page.module.css
Normal file
134
src/static/projects/projects-page.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
187
src/static/projects/projects.module.css
Normal file
187
src/static/projects/projects.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
184
src/static/sponsors/sponsors.module.css
Normal file
184
src/static/sponsors/sponsors.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
254
src/static/testimonials/testimonials.module.css
Normal file
254
src/static/testimonials/testimonials.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,29 @@
|
|||
import { Header } from "@/widgets/header";
|
||||
import { Footer } from "@/widgets/footer";
|
||||
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 = () => {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
|
||||
<Hero />
|
||||
<main className="relative flex flex-col">
|
||||
<Hero />
|
||||
|
||||
<About />
|
||||
|
||||
<Sponsors />
|
||||
|
||||
<Testimonials />
|
||||
|
||||
<Projects />
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
1
src/views/projects/index.ts
Normal file
1
src/views/projects/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as ProjectsPage } from "./ui/ProjectsPage";
|
||||
73
src/views/projects/ui/ProjectsPage.tsx
Normal file
73
src/views/projects/ui/ProjectsPage.tsx
Normal 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;
|
||||
1
src/widgets/cursor/index.ts
Normal file
1
src/widgets/cursor/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as Cursor } from "./ui/Cursor";
|
||||
97
src/widgets/cursor/ui/Cursor.tsx
Normal file
97
src/widgets/cursor/ui/Cursor.tsx
Normal 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;
|
||||
1
src/widgets/footer/index.ts
Normal file
1
src/widgets/footer/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as Footer } from "./ui/Footer";
|
||||
81
src/widgets/footer/ui/Footer.tsx
Normal file
81
src/widgets/footer/ui/Footer.tsx
Normal 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;
|
||||
|
|
@ -1,14 +1,45 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { navigation } from "@/shared/config/navigation/navigation";
|
||||
import styles from "@/static/header/header.module.css";
|
||||
|
||||
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 (
|
||||
<header className={styles.header}>
|
||||
<div className="cont-header">
|
||||
<div className={styles.left}>
|
||||
<a href="/" className={styles.logo}>
|
||||
<Link href="/" className={styles.logo} onClick={close}>
|
||||
Autli
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className={styles.right}>
|
||||
|
|
@ -16,39 +47,19 @@ const Header = () => {
|
|||
<ul>
|
||||
{navigation.map((item) => (
|
||||
<li key={item.href}>
|
||||
<a href={item.href}>
|
||||
{item.label}
|
||||
</a>
|
||||
<Link href={item.href}>{item.label}</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</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
|
||||
className={styles.hamburger}
|
||||
className={`${styles.hamburger} ${open ? styles.active : ""}`}
|
||||
type="button"
|
||||
aria-label="Открыть меню"
|
||||
aria-expanded="false"
|
||||
aria-label={open ? "Закрыть меню" : "Открыть меню"}
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
<span />
|
||||
<span />
|
||||
|
|
@ -57,13 +68,16 @@ const Header = () => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<nav className={styles.mobileMenu} aria-label="Мобильная навигация">
|
||||
<nav
|
||||
className={`${styles.mobileMenu} ${open ? styles.active : ""}`}
|
||||
aria-label="Мобильная навигация"
|
||||
>
|
||||
<ul>
|
||||
{navigation.map((item) => (
|
||||
<li key={item.href}>
|
||||
<a href={item.href}>
|
||||
<Link href={item.href} onClick={close}>
|
||||
{item.label}
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
|
|
|||
1
src/widgets/preloader/index.ts
Normal file
1
src/widgets/preloader/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as Preloader } from "./ui/Preloader";
|
||||
87
src/widgets/preloader/ui/Preloader.tsx
Normal file
87
src/widgets/preloader/ui/Preloader.tsx
Normal 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;
|
||||
1
src/widgets/project-modal/index.ts
Normal file
1
src/widgets/project-modal/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as ProjectModal } from "./ui/ProjectModal";
|
||||
114
src/widgets/project-modal/ui/ProjectModal.tsx
Normal file
114
src/widgets/project-modal/ui/ProjectModal.tsx
Normal 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;
|
||||
1
src/widgets/scroll-counter/index.ts
Normal file
1
src/widgets/scroll-counter/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as ScrollCounter } from "./ui/ScrollCounter";
|
||||
95
src/widgets/scroll-counter/ui/ScrollCounter.tsx
Normal file
95
src/widgets/scroll-counter/ui/ScrollCounter.tsx
Normal 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;
|
||||
Loading…
Add table
Reference in a new issue