add translate i18n

This commit is contained in:
Nikita 2026-09-26 20:32:10 +05:00
parent f5c8acca6e
commit 08e94d7e91
27 changed files with 1173 additions and 411 deletions

View file

@ -1,13 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
<url> <url>
<loc>https://autli.ru/</loc> <loc>https://autli.ru/ru/</loc>
<xhtml:link rel="alternate" hreflang="ru" href="https://autli.ru/ru/" />
<xhtml:link rel="alternate" hreflang="en" href="https://autli.ru/en/" />
<lastmod>2026-09-26</lastmod> <lastmod>2026-09-26</lastmod>
<changefreq>monthly</changefreq> <changefreq>monthly</changefreq>
<priority>1.0</priority> <priority>1.0</priority>
</url> </url>
<url> <url>
<loc>https://autli.ru/projects/</loc> <loc>https://autli.ru/en/</loc>
<xhtml:link rel="alternate" hreflang="ru" href="https://autli.ru/ru/" />
<xhtml:link rel="alternate" hreflang="en" href="https://autli.ru/en/" />
<lastmod>2026-09-26</lastmod>
<changefreq>monthly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://autli.ru/ru/projects/</loc>
<xhtml:link rel="alternate" hreflang="ru" href="https://autli.ru/ru/projects/" />
<xhtml:link rel="alternate" hreflang="en" href="https://autli.ru/en/projects/" />
<lastmod>2026-09-26</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://autli.ru/en/projects/</loc>
<xhtml:link rel="alternate" hreflang="ru" href="https://autli.ru/ru/projects/" />
<xhtml:link rel="alternate" hreflang="en" href="https://autli.ru/en/projects/" />
<lastmod>2026-09-26</lastmod> <lastmod>2026-09-26</lastmod>
<changefreq>monthly</changefreq> <changefreq>monthly</changefreq>
<priority>0.8</priority> <priority>0.8</priority>

86
src/app/[lang]/layout.tsx Normal file
View file

@ -0,0 +1,86 @@
import type { ReactNode } from "react";
import type { Metadata } from "next";
import { I18nProvider, dictionaries, type Locale } from "@/shared/config/i18n";
import { ContextMenu } from "@/widgets/context-menu";
import { CookieConsent } from "@/widgets/cookie-consent";
import { Cursor } from "@/widgets/cursor";
import { Preloader } from "@/widgets/preloader";
import { ScrollCounter } from "@/widgets/scroll-counter";
import { YandexMetrika } from "@/widgets/yandex-metrika";
export function generateStaticParams() {
return [{ lang: "ru" }, { lang: "en" }];
}
export async function generateMetadata({
params,
}: {
params: Promise<{ lang: string }>;
}): Promise<Metadata> {
const { lang } = await params;
const locale: Locale = lang === "en" ? "en" : "ru";
const dict = dictionaries[locale];
const siteUrl = "https://autli.ru";
return {
metadataBase: new URL(siteUrl),
title: {
default: dict.seo.title,
template: `%s | ${locale === "ru" ? "Никита Егоров" : "Nikita Egorov"}`,
},
description: dict.seo.description,
keywords: dict.seo.keywords,
alternates: {
canonical: `/${locale}/`,
languages: {
ru: "/ru/",
en: "/en/",
},
},
openGraph: {
title: dict.seo.title,
description: dict.seo.ogDescription,
url: `${siteUrl}/${locale}/`,
siteName: locale === "ru" ? "Никита Егоров — Портфолио" : "Nikita Egorov — Portfolio",
locale: locale === "ru" ? "ru_RU" : "en_US",
type: "website",
images: [
{
url: "/og-image.png",
width: 1200,
height: 630,
alt: locale === "ru" ? "Никита Егоров — Frontend & Web Developer" : "Nikita Egorov — Frontend & Web Developer",
},
],
},
twitter: {
card: "summary_large_image",
title: dict.seo.title,
description: dict.seo.ogDescription,
images: ["/og-image.png"],
},
};
}
export default async function LocaleLayout({
children,
params,
}: {
children: ReactNode;
params: Promise<{ lang: string }>;
}) {
const { lang } = await params;
const locale: Locale = lang === "en" ? "en" : "ru";
return (
<I18nProvider locale={locale}>
<Preloader />
<Cursor />
<ScrollCounter />
<ContextMenu />
{children}
<CookieConsent />
<YandexMetrika />
</I18nProvider>
);
}

10
src/app/[lang]/page.tsx Normal file
View file

@ -0,0 +1,10 @@
import { HomePage } from "@/views/home";
import "@/static/globals.css";
export function generateStaticParams() {
return [{ lang: "ru" }, { lang: "en" }];
}
export default async function Page() {
return <HomePage />;
}

View file

@ -0,0 +1,39 @@
import type { Metadata } from "next";
import { ProjectsPage } from "@/views/projects";
import { dictionaries, type Locale } from "@/shared/config/i18n";
export function generateStaticParams() {
return [{ lang: "ru" }, { lang: "en" }];
}
export async function generateMetadata({
params,
}: {
params: Promise<{ lang: string }>;
}): Promise<Metadata> {
const { lang } = await params;
const locale: Locale = lang === "en" ? "en" : "ru";
const dict = dictionaries[locale];
const siteUrl = "https://autli.ru";
return {
title: dict.seo.projectsPageTitle,
description: dict.seo.projectsPageDesc,
alternates: {
canonical: `/${locale}/projects/`,
languages: {
ru: "/ru/projects/",
en: "/en/projects/",
},
},
openGraph: {
title: `${dict.seo.projectsPageTitle} | ${locale === "ru" ? "Никита Егоров" : "Nikita Egorov"}`,
description: dict.seo.projectsPageDesc,
url: `${siteUrl}/${locale}/projects/`,
},
};
}
export default async function Projects() {
return <ProjectsPage />;
}

View file

@ -1,12 +1,6 @@
import type { Metadata } from "next";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import { ContextMenu } from "@/widgets/context-menu";
import { CookieConsent } from "@/widgets/cookie-consent";
import { Cursor } from "@/widgets/cursor";
import { Preloader } from "@/widgets/preloader";
import { ScrollCounter } from "@/widgets/scroll-counter";
import { YandexMetrika } from "@/widgets/yandex-metrika";
import "@/static/globals.css"; import "@/static/globals.css";
const geistSans = Geist({ const geistSans = Geist({
@ -55,7 +49,11 @@ export const metadata: Metadata = {
telephone: false, telephone: false,
}, },
alternates: { alternates: {
canonical: "/", canonical: "/ru/",
languages: {
ru: "/ru/",
en: "/en/",
},
}, },
openGraph: { openGraph: {
title: "Никита Егоров — Frontend & Web Developer | Портфолио", title: "Никита Егоров — Frontend & Web Developer | Портфолио",
@ -104,10 +102,7 @@ const jsonLd = {
alternateName: "Nikita Egorov", alternateName: "Nikita Egorov",
jobTitle: "Frontend & Web Developer", jobTitle: "Frontend & Web Developer",
url: siteUrl, url: siteUrl,
sameAs: [ sameAs: ["https://t.me/autli", "https://github.com/autli5"],
"https://t.me/autli",
"https://github.com/autli5",
],
knowsAbout: [ knowsAbout: [
"React", "React",
"Next.js", "Next.js",
@ -137,7 +132,7 @@ const jsonLd = {
], ],
}; };
const RootLayout = ({ children }: { children: ReactNode }) => { export default function RootLayout({ children }: { children: ReactNode }) {
return ( return (
<html <html
lang="ru" lang="ru"
@ -151,16 +146,8 @@ const RootLayout = ({ children }: { children: ReactNode }) => {
/> />
</head> </head>
<body className="min-h-full flex flex-col"> <body className="min-h-full flex flex-col">
<Preloader />
<Cursor />
<ScrollCounter />
<ContextMenu />
{children} {children}
<CookieConsent />
<YandexMetrika />
</body> </body>
</html> </html>
); );
}; }
export default RootLayout;

View file

@ -1,8 +1,23 @@
import { HomePage } from "@/views/home"; "use client";
import "@/static/globals.css";
const Page = () => { import { useEffect } from "react";
return <HomePage />; import { useRouter } from "next/navigation";
};
export default Page; export default function RootPage() {
const router = useRouter();
useEffect(() => {
router.replace("/ru/");
}, [router]);
return (
<>
<head>
<meta httpEquiv="refresh" content="0;url=/ru/" />
</head>
<div style={{ padding: "20px", textAlign: "center", color: "#666" }}>
Redirecting to /ru/...
</div>
</>
);
}

View file

@ -1,23 +1,23 @@
import type { Metadata } from "next"; "use client";
import { ProjectsPage } from "@/views/projects";
export const metadata: Metadata = { import { useEffect } from "react";
title: "Все проекты", import { useRouter } from "next/navigation";
description:
"Коммерческие и пет-проекты Egorov Nikita: веб-приложения, интернет-магазины, SaaS-дашборды и сайты под ключ на React, Next.js, TypeScript и Django.",
alternates: {
canonical: "/projects/",
},
openGraph: {
title: "Все проекты | Никита Егоров",
description:
"Коммерческие и пет-проекты Egorov Nikita: веб-приложения, интернет-магазины, SaaS-дашборды и сайты под ключ.",
url: "https://autli.ru/projects/",
},
};
const Projects = () => { export default function ProjectsRedirect() {
return <ProjectsPage />; const router = useRouter();
};
export default Projects; useEffect(() => {
router.replace("/ru/projects/");
}, [router]);
return (
<>
<head>
<meta httpEquiv="refresh" content="0;url=/ru/projects/" />
</head>
<div style={{ padding: "20px", textAlign: "center", color: "#666" }}>
Redirecting to /ru/projects/...
</div>
</>
);
}

View file

@ -1,6 +1,7 @@
"use client"; "use client";
import { useInView } from "@/shared/hooks/useInView"; import { useInView } from "@/shared/hooks/useInView";
import { useI18n } from "@/shared/config/i18n";
import styles from "@/static/about/about.module.css"; import styles from "@/static/about/about.module.css";
const techStack = [ const techStack = [
@ -13,9 +14,10 @@ const techStack = [
{ name: "Node.js", level: 33 }, { name: "Node.js", level: 33 },
]; ];
const About = () => { export const About = () => {
const [introRef, introInView] = useInView<HTMLDivElement>(0.2); const [introRef, introInView] = useInView<HTMLDivElement>(0.2);
const [skillsRef, skillsInView] = useInView<HTMLDivElement>(0.3); const [skillsRef, skillsInView] = useInView<HTMLDivElement>(0.3);
const { dict } = useI18n();
const reveal = (visible: boolean) => const reveal = (visible: boolean) =>
`transition-all duration-700 ease-out will-change-transform ${ `transition-all duration-700 ease-out will-change-transform ${
@ -33,42 +35,40 @@ const About = () => {
<div ref={introRef} className="page-container relative z-10"> <div ref={introRef} className="page-container relative z-10">
<p className={`${styles.eyebrow} ${reveal(introInView)}`}> <p className={`${styles.eyebrow} ${reveal(introInView)}`}>
<span className={styles.rule} aria-hidden="true" /> <span className={styles.rule} aria-hidden="true" />
обо мне {dict.about.eyebrow}
</p> </p>
<h2 <h2
className={`${styles.title} ${reveal(introInView)}`} className={`${styles.title} ${reveal(introInView)}`}
style={{ transitionDelay: "100ms" }} style={{ transitionDelay: "100ms" }}
> >
Разработчик, для которого важны{" "} {dict.about.titleStart}{" "}
<span className={styles.titleAccent}>продуманность</span> и детали <span className={styles.titleAccent}>{dict.about.titleAccent}</span>{" "}
{dict.about.titleEnd}
</h2> </h2>
<p <p
className={`${styles.text} ${reveal(introInView)}`} className={`${styles.text} ${reveal(introInView)}`}
style={{ transitionDelay: "200ms" }} style={{ transitionDelay: "200ms" }}
> >
Привет! Я Никита — веб-разработчик. Строю современные, быстрые и {dict.about.desc}
аккуратные интерфейсы на React и Next.js. Уделяю внимание
производительности, семантике и мелочам, из которых складывается
хорошее впечатление от сайта.
</p> </p>
<dl className={`${styles.stats} ${reveal(introInView)}`} style={{ transitionDelay: "300ms" }}> <dl className={`${styles.stats} ${reveal(introInView)}`} style={{ transitionDelay: "300ms" }}>
<div> <div>
<dt className={styles.statLabel}>опыт</dt> <dt className={styles.statLabel}>{dict.about.stats.expLabel}</dt>
<dd className={styles.statValue}> <dd className={styles.statValue}>
3<span className={styles.statValueAccent}>+</span> 3<span className={styles.statValueAccent}>+</span>
</dd> </dd>
</div> </div>
<div> <div>
<dt className={styles.statLabel}>проекты</dt> <dt className={styles.statLabel}>{dict.about.stats.projectsLabel}</dt>
<dd className={styles.statValue}> <dd className={styles.statValue}>
10<span className={styles.statValueAccent}>+</span> 10<span className={styles.statValueAccent}>+</span>
</dd> </dd>
</div> </div>
<div> <div>
<dt className={styles.statLabel}>стек</dt> <dt className={styles.statLabel}>{dict.about.stats.stackLabel}</dt>
<dd className={styles.statValue}>{techStack.length}</dd> <dd className={styles.statValue}>{techStack.length}</dd>
</div> </div>
</dl> </dl>
@ -78,7 +78,7 @@ const About = () => {
className={`${styles.skills} ${reveal(skillsInView)}`} className={`${styles.skills} ${reveal(skillsInView)}`}
style={{ transitionDelay: "150ms" }} style={{ transitionDelay: "150ms" }}
> >
<h3 className={styles.skillsTitle}>Стек и навыки</h3> <h3 className={styles.skillsTitle}>{dict.about.skillsTitle}</h3>
<ul className={styles.skillList}> <ul className={styles.skillList}>
{techStack.map((item, index) => ( {techStack.map((item, index) => (

View file

@ -1,6 +1,8 @@
import { sociallinks } from "@/shared/config/social-links/links"; "use client";
import styles from "@/static/hero/hero.module.css";
import { sociallinks } from "@/shared/config/social-links/links";
import { useI18n } from "@/shared/config/i18n";
import styles from "@/static/hero/hero.module.css";
import FlashlightText from "./FlashlightText"; import FlashlightText from "./FlashlightText";
const socialIcon = (label: string) => { const socialIcon = (label: string) => {
@ -34,7 +36,9 @@ const socialIcon = (label: string) => {
} }
}; };
const Hero = () => { export const Hero = () => {
const { dict, locale } = useI18n();
return ( return (
<section className={styles.hero} data-scroll-section> <section className={styles.hero} data-scroll-section>
<div className={`cont-header ${styles.inner}`}> <div className={`cont-header ${styles.inner}`}>
@ -42,21 +46,18 @@ const Hero = () => {
<div className={styles.info}> <div className={styles.info}>
<span className={styles.role}> <span className={styles.role}>
<span className={styles.dot} /> <span className={styles.dot} />
веб-разработчик {dict.hero.role}
</span> </span>
<h1 className={styles.name} data-cursor-hover> <h1 className={styles.name} data-cursor-hover>
<FlashlightText text="Egorov Nikita" /> <FlashlightText text={dict.hero.name} />
</h1> </h1>
<p className={styles.desc}> <p className={styles.desc}>{dict.hero.desc}</p>
Создаю современные, быстрые и продуманные веб-сайты и
веб-приложения под ключ.
</p>
<div className={styles.actions}> <div className={styles.actions}>
<a href="#projects" className={styles.button}> <a href={`/${locale}/#projects`} className={styles.button}>
Смотреть проекты {dict.hero.ctaButton}
</a> </a>
<ul className={styles.socials}> <ul className={styles.socials}>
@ -81,14 +82,14 @@ const Hero = () => {
<div className={styles.right}> <div className={styles.right}>
<div className={styles.visual}> <div className={styles.visual}>
<span className={styles.index} aria-hidden="true"> <span className={styles.index} aria-hidden="true">
<i>портфолио</i> <i>{dict.hero.portfolioWord}</i>
</span> </span>
</div> </div>
</div> </div>
</div> </div>
<div className={styles.hint} aria-hidden="true"> <div className={styles.hint} aria-hidden="true">
листай {dict.hero.scrollHint}
<span className={styles.hintLine} /> <span className={styles.hintLine} />
</div> </div>
</section> </section>

View file

@ -1,34 +1,13 @@
"use client"; "use client";
import { useInView } from "@/shared/hooks/useInView"; import { useInView } from "@/shared/hooks/useInView";
import { useI18n } from "@/shared/config/i18n";
import styles from "@/static/process/process.module.css"; import styles from "@/static/process/process.module.css";
const steps = [ export const Process = () => {
{
label: "01",
title: "Бриф",
text: "Разбираю задачу, аудиторию, цели сайта и фиксирую, какой результат должен получиться.",
},
{
label: "02",
title: "Структура",
text: "Собираю логику страниц, сценарии пользователя и расставляю смысловые акценты.",
},
{
label: "03",
title: "Разработка",
text: "Верстаю адаптивный интерфейс, добавляю интерактив, формы, аналитику и базовое SEO.",
},
{
label: "04",
title: "Запуск",
text: "Проверяю скорость, мобильную версию, мелкие состояния и помогаю спокойно выкатить сайт.",
},
];
const Process = () => {
const [headRef, headInView] = useInView<HTMLDivElement>(0.24); const [headRef, headInView] = useInView<HTMLDivElement>(0.24);
const [stepsRef, stepsInView] = useInView<HTMLDivElement>(0.18); const [stepsRef, stepsInView] = useInView<HTMLDivElement>(0.18);
const { dict } = useI18n();
const reveal = (visible: boolean) => const reveal = (visible: boolean) =>
`transition-all duration-700 ease-out will-change-transform ${ `transition-all duration-700 ease-out will-change-transform ${
@ -47,7 +26,7 @@ const Process = () => {
<div ref={headRef} className={styles.head}> <div ref={headRef} className={styles.head}>
<p className={`${styles.eyebrow} ${reveal(headInView)}`}> <p className={`${styles.eyebrow} ${reveal(headInView)}`}>
<span className={styles.rule} aria-hidden="true" /> <span className={styles.rule} aria-hidden="true" />
процесс {dict.process.eyebrow}
</p> </p>
<div className={styles.headGrid}> <div className={styles.headGrid}>
@ -55,16 +34,14 @@ const Process = () => {
className={`${styles.title} ${reveal(headInView)}`} className={`${styles.title} ${reveal(headInView)}`}
style={{ transitionDelay: "100ms" }} style={{ transitionDelay: "100ms" }}
> >
От идеи до запуска без хаоса и лишней суеты {dict.process.title}
</h2> </h2>
<p <p
className={`${styles.text} ${reveal(headInView)}`} className={`${styles.text} ${reveal(headInView)}`}
style={{ transitionDelay: "200ms" }} style={{ transitionDelay: "200ms" }}
> >
Сайт должен не просто красиво открываться, а вести человека к {dict.process.desc}
действию. Поэтому я двигаюсь по понятным этапам: сначала смысл,
потом интерфейс, затем аккуратная разработка и проверка деталей.
</p> </p>
</div> </div>
</div> </div>
@ -73,13 +50,13 @@ const Process = () => {
ref={stepsRef} ref={stepsRef}
className={`${styles.timeline} ${stepsInView ? styles.active : ""}`} className={`${styles.timeline} ${stepsInView ? styles.active : ""}`}
> >
{steps.map((step, index) => ( {dict.process.steps.map((step, index) => (
<article <article
key={step.label} key={step.number}
className={`${styles.step} ${reveal(stepsInView)}`} className={`${styles.step} ${reveal(stepsInView)}`}
style={{ transitionDelay: `${index * 90}ms` }} style={{ transitionDelay: `${index * 90}ms` }}
> >
<span className={styles.stepNumber}>{step.label}</span> <span className={styles.stepNumber}>{step.number}</span>
<h3 className={styles.stepTitle}>{step.title}</h3> <h3 className={styles.stepTitle}>{step.title}</h3>
<p className={styles.stepText}>{step.text}</p> <p className={styles.stepText}>{step.text}</p>
</article> </article>

View file

@ -1,8 +1,8 @@
"use client"; "use client";
import type { KeyboardEvent, MouseEvent } from "react"; import type { KeyboardEvent, MouseEvent } from "react";
import type { Project } from "@/shared/config/projects/types"; import type { Project } from "@/shared/config/projects/types";
import { useI18n } from "@/shared/config/i18n";
import styles from "@/static/projects/project-card.module.css"; import styles from "@/static/projects/project-card.module.css";
type ProjectCardProps = { type ProjectCardProps = {
@ -10,10 +10,11 @@ type ProjectCardProps = {
onOpen: (project: Project) => void; onOpen: (project: Project) => void;
}; };
const ProjectCard = ({ project, onOpen }: ProjectCardProps) => { export const ProjectCard = ({ project, onOpen }: ProjectCardProps) => {
const { dict } = useI18n();
const handleSpotlight = (e: MouseEvent<HTMLElement>) => { const handleSpotlight = (e: MouseEvent<HTMLElement>) => {
const element = e.currentTarget; const element = e.currentTarget;
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
element.style.setProperty( element.style.setProperty(
@ -40,7 +41,7 @@ const ProjectCard = ({ project, onOpen }: ProjectCardProps) => {
tabIndex={0} tabIndex={0}
data-cursor-hover data-cursor-hover
aria-haspopup="dialog" aria-haspopup="dialog"
aria-label={`${project.title}: подробнее`} aria-label={`${project.title}: ${dict.projectsSection.cardDetails}`}
onClick={() => onOpen(project)} onClick={() => onOpen(project)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onMouseMove={handleSpotlight} onMouseMove={handleSpotlight}
@ -63,7 +64,7 @@ const ProjectCard = ({ project, onOpen }: ProjectCardProps) => {
</ul> </ul>
<span className={styles.cardLink}> <span className={styles.cardLink}>
подробнее {dict.projectsSection.cardDetails}
<span className={styles.cardLinkArrow} aria-hidden="true"> <span className={styles.cardLinkArrow} aria-hidden="true">
↗ ↗
</span> </span>

View file

@ -1,22 +1,21 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useInView } from "@/shared/hooks/useInView"; import { useInView } from "@/shared/hooks/useInView";
import { projects } from "@/shared/config/projects/projects"; import { getProjects } from "@/shared/config/projects/projects";
import type { Project } from "@/shared/config/projects/types"; import type { Project } from "@/shared/config/projects/types";
import { ProjectModal } from "@/widgets/project-modal"; import { ProjectModal } from "@/widgets/project-modal";
import { useI18n } from "@/shared/config/i18n";
import ProjectCard from "./ProjectCard";
import styles from "@/static/projects/projects.module.css"; import styles from "@/static/projects/projects.module.css";
import ProjectCard from "./ProjectCard"; export const ProjectsSection = () => {
const preview = projects.slice(0, 6);
const ProjectsSection = () => {
const [gridRef, gridInView] = useInView<HTMLDivElement>(0.12); const [gridRef, gridInView] = useInView<HTMLDivElement>(0.12);
const [selected, setSelected] = useState<Project | null>(null); const [selected, setSelected] = useState<Project | null>(null);
const { locale, dict } = useI18n();
const preview = getProjects(locale).slice(0, 6);
const reveal = (visible: boolean) => const reveal = (visible: boolean) =>
`transition-all duration-700 ease-out will-change-transform ${ `transition-all duration-700 ease-out will-change-transform ${
@ -36,16 +35,19 @@ const ProjectsSection = () => {
<div> <div>
<p className={styles.eyebrow}> <p className={styles.eyebrow}>
<span className={styles.rule} aria-hidden="true" /> <span className={styles.rule} aria-hidden="true" />
работы {dict.projectsSection.eyebrow}
</p> </p>
<h2 className={styles.title}> <h2 className={styles.title}>
Избранные <span className={styles.titleAccent}>проекты</span> {dict.projectsSection.titleStart}{" "}
<span className={styles.titleAccent}>
{dict.projectsSection.titleAccent}
</span>
</h2> </h2>
</div> </div>
<Link href="/projects" className={styles.viewAll}> <Link href={`/${locale}/projects/`} className={styles.viewAll}>
все проекты {dict.projectsSection.viewAll}
<span className={styles.viewAllArrow} aria-hidden="true"> <span className={styles.viewAllArrow} aria-hidden="true">
↗ ↗
</span> </span>

View file

@ -1,3 +1,6 @@
"use client";
import { useI18n } from "@/shared/config/i18n";
import styles from "@/static/sponsors/sponsors.module.css"; import styles from "@/static/sponsors/sponsors.module.css";
const sponsors = [ const sponsors = [
@ -40,11 +43,13 @@ const List = ({ hidden = false }: { hidden?: boolean }) => (
</ul> </ul>
); );
const Sponsors = () => { export const Sponsors = () => {
const { dict } = useI18n();
return ( return (
<section className={styles.section} aria-label="Партнёры и бренды"> <section className={styles.section} aria-label={dict.sponsors.label}>
<div className="page-container"> <div className="page-container">
<p className={styles.label}>С ними я работал</p> <p className={styles.label}>{dict.sponsors.label}</p>
</div> </div>
<div className={styles.ticker}> <div className={styles.ticker}>

View file

@ -1,37 +1,12 @@
"use client"; "use client";
import { useInView } from "@/shared/hooks/useInView"; import { useInView } from "@/shared/hooks/useInView";
import { useI18n } from "@/shared/config/i18n";
import styles from "@/static/testimonials/testimonials.module.css"; import styles from "@/static/testimonials/testimonials.module.css";
const testimonials = [ export 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 [gridRef, gridInView] = useInView<HTMLDivElement>(0.15);
const { dict } = useI18n();
const reveal = (visible: boolean) => const reveal = (visible: boolean) =>
`transition-all duration-700 ease-out will-change-transform ${ `transition-all duration-700 ease-out will-change-transform ${
@ -49,17 +24,18 @@ const Testimonials = () => {
<div className="page-container relative z-10"> <div className="page-container relative z-10">
<p className={styles.eyebrow}> <p className={styles.eyebrow}>
<span className={styles.rule} aria-hidden="true" /> <span className={styles.rule} aria-hidden="true" />
отзывы {dict.testimonials.eyebrow}
</p> </p>
<h2 className={styles.title}> <h2 className={styles.title}>
Что обо мне <span className={styles.titleAccent}>говорят</span> {dict.testimonials.titleStart}{" "}
<span className={styles.titleAccent}>{dict.testimonials.titleAccent}</span>
</h2> </h2>
<div ref={gridRef} className={styles.grid}> <div ref={gridRef} className={styles.grid}>
{testimonials.map((item, index) => ( {dict.testimonials.items.map((item, index) => (
<article <article
key={item.name} key={item.author}
className={`${styles.card} ${reveal(gridInView)}`} className={`${styles.card} ${reveal(gridInView)}`}
style={{ transitionDelay: `${index * 90}ms` }} style={{ transitionDelay: `${index * 90}ms` }}
> >
@ -75,10 +51,10 @@ const Testimonials = () => {
<footer className={styles.author}> <footer className={styles.author}>
<span className={styles.avatar} aria-hidden="true"> <span className={styles.avatar} aria-hidden="true">
{initials(item.name)} {item.avatar}
</span> </span>
<span> <span>
<span className={styles.authorName}>{item.name}</span> <span className={styles.authorName}>{item.author}</span>
<span className={styles.authorRole}>{item.role}</span> <span className={styles.authorRole}>{item.role}</span>
</span> </span>
</footer> </footer>

View file

@ -0,0 +1,33 @@
"use client";
import { createContext, useContext, ReactNode } from "react";
import type { Dictionary, Locale } from "./types";
import { dictionaries } from "./dictionaries";
interface I18nContextType {
locale: Locale;
dict: Dictionary;
}
const I18nContext = createContext<I18nContextType>({
locale: "ru",
dict: dictionaries.ru,
});
export const I18nProvider = ({
locale,
children,
}: {
locale: Locale;
children: ReactNode;
}) => {
const dict = dictionaries[locale] || dictionaries.ru;
return (
<I18nContext.Provider value={{ locale, dict }}>
{children}
</I18nContext.Provider>
);
};
export const useI18n = () => useContext(I18nContext);

View file

@ -0,0 +1,519 @@
import type { Dictionary, Locale, LocalizedProject } from "./types";
export const dictionaries: Record<Locale, Dictionary> = {
ru: {
locale: "ru",
nav: {
about: "Обо мне",
projects: "Проекты",
contact: "Контакты",
},
hero: {
role: "веб-разработчик",
name: "Egorov Nikita",
desc: "Создаю современные, быстрые и продуманные веб-сайты и веб-приложения под ключ.",
ctaButton: "Смотреть проекты",
portfolioWord: "портфолио",
scrollHint: "листай",
},
about: {
eyebrow: "обо мне",
titleStart: "Разработчик, для которого важны",
titleAccent: "продуманность",
titleEnd: "и детали",
desc: "Привет! Я Никита — веб-разработчик. Строю современные, быстрые и аккуратные интерфейсы на React и Next.js. Уделяю внимание производительности, семантике и мелочам, из которых складывается хорошее впечатление от сайта.",
stats: {
expLabel: "опыт",
expValue: "3+",
projectsLabel: "проекты",
projectsValue: "10+",
stackLabel: "стек",
},
skillsTitle: "Стек и навыки",
},
process: {
eyebrow: "процесс",
title: "От идеи до запуска без хаоса и лишней суеты",
desc: "Сайт должен не просто красиво открываться, а вести человека к действию. Поэтому я двигаюсь по понятным этапам: сначала смысл, потом интерфейс, затем аккуратная разработка и проверка деталей.",
steps: [
{
number: "01",
title: "Бриф",
text: "Разбираю задачу, аудиторию, цели сайта и фиксирую, какой результат должен получиться.",
},
{
number: "02",
title: "Структура",
text: "Собираю логику страниц, сценарии пользователя и расставляю смысловые акценты.",
},
{
number: "03",
title: "Разработка",
text: "Верстаю адаптивный интерфейс, добавляю интерактив, формы, аналитику и базовое SEO.",
},
{
number: "04",
title: "Запуск",
text: "Проверяю скорость, мобильную версию, мелкие состояния и помогаю спокойно выкатить сайт.",
},
],
},
sponsors: {
label: "С ними я работал",
},
testimonials: {
eyebrow: "отзывы",
titleStart: "Что обо мне",
titleAccent: "говорят",
items: [
{
quote: "Никита сделал лендинг, который реально приносит лиды. Быстро, аккуратно, без лишней воды в коммуникации.",
rating: 5,
author: "Алексей Орлов",
role: "Основатель финтех-стартапа",
avatar: "АО",
},
{
quote: "Код понятный, страницы летают, а правки после сдачи вносить легко. Работать с ним — одно удовольствие.",
rating: 5,
author: "Мария Соколова",
role: "Продакт-менеджер",
avatar: "МС",
},
{
quote: "За 2 недели собрал нам интернет-магазин под ключ: каталог, оплата, админка. Всё чётко и в срок.",
rating: 5,
author: "Дмитрий Волков",
role: "Владелец бренда одежды",
avatar: "ДВ",
},
],
},
projectsSection: {
eyebrow: "работы",
titleStart: "Избранные",
titleAccent: "проекты",
viewAll: "все проекты",
cardDetails: "подробнее",
},
projectsPage: {
eyebrow: "проекты",
titleStart: "Все",
titleAccent: "работы",
countSuffix: "проектов под ключ",
},
projectModal: {
aboutTitle: "О проекте",
featuresTitle: "Особенности и результат",
stackTitle: "Стек технологий",
viewSite: "Смотреть сайт",
viewCode: "Исходный код",
closeHint: "Esc для закрытия",
},
footer: {
eyebrow: "контакты",
titleStart: "Есть проект?",
titleAccent: "Напиши мне",
desc: "Отвечаю обычно в течение дня. Расскажи, что хочешь сделать, — я помогу с идеей, оценкой и реализацией.",
telegramButton: "Написать в Telegram",
githubButton: "GitHub",
copyright: "© 2025-2026 Egorov Nikita",
},
contextMenu: {
name: "Никита Егоров",
role: "Frontend & Web Developer",
statusTitle: "Открыт к предложениям",
primaryPdf: "/Nikita_Egorov_Resume_RU.pdf",
primaryFilename: "Nikita_Egorov_Resume_RU.pdf",
primaryLabel: "Скачать резюме (RU)",
primaryBadge: "RU",
secondaryPdf: "/Nikita_Egorov_Resume_EN.pdf",
secondaryFilename: "Nikita_Egorov_Resume_EN.pdf",
secondaryLabel: "Скачать резюме (EN)",
secondaryBadge: "EN",
previewLabel: "Открыть в новой вкладке",
copyLabel: "Скопировать ссылку",
copiedLabel: "Ссылка скопирована!",
telegramLabel: "Написать в Telegram",
footerMenu: "Меню сайта",
esc: "Esc",
},
cookieConsent: {
title: "Мы используем файлы cookie",
text: "Этот сайт использует файлы cookie и сервис веб-аналитики для сбора обезличенных данных и улучшения работы.",
acceptButton: "Понятно",
},
preloader: {
loadingText: "загрузка",
},
seo: {
title: "Никита Егоров — Frontend & Web Developer | Портфолио",
description: "Портфолио веб-разработчика Никиты Егорова. Создание современных, быстрых и адаптивных веб-сайтов, интернет-магазинов и веб-сервисов на React, Next.js, TypeScript и Django.",
keywords: [
"Никита Егоров",
"Nikita Egorov",
"autli",
"веб-разработчик",
"фронтенд разработчик",
"Frontend Developer",
"React",
"Next.js",
"TypeScript",
"Tailwind CSS",
"Django",
],
ogDescription: "Разработка современных, быстрых и продуманных веб-сервисов, интернет-магазинов и дашбордов на React, Next.js и TypeScript.",
projectsPageTitle: "Все проекты",
projectsPageDesc: "Коммерческие и пет-проекты Egorov Nikita: веб-приложения, интернет-магазины, SaaS-дашборды и сайты под ключ на React, Next.js, TypeScript и Django.",
},
},
en: {
locale: "en",
nav: {
about: "About",
projects: "Projects",
contact: "Contact",
},
hero: {
role: "web developer",
name: "Egorov Nikita",
desc: "Crafting modern, high-performance, and thoughtful websites and web applications end-to-end.",
ctaButton: "View projects",
portfolioWord: "portfolio",
scrollHint: "scroll",
},
about: {
eyebrow: "about me",
titleStart: "A developer who cares about",
titleAccent: "precision",
titleEnd: "and detail",
desc: "Hi! I'm Nikita — a web developer. I build modern, fast, and clean interfaces with React and Next.js. I prioritize performance, semantic code, and the small details that make a great digital experience.",
stats: {
expLabel: "experience",
expValue: "3+",
projectsLabel: "projects",
projectsValue: "10+",
stackLabel: "stack",
},
skillsTitle: "Stack & Skills",
},
process: {
eyebrow: "process",
title: "From idea to production without chaos or friction",
desc: "A website shouldn't just look great — it should drive action. That's why I follow a structured approach: strategy first, then design, clean engineering, and rigorous testing.",
steps: [
{
number: "01",
title: "Brief",
text: "Analyzing goals, target audience, technical requirements, and defining clear milestones.",
},
{
number: "02",
title: "Structure",
text: "Mapping user journeys, page hierarchies, data flows, and design semantics.",
},
{
number: "03",
title: "Development",
text: "Writing clean, responsive code, animations, form validations, analytics, and technical SEO.",
},
{
number: "04",
title: "Launch",
text: "Verifying Core Web Vitals, cross-browser compatibility, and ensuring a seamless deployment.",
},
],
},
sponsors: {
label: "Companies I've worked with",
},
testimonials: {
eyebrow: "testimonials",
titleStart: "What people",
titleAccent: "say about me",
items: [
{
quote: "Nikita delivered a high-converting landing page that really brings leads. Fast, meticulous, with zero fluff in communication.",
rating: 5,
author: "Alexey Orlov",
role: "Fintech Startup Founder",
avatar: "AO",
},
{
quote: "Clean code, blazing fast pages, and extremely easy to maintain. Working with him was an absolute pleasure.",
rating: 5,
author: "Maria Sokolova",
role: "Product Manager",
avatar: "MS",
},
{
quote: "Delivered a turnkey e-commerce store in just 2 weeks: catalog, checkout, custom admin panel. On time and top quality.",
rating: 5,
author: "Dmitry Volkov",
role: "Apparel Brand Owner",
avatar: "DV",
},
],
},
projectsSection: {
eyebrow: "works",
titleStart: "Featured",
titleAccent: "projects",
viewAll: "all projects",
cardDetails: "details",
},
projectsPage: {
eyebrow: "projects",
titleStart: "All",
titleAccent: "works",
countSuffix: "completed turnkey projects",
},
projectModal: {
aboutTitle: "About project",
featuresTitle: "Features & Impact",
stackTitle: "Tech Stack",
viewSite: "Live Website",
viewCode: "Source Code",
closeHint: "Esc to close",
},
footer: {
eyebrow: "contact",
titleStart: "Have a project?",
titleAccent: "Let's talk",
desc: "I usually reply within a day. Tell me what you'd like to build — I'll help with planning, estimation, and execution.",
telegramButton: "Message on Telegram",
githubButton: "GitHub",
copyright: "© 2025-2026 Egorov Nikita",
},
contextMenu: {
name: "Nikita Egorov",
role: "Frontend & Web Developer",
statusTitle: "Available for hire",
primaryPdf: "/Nikita_Egorov_Resume_EN.pdf",
primaryFilename: "Nikita_Egorov_Resume_EN.pdf",
primaryLabel: "Download Resume (EN)",
primaryBadge: "EN",
secondaryPdf: "/Nikita_Egorov_Resume_RU.pdf",
secondaryFilename: "Nikita_Egorov_Resume_RU.pdf",
secondaryLabel: "Download Resume (RU)",
secondaryBadge: "RU",
previewLabel: "Open in new tab",
copyLabel: "Copy website link",
copiedLabel: "Link copied!",
telegramLabel: "Message on Telegram",
footerMenu: "Site menu",
esc: "Esc",
},
cookieConsent: {
title: "We use cookies",
text: "This site uses cookies and analytics services to collect anonymized data and optimize your user experience.",
acceptButton: "Got it",
},
preloader: {
loadingText: "loading",
},
seo: {
title: "Nikita Egorov — Frontend & Web Developer | Portfolio",
description: "Portfolio of web developer Nikita Egorov. Building modern, fast, and responsive websites, e-commerce stores, and web apps with React, Next.js, TypeScript, and Django.",
keywords: [
"Nikita Egorov",
"autli",
"Frontend Developer",
"Web Developer",
"React",
"Next.js",
"TypeScript",
"Tailwind CSS",
"Django",
"Web Development",
"Portfolio",
],
ogDescription: "Developing modern, fast, and thoughtful web applications, e-commerce platforms, and dashboards with React, Next.js, and TypeScript.",
projectsPageTitle: "All Projects",
projectsPageDesc: "Commercial and pet projects by Nikita Egorov: web apps, online stores, SaaS dashboards, and turnkey websites built with React, Next.js, TypeScript, and Django.",
},
},
};
export const localizedProjects: Record<Locale, LocalizedProject[]> = {
ru: [
{
id: "01",
title: "Центр инструментов Мастер",
description:
"Корпоративный лендинг для магазина инструментов: каталог, формы обратной связи и высокая производительность.",
year: "2025",
tags: ["HTML", "CSS", "JavaScript", "Django"],
details:
"Одностраничный сайт для компании по продаже инструментов: продуманная структура, анимации появления, формы заявок и интеграция с аналитикой. Упор на скорость загрузки (Lighthouse 95+) и адаптивность.",
features: [
"Скорость загрузки < 1 секунды",
"Анимированные блоки и валидация форм",
"Интеграция с Яндекс.Метрикой",
"Полная адаптация под мобильные устройства",
],
code: "https://github.com/autli5",
link: "https://orentools.ru",
},
{
id: "02",
title: "Запретный плод",
description:
"Маркетплейс с ролевой моделью: каталог, корзина, онлайн-оплата и личные кабинеты.",
year: "2025",
tags: ["Python", "Django", "HTML", "CSS", "PostgreSQL"],
details:
"Платформа для продажи и покупки товаров с разделением ролей покупателя и продавца. Включает каталог с фильтрами, систему корзины, оформление заказов и панель управления для продавцов.",
features: [
"Каталог с динамическими фильтрами и поиском",
"Система корзины и имитация онлайн-оплаты",
"Личный кабинет с историей заказов",
"Админ-панель для управления товарами",
],
code: "https://github.com/autli5",
link: "https://z-plod.ru",
},
{
id: "03",
title: "Лазертаг Беркут",
description:
"SaaS-дашборд с аналитикой в реальном времени: графики, отчёты и экспорт данных.",
year: "2026",
tags: ["Next.js", "TypeScript", "Tailwind CSS"],
details:
"Интерактивная панель управления для бизнеса: витрина метрик в реальном времени, построение пользовательских отчётов и экспорт данных. Поддержка тёмной темы и кастомизация виджетов.",
features: [
"Визуализация данных с помощью интерактивных графиков",
"Конструктор отчётов с экспортом в CSV",
"Настраиваемые виджеты и тёмная тема",
"Ролевая модель доступа к данным",
],
code: "https://github.com/autli5",
link: "https://lasergame56.ru",
},
{
id: "04",
title: "Сайт портфолио",
description:
"Персональный сайт-портфолио на современном стеке с фокусом на производительность и SEO.",
year: "2026",
tags: ["Next.js", "TypeScript", "Tailwind CSS"],
details:
"Оптимизированный сайт для демонстрации проектов: серверный рендеринг (SSR) для отличного SEO, плавные анимации переходов и чистая, минималистичная архитектура компонентов.",
features: [
"Отличная SEO-семантика и Open Graph разметка",
"Высокая производительность (Core Web Vitals)",
"Адаптивный и доступный (a11y) интерфейс",
"Типизированный код на TypeScript",
],
code: "https://github.com/autli5",
},
{
id: "05",
title: "freebie (E-commerce Platform)",
description:
"Современный интернет-магазин с динамической корзиной, каталогом товаров и REST API.",
year: "2026",
tags: ["React", "Vite", "TypeScript", "Django", "Django REST Framework"],
details:
"Полноценное SPA-приложение для электронной коммерции. Фронтенд на React (Vite) взаимодействует с бэкендом на Django. Реализована глобальная корзина в реальном времени, система отзывов, промокодов и адаптивная верстка.",
features: [
"Глобальное состояние корзины с мгновенным обновлением (React Context)",
"REST API на Django для управления товарами и сессиями",
"Адаптивный UI/UX дизайн, вдохновленный топовыми маркетплейсами",
"Динамический расчет стоимости с учетом промокодов и доставки",
],
code: "https://github.com/autli5/freebie",
},
],
en: [
{
id: "01",
title: "Tool Center Master",
description:
"Corporate landing page for a power tools supplier: product catalog, lead forms, and top performance.",
year: "2025",
tags: ["HTML", "CSS", "JavaScript", "Django"],
details:
"High-performance website for an industrial tools retailer: intuitive layout, smooth entrance animations, lead generation forms, and web analytics integration. Focused on instant load times (Lighthouse 95+) and responsive design.",
features: [
"Page load time under 1 second",
"Interactive animated blocks & form validation",
"Yandex Metrika analytics integration",
"Fully responsive layout across all mobile devices",
],
code: "https://github.com/autli5",
link: "https://orentools.ru",
},
{
id: "02",
title: "Zapretny Plod (Marketplace)",
description:
"E-commerce marketplace with role-based access: product catalog, shopping cart, online checkout, and seller dashboards.",
year: "2025",
tags: ["Python", "Django", "HTML", "CSS", "PostgreSQL"],
details:
"Full-fledged buying and selling platform with separated buyer and seller roles. Features dynamic filterable catalog, persistent cart system, checkout flow, and custom Django admin panel for merchants.",
features: [
"Dynamic search and multi-parameter filtering",
"Cart system with simulated online payments",
"User dashboard with full order history",
"Custom management panel for inventory control",
],
code: "https://github.com/autli5",
link: "https://z-plod.ru",
},
{
id: "03",
title: "Lasertag & Airsoft Club Berkut",
description:
"SaaS dashboard with real-time analytics: data visualization charts, custom reports, and CSV exports.",
year: "2026",
tags: ["Next.js", "TypeScript", "Tailwind CSS"],
details:
"Interactive business management platform: live business metrics showcase, custom report builder, and data export tools. Features dark mode UI and modular widget customization.",
features: [
"Real-time interactive chart visualizations",
"Custom report generator with CSV exports",
"Customizable dashboard widgets & dark theme",
"Role-based access control for team members",
],
code: "https://github.com/autli5",
link: "https://lasergame56.ru",
},
{
id: "04",
title: "Developer Portfolio",
description:
"Personal developer portfolio built on a modern stack with strong focus on performance and SEO.",
year: "2026",
tags: ["Next.js", "TypeScript", "Tailwind CSS"],
details:
"High-performance website designed to showcase projects: server-side rendering (SSR) for optimal SEO discovery, smooth entrance animations, and clean modular component architecture.",
features: [
"Semantic SEO architecture and Open Graph metadata",
"Peak Core Web Vitals and Lighthouse scores",
"Fully accessible (a11y) and mobile-first interface",
"Strictly typed codebase in TypeScript",
],
code: "https://github.com/autli5",
},
{
id: "05",
title: "freebie (E-commerce Platform)",
description:
"Modern SPA e-commerce platform with dynamic shopping cart, product catalog, and REST API integration.",
year: "2026",
tags: ["React", "Vite", "TypeScript", "Django", "Django REST Framework"],
details:
"Comprehensive single-page application for e-commerce. React + Vite frontend communicating seamlessly with a Django REST API. Features real-time cart state management, product review system, promo codes, and responsive design.",
features: [
"Global real-time cart state powered by React Context",
"Django REST API for products and session management",
"Modern UI/UX inspired by leading marketplace platforms",
"Dynamic price calculation with promo codes and delivery",
],
code: "https://github.com/autli5/freebie",
},
],
};

View file

@ -0,0 +1,3 @@
export * from "./types";
export * from "./dictionaries";
export * from "./context";

View file

@ -0,0 +1,149 @@
export type Locale = "ru" | "en";
export interface NavItem {
label: string;
href: string;
}
export interface SkillItem {
name: string;
level: number;
}
export interface ProcessStep {
number: string;
title: string;
text: string;
}
export interface TestimonialItem {
quote: string;
rating: number;
author: string;
role: string;
avatar: string;
}
export interface LocalizedProject {
id: string;
title: string;
description: string;
year: string;
tags: string[];
details: string;
features: string[];
code?: string;
link?: string;
}
export interface Dictionary {
locale: Locale;
nav: {
about: string;
projects: string;
contact: string;
};
hero: {
role: string;
name: string;
desc: string;
ctaButton: string;
portfolioWord: string;
scrollHint: string;
};
about: {
eyebrow: string;
titleStart: string;
titleAccent: string;
titleEnd: string;
desc: string;
stats: {
expLabel: string;
expValue: string;
projectsLabel: string;
projectsValue: string;
stackLabel: string;
};
skillsTitle: string;
};
process: {
eyebrow: string;
title: string;
desc: string;
steps: ProcessStep[];
};
sponsors: {
label: string;
};
testimonials: {
eyebrow: string;
titleStart: string;
titleAccent: string;
items: TestimonialItem[];
};
projectsSection: {
eyebrow: string;
titleStart: string;
titleAccent: string;
viewAll: string;
cardDetails: string;
};
projectsPage: {
eyebrow: string;
titleStart: string;
titleAccent: string;
countSuffix: string;
};
projectModal: {
aboutTitle: string;
featuresTitle: string;
stackTitle: string;
viewSite: string;
viewCode: string;
closeHint: string;
};
footer: {
eyebrow: string;
titleStart: string;
titleAccent: string;
desc: string;
telegramButton: string;
githubButton: string;
copyright: string;
};
contextMenu: {
name: string;
role: string;
statusTitle: string;
primaryPdf: string;
primaryFilename: string;
primaryLabel: string;
primaryBadge: string;
secondaryPdf: string;
secondaryFilename: string;
secondaryLabel: string;
secondaryBadge: string;
previewLabel: string;
copyLabel: string;
copiedLabel: string;
telegramLabel: string;
footerMenu: string;
esc: string;
};
cookieConsent: {
title: string;
text: string;
acceptButton: string;
};
preloader: {
loadingText: string;
};
seo: {
title: string;
description: string;
keywords: string[];
ogDescription: string;
projectsPageTitle: string;
projectsPageDesc: string;
};
}

View file

@ -1,92 +1,9 @@
import type { Project } from "./types"; import type { Project } from "./types";
import { localizedProjects } from "@/shared/config/i18n/dictionaries";
import type { Locale } from "@/shared/config/i18n/types";
export const projects: Project[] = [ export const getProjects = (locale: Locale = "ru"): Project[] => {
{ return localizedProjects[locale] || localizedProjects.ru;
id: "01", };
title: "Центр инструментов Мастер",
description: export const projects: Project[] = localizedProjects.ru;
"Корпоративный лендинг для магазина инструментов: каталог, формы обратной связи и высокая производительность.", // <-- ИСПРАВЛЕНО под название
year: "2025",
tags: ["HTML", "CSS", "JavaScript", "Django"],
details:
"Одностраничный сайт для компании по продаже инструментов: продуманная структура, анимации появления, формы заявок и интеграция с аналитикой. Упор на скорость загрузки (Lighthouse 95+) и адаптивность.",
features: [
"Скорость загрузки < 1 секунды",
"Анимированные блоки и валидация форм",
"Интеграция с Яндекс.Метрикой",
"Полная адаптация под мобильные устройства",
],
code: "https://github.com/autli5",
link: "https://orentools.ru",
},
{
id: "02",
title: "Запретный плод",
description:
"Маркетплейс с ролевой моделью: каталог, корзина, онлайн-оплата и личные кабинеты.",
year: "2025",
tags: ["Python", "Django", "HTML", "CSS", "PostgreSQL"], // <-- Добавил Python и БД для солидности
details:
"Платформа для продажи и покупки товаров с разделением ролей покупателя и продавца. Включает каталог с фильтрами, систему корзины, оформление заказов и панель управления для продавцов.",
features: [
"Каталог с динамическими фильтрами и поиском",
"Система корзины и имитация онлайн-оплаты",
"Личный кабинет с историей заказов",
"Админ-панель для управления товарами",
],
code: "https://github.com/autli5",
link: "https://z-plod.ru"
},
{
id: "03",
title: "Лазертаг Беркут",
description:
"SaaS-дашборд с аналитикой в реальном времени: графики, отчёты и экспорт данных.",
year: "2026",
tags: ["Next.js", "TypeScript", "Tailwind CSS"], // <-- Исправлены кавычки и добавлен TS
details:
"Интерактивная панель управления для бизнеса: витрина метрик в реальном времени, построение пользовательских отчётов и экспорт данных. Поддержка тёмной темы и кастомизация виджетов.",
features: [
"Визуализация данных с помощью интерактивных графиков",
"Конструктор отчётов с экспортом в CSV",
"Настраиваемые виджеты и тёмная тема",
"Ролевая модель доступа к данным",
],
code: "https://github.com/autli5",
link: "https://lasergame56.ru"
},
{
id: "04",
title: "Сайт портфолио",
description:
"Персональный сайт-портфолио на современном стеке с фокусом на производительность и SEO.",
year: "2026",
tags: ["Next.js", "TypeScript", "Tailwind CSS"], // <-- Исправлены кавычки
details:
"Оптимизированный сайт для демонстрации проектов: серверный рендеринг (SSR) для отличного SEO, плавные анимации переходов и чистая, минималистичная архитектура компонентов.",
features: [
"Отличная SEO-семантика и Open Graph разметка",
"Высокая производительность (Core Web Vitals)",
"Адаптивный и доступный (a11y) интерфейс",
"Типизированный код на TypeScript",
],
code: "https://github.com/autli5",
},
{
id: "05",
title: "freebie (E-commerce Platform)",
description:
"Современный интернет-магазин с динамической корзиной, каталогом товаров и REST API.",
year: "2026",
tags: ["React", "Vite", "TypeScript", "Django", "Django REST Framework"],
details:
"Полноценное SPA-приложение для электронной коммерции. Фронтенд на React (Vite) взаимодействует с бэкендом на Django. Реализована глобальная корзина в реальном времени, система отзывов, промокодов и адаптивная верстка.",
features: [
"Глобальное состояние корзины с мгновенным обновлением (React Context)",
"REST API на Django для управления товарами и сессиями",
"Адаптивный UI/UX дизайн, вдохновленный топовыми маркетплейсами",
"Динамический расчет стоимости с учетом промокодов и доставки",
],
code: "https://github.com/autli5/freebie",
},
];

View file

@ -60,6 +60,52 @@
gap: var(--space-md); gap: var(--space-md);
} }
/* =========================
LANG TOGGLE
========================= */
.langToggle {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 32px;
padding: 0 12px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 9999px;
color: var(--color-text);
font-size: 11.5px;
font-weight: 600;
letter-spacing: 0.04em;
text-decoration: none;
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
}
.langToggle:hover {
background: rgba(37, 99, 235, 0.15);
border-color: rgba(59, 130, 246, 0.4);
color: #93c5fd;
transform: translateY(-1px);
}
.langToggleDot {
width: 5px;
height: 5px;
border-radius: 50%;
background: #3b82f6;
}
.mobileLangRow {
display: flex;
align-items: center;
margin-top: var(--space-lg);
padding-top: var(--space-md);
border-top: 1px solid var(--color-line);
}
/* ========================= /* =========================
DESKTOP NAV DESKTOP NAV
========================= */ ========================= */

View file

@ -1,20 +1,23 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Header } from "@/widgets/header"; import { Header } from "@/widgets/header";
import { Footer } from "@/widgets/footer"; import { Footer } from "@/widgets/footer";
import { useInView } from "@/shared/hooks/useInView"; import { useInView } from "@/shared/hooks/useInView";
import { projects } from "@/shared/config/projects/projects"; import { getProjects } from "@/shared/config/projects/projects";
import type { Project } from "@/shared/config/projects/types"; import type { Project } from "@/shared/config/projects/types";
import { ProjectModal } from "@/widgets/project-modal"; import { ProjectModal } from "@/widgets/project-modal";
import { useI18n } from "@/shared/config/i18n";
import ProjectCard from "@/components/projects/ProjectCard"; import ProjectCard from "@/components/projects/ProjectCard";
import styles from "@/static/projects/projects-page.module.css"; import styles from "@/static/projects/projects-page.module.css";
const ProjectsPage = () => { export const ProjectsPage = () => {
const [selected, setSelected] = useState<Project | null>(null); const [selected, setSelected] = useState<Project | null>(null);
const [headRef, headInView] = useInView<HTMLDivElement>(0.4); const [headRef, headInView] = useInView<HTMLDivElement>(0.4);
const [gridRef, gridInView] = useInView<HTMLDivElement>(0.08); const [gridRef, gridInView] = useInView<HTMLDivElement>(0.08);
const { locale, dict } = useI18n();
const currentProjects = getProjects(locale);
const reveal = (visible: boolean) => const reveal = (visible: boolean) =>
`transition-all duration-700 ease-out will-change-transform ${ `transition-all duration-700 ease-out will-change-transform ${
@ -29,27 +32,31 @@ const ProjectsPage = () => {
<section className={styles.projectsPage} data-scroll-section> <section className={styles.projectsPage} data-scroll-section>
<span className={styles.glow} aria-hidden="true" /> <span className={styles.glow} aria-hidden="true" />
<span className={styles.ghost} aria-hidden="true"> <span className={styles.ghost} aria-hidden="true">
{String(projects.length).padStart(2, "0")} {String(currentProjects.length).padStart(2, "0")}
</span> </span>
<div className="page-container relative z-10"> <div className="page-container relative z-10">
<div ref={headRef} className={styles.head}> <div ref={headRef} className={styles.head}>
<p className={`${styles.eyebrow} ${reveal(headInView)}`}> <p className={`${styles.eyebrow} ${reveal(headInView)}`}>
<span className={styles.rule} aria-hidden="true" /> <span className={styles.rule} aria-hidden="true" />
проекты {dict.projectsPage.eyebrow}
</p> </p>
<h1 className={`${styles.title} ${reveal(headInView)}`}> <h1 className={`${styles.title} ${reveal(headInView)}`}>
Все <span className={styles.titleAccent}>работы</span> {dict.projectsPage.titleStart}{" "}
<span className={styles.titleAccent}>
{dict.projectsPage.titleAccent}
</span>
</h1> </h1>
<p className={`${styles.count} ${reveal(headInView)}`}> <p className={`${styles.count} ${reveal(headInView)}`}>
{String(projects.length).padStart(2, "0")} проектов под ключ {String(currentProjects.length).padStart(2, "0")}{" "}
{dict.projectsPage.countSuffix}
</p> </p>
</div> </div>
<div ref={gridRef} className={styles.grid}> <div ref={gridRef} className={styles.grid}>
{projects.map((project, index) => ( {currentProjects.map((project, index) => (
<div <div
key={project.id} key={project.id}
className={reveal(gridInView)} className={reveal(gridInView)}

View file

@ -1,72 +1,32 @@
"use client"; "use client";
import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { usePathname } from "next/navigation";
import { useI18n, dictionaries, type Locale } from "@/shared/config/i18n";
import styles from "@/static/context-menu/context-menu.module.css"; import styles from "@/static/context-menu/context-menu.module.css";
const PADDING = 12; const PADDING = 12;
const ESTIMATED_WIDTH = 275; const ESTIMATED_WIDTH = 275;
const ESTIMATED_HEIGHT = 345; const ESTIMATED_HEIGHT = 320;
type Lang = "ru" | "en";
const content = {
ru: {
name: "Никита Егоров",
role: "Frontend & Web Developer",
statusTitle: "Открыт к предложениям",
primaryPdf: "/Nikita_Egorov_Resume_RU.pdf",
primaryFilename: "Nikita_Egorov_Resume_RU.pdf",
primaryLabel: "Скачать резюме (RU)",
primaryBadge: "RU",
secondaryPdf: "/Nikita_Egorov_Resume_EN.pdf",
secondaryFilename: "Nikita_Egorov_Resume_EN.pdf",
secondaryLabel: "Скачать резюме (EN)",
secondaryBadge: "EN",
previewLabel: "Открыть в новой вкладке",
copyLabel: "Скопировать ссылку",
copiedLabel: "Ссылка скопирована!",
telegramLabel: "Написать в Telegram",
footerMenu: "Меню сайта",
esc: "Esc",
},
en: {
name: "Nikita Egorov",
role: "Frontend & Web Developer",
statusTitle: "Available for hire",
primaryPdf: "/Nikita_Egorov_Resume_EN.pdf",
primaryFilename: "Nikita_Egorov_Resume_EN.pdf",
primaryLabel: "Download Resume (EN)",
primaryBadge: "EN",
secondaryPdf: "/Nikita_Egorov_Resume_RU.pdf",
secondaryFilename: "Nikita_Egorov_Resume_RU.pdf",
secondaryLabel: "Download Resume (RU)",
secondaryBadge: "RU",
previewLabel: "Open in new tab",
copyLabel: "Copy website link",
copiedLabel: "Link copied!",
telegramLabel: "Message on Telegram",
footerMenu: "Site menu",
esc: "Esc",
},
};
export const ContextMenu = () => { export const ContextMenu = () => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [lang, setLang] = useState<Lang>("ru");
const [coords, setCoords] = useState({ x: 0, y: 0 }); const [coords, setCoords] = useState({ x: 0, y: 0 });
const [renderCoords, setRenderCoords] = useState({ x: 0, y: 0 }); const [renderCoords, setRenderCoords] = useState({ x: 0, y: 0 });
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
const { locale: contextLocale } = useI18n();
const pathname = usePathname() || "";
const handleSetLang = (newLang: Lang) => { // Auto-detect current active language from URL pathname or i18n context
setLang(newLang); const currentLocale: Locale = pathname.startsWith("/en")
try { ? "en"
localStorage.setItem("portfolio_lang", newLang); : pathname.startsWith("/ru")
} catch { ? "ru"
// ignore : contextLocale || "ru";
}
}; const current = dictionaries[currentLocale].contextMenu;
// Synchronous layout adjustment with actual DOM dimensions // Synchronous layout adjustment with actual DOM dimensions
useLayoutEffect(() => { useLayoutEffect(() => {
@ -87,7 +47,7 @@ export const ContextMenu = () => {
} }
setRenderCoords({ x: Math.round(nextX), y: Math.round(nextY) }); setRenderCoords({ x: Math.round(nextX), y: Math.round(nextY) });
}, [isOpen, coords, lang]); }, [isOpen, coords, currentLocale]);
// Contextmenu listener // Contextmenu listener
useEffect(() => { useEffect(() => {
@ -139,15 +99,6 @@ export const ContextMenu = () => {
targetY = Math.max(PADDING, window.innerHeight - ESTIMATED_HEIGHT - PADDING); targetY = Math.max(PADDING, window.innerHeight - ESTIMATED_HEIGHT - PADDING);
} }
try {
const saved = localStorage.getItem("portfolio_lang") as Lang | null;
if (saved === "ru" || saved === "en") {
setLang(saved);
}
} catch {
// ignore
}
setCoords({ x: e.clientX, y: e.clientY }); setCoords({ x: e.clientX, y: e.clientY });
setRenderCoords({ x: Math.round(targetX), y: Math.round(targetY) }); setRenderCoords({ x: Math.round(targetX), y: Math.round(targetY) });
setCopied(false); setCopied(false);
@ -227,8 +178,6 @@ export const ContextMenu = () => {
if (!isOpen) return null; if (!isOpen) return null;
const current = content[lang];
return ( return (
<div <div
ref={menuRef} ref={menuRef}
@ -242,7 +191,7 @@ export const ContextMenu = () => {
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
> >
{/* Header with name and language switcher */} {/* Header with profile info */}
<div className={styles.header}> <div className={styles.header}>
<div className={styles.headerInfo}> <div className={styles.headerInfo}>
<div className={styles.headerTitleRow}> <div className={styles.headerTitleRow}>
@ -255,33 +204,6 @@ export const ContextMenu = () => {
</div> </div>
<span className={styles.headerSubtitle}>{current.role}</span> <span className={styles.headerSubtitle}>{current.role}</span>
</div> </div>
<div className={styles.langToggle} role="group" aria-label="Язык / Language">
<button
type="button"
className={`${styles.langBtn} ${lang === "ru" ? styles.langBtnActive : ""}`}
onClick={(e) => {
e.stopPropagation();
handleSetLang("ru");
}}
data-cursor-hover
aria-pressed={lang === "ru"}
>
RU
</button>
<button
type="button"
className={`${styles.langBtn} ${lang === "en" ? styles.langBtnActive : ""}`}
onClick={(e) => {
e.stopPropagation();
handleSetLang("en");
}}
data-cursor-hover
aria-pressed={lang === "en"}
>
EN
</button>
</div>
</div> </div>
<div className={styles.divider} /> <div className={styles.divider} />
@ -479,3 +401,5 @@ export const ContextMenu = () => {
</div> </div>
); );
}; };
export default ContextMenu;

View file

@ -1,14 +1,15 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useI18n } from "@/shared/config/i18n";
import styles from "@/static/cookie-consent/cookie-consent.module.css"; import styles from "@/static/cookie-consent/cookie-consent.module.css";
export const COOKIE_CONSENT_KEY = "autli-cookie-consent"; export const COOKIE_CONSENT_KEY = "autli-cookie-consent";
export const COOKIE_CONSENT_EVENT = "autli-cookie-consent-accepted"; export const COOKIE_CONSENT_EVENT = "autli-cookie-consent-accepted";
const CookieConsent = () => { export const CookieConsent = () => {
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const { dict } = useI18n();
useEffect(() => { useEffect(() => {
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
@ -27,18 +28,15 @@ const CookieConsent = () => {
if (!visible) return null; if (!visible) return null;
return ( return (
<section className={styles.banner} aria-label="Согласие на использование cookie"> <section className={styles.banner} aria-label={dict.cookieConsent.title}>
<div className={styles.content}> <div className={styles.content}>
<div className={styles.text}> <div className={styles.text}>
<p className={styles.title}>Cookie-файлы</p> <p className={styles.title}>{dict.cookieConsent.title}</p>
<p className={styles.description}> <p className={styles.description}>{dict.cookieConsent.text}</p>
Я использую cookie и Яндекс Метрику, чтобы понимать, как работает
сайт, и улучшать его для посетителей.
</p>
</div> </div>
<button type="button" className={styles.button} onClick={acceptCookies}> <button type="button" className={styles.button} onClick={acceptCookies}>
Принять {dict.cookieConsent.acceptButton}
</button> </button>
</div> </div>
</section> </section>

View file

@ -1,10 +1,13 @@
import Link from "next/link"; "use client";
import { navigation } from "@/shared/config/navigation/navigation"; import Link from "next/link";
import { sociallinks } from "@/shared/config/social-links/links"; import { sociallinks } from "@/shared/config/social-links/links";
import { useI18n } from "@/shared/config/i18n";
import styles from "@/static/footer/footer.module.css"; import styles from "@/static/footer/footer.module.css";
const Footer = () => { export const Footer = () => {
const { locale, dict } = useI18n();
const telegram = sociallinks.find( const telegram = sociallinks.find(
(link) => link.label.toLowerCase() === "telegram" (link) => link.label.toLowerCase() === "telegram"
); );
@ -12,6 +15,12 @@ const Footer = () => {
(link) => link.label.toLowerCase() === "github" (link) => link.label.toLowerCase() === "github"
); );
const navItems = [
{ label: dict.nav.about, href: `/${locale}/#about` },
{ label: dict.nav.projects, href: `/${locale}/projects/` },
{ label: dict.nav.contact, href: `/${locale}/#contact` },
];
return ( return (
<footer id="contact" className={styles.footer} data-scroll-section> <footer id="contact" className={styles.footer} data-scroll-section>
<span className={styles.watermark} aria-hidden="true"> <span className={styles.watermark} aria-hidden="true">
@ -20,18 +29,15 @@ const Footer = () => {
<div className="page-container"> <div className="page-container">
<div className={styles.cta}> <div className={styles.cta}>
<p className={styles.eyebrow}>контакты</p> <p className={styles.eyebrow}>{dict.footer.eyebrow}</p>
<h2 className={styles.title}> <h2 className={styles.title}>
Есть проект? {dict.footer.titleStart}
<br /> <br />
<span className={styles.titleAccent}>Напиши мне</span> <span className={styles.titleAccent}>{dict.footer.titleAccent}</span>
</h2> </h2>
<p className={styles.text}> <p className={styles.text}>{dict.footer.desc}</p>
Отвечаю обычно в течение дня. Расскажи, что хочешь сделать, — я
помогу с идеей, оценкой и реализацией.
</p>
<div className={styles.actions}> <div className={styles.actions}>
<a <a
@ -40,7 +46,7 @@ const Footer = () => {
rel="noreferrer" rel="noreferrer"
className={styles.button} className={styles.button}
> >
Написать в Telegram {dict.footer.telegramButton}
<span aria-hidden="true">↗</span> <span aria-hidden="true">↗</span>
</a> </a>
@ -50,7 +56,7 @@ const Footer = () => {
rel="noreferrer" rel="noreferrer"
className={styles.buttonGhost} className={styles.buttonGhost}
> >
GitHub {dict.footer.githubButton}
<span aria-hidden="true">↗</span> <span aria-hidden="true">↗</span>
</a> </a>
</div> </div>
@ -58,14 +64,14 @@ const Footer = () => {
<div className={styles.bottom}> <div className={styles.bottom}>
<span className={styles.wordmark}> <span className={styles.wordmark}>
<Link href="/"> <Link href={`/${locale}/`}>
Autli Autli
</Link> </Link>
</span> </span>
<nav className={styles.nav} aria-label="Навигация в подвале"> <nav className={styles.nav} aria-label="Навигация в подвале">
<ul> <ul>
{navigation.map((item) => ( {navItems.map((item) => (
<li key={item.href}> <li key={item.href}>
<Link href={item.href}>{item.label}</Link> <Link href={item.href}>{item.label}</Link>
</li> </li>

View file

@ -1,14 +1,40 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation";
import { navigation } from "@/shared/config/navigation/navigation"; import { useI18n } from "@/shared/config/i18n";
import styles from "@/static/header/header.module.css"; import styles from "@/static/header/header.module.css";
const Header = () => { export const Header = () => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { locale, dict } = useI18n();
const pathname = usePathname() || `/${locale}/`;
// Compute opposite target language and switch link
const targetLocale = locale === "ru" ? "en" : "ru";
const targetLabel = targetLocale.toUpperCase();
// Replace current locale prefix in pathname
let switchHref = pathname;
if (pathname.startsWith("/ru")) {
switchHref = pathname.replace(/^\/ru(\/|$)/, `/${targetLocale}$1`);
} else if (pathname.startsWith("/en")) {
switchHref = pathname.replace(/^\/en(\/|$)/, `/${targetLocale}$1`);
} else {
switchHref = `/${targetLocale}/`;
}
// Ensure trailing slash if needed
if (!switchHref.endsWith("/") && !switchHref.includes("#")) {
switchHref = `${switchHref}/`;
}
const navItems = [
{ label: dict.nav.about, href: `/${locale}/#about` },
{ label: dict.nav.projects, href: `/${locale}/projects/` },
{ label: dict.nav.contact, href: `/${locale}/#contact` },
];
useEffect(() => { useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
@ -37,7 +63,7 @@ const Header = () => {
<header className={styles.header}> <header className={styles.header}>
<div className="cont-header"> <div className="cont-header">
<div className={styles.left}> <div className={styles.left}>
<Link href="/" className={styles.logo} onClick={close}> <Link href={`/${locale}/`} className={styles.logo} onClick={close}>
Autli Autli
</Link> </Link>
</div> </div>
@ -45,7 +71,7 @@ const Header = () => {
<div className={styles.right}> <div className={styles.right}>
<nav className={styles.desktopNav} aria-label="Основная навигация"> <nav className={styles.desktopNav} aria-label="Основная навигация">
<ul> <ul>
{navigation.map((item) => ( {navItems.map((item) => (
<li key={item.href}> <li key={item.href}>
<Link href={item.href}>{item.label}</Link> <Link href={item.href}>{item.label}</Link>
</li> </li>
@ -53,7 +79,18 @@ const Header = () => {
</ul> </ul>
</nav> </nav>
{/* Гамбургер */} {/* Modern Single-Button Language Switcher */}
<Link
href={switchHref}
className={styles.langToggle}
aria-label={`Switch to ${targetLocale === "en" ? "English" : "Russian"}`}
data-cursor-hover
>
<span className={styles.langToggleDot} aria-hidden="true" />
<span>{targetLabel}</span>
</Link>
{/* Hamburger */}
<button <button
className={`${styles.hamburger} ${open ? styles.active : ""}`} className={`${styles.hamburger} ${open ? styles.active : ""}`}
type="button" type="button"
@ -73,7 +110,7 @@ const Header = () => {
aria-label="Мобильная навигация" aria-label="Мобильная навигация"
> >
<ul> <ul>
{navigation.map((item) => ( {navItems.map((item) => (
<li key={item.href}> <li key={item.href}>
<Link href={item.href} onClick={close}> <Link href={item.href} onClick={close}>
{item.label} {item.label}
@ -81,6 +118,18 @@ const Header = () => {
</li> </li>
))} ))}
</ul> </ul>
<div className={styles.mobileLangRow}>
<Link
href={switchHref}
className={styles.langToggle}
onClick={close}
aria-label={`Switch to ${targetLocale === "en" ? "English" : "Russian"}`}
>
<span className={styles.langToggleDot} aria-hidden="true" />
<span>{targetLabel}</span>
</Link>
</div>
</nav> </nav>
</header> </header>
); );

View file

@ -1,13 +1,14 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useI18n } from "@/shared/config/i18n";
import styles from "@/static/preloader/preloader.module.css"; import styles from "@/static/preloader/preloader.module.css";
const Preloader = () => { export const Preloader = () => {
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [done, setDone] = useState(false); const [done, setDone] = useState(false);
const [hidden, setHidden] = useState(false); const [hidden, setHidden] = useState(false);
const { dict } = useI18n();
useEffect(() => { useEffect(() => {
document.documentElement.classList.add("no-scroll"); document.documentElement.classList.add("no-scroll");
@ -76,7 +77,7 @@ const Preloader = () => {
</div> </div>
<div className={styles.meta}> <div className={styles.meta}>
<span className={styles.status}>загрузка</span> <span className={styles.status}>{dict.preloader.loadingText}</span>
<span className={styles.percent}>{progress}%</span> <span className={styles.percent}>{progress}%</span>
</div> </div>
</div> </div>

View file

@ -1,8 +1,8 @@
"use client"; "use client";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import type { Project } from "@/shared/config/projects/types"; import type { Project } from "@/shared/config/projects/types";
import { useI18n } from "@/shared/config/i18n";
import styles from "@/static/project-modal/project-modal.module.css"; import styles from "@/static/project-modal/project-modal.module.css";
type ProjectModalProps = { type ProjectModalProps = {
@ -10,8 +10,9 @@ type ProjectModalProps = {
onClose: () => void; onClose: () => void;
}; };
const ProjectModal = ({ project, onClose }: ProjectModalProps) => { export const ProjectModal = ({ project, onClose }: ProjectModalProps) => {
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
const { dict, locale } = useI18n();
useEffect(() => { useEffect(() => {
if (!project) return; if (!project) return;
@ -38,7 +39,7 @@ const ProjectModal = ({ project, onClose }: ProjectModalProps) => {
className={styles.overlay} className={styles.overlay}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={`Проект ${project.title}`} aria-label={`${dict.projectModal.aboutTitle}: ${project.title}`}
onClick={(e) => { onClick={(e) => {
if (e.target === e.currentTarget) onClose(); if (e.target === e.currentTarget) onClose();
}} }}
@ -46,11 +47,13 @@ const ProjectModal = ({ project, onClose }: ProjectModalProps) => {
<div ref={panelRef} className={styles.panel} tabIndex={-1}> <div ref={panelRef} className={styles.panel} tabIndex={-1}>
<div className={styles.wrap}> <div className={styles.wrap}>
<header className={styles.head}> <header className={styles.head}>
<span className={styles.meta}>Проект {project.id}</span> <span className={styles.meta}>
{locale === "ru" ? `Проект ${project.id}` : `Project ${project.id}`}
</span>
<button <button
type="button" type="button"
className={styles.close} className={styles.close}
aria-label="Закрыть" aria-label={dict.projectModal.closeHint}
onClick={onClose} onClick={onClose}
> >
× ×
@ -63,16 +66,16 @@ const ProjectModal = ({ project, onClose }: ProjectModalProps) => {
<ul className={styles.metaList}> <ul className={styles.metaList}>
<li> <li>
<span>Год</span> <span>{locale === "ru" ? "Год" : "Year"}</span>
<strong>{project.year}</strong> <strong>{project.year}</strong>
</li> </li>
<li> <li>
<span>Стек</span> <span>{dict.projectModal.stackTitle}</span>
<strong>{project.tags.join(" · ")}</strong> <strong>{project.tags.join(" · ")}</strong>
</li> </li>
</ul> </ul>
<h4 className={styles.subheading}>Что внутри</h4> <h4 className={styles.subheading}>{dict.projectModal.featuresTitle}</h4>
<ul className={styles.features}> <ul className={styles.features}>
{project.features.map((feature) => ( {project.features.map((feature) => (
@ -81,14 +84,14 @@ const ProjectModal = ({ project, onClose }: ProjectModalProps) => {
</ul> </ul>
<footer className={styles.actions}> <footer className={styles.actions}>
{project.demo ? ( {project.link ? (
<a <a
href={project.demo} href={project.link}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className={styles.actionButton} className={styles.actionButton}
> >
Живая версия {dict.projectModal.viewSite}
<span aria-hidden="true">↗</span> <span aria-hidden="true">↗</span>
</a> </a>
) : null} ) : null}
@ -100,19 +103,7 @@ const ProjectModal = ({ project, onClose }: ProjectModalProps) => {
rel="noreferrer" rel="noreferrer"
className={styles.actionGhost} className={styles.actionGhost}
> >
Код проекта {dict.projectModal.viewCode}
<span aria-hidden="true">↗</span>
</a>
) : null}
{project.link ? (
<a
href={project.link}
target="_blank"
rel="noreferrer"
className={styles.actionGhost}
>
Перейти
<span aria-hidden="true">↗</span> <span aria-hidden="true">↗</span>
</a> </a>
) : null} ) : null}