diff --git a/README.md b/README.md
index 9e645f8..a6cbacc 100644
--- a/README.md
+++ b/README.md
@@ -6,9 +6,9 @@ Live at **https://shanaty.ru/resume/**. A static Next.js site whose content is r
| On the page | Source |
|---|---|
-| Name, role, bio, skills, experience, education, languages, contacts | [`data/resume.json`](data/resume.json) in this repo (`main` branch) |
+| Name, role, bio, skills, experience, education, languages, contacts | [`data/resume.json`](data/resume.json) (English) and [`data/resume.ru.json`](data/resume.ru.json) (Russian) in this repo (`main` branch) |
| Projects | Public repos of `Emil_Shanaty` on Gitea that carry the topic **`resume`** |
-| The "full portfolio" link and its project count | Gitea's public list of repos with the topic `portfolio` (button in the top bar, in the hero and after the project list) |
+| The "full portfolio" link and its project count | The site's `/portfolio/` page, which lists the public repos with the topic `portfolio` (button in the hero and a block after the project list; also in the top bar) |
| Project picture | Optional `demo.png`, `demo.jpg`, `demo.gif` or `demo.webp` in the root of that repo |
| Project title, kind, description, highlights, tech tags, links, order | Optional `resume-card.json` in the root of that repo |
@@ -37,11 +37,15 @@ Every field is optional. Without a card the page uses the Gitea description, the
The layout adapts to any number of projects: rows of two or three plates with a wider feature plate first, no lone project left in the last row, and a single project gets the whole width.
+## Languages
+
+The site is bilingual and **Russian is the default** (the browser language is ignored). The RU/EN switch is in the top bar, the choice is stored in `localStorage` and is shared with the front page and the portfolio. Both languages are in the markup and `html[data-lang]` picks one, so there is no flash on load. `data/resume.ru.json` must keep the same structure as `data/resume.json`: skills, experience, education, languages, interests and contacts are paired **by position**, and anything missing in the Russian file falls back to the English text. Project names, descriptions and highlights come from the repositories (English) and are the same in both languages.
+
## Editing the text
To point the portfolio button somewhere else (or rename it), add `"portfolio": { "href": "https://...", "label": "All work" }` to `data/resume.json`.
-Edit `data/resume.json`, commit and **push to Gitea** (`git push gitea main`). No rebuild or deploy is needed.
+Edit `data/resume.json` (and its Russian twin `data/resume.ru.json`), commit and **push to Gitea** (`git push gitea main`). No rebuild or deploy is needed.
## Development
@@ -51,7 +55,7 @@ npm run dev # http://localhost:3000, data from the public https://shanaty
npm run build # static export in out/
```
-`BASE_PATH` sets the URL prefix (default `/Emil_Shanayev_Resume`, for GitHub Pages) and `NEXT_PUBLIC_DATA_URL` the data URL. Fonts (Bricolage Grotesque, Instrument Sans; SIL Open Font License) are bundled in `app/fonts`, so building needs no network.
+`BASE_PATH` sets the URL prefix (default `/Emil_Shanayev_Resume`, for GitHub Pages), `NEXT_PUBLIC_DATA_URL` the data URL and `NEXT_PUBLIC_SITE_ORIGIN` where the other pages of shanaty.ru live (empty in the production build, so the bar's links are root-relative). Fonts (Unbounded, Onest; SIL Open Font License) are bundled in `app/fonts`, so building needs no network.
## Deploy
@@ -61,6 +65,10 @@ npm run build # static export in out/
The page is **dark by default** (a night version of the viewport: graphite backdrop, brighter axis colours, and a light closing contact band). A button in the top bar switches to the light theme; the choice is remembered in the browser and applied before the first paint, so there is no flash. Colours live in the tokens at the top of `app/globals.css` (`:root` for light, `:root[data-theme='dark']` for dark); components only use those tokens, so a new theme is a new block of values.
+## One site with the front page and the portfolio
+
+`/resume/` shares its top bar (brand, Resume, Portfolio, Gitea, Contact, RU/EN, theme), navigation, contact band, colour tokens and fonts with `https://shanaty.ru/` and `/portfolio/` (static pages kept in the `auto` repo, `home/`). The bar marks the current page, and this page adds a second row of anchors for its own sections. Keep the three bars in step when a link changes.
+
## Design
The page is laid out like a 3D-editor viewport, a nod to the game-engine work it presents: a studio backdrop with a perspective floor whose red X and blue Z axes match the axis colours of Blender and Unity, and an orientation gizmo in the corner that orbits as the page scrolls (press it to go back to the top). Projects without a demo image get a deterministic isometric voxel sculpture generated from the repo name.
diff --git a/app/fonts/BricolageGrotesque-Variable.woff2 b/app/fonts/BricolageGrotesque-Variable.woff2
deleted file mode 100644
index fcc4eb1..0000000
Binary files a/app/fonts/BricolageGrotesque-Variable.woff2 and /dev/null differ
diff --git a/app/fonts/InstrumentSans-Variable.woff2 b/app/fonts/InstrumentSans-Variable.woff2
deleted file mode 100644
index 8611e41..0000000
Binary files a/app/fonts/InstrumentSans-Variable.woff2 and /dev/null differ
diff --git a/app/fonts/onest-cyrillic.woff2 b/app/fonts/onest-cyrillic.woff2
new file mode 100644
index 0000000..7a139b0
Binary files /dev/null and b/app/fonts/onest-cyrillic.woff2 differ
diff --git a/app/fonts/onest-latin.woff2 b/app/fonts/onest-latin.woff2
new file mode 100644
index 0000000..bd5ac33
Binary files /dev/null and b/app/fonts/onest-latin.woff2 differ
diff --git a/app/fonts/unbounded-cyrillic.woff2 b/app/fonts/unbounded-cyrillic.woff2
new file mode 100644
index 0000000..102b454
Binary files /dev/null and b/app/fonts/unbounded-cyrillic.woff2 differ
diff --git a/app/fonts/unbounded-latin.woff2 b/app/fonts/unbounded-latin.woff2
new file mode 100644
index 0000000..3260a19
Binary files /dev/null and b/app/fonts/unbounded-latin.woff2 differ
diff --git a/app/globals.css b/app/globals.css
index 8165273..4012b92 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -1,3 +1,9 @@
+/* Bundled fonts (SIL Open Font License): Unbounded for headings, Onest for text; Latin and Cyrillic subsets. */
+@font-face { font-family: 'Unbounded'; font-weight: 200 900; font-display: swap; src: url('./fonts/unbounded-latin.woff2') format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; }
+@font-face { font-family: 'Unbounded'; font-weight: 200 900; font-display: swap; src: url('./fonts/unbounded-cyrillic.woff2') format('woff2'); unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; }
+@font-face { font-family: 'Onest'; font-weight: 100 900; font-display: swap; src: url('./fonts/onest-latin.woff2') format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; }
+@font-face { font-family: 'Onest'; font-weight: 100 900; font-display: swap; src: url('./fonts/onest-cyrillic.woff2') format('woff2'); unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; }
+
/* Design tokens: a studio-lit 3D viewport. Axis colours (X red, Y green, Z blue) are marks and lines, not text. */
:root {
color-scheme: light;
@@ -38,9 +44,6 @@
--band-bg: #15181d;
--band-ink: #f4f6f8;
- /* Light type on a dark ground looks heavier, so the display weight is eased off there */
- --name-weight: 800;
- --name-tracking: -0.048em;
/* Derived from the palette above, so they follow the theme */
--line: color-mix(in srgb, var(--ink) 14%, transparent);
@@ -61,8 +64,8 @@
--gutter: clamp(20px, 3vw, 32px);
--section: clamp(84px, 11vw, 148px);
- --font-body: var(--font-text), 'Segoe UI', system-ui, sans-serif;
- --font-head: var(--font-display), 'Segoe UI', system-ui, sans-serif;
+ --font-body: 'Onest', 'Segoe UI', system-ui, sans-serif;
+ --font-head: 'Unbounded', 'Segoe UI', system-ui, sans-serif;
}
:root[data-theme='dark'] {
@@ -98,12 +101,10 @@
--band-bg: #e8ebef;
--band-ink: #15181d;
- --name-weight: 700;
- --name-tracking: -0.036em;
}
* { box-sizing: border-box; }
-html { background: var(--paper); scroll-behavior: smooth; scroll-padding-top: 88px; }
+html { background: var(--paper); scroll-behavior: smooth; scroll-padding-top: 124px; }
body {
margin: 0;
color: var(--ink);
@@ -121,3 +122,7 @@ ul { padding: 0; list-style: none; }
html { scroll-behavior: auto; }
*, *::before, *::after { animation-duration: 0.01ms !important; animation-delay: 0ms !important; transition-duration: 0.01ms !important; }
}
+
+/* Bilingual text: both languages are in the markup, the html[data-lang] attribute picks one. */
+html[data-lang='ru'] [data-en],
+html[data-lang='en'] [data-ru] { display: none !important; }
diff --git a/app/layout.tsx b/app/layout.tsx
index 534ad2a..4638ee5 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,25 +1,28 @@
import type { Metadata } from 'next'
-import localFont from 'next/font/local'
import './globals.css'
-// Both fonts are bundled (SIL Open Font License), so building never depends on the network.
-const display = localFont({ src: './fonts/BricolageGrotesque-Variable.woff2', variable: '--font-display', weight: '200 800', display: 'swap' })
-const text = localFont({ src: './fonts/InstrumentSans-Variable.woff2', variable: '--font-text', weight: '400 700', display: 'swap' })
-
export const metadata: Metadata = {
- title: 'Emil Shanayev — Resume',
- description: 'Resume of Emil Shanayev: backend, DevOps, AI systems, scientific software and game technology.',
- authors: [{ name: 'Emil Shanayev' }],
+ title: 'Эмиль Шанаев — Резюме',
+ description: 'Резюме Эмиля Шанаева: бэкенд, DevOps, системы на базе ИИ, научное ПО и игровые технологии.',
+ authors: [{ name: 'Эмиль Шанаев' }],
}
-// Dark is the default theme; a stored choice of "light" is applied before the first paint so the page never flashes.
-const THEME_SCRIPT = "try{if(localStorage.getItem('theme')==='light')document.documentElement.dataset.theme='light'}catch(e){}"
+// Before the first paint: the language (the stored choice, otherwise Russian) and the theme (dark unless "light" was
+// chosen). Both keys are shared with the rest of shanaty.ru, so a choice carries over between the pages.
+const BOOT_SCRIPT = `try{var d=document.documentElement,l=localStorage.getItem('lang');if(l!=='ru'&&l!=='en')l='ru';d.dataset.lang=l;d.lang=l;if(localStorage.getItem('theme')==='light')d.dataset.theme='light';document.title=d.getAttribute('data-title-'+l)||document.title}catch(e){}`
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
-
+
-
+
{children}
diff --git a/app/page.tsx b/app/page.tsx
index 4a842f5..99bc1e5 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,7 +1,8 @@
import Site from '@/components/Site'
-import resume from '@/data/resume.json'
+import resumeEn from '@/data/resume.json'
+import resumeRu from '@/data/resume.ru.json'
import type { Resume } from '@/lib/types'
export default function Home() {
- return
+ return
}
diff --git a/components/Hero.tsx b/components/Hero.tsx
index e94c462..6db4cbd 100644
--- a/components/Hero.tsx
+++ b/components/Hero.tsx
@@ -1,13 +1,16 @@
'use client'
import { useRef } from 'react'
+import { Bi } from '@/lib/i18n'
import type { Portfolio, Resume } from '@/lib/types'
import styles from './hero.module.css'
-type Props = { resume: Resume; portfolio: Portfolio; mailHref: string; showProjects: boolean }
+type TextKey = 'name' | 'role' | 'bio' | 'availability'
+type Props = { en: Resume; ru: Resume; portfolio: Portfolio; mailHref: string; showProjects: boolean }
-export default function Hero({ resume, portfolio, mailHref, showProjects }: Readonly) {
+export default function Hero({ en, ru, portfolio, mailHref, showProjects }: Readonly) {
const hero = useRef(null)
+ const text = (key: TextKey) =>
// The floor sways a couple of degrees with the pointer, like nudging the viewport.
const onPointerMove = (event: React.PointerEvent) => {
@@ -21,17 +24,19 @@ export default function Hero({ resume, portfolio, mailHref, showProjects }: Read
diff --git a/components/LangSwitch.tsx b/components/LangSwitch.tsx
new file mode 100644
index 0000000..6fabe49
--- /dev/null
+++ b/components/LangSwitch.tsx
@@ -0,0 +1,31 @@
+'use client'
+
+import { useLang } from '@/lib/useLang'
+import type { Lang } from '@/lib/i18n'
+import styles from './topbar.module.css'
+
+/** RU / EN switch. The choice is stored under the key shared with the rest of shanaty.ru. */
+export default function LangSwitch() {
+ const lang = useLang()
+ const choose = (next: Lang) => {
+ const html = document.documentElement
+ html.dataset.lang = next
+ html.lang = next
+ document.title = html.getAttribute(`data-title-${next}`) ?? document.title
+ try {
+ localStorage.setItem('lang', next)
+ } catch {
+ // Storage can be blocked; the language still changes for this visit.
+ }
+ window.dispatchEvent(new Event('langchange'))
+ }
+ return (
+
- The project list could not be loaded right now. All repositories are on {profileUrl.replace('https://', '')}.
+ Список проектов сейчас не загрузился. Все репозитории лежат на {profile}.>}
+ en={<>The project list could not be loaded right now. All repositories are on {profile}.>}
+ />
)}
@@ -105,14 +111,18 @@ export default function Projects({ projects, status, generatedAt, profileUrl, po
diff --git a/components/Sections.tsx b/components/Sections.tsx
index e80fab1..2a90266 100644
--- a/components/Sections.tsx
+++ b/components/Sections.tsx
@@ -1,78 +1,108 @@
+import { Bi } from '@/lib/i18n'
import type { Resume } from '@/lib/types'
import styles from './sections.module.css'
-export function Skills({ resume }: Readonly<{ resume: Resume }>) {
+// The English resume gives the structure; the Russian one is paired with it by position, and anything the
+// Russian file lacks falls back to the English text.
+type Pair = { en: Resume; ru: Resume }
+
+export function Skills({ en, ru }: Readonly) {
return (