add translate i18n
This commit is contained in:
parent
f5c8acca6e
commit
08e94d7e91
27 changed files with 1173 additions and 411 deletions
|
|
@ -1,13 +1,33 @@
|
|||
<?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>
|
||||
<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>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</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>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
|
|
|
|||
86
src/app/[lang]/layout.tsx
Normal file
86
src/app/[lang]/layout.tsx
Normal 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
10
src/app/[lang]/page.tsx
Normal 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 />;
|
||||
}
|
||||
39
src/app/[lang]/projects/page.tsx
Normal file
39
src/app/[lang]/projects/page.tsx
Normal 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 />;
|
||||
}
|
||||
|
|
@ -1,12 +1,6 @@
|
|||
import type { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Metadata } from "next";
|
||||
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";
|
||||
|
||||
const geistSans = Geist({
|
||||
|
|
@ -55,7 +49,11 @@ export const metadata: Metadata = {
|
|||
telephone: false,
|
||||
},
|
||||
alternates: {
|
||||
canonical: "/",
|
||||
canonical: "/ru/",
|
||||
languages: {
|
||||
ru: "/ru/",
|
||||
en: "/en/",
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: "Никита Егоров — Frontend & Web Developer | Портфолио",
|
||||
|
|
@ -104,10 +102,7 @@ const jsonLd = {
|
|||
alternateName: "Nikita Egorov",
|
||||
jobTitle: "Frontend & Web Developer",
|
||||
url: siteUrl,
|
||||
sameAs: [
|
||||
"https://t.me/autli",
|
||||
"https://github.com/autli5",
|
||||
],
|
||||
sameAs: ["https://t.me/autli", "https://github.com/autli5"],
|
||||
knowsAbout: [
|
||||
"React",
|
||||
"Next.js",
|
||||
|
|
@ -137,7 +132,7 @@ const jsonLd = {
|
|||
],
|
||||
};
|
||||
|
||||
const RootLayout = ({ children }: { children: ReactNode }) => {
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html
|
||||
lang="ru"
|
||||
|
|
@ -151,16 +146,8 @@ const RootLayout = ({ children }: { children: ReactNode }) => {
|
|||
/>
|
||||
</head>
|
||||
<body className="min-h-full flex flex-col">
|
||||
<Preloader />
|
||||
<Cursor />
|
||||
<ScrollCounter />
|
||||
<ContextMenu />
|
||||
{children}
|
||||
<CookieConsent />
|
||||
<YandexMetrika />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
};
|
||||
|
||||
export default RootLayout;
|
||||
}
|
||||
|
|
@ -1,8 +1,23 @@
|
|||
import { HomePage } from "@/views/home";
|
||||
import "@/static/globals.css";
|
||||
"use client";
|
||||
|
||||
const Page = () => {
|
||||
return <HomePage />;
|
||||
};
|
||||
import { useEffect } from "react";
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,23 +1,23 @@
|
|||
import type { Metadata } from "next";
|
||||
import { ProjectsPage } from "@/views/projects";
|
||||
"use client";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Все проекты",
|
||||
description:
|
||||
"Коммерческие и пет-проекты Egorov Nikita: веб-приложения, интернет-магазины, SaaS-дашборды и сайты под ключ на React, Next.js, TypeScript и Django.",
|
||||
alternates: {
|
||||
canonical: "/projects/",
|
||||
},
|
||||
openGraph: {
|
||||
title: "Все проекты | Никита Егоров",
|
||||
description:
|
||||
"Коммерческие и пет-проекты Egorov Nikita: веб-приложения, интернет-магазины, SaaS-дашборды и сайты под ключ.",
|
||||
url: "https://autli.ru/projects/",
|
||||
},
|
||||
};
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const Projects = () => {
|
||||
return <ProjectsPage />;
|
||||
};
|
||||
export default function ProjectsRedirect() {
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useInView } from "@/shared/hooks/useInView";
|
||||
import { useI18n } from "@/shared/config/i18n";
|
||||
import styles from "@/static/about/about.module.css";
|
||||
|
||||
const techStack = [
|
||||
|
|
@ -13,9 +14,10 @@ const techStack = [
|
|||
{ name: "Node.js", level: 33 },
|
||||
];
|
||||
|
||||
const About = () => {
|
||||
export const About = () => {
|
||||
const [introRef, introInView] = useInView<HTMLDivElement>(0.2);
|
||||
const [skillsRef, skillsInView] = useInView<HTMLDivElement>(0.3);
|
||||
const { dict } = useI18n();
|
||||
|
||||
const reveal = (visible: boolean) =>
|
||||
`transition-all duration-700 ease-out will-change-transform ${
|
||||
|
|
@ -33,42 +35,40 @@ const About = () => {
|
|||
<div ref={introRef} className="page-container relative z-10">
|
||||
<p className={`${styles.eyebrow} ${reveal(introInView)}`}>
|
||||
<span className={styles.rule} aria-hidden="true" />
|
||||
обо мне
|
||||
{dict.about.eyebrow}
|
||||
</p>
|
||||
|
||||
<h2
|
||||
className={`${styles.title} ${reveal(introInView)}`}
|
||||
style={{ transitionDelay: "100ms" }}
|
||||
>
|
||||
Разработчик, для которого важны{" "}
|
||||
<span className={styles.titleAccent}>продуманность</span> и детали
|
||||
{dict.about.titleStart}{" "}
|
||||
<span className={styles.titleAccent}>{dict.about.titleAccent}</span>{" "}
|
||||
{dict.about.titleEnd}
|
||||
</h2>
|
||||
|
||||
<p
|
||||
className={`${styles.text} ${reveal(introInView)}`}
|
||||
style={{ transitionDelay: "200ms" }}
|
||||
>
|
||||
Привет! Я Никита — веб-разработчик. Строю современные, быстрые и
|
||||
аккуратные интерфейсы на React и Next.js. Уделяю внимание
|
||||
производительности, семантике и мелочам, из которых складывается
|
||||
хорошее впечатление от сайта.
|
||||
{dict.about.desc}
|
||||
</p>
|
||||
|
||||
<dl className={`${styles.stats} ${reveal(introInView)}`} style={{ transitionDelay: "300ms" }}>
|
||||
<div>
|
||||
<dt className={styles.statLabel}>опыт</dt>
|
||||
<dt className={styles.statLabel}>{dict.about.stats.expLabel}</dt>
|
||||
<dd className={styles.statValue}>
|
||||
3<span className={styles.statValueAccent}>+</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className={styles.statLabel}>проекты</dt>
|
||||
<dt className={styles.statLabel}>{dict.about.stats.projectsLabel}</dt>
|
||||
<dd className={styles.statValue}>
|
||||
10<span className={styles.statValueAccent}>+</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className={styles.statLabel}>стек</dt>
|
||||
<dt className={styles.statLabel}>{dict.about.stats.stackLabel}</dt>
|
||||
<dd className={styles.statValue}>{techStack.length}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
|
@ -78,7 +78,7 @@ const About = () => {
|
|||
className={`${styles.skills} ${reveal(skillsInView)}`}
|
||||
style={{ transitionDelay: "150ms" }}
|
||||
>
|
||||
<h3 className={styles.skillsTitle}>Стек и навыки</h3>
|
||||
<h3 className={styles.skillsTitle}>{dict.about.skillsTitle}</h3>
|
||||
|
||||
<ul className={styles.skillList}>
|
||||
{techStack.map((item, index) => (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { sociallinks } from "@/shared/config/social-links/links";
|
||||
import styles from "@/static/hero/hero.module.css";
|
||||
"use client";
|
||||
|
||||
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";
|
||||
|
||||
const socialIcon = (label: string) => {
|
||||
|
|
@ -34,7 +36,9 @@ const socialIcon = (label: string) => {
|
|||
}
|
||||
};
|
||||
|
||||
const Hero = () => {
|
||||
export const Hero = () => {
|
||||
const { dict, locale } = useI18n();
|
||||
|
||||
return (
|
||||
<section className={styles.hero} data-scroll-section>
|
||||
<div className={`cont-header ${styles.inner}`}>
|
||||
|
|
@ -42,21 +46,18 @@ const Hero = () => {
|
|||
<div className={styles.info}>
|
||||
<span className={styles.role}>
|
||||
<span className={styles.dot} />
|
||||
веб-разработчик
|
||||
{dict.hero.role}
|
||||
</span>
|
||||
|
||||
<h1 className={styles.name} data-cursor-hover>
|
||||
<FlashlightText text="Egorov Nikita" />
|
||||
<FlashlightText text={dict.hero.name} />
|
||||
</h1>
|
||||
|
||||
<p className={styles.desc}>
|
||||
Создаю современные, быстрые и продуманные веб-сайты и
|
||||
веб-приложения под ключ.
|
||||
</p>
|
||||
<p className={styles.desc}>{dict.hero.desc}</p>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<a href="#projects" className={styles.button}>
|
||||
Смотреть проекты
|
||||
<a href={`/${locale}/#projects`} className={styles.button}>
|
||||
{dict.hero.ctaButton}
|
||||
</a>
|
||||
|
||||
<ul className={styles.socials}>
|
||||
|
|
@ -81,14 +82,14 @@ const Hero = () => {
|
|||
<div className={styles.right}>
|
||||
<div className={styles.visual}>
|
||||
<span className={styles.index} aria-hidden="true">
|
||||
<i>портфолио</i>
|
||||
<i>{dict.hero.portfolioWord}</i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.hint} aria-hidden="true">
|
||||
листай
|
||||
{dict.hero.scrollHint}
|
||||
<span className={styles.hintLine} />
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,34 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { useInView } from "@/shared/hooks/useInView";
|
||||
import { useI18n } from "@/shared/config/i18n";
|
||||
import styles from "@/static/process/process.module.css";
|
||||
|
||||
const steps = [
|
||||
{
|
||||
label: "01",
|
||||
title: "Бриф",
|
||||
text: "Разбираю задачу, аудиторию, цели сайта и фиксирую, какой результат должен получиться.",
|
||||
},
|
||||
{
|
||||
label: "02",
|
||||
title: "Структура",
|
||||
text: "Собираю логику страниц, сценарии пользователя и расставляю смысловые акценты.",
|
||||
},
|
||||
{
|
||||
label: "03",
|
||||
title: "Разработка",
|
||||
text: "Верстаю адаптивный интерфейс, добавляю интерактив, формы, аналитику и базовое SEO.",
|
||||
},
|
||||
{
|
||||
label: "04",
|
||||
title: "Запуск",
|
||||
text: "Проверяю скорость, мобильную версию, мелкие состояния и помогаю спокойно выкатить сайт.",
|
||||
},
|
||||
];
|
||||
|
||||
const Process = () => {
|
||||
export const Process = () => {
|
||||
const [headRef, headInView] = useInView<HTMLDivElement>(0.24);
|
||||
const [stepsRef, stepsInView] = useInView<HTMLDivElement>(0.18);
|
||||
const { dict } = useI18n();
|
||||
|
||||
const reveal = (visible: boolean) =>
|
||||
`transition-all duration-700 ease-out will-change-transform ${
|
||||
|
|
@ -47,7 +26,7 @@ const Process = () => {
|
|||
<div ref={headRef} className={styles.head}>
|
||||
<p className={`${styles.eyebrow} ${reveal(headInView)}`}>
|
||||
<span className={styles.rule} aria-hidden="true" />
|
||||
процесс
|
||||
{dict.process.eyebrow}
|
||||
</p>
|
||||
|
||||
<div className={styles.headGrid}>
|
||||
|
|
@ -55,16 +34,14 @@ const Process = () => {
|
|||
className={`${styles.title} ${reveal(headInView)}`}
|
||||
style={{ transitionDelay: "100ms" }}
|
||||
>
|
||||
От идеи до запуска без хаоса и лишней суеты
|
||||
{dict.process.title}
|
||||
</h2>
|
||||
|
||||
<p
|
||||
className={`${styles.text} ${reveal(headInView)}`}
|
||||
style={{ transitionDelay: "200ms" }}
|
||||
>
|
||||
Сайт должен не просто красиво открываться, а вести человека к
|
||||
действию. Поэтому я двигаюсь по понятным этапам: сначала смысл,
|
||||
потом интерфейс, затем аккуратная разработка и проверка деталей.
|
||||
{dict.process.desc}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -73,13 +50,13 @@ const Process = () => {
|
|||
ref={stepsRef}
|
||||
className={`${styles.timeline} ${stepsInView ? styles.active : ""}`}
|
||||
>
|
||||
{steps.map((step, index) => (
|
||||
{dict.process.steps.map((step, index) => (
|
||||
<article
|
||||
key={step.label}
|
||||
key={step.number}
|
||||
className={`${styles.step} ${reveal(stepsInView)}`}
|
||||
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>
|
||||
<p className={styles.stepText}>{step.text}</p>
|
||||
</article>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import type { KeyboardEvent, MouseEvent } from "react";
|
||||
|
||||
import type { Project } from "@/shared/config/projects/types";
|
||||
import { useI18n } from "@/shared/config/i18n";
|
||||
import styles from "@/static/projects/project-card.module.css";
|
||||
|
||||
type ProjectCardProps = {
|
||||
|
|
@ -10,10 +10,11 @@ type ProjectCardProps = {
|
|||
onOpen: (project: Project) => void;
|
||||
};
|
||||
|
||||
const ProjectCard = ({ project, onOpen }: ProjectCardProps) => {
|
||||
export const ProjectCard = ({ project, onOpen }: ProjectCardProps) => {
|
||||
const { dict } = useI18n();
|
||||
|
||||
const handleSpotlight = (e: MouseEvent<HTMLElement>) => {
|
||||
const element = e.currentTarget;
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
|
||||
element.style.setProperty(
|
||||
|
|
@ -40,7 +41,7 @@ const ProjectCard = ({ project, onOpen }: ProjectCardProps) => {
|
|||
tabIndex={0}
|
||||
data-cursor-hover
|
||||
aria-haspopup="dialog"
|
||||
aria-label={`${project.title}: подробнее`}
|
||||
aria-label={`${project.title}: ${dict.projectsSection.cardDetails}`}
|
||||
onClick={() => onOpen(project)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onMouseMove={handleSpotlight}
|
||||
|
|
@ -63,7 +64,7 @@ const ProjectCard = ({ project, onOpen }: ProjectCardProps) => {
|
|||
</ul>
|
||||
|
||||
<span className={styles.cardLink}>
|
||||
подробнее
|
||||
{dict.projectsSection.cardDetails}
|
||||
<span className={styles.cardLinkArrow} aria-hidden="true">
|
||||
↗
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -1,22 +1,21 @@
|
|||
"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 { getProjects } from "@/shared/config/projects/projects";
|
||||
import type { Project } from "@/shared/config/projects/types";
|
||||
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 ProjectCard from "./ProjectCard";
|
||||
|
||||
const preview = projects.slice(0, 6);
|
||||
|
||||
const ProjectsSection = () => {
|
||||
export const ProjectsSection = () => {
|
||||
const [gridRef, gridInView] = useInView<HTMLDivElement>(0.12);
|
||||
const [selected, setSelected] = useState<Project | null>(null);
|
||||
const { locale, dict } = useI18n();
|
||||
|
||||
const preview = getProjects(locale).slice(0, 6);
|
||||
|
||||
const reveal = (visible: boolean) =>
|
||||
`transition-all duration-700 ease-out will-change-transform ${
|
||||
|
|
@ -36,16 +35,19 @@ const ProjectsSection = () => {
|
|||
<div>
|
||||
<p className={styles.eyebrow}>
|
||||
<span className={styles.rule} aria-hidden="true" />
|
||||
работы
|
||||
{dict.projectsSection.eyebrow}
|
||||
</p>
|
||||
|
||||
<h2 className={styles.title}>
|
||||
Избранные <span className={styles.titleAccent}>проекты</span>
|
||||
{dict.projectsSection.titleStart}{" "}
|
||||
<span className={styles.titleAccent}>
|
||||
{dict.projectsSection.titleAccent}
|
||||
</span>
|
||||
</h2>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useI18n } from "@/shared/config/i18n";
|
||||
import styles from "@/static/sponsors/sponsors.module.css";
|
||||
|
||||
const sponsors = [
|
||||
|
|
@ -40,11 +43,13 @@ const List = ({ hidden = false }: { hidden?: boolean }) => (
|
|||
</ul>
|
||||
);
|
||||
|
||||
const Sponsors = () => {
|
||||
export const Sponsors = () => {
|
||||
const { dict } = useI18n();
|
||||
|
||||
return (
|
||||
<section className={styles.section} aria-label="Партнёры и бренды">
|
||||
<section className={styles.section} aria-label={dict.sponsors.label}>
|
||||
<div className="page-container">
|
||||
<p className={styles.label}>С ними я работал</p>
|
||||
<p className={styles.label}>{dict.sponsors.label}</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.ticker}>
|
||||
|
|
|
|||
|
|
@ -1,37 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { useInView } from "@/shared/hooks/useInView";
|
||||
import { useI18n } from "@/shared/config/i18n";
|
||||
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 = () => {
|
||||
export const Testimonials = () => {
|
||||
const [gridRef, gridInView] = useInView<HTMLDivElement>(0.15);
|
||||
const { dict } = useI18n();
|
||||
|
||||
const reveal = (visible: boolean) =>
|
||||
`transition-all duration-700 ease-out will-change-transform ${
|
||||
|
|
@ -49,17 +24,18 @@ const Testimonials = () => {
|
|||
<div className="page-container relative z-10">
|
||||
<p className={styles.eyebrow}>
|
||||
<span className={styles.rule} aria-hidden="true" />
|
||||
отзывы
|
||||
{dict.testimonials.eyebrow}
|
||||
</p>
|
||||
|
||||
<h2 className={styles.title}>
|
||||
Что обо мне <span className={styles.titleAccent}>говорят</span>
|
||||
{dict.testimonials.titleStart}{" "}
|
||||
<span className={styles.titleAccent}>{dict.testimonials.titleAccent}</span>
|
||||
</h2>
|
||||
|
||||
<div ref={gridRef} className={styles.grid}>
|
||||
{testimonials.map((item, index) => (
|
||||
{dict.testimonials.items.map((item, index) => (
|
||||
<article
|
||||
key={item.name}
|
||||
key={item.author}
|
||||
className={`${styles.card} ${reveal(gridInView)}`}
|
||||
style={{ transitionDelay: `${index * 90}ms` }}
|
||||
>
|
||||
|
|
@ -75,10 +51,10 @@ const Testimonials = () => {
|
|||
|
||||
<footer className={styles.author}>
|
||||
<span className={styles.avatar} aria-hidden="true">
|
||||
{initials(item.name)}
|
||||
{item.avatar}
|
||||
</span>
|
||||
<span>
|
||||
<span className={styles.authorName}>{item.name}</span>
|
||||
<span className={styles.authorName}>{item.author}</span>
|
||||
<span className={styles.authorRole}>{item.role}</span>
|
||||
</span>
|
||||
</footer>
|
||||
|
|
|
|||
33
src/shared/config/i18n/context.tsx
Normal file
33
src/shared/config/i18n/context.tsx
Normal 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);
|
||||
519
src/shared/config/i18n/dictionaries.ts
Normal file
519
src/shared/config/i18n/dictionaries.ts
Normal 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",
|
||||
},
|
||||
],
|
||||
};
|
||||
3
src/shared/config/i18n/index.ts
Normal file
3
src/shared/config/i18n/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from "./types";
|
||||
export * from "./dictionaries";
|
||||
export * from "./context";
|
||||
149
src/shared/config/i18n/types.ts
Normal file
149
src/shared/config/i18n/types.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
|
|
@ -1,92 +1,9 @@
|
|||
import type { Project } from "./types";
|
||||
import { localizedProjects } from "@/shared/config/i18n/dictionaries";
|
||||
import type { Locale } from "@/shared/config/i18n/types";
|
||||
|
||||
export const projects: Project[] = [
|
||||
{
|
||||
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"], // <-- Добавил 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",
|
||||
},
|
||||
];
|
||||
export const getProjects = (locale: Locale = "ru"): Project[] => {
|
||||
return localizedProjects[locale] || localizedProjects.ru;
|
||||
};
|
||||
|
||||
export const projects: Project[] = localizedProjects.ru;
|
||||
|
|
@ -60,6 +60,52 @@
|
|||
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
|
||||
========================= */
|
||||
|
|
|
|||
|
|
@ -1,20 +1,23 @@
|
|||
"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 { getProjects } from "@/shared/config/projects/projects";
|
||||
import type { Project } from "@/shared/config/projects/types";
|
||||
import { ProjectModal } from "@/widgets/project-modal";
|
||||
import { useI18n } from "@/shared/config/i18n";
|
||||
import ProjectCard from "@/components/projects/ProjectCard";
|
||||
import styles from "@/static/projects/projects-page.module.css";
|
||||
|
||||
const ProjectsPage = () => {
|
||||
export const ProjectsPage = () => {
|
||||
const [selected, setSelected] = useState<Project | null>(null);
|
||||
const [headRef, headInView] = useInView<HTMLDivElement>(0.4);
|
||||
const [gridRef, gridInView] = useInView<HTMLDivElement>(0.08);
|
||||
const { locale, dict } = useI18n();
|
||||
|
||||
const currentProjects = getProjects(locale);
|
||||
|
||||
const reveal = (visible: boolean) =>
|
||||
`transition-all duration-700 ease-out will-change-transform ${
|
||||
|
|
@ -29,27 +32,31 @@ const ProjectsPage = () => {
|
|||
<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")}
|
||||
{String(currentProjects.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" />
|
||||
проекты
|
||||
{dict.projectsPage.eyebrow}
|
||||
</p>
|
||||
|
||||
<h1 className={`${styles.title} ${reveal(headInView)}`}>
|
||||
Все <span className={styles.titleAccent}>работы</span>
|
||||
{dict.projectsPage.titleStart}{" "}
|
||||
<span className={styles.titleAccent}>
|
||||
{dict.projectsPage.titleAccent}
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className={`${styles.count} ${reveal(headInView)}`}>
|
||||
{String(projects.length).padStart(2, "0")} проектов под ключ
|
||||
{String(currentProjects.length).padStart(2, "0")}{" "}
|
||||
{dict.projectsPage.countSuffix}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div ref={gridRef} className={styles.grid}>
|
||||
{projects.map((project, index) => (
|
||||
{currentProjects.map((project, index) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className={reveal(gridInView)}
|
||||
|
|
|
|||
|
|
@ -1,72 +1,32 @@
|
|||
"use client";
|
||||
|
||||
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";
|
||||
|
||||
const PADDING = 12;
|
||||
const ESTIMATED_WIDTH = 275;
|
||||
const ESTIMATED_HEIGHT = 345;
|
||||
|
||||
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",
|
||||
},
|
||||
};
|
||||
const ESTIMATED_HEIGHT = 320;
|
||||
|
||||
export const ContextMenu = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [lang, setLang] = useState<Lang>("ru");
|
||||
const [coords, setCoords] = useState({ x: 0, y: 0 });
|
||||
const [renderCoords, setRenderCoords] = useState({ x: 0, y: 0 });
|
||||
const [copied, setCopied] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { locale: contextLocale } = useI18n();
|
||||
const pathname = usePathname() || "";
|
||||
|
||||
const handleSetLang = (newLang: Lang) => {
|
||||
setLang(newLang);
|
||||
try {
|
||||
localStorage.setItem("portfolio_lang", newLang);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
// Auto-detect current active language from URL pathname or i18n context
|
||||
const currentLocale: Locale = pathname.startsWith("/en")
|
||||
? "en"
|
||||
: pathname.startsWith("/ru")
|
||||
? "ru"
|
||||
: contextLocale || "ru";
|
||||
|
||||
const current = dictionaries[currentLocale].contextMenu;
|
||||
|
||||
// Synchronous layout adjustment with actual DOM dimensions
|
||||
useLayoutEffect(() => {
|
||||
|
|
@ -87,7 +47,7 @@ export const ContextMenu = () => {
|
|||
}
|
||||
|
||||
setRenderCoords({ x: Math.round(nextX), y: Math.round(nextY) });
|
||||
}, [isOpen, coords, lang]);
|
||||
}, [isOpen, coords, currentLocale]);
|
||||
|
||||
// Contextmenu listener
|
||||
useEffect(() => {
|
||||
|
|
@ -139,15 +99,6 @@ export const ContextMenu = () => {
|
|||
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 });
|
||||
setRenderCoords({ x: Math.round(targetX), y: Math.round(targetY) });
|
||||
setCopied(false);
|
||||
|
|
@ -227,8 +178,6 @@ export const ContextMenu = () => {
|
|||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const current = content[lang];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
|
|
@ -242,7 +191,7 @@ export const ContextMenu = () => {
|
|||
onClick={(e) => e.stopPropagation()}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{/* Header with name and language switcher */}
|
||||
{/* Header with profile info */}
|
||||
<div className={styles.header}>
|
||||
<div className={styles.headerInfo}>
|
||||
<div className={styles.headerTitleRow}>
|
||||
|
|
@ -255,33 +204,6 @@ export const ContextMenu = () => {
|
|||
</div>
|
||||
<span className={styles.headerSubtitle}>{current.role}</span>
|
||||
</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 className={styles.divider} />
|
||||
|
|
@ -479,3 +401,5 @@ export const ContextMenu = () => {
|
|||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContextMenu;
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useI18n } from "@/shared/config/i18n";
|
||||
import styles from "@/static/cookie-consent/cookie-consent.module.css";
|
||||
|
||||
export const COOKIE_CONSENT_KEY = "autli-cookie-consent";
|
||||
export const COOKIE_CONSENT_EVENT = "autli-cookie-consent-accepted";
|
||||
|
||||
const CookieConsent = () => {
|
||||
export const CookieConsent = () => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const { dict } = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
|
|
@ -27,18 +28,15 @@ const CookieConsent = () => {
|
|||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<section className={styles.banner} aria-label="Согласие на использование cookie">
|
||||
<section className={styles.banner} aria-label={dict.cookieConsent.title}>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.text}>
|
||||
<p className={styles.title}>Cookie-файлы</p>
|
||||
<p className={styles.description}>
|
||||
Я использую cookie и Яндекс Метрику, чтобы понимать, как работает
|
||||
сайт, и улучшать его для посетителей.
|
||||
</p>
|
||||
<p className={styles.title}>{dict.cookieConsent.title}</p>
|
||||
<p className={styles.description}>{dict.cookieConsent.text}</p>
|
||||
</div>
|
||||
|
||||
<button type="button" className={styles.button} onClick={acceptCookies}>
|
||||
Принять
|
||||
{dict.cookieConsent.acceptButton}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -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 { useI18n } from "@/shared/config/i18n";
|
||||
import styles from "@/static/footer/footer.module.css";
|
||||
|
||||
const Footer = () => {
|
||||
export const Footer = () => {
|
||||
const { locale, dict } = useI18n();
|
||||
|
||||
const telegram = sociallinks.find(
|
||||
(link) => link.label.toLowerCase() === "telegram"
|
||||
);
|
||||
|
|
@ -12,6 +15,12 @@ const Footer = () => {
|
|||
(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 (
|
||||
<footer id="contact" className={styles.footer} data-scroll-section>
|
||||
<span className={styles.watermark} aria-hidden="true">
|
||||
|
|
@ -20,18 +29,15 @@ const Footer = () => {
|
|||
|
||||
<div className="page-container">
|
||||
<div className={styles.cta}>
|
||||
<p className={styles.eyebrow}>контакты</p>
|
||||
<p className={styles.eyebrow}>{dict.footer.eyebrow}</p>
|
||||
|
||||
<h2 className={styles.title}>
|
||||
Есть проект?
|
||||
{dict.footer.titleStart}
|
||||
<br />
|
||||
<span className={styles.titleAccent}>Напиши мне</span>
|
||||
<span className={styles.titleAccent}>{dict.footer.titleAccent}</span>
|
||||
</h2>
|
||||
|
||||
<p className={styles.text}>
|
||||
Отвечаю обычно в течение дня. Расскажи, что хочешь сделать, — я
|
||||
помогу с идеей, оценкой и реализацией.
|
||||
</p>
|
||||
<p className={styles.text}>{dict.footer.desc}</p>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<a
|
||||
|
|
@ -40,7 +46,7 @@ const Footer = () => {
|
|||
rel="noreferrer"
|
||||
className={styles.button}
|
||||
>
|
||||
Написать в Telegram
|
||||
{dict.footer.telegramButton}
|
||||
<span aria-hidden="true">↗</span>
|
||||
</a>
|
||||
|
||||
|
|
@ -50,7 +56,7 @@ const Footer = () => {
|
|||
rel="noreferrer"
|
||||
className={styles.buttonGhost}
|
||||
>
|
||||
GitHub
|
||||
{dict.footer.githubButton}
|
||||
<span aria-hidden="true">↗</span>
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -58,14 +64,14 @@ const Footer = () => {
|
|||
|
||||
<div className={styles.bottom}>
|
||||
<span className={styles.wordmark}>
|
||||
<Link href="/">
|
||||
<Link href={`/${locale}/`}>
|
||||
Autli
|
||||
</Link>
|
||||
</span>
|
||||
|
||||
<nav className={styles.nav} aria-label="Навигация в подвале">
|
||||
<ul>
|
||||
{navigation.map((item) => (
|
||||
{navItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href}>{item.label}</Link>
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,40 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { navigation } from "@/shared/config/navigation/navigation";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useI18n } from "@/shared/config/i18n";
|
||||
import styles from "@/static/header/header.module.css";
|
||||
|
||||
const Header = () => {
|
||||
export const Header = () => {
|
||||
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(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
|
|
@ -37,7 +63,7 @@ const Header = () => {
|
|||
<header className={styles.header}>
|
||||
<div className="cont-header">
|
||||
<div className={styles.left}>
|
||||
<Link href="/" className={styles.logo} onClick={close}>
|
||||
<Link href={`/${locale}/`} className={styles.logo} onClick={close}>
|
||||
Autli
|
||||
</Link>
|
||||
</div>
|
||||
|
|
@ -45,7 +71,7 @@ const Header = () => {
|
|||
<div className={styles.right}>
|
||||
<nav className={styles.desktopNav} aria-label="Основная навигация">
|
||||
<ul>
|
||||
{navigation.map((item) => (
|
||||
{navItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href}>{item.label}</Link>
|
||||
</li>
|
||||
|
|
@ -53,7 +79,18 @@ const Header = () => {
|
|||
</ul>
|
||||
</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
|
||||
className={`${styles.hamburger} ${open ? styles.active : ""}`}
|
||||
type="button"
|
||||
|
|
@ -73,7 +110,7 @@ const Header = () => {
|
|||
aria-label="Мобильная навигация"
|
||||
>
|
||||
<ul>
|
||||
{navigation.map((item) => (
|
||||
{navItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} onClick={close}>
|
||||
{item.label}
|
||||
|
|
@ -81,6 +118,18 @@ const Header = () => {
|
|||
</li>
|
||||
))}
|
||||
</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>
|
||||
</header>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useI18n } from "@/shared/config/i18n";
|
||||
import styles from "@/static/preloader/preloader.module.css";
|
||||
|
||||
const Preloader = () => {
|
||||
export const Preloader = () => {
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [done, setDone] = useState(false);
|
||||
const [hidden, setHidden] = useState(false);
|
||||
const { dict } = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.add("no-scroll");
|
||||
|
|
@ -76,7 +77,7 @@ const Preloader = () => {
|
|||
</div>
|
||||
|
||||
<div className={styles.meta}>
|
||||
<span className={styles.status}>загрузка</span>
|
||||
<span className={styles.status}>{dict.preloader.loadingText}</span>
|
||||
<span className={styles.percent}>{progress}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import type { Project } from "@/shared/config/projects/types";
|
||||
import { useI18n } from "@/shared/config/i18n";
|
||||
import styles from "@/static/project-modal/project-modal.module.css";
|
||||
|
||||
type ProjectModalProps = {
|
||||
|
|
@ -10,8 +10,9 @@ type ProjectModalProps = {
|
|||
onClose: () => void;
|
||||
};
|
||||
|
||||
const ProjectModal = ({ project, onClose }: ProjectModalProps) => {
|
||||
export const ProjectModal = ({ project, onClose }: ProjectModalProps) => {
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const { dict, locale } = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
if (!project) return;
|
||||
|
|
@ -38,7 +39,7 @@ const ProjectModal = ({ project, onClose }: ProjectModalProps) => {
|
|||
className={styles.overlay}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Проект ${project.title}`}
|
||||
aria-label={`${dict.projectModal.aboutTitle}: ${project.title}`}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
|
|
@ -46,11 +47,13 @@ const ProjectModal = ({ project, onClose }: ProjectModalProps) => {
|
|||
<div ref={panelRef} className={styles.panel} tabIndex={-1}>
|
||||
<div className={styles.wrap}>
|
||||
<header className={styles.head}>
|
||||
<span className={styles.meta}>Проект {project.id}</span>
|
||||
<span className={styles.meta}>
|
||||
{locale === "ru" ? `Проект ${project.id}` : `Project ${project.id}`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.close}
|
||||
aria-label="Закрыть"
|
||||
aria-label={dict.projectModal.closeHint}
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
|
|
@ -63,16 +66,16 @@ const ProjectModal = ({ project, onClose }: ProjectModalProps) => {
|
|||
|
||||
<ul className={styles.metaList}>
|
||||
<li>
|
||||
<span>Год</span>
|
||||
<span>{locale === "ru" ? "Год" : "Year"}</span>
|
||||
<strong>{project.year}</strong>
|
||||
</li>
|
||||
<li>
|
||||
<span>Стек</span>
|
||||
<span>{dict.projectModal.stackTitle}</span>
|
||||
<strong>{project.tags.join(" · ")}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h4 className={styles.subheading}>Что внутри</h4>
|
||||
<h4 className={styles.subheading}>{dict.projectModal.featuresTitle}</h4>
|
||||
|
||||
<ul className={styles.features}>
|
||||
{project.features.map((feature) => (
|
||||
|
|
@ -81,14 +84,14 @@ const ProjectModal = ({ project, onClose }: ProjectModalProps) => {
|
|||
</ul>
|
||||
|
||||
<footer className={styles.actions}>
|
||||
{project.demo ? (
|
||||
{project.link ? (
|
||||
<a
|
||||
href={project.demo}
|
||||
href={project.link}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={styles.actionButton}
|
||||
>
|
||||
Живая версия
|
||||
{dict.projectModal.viewSite}
|
||||
<span aria-hidden="true">↗</span>
|
||||
</a>
|
||||
) : null}
|
||||
|
|
@ -100,19 +103,7 @@ const ProjectModal = ({ project, onClose }: ProjectModalProps) => {
|
|||
rel="noreferrer"
|
||||
className={styles.actionGhost}
|
||||
>
|
||||
Код проекта
|
||||
<span aria-hidden="true">↗</span>
|
||||
</a>
|
||||
) : null}
|
||||
|
||||
{project.link ? (
|
||||
<a
|
||||
href={project.link}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={styles.actionGhost}
|
||||
>
|
||||
Перейти
|
||||
{dict.projectModal.viewCode}
|
||||
<span aria-hidden="true">↗</span>
|
||||
</a>
|
||||
) : null}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue