Make the resume bilingual and part of the shared site chrome
Adds a Russian version (data/resume.ru.json, default language) with an RU/EN switch stored under the key shared with the front page and the portfolio. The top bar becomes the site-wide one (brand, Resume, Portfolio, Gitea, Contact, language, theme) with a second row of anchors for this page's sections, and the fonts move to Unbounded and Onest so the three pages share one type system. Portfolio links now point at /portfolio/. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XtcZtnG4GQA1d6mW5PNrS9
This commit is contained in:
@@ -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.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+13
-8
@@ -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; }
|
||||
|
||||
+15
-12
@@ -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 (
|
||||
<html lang="en" data-theme="dark" className={`${display.variable} ${text.variable}`} suppressHydrationWarning>
|
||||
<html
|
||||
lang="ru"
|
||||
data-lang="ru"
|
||||
data-theme="dark"
|
||||
data-title-ru="Эмиль Шанаев — Резюме"
|
||||
data-title-en="Emil Shanayev — Resume"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: THEME_SCRIPT }} />
|
||||
<script dangerouslySetInnerHTML={{ __html: BOOT_SCRIPT }} />
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
|
||||
+3
-2
@@ -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 <Site initial={resume as unknown as Resume} />
|
||||
return <Site initialEn={resumeEn as unknown as Resume} initialRu={resumeRu as unknown as Resume} />
|
||||
}
|
||||
|
||||
+15
-10
@@ -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<Props>) {
|
||||
export default function Hero({ en, ru, portfolio, mailHref, showProjects }: Readonly<Props>) {
|
||||
const hero = useRef<HTMLElement>(null)
|
||||
const text = (key: TextKey) => <Bi ru={ru[key] || en[key]} en={en[key]} />
|
||||
|
||||
// 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
|
||||
<div className={styles.content}>
|
||||
<p className={styles.status}>
|
||||
<span className={styles.dot} />
|
||||
{resume.availability}
|
||||
<span>{text('availability')}</span>
|
||||
</p>
|
||||
<h1 className={styles.name}>{resume.name}</h1>
|
||||
<p className={styles.role}>{resume.role}</p>
|
||||
<p className={styles.bio}>{resume.bio}</p>
|
||||
<h1 className={styles.name}>{text('name')}</h1>
|
||||
<p className={styles.role}>{text('role')}</p>
|
||||
<p className={styles.bio}>{text('bio')}</p>
|
||||
<div className={styles.actions}>
|
||||
{showProjects && <a className={`${styles.button} ${styles.primary}`} href="#projects">See projects</a>}
|
||||
<a className={`${styles.button} ${showProjects ? styles.secondary : styles.primary}`} href={portfolio.href} target="_blank" rel="noreferrer">
|
||||
Full portfolio
|
||||
{showProjects && (
|
||||
<a className={`${styles.button} ${styles.primary}`} href="#projects"><Bi ru="Проекты" en="See projects" /></a>
|
||||
)}
|
||||
<a className={`${styles.button} ${showProjects ? styles.secondary : styles.primary}`} href={portfolio.href}>
|
||||
<Bi ru={portfolio.label ?? 'Всё портфолио'} en={portfolio.label ?? 'Full portfolio'} />
|
||||
</a>
|
||||
<a className={styles.link} href={mailHref}>Email me</a>
|
||||
<a className={styles.link} href={mailHref}><Bi ru="Написать" en="Email me" /></a>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.stage} aria-hidden="true">
|
||||
|
||||
@@ -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 (
|
||||
<div className={styles.seg} role="group" aria-label={lang === 'ru' ? 'Язык' : 'Language'}>
|
||||
{(['ru', 'en'] as const).map((code) => (
|
||||
<button key={code} type="button" aria-pressed={lang === code} onClick={() => choose(code)}>
|
||||
{code.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+30
-20
@@ -1,13 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Bi, count, monthYear } from '@/lib/i18n'
|
||||
import { planSpans, type Span } from '@/lib/plan'
|
||||
import type { Portfolio, Project } from '@/lib/types'
|
||||
import Cover from './Cover'
|
||||
import styles from './projects.module.css'
|
||||
|
||||
const monthYear = (iso?: string) => (iso ? new Date(iso).toLocaleDateString('en', { month: 'short', year: 'numeric' }) : '')
|
||||
|
||||
// Aspect ratio of a plate for each span on desktop, used to decide whether a demo image can be cropped to fill it.
|
||||
const PLATE_RATIO: Record<Span, number> = { 2: 4 / 3, 3: 3 / 2, 4: 16 / 9, 6: 16 / 10 }
|
||||
|
||||
@@ -27,21 +26,21 @@ function Plate({ project, span }: Readonly<{ project: Project; span: Span }>) {
|
||||
<>
|
||||
{/* An image that does not match the plate is shown whole, over a blurred copy of itself. */}
|
||||
{!fits && image(styles.backdrop, { alt: '', 'aria-hidden': true, loading: 'lazy' })}
|
||||
{image(fits ? styles.cover : styles.contain, { alt: `${title}, demo`, loading: 'lazy', onError: () => setBroken(true) })}
|
||||
{image(fits ? styles.cover : styles.contain, { alt: title, loading: 'lazy', onError: () => setBroken(true) })}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Item({ project, span }: Readonly<{ project: Project; span: Span }>) {
|
||||
const title = project.title ?? project.name
|
||||
const links = [
|
||||
{ label: 'Repository', href: project.url },
|
||||
...(project.homepage ? [{ label: 'Live site', href: project.homepage }] : []),
|
||||
...(project.links ?? []),
|
||||
const links: { label: React.ReactNode; href: string }[] = [
|
||||
{ label: <Bi ru="Репозиторий" en="Repository" />, href: project.url },
|
||||
...(project.homepage ? [{ label: <Bi ru="Сайт" en="Live site" />, href: project.homepage }] : []),
|
||||
...(project.links ?? []).map((link) => ({ label: link.label, href: link.href })),
|
||||
]
|
||||
return (
|
||||
<article className={styles.item} data-span={span}>
|
||||
<a className={styles.plate} href={project.url} target="_blank" rel="noreferrer" aria-label={`${title}: open the repository`}>
|
||||
<a className={styles.plate} href={project.url} target="_blank" rel="noreferrer" aria-label={title}>
|
||||
<Plate project={project} span={span} />
|
||||
</a>
|
||||
<div className={styles.caption}>
|
||||
@@ -74,12 +73,21 @@ type Props = {
|
||||
|
||||
export default function Projects({ projects, status, generatedAt, profileUrl, portfolio }: Readonly<Props>) {
|
||||
const spans = useMemo(() => planSpans(projects.length), [projects.length])
|
||||
const profile = profileUrl.replace('https://', '')
|
||||
const total = portfolio.count ? count(portfolio.count, ['проект', 'проекта', 'проектов'], ['project', 'projects']) : null
|
||||
return (
|
||||
<section className={styles.section} id="projects">
|
||||
<div className={styles.shell}>
|
||||
<div className={styles.head}>
|
||||
<h2>Selected work</h2>
|
||||
{status === 'live' && <p className={styles.note}>Read live from Gitea, updated {monthYear(generatedAt)}</p>}
|
||||
<h2><Bi ru="Избранные проекты" en="Selected work" /></h2>
|
||||
{status === 'live' && (
|
||||
<p className={styles.note}>
|
||||
<Bi
|
||||
ru={`Данные читаются из Gitea, обновлено ${monthYear(generatedAt, 'ru')}`}
|
||||
en={`Read live from Gitea, updated ${monthYear(generatedAt, 'en')}`}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{status === 'loading' && (
|
||||
@@ -87,14 +95,12 @@ export default function Projects({ projects, status, generatedAt, profileUrl, po
|
||||
<span /><span /><span />
|
||||
</div>
|
||||
)}
|
||||
{status === 'loading' && (
|
||||
<noscript>
|
||||
<p className={styles.notice}>The project list is loaded live and needs JavaScript. See <a href={profileUrl}>{profileUrl.replace('https://', '')}</a>.</p>
|
||||
</noscript>
|
||||
)}
|
||||
{status === 'offline' && (
|
||||
<p className={styles.notice}>
|
||||
The project list could not be loaded right now. All repositories are on <a href={profileUrl}>{profileUrl.replace('https://', '')}</a>.
|
||||
<Bi
|
||||
ru={<>Список проектов сейчас не загрузился. Все репозитории лежат на <a href={profileUrl}>{profile}</a>.</>}
|
||||
en={<>The project list could not be loaded right now. All repositories are on <a href={profileUrl}>{profile}</a>.</>}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -105,14 +111,18 @@ export default function Projects({ projects, status, generatedAt, profileUrl, po
|
||||
</div>
|
||||
|
||||
{status === 'live' && projects.length > 0 && (
|
||||
<a className={styles.more} href={portfolio.href} target="_blank" rel="noreferrer">
|
||||
<a className={styles.more} href={portfolio.href}>
|
||||
<span>
|
||||
<span className={styles.moreTitle}>There is more in the portfolio</span>
|
||||
<span className={styles.moreTitle}><Bi ru="Есть ещё в портфолио" en="There is more in the portfolio" /></span>
|
||||
<span className={styles.moreText}>
|
||||
{portfolio.count ? `${portfolio.count} projects in total, including the ones above.` : 'The full list of projects, including the ones above.'}
|
||||
{total ? (
|
||||
<Bi ru={`Всего ${total.ru}, включая эти.`} en={`${total.en} in total, including the ones above.`} />
|
||||
) : (
|
||||
<Bi ru="Полный список проектов, включая эти." en="The full list of projects, including the ones above." />
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<span className={styles.moreButton}>{portfolio.label ?? 'Open the portfolio'}</span>
|
||||
<span className={styles.moreButton}><Bi ru="Открыть портфолио" en="Open the portfolio" /></span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+88
-50
@@ -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<Pair>) {
|
||||
return (
|
||||
<section className={styles.section} id="skills">
|
||||
<div className={styles.shell}>
|
||||
<h2 className={styles.title}>What I work with</h2>
|
||||
<h2 className={styles.title}><Bi ru="Чем я занимаюсь" en="What I work with" /></h2>
|
||||
<div className={styles.skills}>
|
||||
{resume.skills.map((skill) => (
|
||||
<article className={styles.skill} key={skill.title}>
|
||||
<h3>{skill.title}</h3>
|
||||
<p>{skill.text}</p>
|
||||
<ul className={styles.tags}>{skill.stack.map((tag) => <li key={tag}>{tag}</li>)}</ul>
|
||||
</article>
|
||||
))}
|
||||
{en.skills.map((skill, i) => {
|
||||
const r = ru.skills?.[i]
|
||||
return (
|
||||
<article className={styles.skill} key={skill.title}>
|
||||
<h3><Bi ru={r?.title ?? skill.title} en={skill.title} /></h3>
|
||||
<p><Bi ru={r?.text ?? skill.text} en={skill.text} /></p>
|
||||
<ul className={styles.tags}>
|
||||
{skill.stack.map((tag, t) => (
|
||||
<li key={tag}><Bi ru={r?.stack?.[t] ?? tag} en={tag} /></li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function Experience({ resume }: Readonly<{ resume: Resume }>) {
|
||||
export function Experience({ en, ru }: Readonly<Pair>) {
|
||||
return (
|
||||
<section className={styles.section} id="experience">
|
||||
<div className={styles.shell}>
|
||||
<h2 className={styles.title}>Experience</h2>
|
||||
<h2 className={styles.title}><Bi ru="Опыт" en="Experience" /></h2>
|
||||
<div className={styles.entries}>
|
||||
{resume.experience.map((entry) => (
|
||||
<article className={styles.entry} key={entry.title}>
|
||||
<p className={styles.meta}>{entry.meta}</p>
|
||||
<div>
|
||||
<h3>{entry.title}</h3>
|
||||
<ul className={styles.list}>{entry.points.map((point) => <li key={point}>{point}</li>)}</ul>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{en.experience.map((entry, i) => {
|
||||
const r = ru.experience?.[i]
|
||||
return (
|
||||
<article className={styles.entry} key={entry.title}>
|
||||
<p className={styles.meta}><Bi ru={r?.meta ?? entry.meta} en={entry.meta} /></p>
|
||||
<div>
|
||||
<h3><Bi ru={r?.title ?? entry.title} en={entry.title} /></h3>
|
||||
<ul className={styles.list}>
|
||||
{entry.points.map((point, p) => (
|
||||
<li key={point}><Bi ru={r?.points?.[p] ?? point} en={point} /></li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function Education({ resume }: Readonly<{ resume: Resume }>) {
|
||||
export function Education({ en, ru }: Readonly<Pair>) {
|
||||
return (
|
||||
<section className={styles.section} id="education">
|
||||
<div className={styles.shell}>
|
||||
<h2 className={styles.title}>Education and languages</h2>
|
||||
<h2 className={styles.title}><Bi ru="Образование и языки" en="Education and languages" /></h2>
|
||||
<div className={styles.columns}>
|
||||
<div>
|
||||
<h3 className={styles.label}>Education</h3>
|
||||
<h3 className={styles.label}><Bi ru="Образование" en="Education" /></h3>
|
||||
<div className={styles.stack}>
|
||||
{resume.education.map((entry) => (
|
||||
<div key={entry.institution}>
|
||||
<p className={styles.strong}>{entry.institution}</p>
|
||||
<p>{entry.program}</p>
|
||||
<p className={styles.meta}>{entry.period}</p>
|
||||
</div>
|
||||
))}
|
||||
{en.education.map((entry, i) => {
|
||||
const r = ru.education?.[i]
|
||||
return (
|
||||
<div key={entry.institution}>
|
||||
<p className={styles.strong}><Bi ru={r?.institution ?? entry.institution} en={entry.institution} /></p>
|
||||
<p><Bi ru={r?.program ?? entry.program} en={entry.program} /></p>
|
||||
<p className={styles.meta}><Bi ru={r?.period ?? entry.period} en={entry.period} /></p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className={styles.label}>Languages</h3>
|
||||
<h3 className={styles.label}><Bi ru="Языки" en="Languages" /></h3>
|
||||
<dl className={styles.languages}>
|
||||
{resume.languages.map((language) => (
|
||||
<div key={language.name}>
|
||||
<dt>{language.name}</dt>
|
||||
<dd>{language.level}</dd>
|
||||
</div>
|
||||
))}
|
||||
{en.languages.map((language, i) => {
|
||||
const r = ru.languages?.[i]
|
||||
return (
|
||||
<div key={language.name}>
|
||||
<dt><Bi ru={r?.name ?? language.name} en={language.name} /></dt>
|
||||
<dd><Bi ru={r?.level ?? language.level} en={language.level} /></dd>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</dl>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className={styles.label}>Interests</h3>
|
||||
<p>{resume.interests.join(', ')}.</p>
|
||||
<h3 className={styles.label}><Bi ru="Интересы" en="Interests" /></h3>
|
||||
<p>
|
||||
<Bi
|
||||
ru={`${(ru.interests?.length ? ru.interests : en.interests).join(', ')}.`}
|
||||
en={`${en.interests.join(', ')}.`}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,23 +110,31 @@ export function Education({ resume }: Readonly<{ resume: Resume }>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function Contact({ resume }: Readonly<{ resume: Resume }>) {
|
||||
const [primary, ...others] = resume.contacts
|
||||
export function Contact({ en, ru }: Readonly<Pair>) {
|
||||
const [primary, ...others] = en.contacts
|
||||
return (
|
||||
<footer className={styles.contact} id="contact">
|
||||
<div className={styles.shell}>
|
||||
<h2 className={styles.contactTitle}>Get in touch</h2>
|
||||
<p className={styles.contactLead}>Email is the fastest way to reach me.</p>
|
||||
<h2 className={styles.contactTitle}><Bi ru="Связаться" en="Get in touch" /></h2>
|
||||
<p className={styles.contactLead}><Bi ru="Почта — самый быстрый способ." en="Email is the fastest way to reach me." /></p>
|
||||
<a className={styles.mail} href={primary.href}>{primary.value}</a>
|
||||
<ul className={styles.contacts}>
|
||||
{others.map((contact) => (
|
||||
<li key={contact.value}>
|
||||
<span>{contact.label}</span>
|
||||
<a href={contact.href} target={contact.href.startsWith('http') ? '_blank' : undefined} rel="noreferrer">{contact.value}</a>
|
||||
</li>
|
||||
))}
|
||||
{others.map((contact, i) => {
|
||||
const label = ru.contacts?.[i + 1]?.label ?? contact.label
|
||||
return (
|
||||
<li key={contact.value}>
|
||||
<span>{label === contact.label ? contact.label : <Bi ru={label} en={contact.label} />}</span>
|
||||
<a href={contact.href} target={contact.href.startsWith('http') ? '_blank' : undefined} rel="noreferrer">{contact.value}</a>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
<p className={styles.legal}>© 2026 {resume.name}. The text and the projects on this page are read live from Gitea.</p>
|
||||
<p className={styles.legal}>
|
||||
<Bi
|
||||
ru="© 2026 Эмиль Шанаев. Сайт работает на собственном сервере: Gitea, Caddy и Docker. Резюме и проекты подтягиваются из репозиториев."
|
||||
en="© 2026 Emil Shanayev. This site runs on my own server: Gitea, Caddy and Docker. The resume and projects are read from repositories."
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
|
||||
+18
-12
@@ -6,18 +6,20 @@ import Gizmo from './Gizmo'
|
||||
import Hero from './Hero'
|
||||
import Projects from './Projects'
|
||||
import { Contact, Education, Experience, Skills } from './Sections'
|
||||
import TitleSync from './TitleSync'
|
||||
import TopBar from './TopBar'
|
||||
|
||||
// Deployed on shanaty.ru the build points at the same-origin file; elsewhere (dev, GitHub Pages) it uses the public URL.
|
||||
const DATA_URL = process.env.NEXT_PUBLIC_DATA_URL ?? 'https://shanaty.ru/data/resume.json'
|
||||
const GITEA_PROFILE = 'https://git.shanaty.ru/Emil_Shanaty'
|
||||
// Used until (or unless) the live data supplies the link, so the portfolio button is always there.
|
||||
const PORTFOLIO_FALLBACK: Portfolio = { href: 'https://git.shanaty.ru/explore/repos?q=portfolio&topic=1' }
|
||||
const PORTFOLIO_FALLBACK: Portfolio = { href: `${process.env.NEXT_PUBLIC_SITE_ORIGIN ?? 'https://shanaty.ru'}/portfolio/` }
|
||||
|
||||
type Status = 'loading' | 'live' | 'offline'
|
||||
|
||||
export default function Site({ initial }: Readonly<{ initial: Resume }>) {
|
||||
const [resume, setResume] = useState<Resume>(initial)
|
||||
export default function Site({ initialEn, initialRu }: Readonly<{ initialEn: Resume; initialRu: Resume }>) {
|
||||
const [en, setEn] = useState<Resume>(initialEn)
|
||||
const [ru, setRu] = useState<Resume>(initialRu)
|
||||
const [projects, setProjects] = useState<Project[]>([])
|
||||
const [portfolio, setPortfolio] = useState<Portfolio>(PORTFOLIO_FALLBACK)
|
||||
const [generatedAt, setGeneratedAt] = useState<string>()
|
||||
@@ -29,7 +31,8 @@ export default function Site({ initial }: Readonly<{ initial: Resume }>) {
|
||||
.then((response) => (response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`))))
|
||||
.then((data: Live) => {
|
||||
if (!data?.resume || !Array.isArray(data.projects)) throw new Error('unexpected data')
|
||||
setResume(data.resume)
|
||||
setEn(data.resume)
|
||||
if (data.resume_ru) setRu(data.resume_ru)
|
||||
setProjects(data.projects)
|
||||
if (data.portfolio?.href) setPortfolio(data.portfolio)
|
||||
setGeneratedAt(data.generated_at)
|
||||
@@ -43,20 +46,23 @@ export default function Site({ initial }: Readonly<{ initial: Resume }>) {
|
||||
|
||||
// Once the list has loaded and is empty (no repository carries the `resume` topic yet) the section is left out.
|
||||
const showProjects = status !== 'live' || projects.length > 0
|
||||
const mailHref = resume.contacts[0].href
|
||||
const mailHref = en.contacts[0].href
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopBar name={resume.name} portfolio={portfolio} showProjects={showProjects} />
|
||||
<TitleSync />
|
||||
<TopBar showProjects={showProjects} />
|
||||
<Gizmo />
|
||||
<main>
|
||||
<Hero resume={resume} portfolio={portfolio} mailHref={mailHref} showProjects={showProjects} />
|
||||
{showProjects && <Projects projects={projects} status={status} generatedAt={generatedAt} profileUrl={GITEA_PROFILE} portfolio={portfolio} />}
|
||||
<Skills resume={resume} />
|
||||
<Experience resume={resume} />
|
||||
<Education resume={resume} />
|
||||
<Hero en={en} ru={ru} portfolio={portfolio} mailHref={mailHref} showProjects={showProjects} />
|
||||
{showProjects && (
|
||||
<Projects projects={projects} status={status} generatedAt={generatedAt} profileUrl={GITEA_PROFILE} portfolio={portfolio} />
|
||||
)}
|
||||
<Skills en={en} ru={ru} />
|
||||
<Experience en={en} ru={ru} />
|
||||
<Education en={en} ru={ru} />
|
||||
</main>
|
||||
<Contact resume={resume} />
|
||||
<Contact en={en} ru={ru} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useLang } from '@/lib/useLang'
|
||||
import styles from './topbar.module.css'
|
||||
|
||||
type Theme = 'dark' | 'light'
|
||||
@@ -8,6 +9,7 @@ type Theme = 'dark' | 'light'
|
||||
/** Switches between the dark (default) and light theme; the choice is remembered in this browser. */
|
||||
export default function ThemeToggle() {
|
||||
const [theme, setTheme] = useState<Theme>('dark')
|
||||
const lang = useLang()
|
||||
|
||||
// The page starts dark; a small script in <head> applies a stored "light" choice before the first paint.
|
||||
useEffect(() => {
|
||||
@@ -30,7 +32,11 @@ export default function ThemeToggle() {
|
||||
type="button"
|
||||
className={styles.toggle}
|
||||
onClick={toggle}
|
||||
aria-label={theme === 'dark' ? 'Switch to the light theme' : 'Switch to the dark theme'}
|
||||
aria-label={
|
||||
theme === 'dark'
|
||||
? lang === 'ru' ? 'Включить светлую тему' : 'Switch to the light theme'
|
||||
: lang === 'ru' ? 'Включить тёмную тему' : 'Switch to the dark theme'
|
||||
}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
|
||||
/** Keeps the tab title in the visitor's language: Next re-applies its default title while hydrating. */
|
||||
export default function TitleSync() {
|
||||
useEffect(() => {
|
||||
const apply = () => {
|
||||
const html = document.documentElement
|
||||
const wanted = html.getAttribute(`data-title-${html.dataset.lang}`)
|
||||
if (wanted && document.title !== wanted) document.title = wanted
|
||||
}
|
||||
apply()
|
||||
const observer = new MutationObserver(apply)
|
||||
observer.observe(document.head, { childList: true, subtree: true, characterData: true })
|
||||
window.addEventListener('langchange', apply)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
window.removeEventListener('langchange', apply)
|
||||
}
|
||||
}, [])
|
||||
return null
|
||||
}
|
||||
+25
-12
@@ -1,27 +1,40 @@
|
||||
'use client'
|
||||
|
||||
import { Bi, SITE } from '@/lib/i18n'
|
||||
import { useScrolled } from '@/lib/useScroll'
|
||||
import type { Portfolio } from '@/lib/types'
|
||||
import LangSwitch from './LangSwitch'
|
||||
import ThemeToggle from './ThemeToggle'
|
||||
import styles from './topbar.module.css'
|
||||
|
||||
type Props = { name: string; portfolio: Portfolio; showProjects: boolean }
|
||||
|
||||
export default function TopBar({ name, portfolio, showProjects }: Readonly<Props>) {
|
||||
/** The same bar as on the front page and the portfolio, plus a row of anchors for the sections of this page. */
|
||||
export default function TopBar({ showProjects }: Readonly<{ showProjects: boolean }>) {
|
||||
const scrolled = useScrolled()
|
||||
return (
|
||||
<header className={`${styles.bar} ${scrolled ? styles.solid : ''}`}>
|
||||
<div className={styles.inner}>
|
||||
<a className={styles.mark} href="#top">{name}</a>
|
||||
<nav className={styles.nav} aria-label="Sections">
|
||||
{showProjects && <a href="#projects">Projects</a>}
|
||||
<a href="#skills">Skills</a>
|
||||
<a href="#experience">Experience</a>
|
||||
<a href="#education">Education</a>
|
||||
<a href="#contact">Contact</a>
|
||||
<a className={styles.brand} href={`${SITE}/`} aria-label="shanaty.ru">
|
||||
<svg viewBox="0 0 100 100" width="30" height="30" aria-hidden="true">
|
||||
<path d="M70 30A20 20 0 1 0 50 50A20 20 0 1 1 30 70" transform="translate(50 50) scale(.78) translate(-50 -50)" fill="none" stroke="currentColor" strokeWidth="13" strokeLinecap="round" />
|
||||
<circle cx="50" cy="50" r="8" fill="var(--y)" />
|
||||
</svg>
|
||||
<span>shanaty.ru</span>
|
||||
</a>
|
||||
<nav className={styles.nav} aria-label="shanaty.ru">
|
||||
<a href={`${SITE}/resume/`} aria-current="page"><Bi ru="Резюме" en="Resume" /></a>
|
||||
<a href={`${SITE}/portfolio/`}><Bi ru="Портфолио" en="Portfolio" /></a>
|
||||
<a href="https://git.shanaty.ru/Emil_Shanaty">Gitea</a>
|
||||
<a href="#contact"><Bi ru="Контакты" en="Contact" /></a>
|
||||
</nav>
|
||||
<LangSwitch />
|
||||
<ThemeToggle />
|
||||
<a className={styles.cta} href={portfolio.href} target="_blank" rel="noreferrer">{portfolio.label ?? 'Portfolio'}</a>
|
||||
</div>
|
||||
<div className={styles.subwrap}>
|
||||
<nav className={styles.sub} aria-label="Page sections">
|
||||
{showProjects && <a href="#projects"><Bi ru="Проекты" en="Projects" /></a>}
|
||||
<a href="#skills"><Bi ru="Навыки" en="Skills" /></a>
|
||||
<a href="#experience"><Bi ru="Опыт" en="Experience" /></a>
|
||||
<a href="#education"><Bi ru="Образование" en="Education" /></a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.gizmo {
|
||||
position: fixed;
|
||||
z-index: 40;
|
||||
top: 84px;
|
||||
top: 128px;
|
||||
right: max(16px, calc((100vw - 1240px) / 2 - 8px));
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
|
||||
@@ -107,14 +107,15 @@
|
||||
|
||||
.name {
|
||||
margin: 30px 0 0 -0.045em;
|
||||
font: var(--name-weight) clamp(56px, 12.6vw, 204px) / 0.88 var(--font-head);
|
||||
letter-spacing: var(--name-tracking);
|
||||
font: 700 clamp(38px, 7.1vw, 118px) / 1 var(--font-head);
|
||||
letter-spacing: -0.05em;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.role {
|
||||
margin-top: 22px;
|
||||
font: 500 clamp(22px, 2.7vw, 38px) / 1.15 var(--font-head);
|
||||
letter-spacing: -0.022em;
|
||||
font: 500 clamp(18px, 2.1vw, 30px) / 1.2 var(--font-head);
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
|
||||
.bio {
|
||||
@@ -182,6 +183,12 @@
|
||||
to { clip-path: inset(0 0 0 0); }
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.name {
|
||||
font-size: clamp(44px, 12.5vw, 104px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.content { padding: 112px 0 72px; }
|
||||
.bio { margin-top: 32px; font-size: 18px; }
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
}
|
||||
|
||||
.head h2 {
|
||||
font: 700 clamp(40px, 5.6vw, 80px) / 1 var(--font-head);
|
||||
letter-spacing: -0.04em;
|
||||
font: 700 clamp(28px, 4.2vw, 60px) / 1.05 var(--font-head);
|
||||
letter-spacing: -0.045em;
|
||||
}
|
||||
|
||||
.note {
|
||||
@@ -96,8 +96,8 @@
|
||||
}
|
||||
|
||||
.caption h3 {
|
||||
font: 700 clamp(24px, 2.2vw, 30px) / 1.1 var(--font-head);
|
||||
letter-spacing: -0.03em;
|
||||
font: 700 clamp(19px, 1.9vw, 25px) / 1.15 var(--font-head);
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.kind {
|
||||
@@ -257,8 +257,8 @@
|
||||
}
|
||||
|
||||
.moreTitle {
|
||||
font: 700 clamp(28px, 3.8vw, 54px) / 1.05 var(--font-head);
|
||||
letter-spacing: -0.035em;
|
||||
font: 700 clamp(22px, 3vw, 40px) / 1.1 var(--font-head);
|
||||
letter-spacing: -0.045em;
|
||||
}
|
||||
|
||||
.moreText {
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
|
||||
.title {
|
||||
margin-bottom: clamp(32px, 4.5vw, 56px);
|
||||
font: 700 clamp(36px, 5vw, 72px) / 1 var(--font-head);
|
||||
letter-spacing: -0.04em;
|
||||
font: 700 clamp(28px, 4.2vw, 60px) / 1.05 var(--font-head);
|
||||
letter-spacing: -0.045em;
|
||||
}
|
||||
|
||||
/* Skills: two columns of rows divided by a rule, no boxes. */
|
||||
@@ -30,8 +30,8 @@
|
||||
|
||||
.skill h3,
|
||||
.entry h3 {
|
||||
font: 700 clamp(22px, 2.2vw, 28px) / 1.15 var(--font-head);
|
||||
letter-spacing: -0.03em;
|
||||
font: 700 clamp(18px, 1.8vw, 23px) / 1.2 var(--font-head);
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.skill p {
|
||||
@@ -102,8 +102,8 @@
|
||||
|
||||
.label {
|
||||
margin-bottom: 14px;
|
||||
font: 700 20px/1.2 var(--font-head);
|
||||
letter-spacing: -0.02em;
|
||||
font: 700 17px/1.2 var(--font-head);
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.stack {
|
||||
@@ -141,8 +141,8 @@
|
||||
}
|
||||
|
||||
.contactTitle {
|
||||
font: 700 clamp(36px, 5vw, 72px) / 1 var(--font-head);
|
||||
letter-spacing: -0.04em;
|
||||
font: 700 clamp(28px, 4.2vw, 60px) / 1.05 var(--font-head);
|
||||
letter-spacing: -0.045em;
|
||||
}
|
||||
|
||||
.contactLead {
|
||||
@@ -153,8 +153,8 @@
|
||||
.mail {
|
||||
display: inline-block;
|
||||
margin-top: clamp(28px, 4vw, 48px);
|
||||
font: 700 clamp(26px, 6.4vw, 96px) / 1.05 var(--font-head);
|
||||
letter-spacing: -0.045em;
|
||||
font: 700 clamp(17px, 5vw, 76px) / 1.08 var(--font-head);
|
||||
letter-spacing: -0.05em;
|
||||
overflow-wrap: anywhere;
|
||||
text-decoration: none;
|
||||
background: linear-gradient(var(--z), var(--z)) left bottom / 100% 0.08em no-repeat;
|
||||
|
||||
@@ -19,26 +19,30 @@
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.mark {
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-right: auto;
|
||||
font: 700 20px/1 var(--font-head);
|
||||
letter-spacing: -0.03em;
|
||||
text-decoration: none;
|
||||
font: 700 18px/1 var(--font-head);
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 28px;
|
||||
margin-right: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav a {
|
||||
text-decoration: none;
|
||||
padding: 6px 0;
|
||||
text-decoration: none;
|
||||
background: linear-gradient(currentColor, currentColor) left bottom / 0 1.5px no-repeat;
|
||||
transition: background-size 0.25s;
|
||||
}
|
||||
@@ -47,26 +51,32 @@
|
||||
background-size: 100% 1.5px;
|
||||
}
|
||||
|
||||
.cta {
|
||||
margin-left: -12px;
|
||||
padding: 10px 18px;
|
||||
.nav a[aria-current='page'] {
|
||||
font-weight: 700;
|
||||
background-size: 100% 2px;
|
||||
}
|
||||
|
||||
.seg {
|
||||
display: flex;
|
||||
padding: 3px;
|
||||
border-radius: var(--radius-control);
|
||||
box-shadow: inset 0 0 0 1px var(--line);
|
||||
}
|
||||
|
||||
.seg button {
|
||||
padding: 6px 10px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font: 600 13px/1.2 var(--font-body);
|
||||
letter-spacing: 0.02em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.seg button[aria-pressed='true'] {
|
||||
background: var(--ink);
|
||||
color: var(--paper);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.cta:hover {
|
||||
background: var(--z);
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.nav {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.toggle {
|
||||
@@ -97,3 +107,34 @@
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
/* Second row: anchors for the sections of this page. */
|
||||
.subwrap {
|
||||
width: var(--shell);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.sub {
|
||||
display: flex;
|
||||
gap: 22px;
|
||||
height: 34px;
|
||||
align-items: flex-start;
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.sub a {
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.sub a:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.nav,
|
||||
.subwrap {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ SITE_DIR=/opt/gitea/site
|
||||
[ -r "$KEY" ] || { echo "deploy: ssh key not found: $KEY" >&2; exit 1; }
|
||||
|
||||
rm -rf .next out
|
||||
BASE_PATH=/resume NEXT_PUBLIC_DATA_URL=/data/resume.json npm run build
|
||||
BASE_PATH=/resume NEXT_PUBLIC_DATA_URL=/data/resume.json NEXT_PUBLIC_SITE_ORIGIN= npm run build
|
||||
[ -f out/index.html ] || { echo "deploy: build failed (out/index.html missing)" >&2; exit 1; }
|
||||
|
||||
tar -C out -c . | ssh -i "$KEY" -o IgnoreUnknown=WarnWeakCrypto -o WarnWeakCrypto=no "$HOST" "
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export type Lang = 'ru' | 'en'
|
||||
|
||||
/** Both languages side by side; html[data-lang] decides which one is shown, so there is no flash and no state. */
|
||||
export function Bi({ ru, en }: Readonly<{ ru: ReactNode; en: ReactNode }>) {
|
||||
return (
|
||||
<>
|
||||
<span data-ru>{ru}</span>
|
||||
<span data-en>{en}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const pluralRu = new Intl.PluralRules('ru')
|
||||
const pluralEn = new Intl.PluralRules('en')
|
||||
|
||||
/** "21 проект" / "21 project": Russian has three forms (1 / 2-4 / 5+), English two. */
|
||||
export function count(n: number, ru: [string, string, string], en: [string, string]) {
|
||||
const r = pluralRu.select(n)
|
||||
return {
|
||||
ru: `${n} ${r === 'one' ? ru[0] : r === 'few' ? ru[1] : ru[2]}`,
|
||||
en: `${n} ${pluralEn.select(n) === 'one' ? en[0] : en[1]}`,
|
||||
}
|
||||
}
|
||||
|
||||
export const monthYear = (iso: string | undefined, lang: Lang) =>
|
||||
iso ? new Date(iso).toLocaleDateString(lang, { month: 'short', year: 'numeric' }) : ''
|
||||
|
||||
// Where the other pages of shanaty.ru live. Empty in the production build (root-relative links); the default is
|
||||
// for builds that run elsewhere (dev, GitHub Pages), where relative links would point at the wrong site.
|
||||
export const SITE = process.env.NEXT_PUBLIC_SITE_ORIGIN ?? 'https://shanaty.ru'
|
||||
+1
-1
@@ -44,4 +44,4 @@ export type Project = {
|
||||
export type Portfolio = { href: string; count?: number; label?: string }
|
||||
|
||||
// Shape of /data/resume.json, written every minute by the server-side sync.
|
||||
export type Live = { generated_at: string; resume: Resume; portfolio?: Portfolio; projects: Project[] }
|
||||
export type Live = { generated_at: string; resume: Resume; resume_ru?: Resume; portfolio?: Portfolio; stats?: { repos?: number }; projects: Project[] }
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { Lang } from './i18n'
|
||||
|
||||
/** The current language (html[data-lang], set before the first paint); re-reads when the switch is used. */
|
||||
export function useLang(): Lang {
|
||||
const [lang, setLang] = useState<Lang>('ru')
|
||||
useEffect(() => {
|
||||
const read = () => {
|
||||
const html = document.documentElement
|
||||
const current: Lang = html.dataset.lang === 'en' ? 'en' : 'ru'
|
||||
setLang(current)
|
||||
}
|
||||
read()
|
||||
window.addEventListener('langchange', read)
|
||||
return () => window.removeEventListener('langchange', read)
|
||||
}, [])
|
||||
return lang
|
||||
}
|
||||
Reference in New Issue
Block a user