Compare commits

..
23 Commits
Author SHA1 Message Date
EmilandClaude Sonnet 5 e433796bc6 Dark theme: the closing band is a dark slab instead of a light one
deploy-shanaty / deploy (push) Successful in 50s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XtcZtnG4GQA1d6mW5PNrS9
2026-09-21 04:57:33 +03:00
EmilandClaude Sonnet 5 6c6ce6f36a README: deployment is done by the Actions workflow
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XtcZtnG4GQA1d6mW5PNrS9
2026-09-21 04:02:03 +03:00
EmilandClaude Sonnet 5 15f9959229 Deploy shanaty.ru/resume from Gitea Actions on every push to main
deploy-shanaty / deploy (push) Successful in 54s
Gitea runs .gitea/workflows instead of .github/workflows, so the GitHub Pages workflow of the GitHub copy stops
running on the server (it built for GitHub Pages and could not deploy there anyway). ./deploy-shanaty.sh still works.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XtcZtnG4GQA1d6mW5PNrS9
2026-09-21 03:59:50 +03:00
EmilandClaude Sonnet 5 364ef97047 Nav: Contacts becomes Devlog (link to shanaty.ru/devlog/)
Deploy to GitHub Pages / build (push) Failing after 5m45s
Deploy to GitHub Pages / deploy (push) Skipped
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XtcZtnG4GQA1d6mW5PNrS9
2026-09-21 00:45:16 +03:00
EmilandClaude Sonnet 5 04c1cc5877 Make the resume bilingual and part of the shared site chrome
Deploy to GitHub Pages / build (push) Canceled after 0s
Deploy to GitHub Pages / deploy (push) Canceled after 0s
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
2026-09-20 23:48:38 +03:00
EmilandClaude Sonnet 5 4d485fc19d Add the Russian text of the resume
Deploy to GitHub Pages / build (push) Canceled after 0s
Deploy to GitHub Pages / deploy (push) Canceled after 0s
data/resume.ru.json mirrors data/resume.json (same structure, paired by
position). Skills, experience and education follow the hh.ru resume.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XtcZtnG4GQA1d6mW5PNrS9
2026-09-20 23:30:02 +03:00
EmilandClaude Sonnet 5 7471d65f54 Add a prominent link to the full portfolio
Deploy to GitHub Pages / build (push) Canceled after 0s
Deploy to GitHub Pages / deploy (push) Canceled after 0s
The top bar's accent button becomes Portfolio (Email stays in the hero and
the contact band), the hero gets a Full portfolio button, and the project
list ends with a large block linking to it. The link points at Gitea's list
of repos with the portfolio topic; the sync supplies the count and the
resume file can override the target and label. Also eases the display
weight and tracking of the name in the dark theme.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XtcZtnG4GQA1d6mW5PNrS9
2026-09-20 22:58:37 +03:00
EmilandClaude Sonnet 5 15d36d6437 Add a dark theme, default, with a switch in the top bar
Deploy to GitHub Pages / build (push) Canceled after 0s
Deploy to GitHub Pages / deploy (push) Canceled after 0s
Colours move into theme tokens (light values in :root, dark in
[data-theme=dark]); the closing contact band inverts on each theme. The
choice is stored in localStorage and applied by a head script before the
first paint. Voxel covers and image shadows follow the theme.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XtcZtnG4GQA1d6mW5PNrS9
2026-09-20 22:51:13 +03:00
Emil_Shanaty 0c95e0c5d0 Обновить data/resume.json
Deploy to GitHub Pages / build (push) Canceled after 0s
Deploy to GitHub Pages / deploy (push) Canceled after 0s
2026-09-20 19:36:34 +00:00
Emil_Shanaty 283d4db07a Обновить data/resume.json
Deploy to GitHub Pages / build (push) Canceled after 0s
Deploy to GitHub Pages / deploy (push) Canceled after 0s
2026-09-20 19:33:38 +00:00
EmilandClaude Sonnet 5 1c2f6f472b Add a favicon drawn as the orientation gizmo
Deploy to GitHub Pages / build (push) Canceled after 0s
Deploy to GitHub Pages / deploy (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XtcZtnG4GQA1d6mW5PNrS9
2026-09-20 21:40:14 +03:00
EmilandClaude Sonnet 5 b5ffc67a82 Redesign the resume site as a 3D-editor viewport
Deploy to GitHub Pages / build (push) Canceled after 0s
Deploy to GitHub Pages / deploy (push) Canceled after 0s
New design: studio backdrop with a perspective floor and X/Z axis lines, an
orientation gizmo that orbits on scroll, Bricolage Grotesque and Instrument
Sans (bundled, no build-time network). Projects are laid out as plates by a
row planner that keeps every row full for any count; a demo image from the
repo root is shown when present, otherwise a generated voxel sculpture.
Text and projects still come live from Gitea.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XtcZtnG4GQA1d6mW5PNrS9
2026-09-20 21:37:35 +03:00
EmilandClaude Sonnet 5 32c57a5ada Resume site with live content from Gitea
Deploy to GitHub Pages / build (push) Canceled after 0s
Deploy to GitHub Pages / deploy (push) Canceled after 0s
Replaces the static portfolio with a resume site that reads /data/resume.json:
the text comes from data/resume.json in this repo, and the projects are the
public Gitea repos tagged `resume`. Merges in the content of the hh.ru resume
(backend/DevOps skills, experience, languages) without personal details.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 20:55:05 +03:00
Emil b001ddf6b5 Fix hero decoration overlaps and grid edge cases
Deploy to GitHub Pages / build (push) Canceled after 0s
Deploy to GitHub Pages / deploy (push) Canceled after 0s
2026-08-27 21:43:23 +03:00
Emil 8e723e306d Add HSE education and polish responsive layout 2026-08-27 21:19:25 +03:00
Emil b90b64ee86 Refresh portfolio with latest AI projects 2026-08-27 21:14:45 +03:00
emil28092005 75cb20939d Polish responsive portfolio experience 2026-07-22 04:10:02 +03:00
emil28092005 b3648753eb Update portfolio details and link icons 2026-07-22 04:02:59 +03:00
emil28092005 101d9cb494 Refresh portfolio dark theme 2026-07-22 03:49:53 +03:00
emil28092005 7bde4bad07 feat: update portfolio website 2026-07-22 03:43:37 +03:00
emil28092005andClaude Sonnet 4.6 0a2d1941d5 ci: rewrite CI workflow for Next.js, drop Hugo steps
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 04:16:29 +03:00
emil28092005andClaude Sonnet 4.6 727996503e feat: rewrite site as Next.js 14 portfolio with terminal aesthetic
Replaces Hugo site with a Next.js App Router (TypeScript) build.
Tailwind CSS custom terminal palette, Framer Motion scroll reveals,
JetBrains Mono font, scanline/vignette overlays, typing animation.
Sections: Hero, Skills, Projects, Contact. Content driven from data/resume.ts.
Deploy workflow updated for static export to gh-pages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 04:15:07 +03:00
emilandClaude Sonnet 4.6 a6ed78c2ca Fix baseURL to include repo subpath for GitHub Pages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 01:03:04 +03:00
66 changed files with 3555 additions and 2510 deletions
+40
View File
@@ -0,0 +1,40 @@
# Builds the resume and publishes it at https://shanaty.ru/resume/ on every push to main (replaces ./deploy-shanaty.sh, which still works by hand).
# The text and the project list are NOT baked into the build: the page reads /data/resume.json, which the server keeps in step with data/resume.json
# in this repository. So a change that only touches data/ or the docs needs no rebuild.
# Gitea runs the workflows of .gitea/workflows INSTEAD of .github/workflows, so the GitHub Pages workflow of the GitHub copy does not run here.
name: deploy-shanaty
on:
push:
branches: [main]
paths-ignore:
- 'data/**'
- '*.md'
jobs:
deploy:
runs-on: ubuntu-latest
container:
image: docker.gitea.com/runner-images:ubuntu-latest
volumes:
- /opt/gitea/site:/site
steps:
- uses: actions/checkout@v4
- name: Node
run: node --version && npm --version
- name: Install
run: npm ci
- name: Build
run: BASE_PATH=/resume NEXT_PUBLIC_DATA_URL=/data/resume.json NEXT_PUBLIC_SITE_ORIGIN= npm run build
- name: Check the build
run: test -f out/index.html
- name: Publish (the folder is swapped in, so the site is never half-updated)
run: |
set -e
cd /site
rm -rf .resume.new .resume.old
mkdir .resume.new
cp -r "$GITHUB_WORKSPACE"/out/. .resume.new/
chmod -R a+rX .resume.new
if [ -d resume ]; then mv resume .resume.old; fi
mv .resume.new resume
rm -rf .resume.old
echo "Published: https://shanaty.ru/resume/"
-47
View File
@@ -1,47 +0,0 @@
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Hugo
uses: peaceiris/actions-hugo@v2
with:
hugo-version: 'latest'
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Install markdownlint and HTMLHint
run: npm install -D markdownlint-cli htmlhint
- name: Lint markdown files
run: npx markdownlint "**/*.md" --config .markdownlint.json || echo "No markdownlint config – skipping"
- name: Lint generated HTML (if any)
run: npx htmlhint "**/public/**/*.html" || echo "No HTML to lint yet"
- name: Build CSS
run: npm run build:css
- name: Build site with Hugo
run: hugo --minify
- name: Verify Hugo output exists
run: |
if [ ! -d "public" ]; then
echo "public/ not found"
exit 1
fi
+33 -21
View File
@@ -1,41 +1,53 @@
name: Deploy to GitHub Pages
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: write
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true
jobs:
build-deploy:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- name: Checkout
uses: actions/checkout@v4
- name: Setup Hugo
uses: peaceiris/actions-hugo@v2
with:
hugo-version: 'latest'
- name: Set up Node.js for Tailwindcss
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Build CSS asset
run: npm run build:css
- name: Build static site
run: npm run build
- name: Build Hugo site
run: hugo --minify
- name: Configure Pages
uses: actions/configure-pages@v5
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v3
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_branch: gh-pages
keep_files: false
force_orphan: true
commit_message: Deploy Hugo site
path: ./out
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+34 -16
View File
@@ -1,23 +1,41 @@
# Node dependencies
# Dependencies
node_modules/
# Build output
/public
/dist
/build
# Next.js
.next/
out/
# Hugo
*.log
*.patch
.hugo_build.lock
# Production
build/
dist/
# OS & editor artifacts
.DS_Store
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# TypeScript
*.tsbuildinfo
next-env.d.ts
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editors
.vscode/
.idea/
*.swp
*~
*.md~
*.swo
# Tailwind / PostCSS caches
/.cache/
assets/css/main.css
# Operating systems
.DS_Store
Thumbs.db
# Local tooling
.vercel/
.turbo/
-1
View File
@@ -1 +0,0 @@
{\"repoUrl\": \"file:///home/emil/Desktop/Coding/AI/UI_test\"}
-14
View File
@@ -1,14 +0,0 @@
# Task
Describe your task here.
## Goals
- Goal 1
- Goal 2
## Checklist
- [ ] Item 1
- [ ] Item 2
## Notes
(Update this as you work)
-14
View File
@@ -1,14 +0,0 @@
{
"name": "proceed",
"taskFile": ".ralph/proceed.md",
"iteration": 2,
"maxIterations": 50,
"itemsPerIteration": 0,
"reflectEvery": 0,
"reflectInstructions": "REFLECTION CHECKPOINT\n\nPause and reflect on your progress:\n1. What has been accomplished so far?\n2. What's working well?\n3. What's not working or blocking progress?\n4. Should the approach be adjusted?\n5. What are the next priorities?\n\nUpdate the task file with your reflection, then continue working.",
"active": false,
"status": "completed",
"startedAt": "2026-04-29T07:23:46.928Z",
"lastReflectionAt": 0,
"completedAt": "2026-04-29T07:38:48.762Z"
}
-271
View File
@@ -1,271 +0,0 @@
./content/_index.md
./package.json
./node_modules/@nodelib/fs.walk/package.json
./node_modules/@nodelib/fs.walk/README.md
./node_modules/@nodelib/fs.scandir/package.json
./node_modules/@nodelib/fs.scandir/README.md
./node_modules/@nodelib/fs.stat/package.json
./node_modules/@nodelib/fs.stat/README.md
./node_modules/@jridgewell/trace-mapping/package.json
./node_modules/@jridgewell/trace-mapping/README.md
./node_modules/@jridgewell/sourcemap-codec/package.json
./node_modules/@jridgewell/sourcemap-codec/README.md
./node_modules/@jridgewell/resolve-uri/package.json
./node_modules/@jridgewell/resolve-uri/README.md
./node_modules/@jridgewell/gen-mapping/package.json
./node_modules/@jridgewell/gen-mapping/README.md
./node_modules/postcss/package.json
./node_modules/postcss/README.md
./node_modules/@alloc/quick-lru/package.json
./node_modules/@alloc/quick-lru/readme.md
./node_modules/browserslist/package.json
./node_modules/browserslist/README.md
./node_modules/caniuse-lite/package.json
./node_modules/caniuse-lite/README.md
./node_modules/tailwindcss/lib/value-parser/README.md
./node_modules/tailwindcss/lib/postcss-plugins/nesting/README.md
./node_modules/tailwindcss/src/value-parser/README.md
./node_modules/tailwindcss/src/postcss-plugins/nesting/README.md
./node_modules/tailwindcss/package.json
./node_modules/tailwindcss/README.md
./node_modules/fraction.js/package.json
./node_modules/fraction.js/CHANGELOG.md
./node_modules/fraction.js/README.md
./node_modules/postcss-value-parser/package.json
./node_modules/postcss-value-parser/README.md
./node_modules/baseline-browser-mapping/package.json
./node_modules/baseline-browser-mapping/README.md
./node_modules/update-browserslist-db/package.json
./node_modules/update-browserslist-db/README.md
./node_modules/nanoid/async/package.json
./node_modules/nanoid/non-secure/package.json
./node_modules/nanoid/url-alphabet/package.json
./node_modules/nanoid/package.json
./node_modules/nanoid/README.md
./node_modules/source-map-js/package.json
./node_modules/source-map-js/README.md
./node_modules/arg/package.json
./node_modules/arg/LICENSE.md
./node_modules/arg/README.md
./node_modules/didyoumean/package.json
./node_modules/didyoumean/README.md
./node_modules/dlv/package.json
./node_modules/dlv/README.md
./node_modules/picocolors/package.json
./node_modules/picocolors/README.md
./node_modules/glob-parent/package.json
./node_modules/glob-parent/README.md
./node_modules/is-glob/package.json
./node_modules/is-glob/README.md
./node_modules/jiti/package.json
./node_modules/jiti/README.md
./node_modules/lilconfig/package.json
./node_modules/lilconfig/readme.md
./node_modules/micromatch/package.json
./node_modules/micromatch/README.md
./node_modules/postcss-import/package.json
./node_modules/postcss-import/README.md
./node_modules/object-hash/package.json
./node_modules/normalize-path/package.json
./node_modules/normalize-path/README.md
./node_modules/postcss-js/package.json
./node_modules/postcss-js/README.md
./node_modules/postcss-load-config/package.json
./node_modules/postcss-load-config/README.md
./node_modules/postcss-nested/package.json
./node_modules/postcss-nested/README.md
./node_modules/postcss-selector-parser/package.json
./node_modules/postcss-selector-parser/API.md
./node_modules/postcss-selector-parser/CHANGELOG.md
./node_modules/postcss-selector-parser/README.md
./node_modules/sucrase/package.json
./node_modules/sucrase/README.md
./node_modules/resolve/test/resolver/symlinked/package/package.json
./node_modules/resolve/test/resolver/browser_field/package.json
./node_modules/resolve/test/resolver/nested_symlinks/mylib/package.json
./node_modules/resolve/test/resolver/baz/package.json
./node_modules/resolve/test/resolver/dot_main/package.json
./node_modules/resolve/test/resolver/dot_slash_main/package.json
./node_modules/resolve/test/resolver/false_main/package.json
./node_modules/resolve/test/resolver/incorrect_main/package.json
./node_modules/resolve/test/resolver/multirepo/packages/package-a/package.json
./node_modules/resolve/test/resolver/multirepo/packages/package-b/package.json
./node_modules/resolve/test/resolver/multirepo/package.json
./node_modules/resolve/test/resolver/invalid_main/package.json
./node_modules/resolve/test/module_dir/zmodules/bbb/package.json
./node_modules/resolve/package.json
./node_modules/resolve/.claude/notes.md
./node_modules/resolve/.github/INCIDENT_RESPONSE_PROCESS.md
./node_modules/resolve/.github/THREAT_MODEL.md
./node_modules/resolve/SECURITY.md
./node_modules/anymatch/package.json
./node_modules/anymatch/README.md
./node_modules/electron-to-chromium/package.json
./node_modules/electron-to-chromium/README.md
./node_modules/node-releases/package.json
./node_modules/node-releases/README.md
./node_modules/braces/package.json
./node_modules/braces/README.md
./node_modules/readdirp/package.json
./node_modules/readdirp/README.md
./node_modules/is-binary-path/package.json
./node_modules/is-binary-path/readme.md
./node_modules/merge2/package.json
./node_modules/merge2/README.md
./node_modules/fill-range/package.json
./node_modules/fill-range/README.md
./node_modules/picomatch/package.json
./node_modules/picomatch/README.md
./node_modules/fastq/package.json
./node_modules/fastq/README.md
./node_modules/fastq/SECURITY.md
./node_modules/to-regex-range/package.json
./node_modules/to-regex-range/README.md
./node_modules/run-parallel/package.json
./node_modules/run-parallel/README.md
./node_modules/reusify/package.json
./node_modules/reusify/README.md
./node_modules/reusify/SECURITY.md
./node_modules/binary-extensions/package.json
./node_modules/binary-extensions/readme.md
./node_modules/is-extglob/package.json
./node_modules/is-extglob/README.md
./node_modules/read-cache/package.json
./node_modules/read-cache/README.md
./node_modules/camelcase-css/package.json
./node_modules/camelcase-css/README.md
./node_modules/cssesc/package.json
./node_modules/cssesc/README.md
./node_modules/pify/package.json
./node_modules/pify/readme.md
./node_modules/util-deprecate/package.json
./node_modules/util-deprecate/README.md
./node_modules/util-deprecate/History.md
./node_modules/es-errors/package.json
./node_modules/es-errors/CHANGELOG.md
./node_modules/es-errors/README.md
./node_modules/is-core-module/package.json
./node_modules/is-core-module/CHANGELOG.md
./node_modules/is-core-module/README.md
./node_modules/supports-preserve-symlinks-flag/package.json
./node_modules/supports-preserve-symlinks-flag/CHANGELOG.md
./node_modules/supports-preserve-symlinks-flag/README.md
./node_modules/path-parse/package.json
./node_modules/path-parse/README.md
./node_modules/hasown/package.json
./node_modules/hasown/CHANGELOG.md
./node_modules/hasown/README.md
./node_modules/function-bind/package.json
./node_modules/function-bind/CHANGELOG.md
./node_modules/function-bind/README.md
./node_modules/function-bind/.github/SECURITY.md
./node_modules/queue-microtask/package.json
./node_modules/queue-microtask/README.md
./node_modules/commander/package.json
./node_modules/commander/CHANGELOG.md
./node_modules/commander/Readme.md
./node_modules/lines-and-columns/package.json
./node_modules/lines-and-columns/README.md
./node_modules/mz/package.json
./node_modules/mz/README.md
./node_modules/mz/HISTORY.md
./node_modules/pirates/package.json
./node_modules/pirates/README.md
./node_modules/tinyglobby/node_modules/fdir/package.json
./node_modules/tinyglobby/node_modules/fdir/README.md
./node_modules/tinyglobby/node_modules/picomatch/package.json
./node_modules/tinyglobby/node_modules/picomatch/README.md
./node_modules/tinyglobby/package.json
./node_modules/tinyglobby/README.md
./node_modules/ts-interface-checker/package.json
./node_modules/ts-interface-checker/README.md
./node_modules/object-assign/package.json
./node_modules/object-assign/readme.md
./node_modules/any-promise/package.json
./node_modules/any-promise/README.md
./node_modules/thenify-all/package.json
./node_modules/thenify-all/README.md
./node_modules/thenify-all/History.md
./node_modules/thenify/package.json
./node_modules/thenify/History.md
./node_modules/thenify/README.md
./node_modules/is-number/package.json
./node_modules/is-number/README.md
./node_modules/escalade/package.json
./node_modules/escalade/readme.md
./node_modules/autoprefixer/lib/hacks/flex-spec.js
./node_modules/autoprefixer/package.json
./node_modules/autoprefixer/README.md
./node_modules/chokidar/node_modules/glob-parent/package.json
./node_modules/chokidar/node_modules/glob-parent/CHANGELOG.md
./node_modules/chokidar/node_modules/glob-parent/README.md
./node_modules/chokidar/package.json
./node_modules/chokidar/README.md
./node_modules/fast-glob/node_modules/glob-parent/package.json
./node_modules/fast-glob/node_modules/glob-parent/CHANGELOG.md
./node_modules/fast-glob/node_modules/glob-parent/README.md
./node_modules/fast-glob/package.json
./node_modules/fast-glob/README.md
./node_modules/postcss-reporter/package.json
./node_modules/postcss-reporter/README.md
./node_modules/fs-extra/package.json
./node_modules/fs-extra/README.md
./node_modules/pretty-hrtime/package.json
./node_modules/pretty-hrtime/README.md
./node_modules/slash/package.json
./node_modules/slash/readme.md
./node_modules/yargs/helpers/package.json
./node_modules/yargs/package.json
./node_modules/yargs/README.md
./node_modules/graceful-fs/package.json
./node_modules/graceful-fs/README.md
./node_modules/jsonfile/package.json
./node_modules/jsonfile/README.md
./node_modules/universalify/package.json
./node_modules/universalify/README.md
./node_modules/thenby/package.json
./node_modules/thenby/README.md
./node_modules/cliui/package.json
./node_modules/cliui/CHANGELOG.md
./node_modules/cliui/README.md
./node_modules/get-caller-file/package.json
./node_modules/get-caller-file/LICENSE.md
./node_modules/get-caller-file/README.md
./node_modules/require-directory/package.json
./node_modules/require-directory/README.markdown
./node_modules/string-width/package.json
./node_modules/string-width/readme.md
./node_modules/y18n/package.json
./node_modules/y18n/CHANGELOG.md
./node_modules/y18n/README.md
./node_modules/yargs-parser/package.json
./node_modules/yargs-parser/CHANGELOG.md
./node_modules/yargs-parser/README.md
./node_modules/strip-ansi/package.json
./node_modules/strip-ansi/readme.md
./node_modules/wrap-ansi/package.json
./node_modules/wrap-ansi/readme.md
./node_modules/emoji-regex/package.json
./node_modules/emoji-regex/README.md
./node_modules/is-fullwidth-code-point/package.json
./node_modules/is-fullwidth-code-point/readme.md
./node_modules/ansi-regex/package.json
./node_modules/ansi-regex/readme.md
./node_modules/ansi-styles/package.json
./node_modules/ansi-styles/readme.md
./node_modules/color-convert/package.json
./node_modules/color-convert/CHANGELOG.md
./node_modules/color-convert/README.md
./node_modules/color-name/package.json
./node_modules/color-name/README.md
./node_modules/yaml/browser/package.json
./node_modules/yaml/package.json
./node_modules/yaml/README.md
./node_modules/dependency-graph/specs/dep_graph_spec.js
./node_modules/dependency-graph/package.json
./node_modules/dependency-graph/CHANGELOG.md
./node_modules/dependency-graph/README.md
./node_modules/postcss-cli/node_modules/postcss-load-config/package.json
./node_modules/postcss-cli/node_modules/postcss-load-config/README.md
./node_modules/postcss-cli/package.json
./node_modules/postcss-cli/README.md
-17
View File
@@ -1,17 +0,0 @@
# IMPORTANT Rules
Always be kind. Prioritize user well-being.
# Code Quality Principles
Write clear, concise, and efficient code. Avoid unnecessary complexity.
# Coding Style Preferences
Use consistent 2-space indentation. Use descriptive variable names. Avoid trailing whitespace.
# Architecture Principles
Favor modular design. Consider scalability early. Prefer simplicity when possible.
# Development Workflow
Use Git for version control. Write descriptive commit messages. Use pull requests. Run tests before merging.
# Technical Preferences
Use TypeScript for type safety. Prefer REST over GraphQL for simplicity. Use PostgreSQL for relational data. Use Redis for caching. Use Docker for containerization.
-49
View File
@@ -1,49 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Hugo static resume website for Emil Shanayev (DevOps & Backend Engineer), styled with Tailwind CSS 3.x and deployed to GitHub Pages via GitHub Actions.
## Commands
```bash
# Local dev server (watches for changes)
hugo server
# Compile Tailwind CSS only
npm run build:css
# Full production build (CSS + Hugo minified)
npm run build
```
CSS must be compiled before Hugo can use it — `npm run build` handles this in the correct order.
## Architecture
**Hugo + PostCSS pipeline:**
- `assets/css/tailwind.css` — Tailwind entry point (source); processed by PostCSS → `assets/css/main.css` (generated, not committed)
- `tailwind.config.js` — content glob scans `**/*.{html,js,ts,jsx,md,go,rs}`; dark mode via `class`; custom color palette under `primary`, `secondary`, `dark`, `bg`
- `layouts/` — Hugo HTML templates (currently `_default/` only)
- `content/` — Markdown pages; `_index.md` is the homepage resume content (written in Russian)
- `config.toml` — Hugo site config (author, description, menu entries)
**CI/CD (`.github/workflows/`):**
- `ci.yml` — runs on PRs to `main`: installs deps, lints Markdown (`markdownlint`) and generated HTML (`htmlhint`), then builds
- `deploy.yml` — runs on push to `main`: builds site and pushes `public/` to `gh-pages` branch via `peaceiris/actions-gh-pages`
**Utility directories:**
- `core/user/` — user identity and preference notes consumed by AI tooling
- `templates/` — blank identity/preference templates
- `.pi/` — pi-agent settings (repo URL reference)
## Style Preferences
- 2-space indentation
- Descriptive variable names, no trailing whitespace
- TypeScript preferred for any scripting; REST over GraphQL; PostgreSQL/Redis for data
-1
View File
@@ -1 +0,0 @@
[['menu]\n [[menu.main]]\n name = "Resume"\n url = "/resume/', 'weight = 1\n [[menu.main]]\n name = "About"\n url = "/about/', 'weight = 2\n\n[params]\n author = "Emil Shanayev"\n description = "DevOps & Backend Engineer"\n keywords = ["DevOps', 'Backend', 'Go', 'Tailwind', 'Resume'], {'name': 'resume-website', 'private': True, 'scripts': {'build:css': 'postcss assets/css/tailwind.css -o assets/css/main.css', 'build': 'npm run build:css && hugo --minify', 'lint:md': "markdownlint '**/*.md' --config .markdownlint.json || echo 'No markdownlint config'", 'lint:html': "htmlhint 'public/**/*.html' || echo 'No HTML output yet'"}, 'devDependencies': {'autoprefixer': '^10.5.0', 'postcss': '^8.5.12', 'postcss-cli': '^11.0.1', 'tailwindcss': '^3.4.4', 'markdownlint-cli': '^0.35.0', 'htmlhint': '^1.2.0'}}]
+64
View File
@@ -0,0 +1,64 @@
# Emil Shanayev — resume site
Live at **https://shanaty.ru/resume/**. A static Next.js site whose content is read live from Gitea, so it is updated by editing repositories, not by redeploying.
## What comes from where
| On the page | Source |
|---|---|
| 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 | 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 |
A timer on the server (`resume_sync.py` in the `auto` repo) rebuilds `https://shanaty.ru/data/resume.json` every minute from these sources, so a change shows up within about a minute. If a source is broken (invalid JSON, missing fields, unsafe links) the previous published data stays and the problem is logged (`journalctl -u gitea-resume`).
## Adding a project
1. On Gitea, add the topic `resume` to the repository (it must be public).
2. Optional: put a `demo.png` (or `.jpg`, `.gif`, `.webp`) in the repo root. Up to 6 MB, any proportions; animated GIFs and WebP keep animating. Without one the project gets a generated picture.
3. Optional: put a `resume-card.json` in the repo root to control how the project reads:
```json
{
"title": "Cortex Engine",
"kind": "AI-native 3D game engine",
"description": "One or two sentences on what it is and what makes it interesting.",
"highlights": ["Pure Vulkan 1.3 backend", "Playable FPS demo"],
"tech": ["C#", "Vulkan", "MCP"],
"links": [{ "label": "Docs", "href": "https://example.com/docs" }],
"rank": 1,
"hidden": false
}
```
Every field is optional. Without a card the page uses the Gitea description, the repo language and its topics (the topics `resume`, `portfolio` and `featured` are not shown as tags). `rank` puts a project first (lower first, unranked ones follow, newest first); `"hidden": true` removes it from the page. The same values can also live centrally in `projectNotes` (keyed by repo name) in `data/resume.json`; a `resume-card.json` wins over it.
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` (and its Russian twin `data/resume.ru.json`), commit and **push to Gitea** (`git push gitea main`). No rebuild or deploy is needed.
## Development
```bash
npm install
npm run dev # http://localhost:3000, data from the public https://shanaty.ru/data/resume.json
npm run build # static export in out/
```
`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
Automatic: every push to `main` (except changes that only touch `data/**` or `*.md`) runs `.gitea/workflows/deploy.yml` on the server's Gitea Actions runner. It builds the site and swaps the
`resume/` folder of shanaty.ru in. Gitea runs `.gitea/workflows` instead of `.github/workflows`, so the GitHub Pages workflow (for the copy on GitHub) does not run on the server.
By hand, with the ssh key `~/.ssh/MyGitea`: `./deploy-shanaty.sh`.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+128
View File
@@ -0,0 +1,128 @@
/* 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;
/* Palette. The page is a 3D viewport: a studio backdrop and the three axis colours (X red, Y green, Z blue),
which are marks and lines rather than text. Dark is the default theme; see :root[data-theme='dark']. */
--paper: #f4f6f8;
--ink: #15181d;
--muted: #5b6571;
--x: #e0384b;
--y: #5aa300;
--z: #2c63e0;
--z-text: #2455c9;
/* Surfaces */
--hero-top: #f5f7fa;
--hero-bottom: #e6e9ee;
--stage-top: #d8dde4;
--stage-bottom: #cdd3db;
--plate-a: #f3f5f8;
--plate-b: #e6e9ed;
--plate-c: #d3d9e0;
--pill-bg: rgba(255, 255, 255, 0.72);
--disc-bg: rgba(244, 246, 248, 0.62);
--topbar-bg: rgba(244, 246, 248, 0.86);
--grid: rgba(21, 24, 29, 0.2);
--img-shadow: rgba(21, 24, 29, 0.28);
--dot-glow: rgba(90, 163, 0, 0.2);
/* Voxel covers of projects without a demo image */
--cube-top: #f9fafb;
--cube-left: #d6dbe2;
--cube-right: #b8c0cb;
--cube-edge: rgba(21, 24, 29, 0.16);
--cover-grid: rgba(21, 24, 29, 0.09);
/* The closing contact band is the inverse of the page */
--band-bg: #15181d;
--band-ink: #f4f6f8;
/* Derived from the palette above, so they follow the theme */
--line: color-mix(in srgb, var(--ink) 14%, transparent);
--ring: color-mix(in srgb, var(--ink) 9%, transparent);
--horizon: color-mix(in srgb, var(--ink) 22%, transparent);
--chip: color-mix(in srgb, var(--ink) 7%, transparent);
--ink-2: color-mix(in srgb, var(--ink) 86%, transparent);
--ink-3: color-mix(in srgb, var(--ink) 76%, transparent);
--ink-line: color-mix(in srgb, var(--ink) 32%, transparent);
--band-2: color-mix(in srgb, var(--band-ink) 70%, transparent);
--band-3: color-mix(in srgb, var(--band-ink) 58%, transparent);
--band-line: color-mix(in srgb, var(--band-ink) 22%, transparent);
--band-underline: color-mix(in srgb, var(--band-ink) 38%, transparent);
--radius-plate: 18px;
--radius-control: 10px;
--shell: min(1240px, calc(100% - 48px));
--gutter: clamp(20px, 3vw, 32px);
--section: clamp(84px, 11vw, 148px);
--font-body: 'Onest', 'Segoe UI', system-ui, sans-serif;
--font-head: 'Unbounded', 'Segoe UI', system-ui, sans-serif;
}
:root[data-theme='dark'] {
color-scheme: dark;
--paper: #1c2026;
--ink: #e9edf2;
--muted: #9aa5b1;
--x: #ff5a6e;
--y: #86d62c;
--z: #5b8dff;
--z-text: #8db0ff;
--hero-top: #282d35;
--hero-bottom: #1c2026;
--stage-top: #14171b;
--stage-bottom: #0f1114;
--plate-a: #363c46;
--plate-b: #2a2f37;
--plate-c: #1f2329;
--pill-bg: rgba(255, 255, 255, 0.07);
--disc-bg: rgba(40, 45, 53, 0.72);
--topbar-bg: rgba(28, 32, 38, 0.86);
--grid: rgba(255, 255, 255, 0.15);
--img-shadow: rgba(0, 0, 0, 0.55);
--dot-glow: rgba(134, 214, 44, 0.24);
--cube-top: #e9edf2;
--cube-left: #9aa5b3;
--cube-right: #69737f;
--cube-edge: rgba(0, 0, 0, 0.4);
--cover-grid: rgba(255, 255, 255, 0.09);
/* The closing band: on a dark page it is a darker slab, not a light one (a light band dazzles at night). */
--band-bg: #101317;
--band-ink: #e9edf2;
}
* { box-sizing: border-box; }
html { background: var(--paper); scroll-behavior: smooth; scroll-padding-top: 124px; }
body {
margin: 0;
color: var(--ink);
font: 400 17px/1.6 var(--font-body);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
a { color: inherit; }
h1, h2, h3, p, ul, dl, dd, figure { margin: 0; }
ul { padding: 0; list-style: none; }
::selection { background: var(--z); color: #fff; }
:focus-visible { outline: 3px solid var(--z); outline-offset: 3px; border-radius: 4px; }
@media (prefers-reduced-motion: reduce) {
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; }
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#15181d"/>
<g stroke-width="5" stroke-linecap="round">
<line x1="30" y1="37" x2="30" y2="13" stroke="#5aa300"/>
<line x1="30" y1="37" x2="51" y2="27" stroke="#e0384b"/>
<line x1="30" y1="37" x2="16" y2="51" stroke="#2c63e0"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 360 B

+30
View File
@@ -0,0 +1,30 @@
import type { Metadata } from 'next'
import './globals.css'
export const metadata: Metadata = {
title: 'Эмиль Шанаев — Резюме',
description: 'Резюме Эмиля Шанаева: бэкенд, DevOps, системы на базе ИИ, научное ПО и игровые технологии.',
authors: [{ name: 'Эмиль Шанаев' }],
}
// 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="ru"
data-lang="ru"
data-theme="dark"
data-title-ru="Эмиль Шанаев — Резюме"
data-title-en="Emil Shanayev — Resume"
suppressHydrationWarning
>
<head>
<script dangerouslySetInnerHTML={{ __html: BOOT_SCRIPT }} />
</head>
<body>{children}</body>
</html>
)
}
+8
View File
@@ -0,0 +1,8 @@
import Site from '@/components/Site'
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 initialEn={resumeEn as unknown as Resume} initialRu={resumeRu as unknown as Resume} />
}
-97
View File
@@ -1,97 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
background-image: radial-gradient(rgb(30 41 59 / 0.45) 1px, transparent 1px);
background-size: 28px 28px;
}
}
@layer components {
/* Section header cards */
.resume-content h2 {
font-size: 0.68rem !important;
font-weight: 700 !important;
text-transform: uppercase;
letter-spacing: 0.16em;
color: rgb(96 165 250) !important;
margin-top: 3rem !important;
margin-bottom: 1.25rem !important;
padding: 0.55rem 1rem;
background: rgb(15 23 42 / 0.8);
border-left: 3px solid rgb(96 165 250);
border-radius: 0 0.375rem 0.375rem 0;
margin-left: -1rem;
box-shadow: -4px 0 24px rgb(96 165 250 / 0.08), inset 0 0 0 1px rgb(96 165 250 / 0.06);
}
.resume-content h3 {
font-size: 0.9rem !important;
font-weight: 600 !important;
color: rgb(226 232 240) !important;
margin-top: 1.25rem !important;
margin-bottom: 0.25rem !important;
}
.resume-content p {
color: rgb(148 163 184);
line-height: 1.75;
}
.resume-content li {
color: rgb(148 163 184);
}
.resume-content li::marker {
color: rgb(96 165 250) !important;
}
.resume-content strong {
color: rgb(226 232 240) !important;
font-weight: 600 !important;
}
.resume-content a {
color: rgb(96 165 250) !important;
text-decoration: none !important;
}
.resume-content a:hover {
text-decoration: underline !important;
}
.resume-content hr {
border-color: rgb(30 41 59 / 0.5) !important;
margin-top: 0.5rem !important;
margin-bottom: 0.5rem !important;
}
.skills-grid {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0.75rem 0 1.5rem;
}
.skills-grid span {
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.72rem;
font-family: ui-monospace, monospace;
background: rgb(96 165 250 / 0.1);
color: rgb(96 165 250);
border: 1px solid rgb(96 165 250 / 0.25);
white-space: nowrap;
}
.resume-content code {
color: rgb(147 197 253) !important;
background: rgb(15 23 42) !important;
padding: 0.1em 0.4em !important;
border-radius: 0.25rem !important;
font-size: 0.85em !important;
}
}
+102
View File
@@ -0,0 +1,102 @@
import { useMemo } from 'react'
// Deterministic isometric "sculpture" for a project without a demo image: the repo name seeds a small voxel
// stack, so every project gets its own picture that stays the same between visits.
function hash(text: string): number {
let h = 2166136261
for (let i = 0; i < text.length; i++) {
h ^= text.charCodeAt(i)
h = Math.imul(h, 16777619)
}
return h >>> 0
}
function random(seed: number) {
let a = seed
return () => {
a = (a + 0x6d2b79f5) | 0
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
const GRID = 5
const TILE = 54
const RISE = 30
const ACCENTS = ['#e0384b', '#5aa300', '#2c63e0']
const NEUTRAL = { top: 'var(--cube-top)', left: 'var(--cube-left)', right: 'var(--cube-right)' }
function mix(hex: string, other: number, amount: number): string {
const channel = (i: number) => parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16)
const to = (i: number) => Math.round(channel(i) * (1 - amount) + other * amount).toString(16).padStart(2, '0')
return `#${to(0)}${to(1)}${to(2)}`
}
type Cube = { i: number; j: number; k: number; top: string; left: string; right: string }
function build(name: string): Cube[] {
const next = random(hash(name))
const cubes: Cube[] = []
const accent = ACCENTS[Math.floor(next() * ACCENTS.length)]
const second = ACCENTS[Math.floor(next() * ACCENTS.length)]
for (let i = 0; i < GRID; i++) {
for (let j = 0; j < GRID; j++) {
const edge = Math.abs(i - 2) + Math.abs(j - 2)
const height = Math.max(0, Math.round(next() ** 1.3 * 4 - edge * 0.35))
for (let k = 0; k < height; k++) {
const isTop = k === height - 1
const pick = next()
const color = isTop && pick < 0.3 ? accent : isTop && pick < 0.4 ? second : null
cubes.push(
color
? { i, j, k, top: mix(color, 255, 0.22), left: color, right: mix(color, 0, 0.24) }
: { i, j, k, ...NEUTRAL },
)
}
}
}
if (cubes.length < 5) cubes.push({ i: 2, j: 2, k: 0, ...NEUTRAL }, { i: 2, j: 2, k: 1, top: mix(accent, 255, 0.22), left: accent, right: mix(accent, 0, 0.24) })
return cubes.sort((a, b) => a.i + a.j - (b.i + b.j) || a.k - b.k)
}
const face = (points: [number, number][]) => points.map((p) => p.join(',')).join(' ')
export default function Cover({ name }: Readonly<{ name: string }>) {
const cubes = useMemo(() => build(name), [name])
const originX = 220
const originY = 150
const point = (i: number, j: number, z: number, dx: number, dy: number): [number, number] => [
+(originX + (i - j) * (TILE / 2) + dx).toFixed(1),
+(originY + (i + j) * (TILE / 4) + dy - z).toFixed(1),
]
return (
<svg viewBox="0 0 440 320" preserveAspectRatio="xMidYMid meet" role="presentation" aria-hidden="true">
{Array.from({ length: GRID * GRID }, (_, n) => {
const i = Math.floor(n / GRID)
const j = n % GRID
return (
<polygon
key={n}
points={face([point(i, j, 0, 0, 0), point(i, j, 0, TILE / 2, TILE / 4), point(i, j, 0, 0, TILE / 2), point(i, j, 0, -TILE / 2, TILE / 4)])}
fill="none"
style={{ stroke: 'var(--cover-grid)' }}
/>
)
})}
{cubes.map(({ i, j, k, top, left, right }) => {
const z = (k + 1) * RISE
const base = k * RISE
return (
<g key={`${i}-${j}-${k}`} strokeLinejoin="round" strokeWidth="0.75" style={{ stroke: 'var(--cube-edge)' }}>
<polygon points={face([point(i, j, z, -TILE / 2, TILE / 4), point(i, j, z, 0, TILE / 2), point(i, j, base, 0, TILE / 2), point(i, j, base, -TILE / 2, TILE / 4)])} style={{ fill: left }} />
<polygon points={face([point(i, j, z, 0, TILE / 2), point(i, j, z, TILE / 2, TILE / 4), point(i, j, base, TILE / 2, TILE / 4), point(i, j, base, 0, TILE / 2)])} style={{ fill: right }} />
<polygon points={face([point(i, j, z, 0, 0), point(i, j, z, TILE / 2, TILE / 4), point(i, j, z, 0, TILE / 2), point(i, j, z, -TILE / 2, TILE / 4)])} style={{ fill: top }} />
</g>
)
})}
</svg>
)
}
+59
View File
@@ -0,0 +1,59 @@
'use client'
import { useScrollProgress } from '@/lib/useScroll'
import styles from './gizmo.module.css'
const AXES = [
{ label: 'X', vector: [1, 0, 0], color: 'var(--x)' },
{ label: 'Y', vector: [0, 1, 0], color: 'var(--y)' },
{ label: 'Z', vector: [0, 0, 1], color: 'var(--z)' },
] as const
const PITCH = 0.46
const LENGTH = 29
const CENTER = 48
function project([x, y, z]: readonly [number, number, number], yaw: number) {
const x1 = x * Math.cos(yaw) + z * Math.sin(yaw)
const z1 = -x * Math.sin(yaw) + z * Math.cos(yaw)
const y2 = y * Math.cos(PITCH) - z1 * Math.sin(PITCH)
const depth = y * Math.sin(PITCH) + z1 * Math.cos(PITCH)
return { x: CENTER + x1 * LENGTH, y: CENTER - y2 * LENGTH, depth }
}
/** Orientation gizmo of a 3D viewport. It orbits as the page scrolls and returns to the top when pressed. */
export default function Gizmo() {
const progress = useScrollProgress()
const yaw = 0.62 + progress * Math.PI * 1.35
const ends = AXES.flatMap((axis) =>
([1, -1] as const).map((sign) => ({
axis,
positive: sign === 1,
...project([axis.vector[0] * sign, axis.vector[1] * sign, axis.vector[2] * sign], yaw),
})),
).sort((a, b) => a.depth - b.depth)
return (
<button
type="button"
className={styles.gizmo}
aria-label="Back to top"
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
>
<svg viewBox="0 0 96 96" aria-hidden="true">
{ends.map(({ axis, positive, x, y }) =>
positive ? (
<g key={axis.label + '+'}>
<line x1={CENTER} y1={CENTER} x2={x} y2={y} stroke={axis.color} strokeWidth="3.2" strokeLinecap="round" />
<circle cx={x} cy={y} r="9.5" fill={axis.color} />
<text x={x} y={y + 3.6} textAnchor="middle" className={styles.letter}>{axis.label}</text>
</g>
) : (
<circle key={axis.label + '-'} cx={x} cy={y} r="6" fill={axis.color} opacity="0.32" />
),
)}
</svg>
</button>
)
}
+49
View File
@@ -0,0 +1,49 @@
'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 TextKey = 'name' | 'role' | 'bio' | 'availability'
type Props = { en: Resume; ru: Resume; portfolio: Portfolio; mailHref: string; showProjects: boolean }
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) => {
if (event.pointerType !== 'mouse' || !hero.current) return
const offset = event.clientX / window.innerWidth - 0.5
hero.current.style.setProperty('--sway', `${(offset * 3).toFixed(2)}deg`)
}
return (
<section className={styles.hero} id="top" ref={hero} onPointerMove={onPointerMove}>
<div className={styles.content}>
<p className={styles.status}>
<span className={styles.dot} />
<span>{text('availability')}</span>
</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"><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}><Bi ru="Написать" en="Email me" /></a>
</div>
</div>
<div className={styles.stage} aria-hidden="true">
<div className={styles.floor}>
<div className={styles.plane} />
</div>
</div>
</section>
)
}
+31
View File
@@ -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>
)
}
+131
View File
@@ -0,0 +1,131 @@
'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'
// 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 }
function Plate({ project, span }: Readonly<{ project: Project; span: Span }>) {
const [broken, setBroken] = useState(false)
const demo = project.demo
const title = project.title ?? project.name
if (!demo || broken) return <Cover name={project.name} />
const drift = Math.abs(Math.log(demo.width / demo.height / PLATE_RATIO[span]))
const fits = drift < Math.log(1.7)
const image = (className: string, extra: React.ImgHTMLAttributes<HTMLImageElement>) => (
// eslint-disable-next-line @next/next/no-img-element
<img className={className} src={demo.url} width={demo.width} height={demo.height} decoding="async" {...extra} />
)
return (
<>
{/* 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, loading: 'lazy', onError: () => setBroken(true) })}
</>
)
}
function Item({ project, span }: Readonly<{ project: Project; span: Span }>) {
const title = project.title ?? project.name
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}>
<Plate project={project} span={span} />
</a>
<div className={styles.caption}>
<h3>{title}</h3>
{(project.kind ?? project.language) && <p className={styles.kind}>{project.kind ?? project.language}</p>}
{project.description && <p className={styles.description}>{project.description}</p>}
{project.highlights && project.highlights.length > 0 && (
<ul className={styles.points}>{project.highlights.map((point) => <li key={point}>{point}</li>)}</ul>
)}
<div className={styles.foot}>
{project.tech.length > 0 && <ul className={styles.tags}>{project.tech.map((tag) => <li key={tag}>{tag}</li>)}</ul>}
<p className={styles.links}>
{links.map((link) => (
<a key={link.href} href={link.href} target="_blank" rel="noreferrer">{link.label}</a>
))}
</p>
</div>
</div>
</article>
)
}
type Props = {
projects: Project[]
status: 'loading' | 'live' | 'offline'
generatedAt?: string
profileUrl: string
portfolio: Portfolio
}
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><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' && (
<div className={styles.skeleton} aria-hidden="true">
<span /><span /><span />
</div>
)}
{status === 'offline' && (
<p className={styles.notice}>
<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>
)}
<div className={styles.grid} data-count={projects.length}>
{projects.map((project, index) => (
<Item key={project.name} project={project} span={spans[index]} />
))}
</div>
{status === 'live' && projects.length > 0 && (
<a className={styles.more} href={portfolio.href}>
<span>
<span className={styles.moreTitle}><Bi ru="Есть ещё в портфолио" en="There is more in the portfolio" /></span>
<span className={styles.moreText}>
{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}><Bi ru="Открыть портфолио" en="Open the portfolio" /></span>
</a>
)}
</div>
</section>
)
}
+141
View File
@@ -0,0 +1,141 @@
import { Bi } from '@/lib/i18n'
import type { Resume } from '@/lib/types'
import styles from './sections.module.css'
// 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}><Bi ru="Чем я занимаюсь" en="What I work with" /></h2>
<div className={styles.skills}>
{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({ en, ru }: Readonly<Pair>) {
return (
<section className={styles.section} id="experience">
<div className={styles.shell}>
<h2 className={styles.title}><Bi ru="Опыт" en="Experience" /></h2>
<div className={styles.entries}>
{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({ en, ru }: Readonly<Pair>) {
return (
<section className={styles.section} id="education">
<div className={styles.shell}>
<h2 className={styles.title}><Bi ru="Образование и языки" en="Education and languages" /></h2>
<div className={styles.columns}>
<div>
<h3 className={styles.label}><Bi ru="Образование" en="Education" /></h3>
<div className={styles.stack}>
{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}><Bi ru="Языки" en="Languages" /></h3>
<dl className={styles.languages}>
{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}><Bi ru="Интересы" en="Interests" /></h3>
<p>
<Bi
ru={`${(ru.interests?.length ? ru.interests : en.interests).join(', ')}.`}
en={`${en.interests.join(', ')}.`}
/>
</p>
</div>
</div>
</div>
</section>
)
}
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}><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, 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}>
<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>
)
}
+68
View File
@@ -0,0 +1,68 @@
'use client'
import { useEffect, useState } from 'react'
import type { Live, Portfolio, Project, Resume } from '@/lib/types'
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: `${process.env.NEXT_PUBLIC_SITE_ORIGIN ?? 'https://shanaty.ru'}/portfolio/` }
type Status = 'loading' | 'live' | 'offline'
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>()
const [status, setStatus] = useState<Status>('loading')
useEffect(() => {
const controller = new AbortController()
fetch(DATA_URL, { cache: 'no-store', signal: controller.signal })
.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')
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)
setStatus('live')
})
.catch((error: Error) => {
if (error.name !== 'AbortError') setStatus('offline')
})
return () => controller.abort()
}, [])
// 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 = en.contacts[0].href
return (
<>
<TitleSync />
<TopBar showProjects={showProjects} />
<Gizmo />
<main>
<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 en={en} ru={ru} />
</>
)
}
+53
View File
@@ -0,0 +1,53 @@
'use client'
import { useEffect, useState } from 'react'
import { useLang } from '@/lib/useLang'
import styles from './topbar.module.css'
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(() => {
setTheme(document.documentElement.dataset.theme === 'light' ? 'light' : 'dark')
}, [])
const toggle = () => {
const next: Theme = theme === 'dark' ? 'light' : 'dark'
document.documentElement.dataset.theme = next
try {
localStorage.setItem('theme', next)
} catch {
// Storage can be blocked; the theme still switches for this visit.
}
setTheme(next)
}
return (
<button
type="button"
className={styles.toggle}
onClick={toggle}
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">
<circle cx="12" cy="12" r="4" />
<path d="M12 2.5v2.2M12 19.3v2.2M4.2 4.2l1.6 1.6M18.2 18.2l1.6 1.6M2.5 12h2.2M19.3 12h2.2M4.2 19.8l1.6-1.6M18.2 5.8l1.6-1.6" />
</svg>
) : (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M20.5 14.2A8.5 8.5 0 0 1 9.8 3.5a8.5 8.5 0 1 0 10.7 10.7z" />
</svg>
)}
</button>
)
}
+23
View File
@@ -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
}
+41
View File
@@ -0,0 +1,41 @@
'use client'
import { Bi, SITE } from '@/lib/i18n'
import { useScrolled } from '@/lib/useScroll'
import LangSwitch from './LangSwitch'
import ThemeToggle from './ThemeToggle'
import styles from './topbar.module.css'
/** 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.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={`${SITE}/devlog/`}><Bi ru="Девлог" en="Devlog" /></a>
</nav>
<LangSwitch />
<ThemeToggle />
</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>
)
}
+38
View File
@@ -0,0 +1,38 @@
.gizmo {
position: fixed;
z-index: 40;
top: 128px;
right: max(16px, calc((100vw - 1240px) / 2 - 8px));
width: 88px;
height: 88px;
padding: 0;
border: 0;
border-radius: 50%;
background: var(--disc-bg);
box-shadow: 0 0 0 1px var(--ring);
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
cursor: pointer;
}
.gizmo svg {
display: block;
width: 100%;
height: 100%;
}
.letter {
fill: #fff;
font: 700 10.5px var(--font-body);
pointer-events: none;
}
@media (max-width: 760px) {
.gizmo {
top: auto;
bottom: 16px;
right: 16px;
width: 64px;
height: 64px;
}
}
+220
View File
@@ -0,0 +1,220 @@
.hero {
--sway: 0deg;
position: relative;
isolation: isolate;
display: flex;
flex-direction: column;
min-height: min(100svh, 1000px);
overflow: clip;
background: linear-gradient(180deg, var(--hero-top) 0%, var(--hero-bottom) 100%);
}
/* The floor is a stage of its own below the text, so the grid never runs through the copy at any window height. */
.stage {
position: relative;
flex: 1;
min-height: 260px;
overflow: hidden;
background: linear-gradient(180deg, var(--stage-top) 0%, var(--stage-bottom) 100%);
border-top: 1px solid var(--horizon);
}
.stage::after {
content: '';
position: absolute;
inset: auto 0 0 0;
height: 45%;
background: linear-gradient(180deg, transparent, var(--paper));
}
.floor {
position: absolute;
inset: 0;
perspective: 620px;
perspective-origin: 50% 0;
animation: open 1.7s cubic-bezier(0.2, 0.7, 0.2, 1) 0.1s both;
}
.plane {
position: absolute;
left: -70%;
right: -70%;
top: 0;
height: 300%;
transform-origin: 50% 0;
transform: rotateX(76deg) rotateZ(var(--sway));
background-image:
linear-gradient(to right, var(--grid) 1px, transparent 1px),
linear-gradient(to bottom, var(--grid) 1px, transparent 1px);
background-size: 80px 80px;
background-position: center top;
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 18%, #000 100%);
mask-image: linear-gradient(to bottom, transparent 0%, #000 18%, #000 100%);
}
/* The red X axis crosses the floor; the blue Z axis runs into the distance, like the axes of a 3D viewport. */
.plane::before,
.plane::after {
content: '';
position: absolute;
}
.plane::before {
left: 0;
right: 0;
top: 240px;
height: 3px;
background: var(--x);
}
.plane::after {
top: 0;
bottom: 0;
left: calc(50% - 1.5px);
width: 3px;
background: var(--z);
}
.content {
position: relative;
width: var(--shell);
margin: 0 auto;
padding: 136px 0 72px;
}
.status {
display: inline-flex;
align-items: center;
gap: 10px;
max-width: 100%;
padding: 8px 16px 8px 13px;
border-radius: 999px;
background: var(--pill-bg);
box-shadow: inset 0 0 0 1px var(--line);
font-size: 15px;
font-weight: 500;
line-height: 1.35;
}
.dot {
flex: none;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--y);
box-shadow: 0 0 0 4px var(--dot-glow);
}
.name {
margin: 30px 0 0 -0.045em;
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(18px, 2.1vw, 30px) / 1.2 var(--font-head);
letter-spacing: -0.035em;
}
.bio {
max-width: 58ch;
margin-top: 44px;
font-size: 19px;
line-height: 1.62;
color: var(--ink-2);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 32px;
}
.button {
padding: 14px 24px;
border-radius: var(--radius-control);
font: 600 16px/1.2 var(--font-body);
text-decoration: none;
transition: background-color 0.2s, color 0.2s, box-shadow 0.2s;
}
.primary {
background: var(--ink);
color: var(--paper);
}
.primary:hover {
background: var(--z);
}
.secondary {
box-shadow: inset 0 0 0 1.5px var(--ink);
}
.secondary:hover {
background: var(--ink);
color: var(--paper);
}
/* One orchestrated moment on load: the floor opens out from the Z axis while the text settles in. */
.status,
.name,
.role,
.bio,
.actions {
animation: rise 0.9s cubic-bezier(0.2, 0.7, 0.2, 1) both;
}
.name { animation-delay: 0.06s; }
.role { animation-delay: 0.16s; }
.bio { animation-delay: 0.26s; }
.actions { animation-delay: 0.34s; }
@keyframes rise {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: none; }
}
@keyframes open {
from { clip-path: inset(0 50% 0 50%); }
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; }
.stage { min-height: 200px; }
.plane::before { top: 170px; }
.plane { background-size: 56px 56px; }
}
.link {
margin-left: 10px;
padding: 14px 0;
font: 600 16px/1.2 var(--font-body);
text-decoration: underline;
text-decoration-thickness: 1.5px;
text-underline-offset: 5px;
text-decoration-color: var(--ink-line);
transition: text-decoration-color 0.2s, color 0.2s;
}
.link:hover {
color: var(--z-text);
text-decoration-color: currentColor;
}
@media (max-width: 620px) {
.link {
margin-left: 0;
}
}
+282
View File
@@ -0,0 +1,282 @@
.section {
padding: 24px 0 var(--section);
}
.shell {
width: var(--shell);
margin: 0 auto;
}
.head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 8px 24px;
margin-bottom: clamp(36px, 5vw, 64px);
}
.head h2 {
font: 700 clamp(28px, 4.2vw, 60px) / 1.05 var(--font-head);
letter-spacing: -0.045em;
}
.note {
font-size: 15px;
color: var(--muted);
}
.notice {
padding: 28px 0;
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
color: var(--muted);
}
.notice a {
color: var(--z-text);
}
/* Mobile first: one column. Tablet: two. Desktop: six columns, with the spans planned per project count. */
.grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 56px 28px;
}
.item {
display: flex;
flex-direction: column;
gap: 20px;
min-width: 0;
}
.plate {
position: relative;
display: block;
aspect-ratio: 3 / 2;
overflow: hidden;
border-radius: var(--radius-plate);
background: radial-gradient(120% 90% at 50% 0%, var(--plate-a) 0%, var(--plate-b) 55%, var(--plate-c) 100%);
box-shadow: inset 0 0 0 1px var(--ring);
}
.plate img,
.plate svg {
display: block;
width: 100%;
height: 100%;
}
.cover {
object-fit: cover;
}
.contain {
position: relative;
object-fit: contain;
padding: 6%;
filter: drop-shadow(0 10px 24px var(--img-shadow));
}
.backdrop {
position: absolute;
inset: 0;
object-fit: cover;
transform: scale(1.3);
filter: blur(26px) saturate(1.2);
opacity: 0.55;
}
.caption {
display: flex;
flex: 1;
flex-direction: column;
gap: 10px;
}
.caption h3 {
font: 700 clamp(19px, 1.9vw, 25px) / 1.15 var(--font-head);
letter-spacing: -0.04em;
}
.kind {
font-size: 15px;
font-weight: 500;
color: var(--muted);
}
.description {
max-width: 62ch;
color: var(--ink-2);
}
.points {
display: grid;
gap: 4px;
font-size: 15px;
color: var(--ink-3);
}
.points li {
position: relative;
padding-left: 18px;
}
.points li::before {
content: '';
position: absolute;
left: 2px;
top: 0.62em;
width: 6px;
height: 6px;
border-radius: 1px;
background: var(--z);
}
.foot {
display: flex;
flex-direction: column;
gap: 16px;
margin-top: auto;
padding-top: 8px;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tags li {
padding: 3px 11px;
border-radius: 999px;
box-shadow: inset 0 0 0 1px var(--line);
font-size: 13.5px;
line-height: 1.5;
}
.links {
display: flex;
flex-wrap: wrap;
gap: 4px 20px;
font-size: 15px;
font-weight: 600;
}
.links a {
text-decoration: underline;
text-decoration-thickness: 1.5px;
text-underline-offset: 4px;
text-decoration-color: var(--ink-line);
transition: text-decoration-color 0.2s, color 0.2s;
}
.links a:hover {
color: var(--z-text);
text-decoration-color: currentColor;
}
/* Placeholder while the list loads: three empty plates that hold the space. */
.skeleton {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 28px;
}
.skeleton span {
aspect-ratio: 3 / 2;
border-radius: var(--radius-plate);
background: linear-gradient(160deg, var(--plate-a), var(--plate-c));
}
@media (min-width: 641px) and (max-width: 1000px) {
.grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
/* An odd last project spans the full row instead of leaving a hole. */
.item:last-child:nth-child(odd) {
grid-column: 1 / -1;
}
.item:last-child:nth-child(odd):not(:only-child) .plate {
aspect-ratio: 16 / 9;
}
}
@media (max-width: 640px) {
.skeleton {
grid-template-columns: minmax(0, 1fr);
}
.skeleton span:nth-child(n + 2) {
display: none;
}
}
@media (min-width: 1001px) {
.grid {
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 72px 32px;
}
.item[data-span='2'] { grid-column: span 2; }
.item[data-span='3'] { grid-column: span 3; }
.item[data-span='4'] { grid-column: span 4; }
.item[data-span='6'] { grid-column: span 6; }
.item[data-span='2'] .plate { aspect-ratio: 4 / 3; }
.item[data-span='3'] .plate { aspect-ratio: 3 / 2; }
.item[data-span='4'] .plate { aspect-ratio: 16 / 9; }
/* A single project gets the whole width: plate and text side by side. */
.item[data-span='6'] {
display: grid;
grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
align-items: center;
gap: 40px;
}
.item[data-span='6'] .plate { aspect-ratio: 16 / 10; }
}
/* The whole row is a link to the full portfolio; it closes the list. */
.more {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 24px 40px;
margin-top: clamp(56px, 7vw, 104px);
padding: clamp(28px, 4vw, 52px) 0;
border-top: 1px solid var(--ink);
border-bottom: 1px solid var(--line);
text-decoration: none;
}
.more > span:first-child {
display: flex;
flex-direction: column;
gap: 10px;
}
.moreTitle {
font: 700 clamp(22px, 3vw, 40px) / 1.1 var(--font-head);
letter-spacing: -0.045em;
}
.moreText {
max-width: 52ch;
color: var(--muted);
}
.moreButton {
flex: none;
padding: 16px 28px;
border-radius: var(--radius-control);
background: var(--ink);
color: var(--paper);
font: 600 17px/1.2 var(--font-body);
transition: background-color 0.2s, color 0.2s;
}
.more:hover .moreButton {
background: var(--z);
color: #fff;
}
+226
View File
@@ -0,0 +1,226 @@
.section {
padding: var(--section) 0 0;
}
.shell {
width: var(--shell);
margin: 0 auto;
}
.title {
margin-bottom: clamp(32px, 4.5vw, 56px);
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. */
.skills {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 clamp(32px, 6vw, 96px);
}
.skill {
display: flex;
flex-direction: column;
gap: 10px;
padding: 28px 0 36px;
border-top: 1px solid var(--line);
}
.skill h3,
.entry h3 {
font: 700 clamp(18px, 1.8vw, 23px) / 1.2 var(--font-head);
letter-spacing: -0.04em;
}
.skill p {
max-width: 52ch;
color: var(--ink-2);
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
}
.tags li {
padding: 3px 11px;
border-radius: 999px;
background: var(--chip);
font-size: 13.5px;
line-height: 1.5;
}
/* Experience */
.entry {
display: grid;
grid-template-columns: minmax(150px, 1fr) minmax(0, 3fr);
gap: 24px 32px;
padding: 32px 0 40px;
border-top: 1px solid var(--line);
}
.meta {
color: var(--muted);
font-size: 15px;
}
.list {
display: grid;
gap: 10px;
margin-top: 18px;
max-width: 66ch;
}
.list li {
position: relative;
padding-left: 22px;
}
.list li::before {
content: '';
position: absolute;
left: 2px;
top: 0.7em;
width: 7px;
height: 7px;
border-radius: 1px;
background: var(--z);
}
/* Education, languages, interests */
.columns {
display: grid;
grid-template-columns: 1.4fr 1fr 1.2fr;
gap: 40px clamp(32px, 5vw, 72px);
padding-top: 28px;
border-top: 1px solid var(--line);
}
.label {
margin-bottom: 14px;
font: 700 17px/1.2 var(--font-head);
letter-spacing: -0.03em;
}
.stack {
display: grid;
gap: 22px;
}
.strong {
font-weight: 600;
}
.languages {
display: grid;
gap: 8px;
}
.languages div {
display: flex;
justify-content: space-between;
gap: 16px;
padding-bottom: 8px;
border-bottom: 1px solid var(--line);
}
.languages dd {
color: var(--muted);
}
/* Contact: the one inverted band, closing the page. */
.contact {
margin-top: var(--section);
padding: clamp(72px, 9vw, 128px) 0 40px;
background: var(--band-bg);
color: var(--band-ink);
}
.contactTitle {
font: 700 clamp(28px, 4.2vw, 60px) / 1.05 var(--font-head);
letter-spacing: -0.045em;
}
.contactLead {
margin-top: 16px;
color: var(--band-2);
}
.mail {
display: inline-block;
margin-top: clamp(28px, 4vw, 48px);
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;
transition: background-size 0.25s, color 0.25s;
}
.mail:hover {
background-size: 100% 100%;
}
.contacts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0 32px;
margin-top: clamp(56px, 7vw, 96px);
}
.contacts li {
display: flex;
flex-direction: column;
gap: 2px;
padding: 18px 0;
border-top: 1px solid var(--band-line);
}
.contacts span {
font-size: 14px;
color: var(--band-3);
}
.contacts a {
text-decoration: underline;
text-decoration-thickness: 1.5px;
text-underline-offset: 4px;
text-decoration-color: var(--band-underline);
overflow-wrap: anywhere;
}
.contacts a:hover {
text-decoration-color: currentColor;
}
.legal {
margin-top: clamp(48px, 6vw, 80px);
font-size: 14px;
color: var(--band-3);
}
@media (max-width: 900px) {
.skills {
grid-template-columns: minmax(0, 1fr);
}
.columns {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 760px) {
.legal {
padding-right: 76px; /* keeps clear of the floating gizmo */
}
}
@media (max-width: 700px) {
.entry {
grid-template-columns: minmax(0, 1fr);
gap: 10px;
}
}
+140
View File
@@ -0,0 +1,140 @@
.bar {
position: fixed;
z-index: 50;
inset: 0 0 auto 0;
border-bottom: 1px solid transparent;
transition: background-color 0.25s, border-color 0.25s;
}
.solid {
background: var(--topbar-bg);
border-bottom-color: var(--line);
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
}
.inner {
width: var(--shell);
height: 68px;
margin: 0 auto;
display: flex;
align-items: center;
gap: 14px;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
margin-right: auto;
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 {
padding: 6px 0;
text-decoration: none;
background: linear-gradient(currentColor, currentColor) left bottom / 0 1.5px no-repeat;
transition: background-size 0.25s;
}
.nav a:hover {
background-size: 100% 1.5px;
}
.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);
}
.toggle {
display: grid;
place-items: center;
width: 40px;
height: 40px;
padding: 0;
border: 0;
border-radius: var(--radius-control);
background: transparent;
color: var(--ink);
box-shadow: inset 0 0 0 1px var(--line);
cursor: pointer;
transition: box-shadow 0.2s;
}
.toggle:hover {
box-shadow: inset 0 0 0 1.5px var(--ink);
}
.toggle svg {
width: 20px;
height: 20px;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
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;
}
}
-10
View File
@@ -1,10 +0,0 @@
baseURL = "https://emil28092005.github.io/"
languageCode = "ru"
title = "Emil Shanayev"
[params]
author = "Emil Shanayev"
description = "DevOps & Backend Engineer"
keywords = ["DevOps", "Backend", "Go", "Tailwind", "Resume"]
github = "https://github.com/emil28092005"
email = "emil28092005@gmail.com"
-58
View File
@@ -1,58 +0,0 @@
---
title: "Emil Shanayev"
---
## Обо мне
Молодой разработчик с глубокими знаниями в backend‑разработке и DevOps‑инженерии. Специализируюсь на Python/FastAPI, контейнеризации (Docker, Kubernetes) и Linux‑системах. Имею практический опыт проектирования архитектур, работы с CI/CD пайплайнами и управления инфраструктурой. Предпочитаю командную работу, обладаю лидерскими качествами и умею находить творческие решения сложных задач.
---
## Ключевые навыки
### Backend & Web Development
{{< skills "Python · FastAPI · asyncio" "Go · микросервисы" "JavaScript / TypeScript" "PostgreSQL · Redis · SQLite" "Tailwind CSS" >}}
### DevOps & Infrastructure
{{< skills "Docker · Compose" "Kubernetes · Helm" "GitHub Actions · Jenkins" "Linux · Bash" "Harbor" >}}
### Дополнительные компетенции
{{< skills "Git · GitHub Actions" "Agile · Scrum" "Java · C#" "VPS · сервер‑администрирование" >}}
---
## Образование
**Университет Иннополис** – Программа Data Structures and Artificial Intelligence (DSAI)
*2 года обучения*, факультатив *Product Management* – отличная оценка
---
## Профессиональный опыт
**Самостоятельные проекты**
- Разработка backend‑приложений на **Python/FastAPI** с асинхронным программированием
- Контейнеризация в **Docker**, оркестрация в **Kubernetes**
- Настройка **CI/CD**‑пайплайнов с **GitHub Actions** и **Jenkins**
- Администрирование **Linux‑серверов** (Debian, Ubuntu)
**Командный опыт**
- Вузовские учебные проекты – лидерство в небольших командах
- Agile‑процессы, Scrum‑методики
---
## Дополнительно
- **Обучение**: постоянное изучение документаций, YouTube‑курсов, Perplexity AI
- **Открытый исходный код**: работа с инструментами DevOps‑сферы
- **Командная работа**: наставничество, менторинг
---
*Продолжаю развиваться в направлении DevOps и облачных технологий, активно экспериментирую с новыми подходами к инфраструктуре и автоматизации.*
-5
View File
@@ -1,5 +0,0 @@
---
title: "DevOps Engineer"
tags: ["DevOps", "Backend", "Go"]
draft: true
---
@@ -1 +0,0 @@
# Резюме _DevOps-инженер _ Backend-разработчик
-6
View File
@@ -1,6 +0,0 @@
GitHub indicates standard conventions: tailwind.css -> main.css (built). Hugo builds site to .???
I need to examine the assets/css folder:
</parameter>
</function>
</tool_call>
-1
View File
@@ -1 +0,0 @@
# Identity Template
-1
View File
@@ -1 +0,0 @@
# Preferences Template
-6
View File
@@ -1,6 +0,0 @@
- IMPORTANT Rules: Always be kind. Prioritize user well-being.
- Code Quality Principles: Write clear, concise, and efficient code. Avoid unnecessary complexity.
- Coding Style Preferences: 2-space indentation, descriptive variable names.
- Architecture Principles: Modular design, scalability focus.
- Development Workflow: Git + PRs + descriptive commits + tests.
- Technical Preferences: TypeScript, REST, PostgreSQL, Redis, Docker.
+366
View File
@@ -0,0 +1,366 @@
{
"name": "Emil Shanayev",
"eyebrow": "Backend, DevOps, AI systems, scientific computing and game tech",
"role": "Backend, DevOps & AI Systems Developer",
"bio": "I build AI-native products, backend services and the infrastructure to run them: from Python/FastAPI and Go services, WebSocket APIs and containerised CI/CD pipelines to distributed molecular workloads, LLM-controlled worlds and a Vulkan game engine. I run self-hosted services on my own Linux servers.",
"location": "Russia",
"availability": "Open to internships, part-time or project work. Remote, hybrid or on-site.",
"contacts": [
{
"label": "Email",
"value": "emil28092005@yandex.ru",
"href": "mailto:emil28092005@yandex.ru",
"note": "preferred"
},
{
"label": "Email, alternative",
"value": "emil28092005@gmail.com",
"href": "mailto:emil28092005@gmail.com"
},
{
"label": "Telegram",
"value": "@emilshanaty",
"href": "https://t.me/emilshanaty"
},
{
"label": "GitHub",
"value": "emil28092005",
"href": "https://github.com/emil28092005"
},
{
"label": "Gitea",
"value": "git.shanaty.ru/Emil_Shanaty",
"href": "https://git.shanaty.ru/Emil_Shanaty"
}
],
"skills": [
{
"title": "Backend & web",
"text": "Asynchronous REST and WebSocket services, database design and query optimisation.",
"stack": [
"Python",
"FastAPI",
"asyncio",
"Go",
"Rust",
"TypeScript",
"SQL",
"PostgreSQL",
"SQLite",
"Redis",
"WebSocket",
"JSON-RPC",
"Java (basic)"
]
},
{
"title": "DevOps & infrastructure",
"text": "Containerised delivery, CI/CD automation and day-to-day Linux server administration.",
"stack": [
"Docker",
"docker-compose",
"Kubernetes",
"GitHub Actions",
"Jenkins",
"Harbor",
"Linux",
"Bash",
"VPS",
"Debian",
"Ubuntu"
]
},
{
"title": "AI-native systems",
"text": "Agent protocols, recursive research teams, MCP integrations and human-in-the-loop feedback systems.",
"stack": [
"MCP",
"ACP / JSON-RPC",
"LLM APIs",
"TypeScript",
"Python"
]
},
{
"title": "Scientific software",
"text": "Molecular data processing, cheminformatics workloads, deterministic pipelines and reproducible command-line tools.",
"stack": [
"Python",
"RDKit",
"ChEMBL",
"Go",
"PostgreSQL"
]
},
{
"title": "Graphics & game tech",
"text": "Performance-oriented engines, native audio tools and 3D content pipelines.",
"stack": [
"C# / .NET",
"Vulkan",
"Unity",
"Blender",
"Rust",
"C++"
]
},
{
"title": "Team & process",
"text": "Leading small teams, Agile/Scrum coordination and clear communication with stakeholders.",
"stack": [
"Agile",
"Scrum",
"Team leading",
"Git",
"GitHub"
]
}
],
"experience": [
{
"title": "Independent projects & self-directed engineering",
"meta": "Ongoing",
"points": [
"Full-featured asynchronous backend applications in Python/FastAPI.",
"Containerised applications with Docker, deployed to Kubernetes.",
"CI/CD pipelines with GitHub Actions for automated testing and deployment.",
"Administration of Linux servers (Debian VPS) and self-hosted services.",
"Databases in practice: SQLite, Redis, PostgreSQL."
]
},
{
"title": "Team experience",
"meta": "University and agency-style projects",
"points": [
"Led teams in university projects and managed a team in an agency-style setting.",
"Worked in Agile environments using Scrum; task distribution and coordination.",
"Regular communication with team members and stakeholders."
]
}
],
"education": [
{
"period": "2026 to 2030",
"institution": "HSE University, Moscow",
"program": "Bachelor’s programme in Game and Digital Product Development. First year."
},
{
"period": "2023 to 2025",
"institution": "Innopolis University",
"program": "Data Structures and Artificial Intelligence (DSAI). Two years, higher education not completed."
}
],
"languages": [
{
"name": "Russian",
"level": "Native"
},
{
"name": "English",
"level": "C1"
}
],
"interests": [
"DevOps and Infrastructure as Code",
"high-load backend systems",
"Linux and systems administration",
"computer graphics and game development",
"open source",
"learning from documentation and by building"
],
"projectNotes": {
"Cortex_Engine": {
"title": "Cortex Engine",
"rank": 1,
"kind": "AI-native 3D game engine",
"description": "A C#/.NET 9 game engine with a pure P/Invoke Vulkan 1.3 renderer, PBR shading, cubemap-array shadows, native Box3D physics and Flecs ECS. Its editor and runtime expose scene operations to AI agents through MCP.",
"highlights": [
"Pure Vulkan 1.3 backend",
"PBR + multi-light shadows",
"Playable FPS demo",
"7 MCP scene tools"
],
"tech": [
"C#",
".NET 9",
"Vulkan",
"SDL3",
"Box3D",
"Flecs",
"MCP"
],
"links": [
{
"label": "GitHub",
"href": "https://github.com/emil28092005/Cortex_Engine"
}
]
},
"SciMesh": {
"title": "SciMesh",
"rank": 2,
"kind": "Distributed scientific computing platform",
"description": "A local-first platform that turns molecular computations into independent tasks, dispatches them to Go workers and deterministically combines checksum-protected artifacts. The Python SDK provides exact ChEMBL workloads without dense N×N matrices.",
"highlights": [
"Python workload SDK",
"Go coordinator + workers",
"PostgreSQL / SQLite modes",
"Cross-platform signed releases"
],
"tech": [
"Python",
"Go",
"RDKit",
"PostgreSQL",
"Docker",
"SHA-256"
],
"links": [
{
"label": "GitHub",
"href": "https://github.com/emil28092005/SciMesh"
}
]
},
"AICC-Protocol": {
"title": "AICC Protocol",
"rank": 3,
"kind": "LLM-controlled character protocol",
"description": "An engine-agnostic protocol and Python SDK for connecting language-model agents to virtual environments. The reference capsule testbed proves the full loop: perception, movement and interaction over WebSocket with a protocol-conformant bridge.",
"highlights": [
"Async-first Python SDK",
"WebSocket + in-process transports",
"9/9 conformance scenarios",
"Vision and navigation demo"
],
"tech": [
"Python",
"JSON Schema",
"WebSocket",
"Pydantic",
"LLM agents"
],
"links": [
{
"label": "GitHub",
"href": "https://github.com/emil28092005/AICC-Protocol"
},
{
"label": "aicc-py",
"href": "https://github.com/emil28092005/aicc-py"
}
]
},
"hypothesis-machine": {
"title": "Hypothesis Machine",
"rank": 4,
"kind": "Recursive research-agent extension",
"description": "A TypeScript extension for the Pi Coding Agent that turns one interactive session into a persistent, recursive research team. It combines parallel agents, recoverable trees, full-text research memory and isolated Docker experiments with independent review.",
"highlights": [
"Recursive / parallel agents",
"Persistent tree recovery",
"SQLite FTS research memory",
"Sandboxed experiment runner"
],
"tech": [
"TypeScript",
"Node.js 22",
"SQLite",
"Docker",
"Pi Agent"
],
"links": [
{
"label": "GitHub",
"href": "https://github.com/emil28092005/hypothesis-machine"
}
]
},
"bibi_bot": {
"title": "Bibi Bot",
"rank": 5,
"kind": "Self-hosted Telegram AI assistant",
"description": "A production-oriented Telegram assistant for private groups: one-way HLS game streaming in a Mini App, DeepSeek-backed chat, repository-grounded answers, scoped SQLite memory and PostgreSQL-backed team context.",
"highlights": [
"Telegram Mini App + HLS",
"Repository research with partial clones",
"Scoped memory and privacy controls",
"Docker Compose deployment"
],
"tech": [
"Python",
"FastAPI",
"Telegram",
"DeepSeek",
"PostgreSQL",
"Docker"
],
"links": [
{
"label": "GitHub",
"href": "https://github.com/emil28092005/bibi_bot"
}
]
},
"Soundgen": {
"title": "Soundgen",
"rank": 11,
"kind": "Tool / experiment",
"description": "Rust 8-bit synthesis, MCP generation and human-feedback training loop",
"links": [
{
"label": "GitHub",
"href": "https://github.com/emil28092005/Soundgen"
}
]
},
"KimiFleet": {
"title": "KimiFleet",
"rank": 12,
"kind": "Tool / experiment",
"description": "Visible multi-agent orchestrator over ACP with live dashboard and handoffs",
"links": [
{
"label": "GitHub",
"href": "https://github.com/emil28092005/KimiFleet"
}
]
},
"Imagen": {
"title": "Imagen",
"rank": 13,
"kind": "Tool / experiment",
"description": "MCP pixel-art generator with transparent backgrounds and rated references",
"links": [
{
"label": "GitHub",
"href": "https://github.com/emil28092005/Imagen"
}
]
},
"Fractalier": {
"title": "Fractalier",
"rank": 14,
"kind": "Tool / experiment",
"description": "Procedural fractal evolution game with selection, crossover and mutation",
"links": [
{
"label": "GitHub",
"href": "https://github.com/emil28092005/Fractalier"
}
]
},
"hermes-plugin-mome": {
"title": "MoME",
"rank": 15,
"kind": "Tool / experiment",
"description": "Sparse-gated personal memory plugin with an online-learning router",
"links": [
{
"label": "GitHub",
"href": "https://github.com/emil28092005/hermes-plugin-mome"
}
]
}
}
}
+171
View File
@@ -0,0 +1,171 @@
{
"name": "Эмиль Шанаев",
"eyebrow": "Бэкенд, DevOps, системы на базе ИИ, научные вычисления и игровые технологии",
"role": "Разработчик: бэкенд, DevOps и системы на базе ИИ",
"bio": "Строю AI-native продукты, backend-сервисы и инфраструктуру для них: от Python/FastAPI- и Go-сервисов, WebSocket API и контейнеризированных CI/CD-пайплайнов до распределённых молекулярных вычислений, миров под управлением LLM и игрового движка на Vulkan. Держу собственные сервисы на своих Linux-серверах.",
"location": "Россия",
"availability": "Открыт к стажировкам, частичной занятости и проектной работе. Удалённо, гибрид или в офисе.",
"contacts": [
{
"label": "Почта",
"value": "emil28092005@yandex.ru",
"href": "mailto:emil28092005@yandex.ru",
"note": "основная"
},
{
"label": "Почта, запасная",
"value": "emil28092005@gmail.com",
"href": "mailto:emil28092005@gmail.com"
},
{
"label": "Telegram",
"value": "@emilshanaty",
"href": "https://t.me/emilshanaty"
},
{
"label": "GitHub",
"value": "emil28092005",
"href": "https://github.com/emil28092005"
},
{
"label": "Gitea",
"value": "git.shanaty.ru/Emil_Shanaty",
"href": "https://git.shanaty.ru/Emil_Shanaty"
}
],
"skills": [
{
"title": "Backend и веб",
"text": "Асинхронные REST- и WebSocket-сервисы, проектирование баз данных и оптимизация запросов.",
"stack": [
"Python",
"FastAPI",
"asyncio",
"Go",
"Rust",
"TypeScript",
"SQL",
"PostgreSQL",
"SQLite",
"Redis",
"WebSocket",
"JSON-RPC",
"Java (базово)"
]
},
{
"title": "DevOps и инфраструктура",
"text": "Контейнеризация, CI/CD и повседневное администрирование Linux-серверов.",
"stack": [
"Docker",
"docker-compose",
"Kubernetes",
"GitHub Actions",
"Jenkins",
"Harbor",
"Linux",
"Bash",
"VPS",
"Debian",
"Ubuntu"
]
},
{
"title": "Системы на базе ИИ",
"text": "Протоколы агентов, рекурсивные исследовательские команды, MCP-интеграции и системы с обратной связью от человека.",
"stack": [
"MCP",
"ACP / JSON-RPC",
"LLM API",
"TypeScript",
"Python"
]
},
{
"title": "Научное ПО",
"text": "Обработка молекулярных данных, хемоинформатика, детерминированные пайплайны и воспроизводимые консольные инструменты.",
"stack": [
"Python",
"RDKit",
"ChEMBL",
"Go",
"PostgreSQL"
]
},
{
"title": "Графика и игровые технологии",
"text": "Производительные движки, нативные аудио-инструменты и пайплайны 3D-контента.",
"stack": [
"C# / .NET",
"Vulkan",
"Unity",
"Blender",
"Rust",
"C++"
]
},
{
"title": "Команда и процессы",
"text": "Руководство небольшими командами, Agile/Scrum и чёткая коммуникация со стейкхолдерами.",
"stack": [
"Agile",
"Scrum",
"Тимлидинг",
"Git",
"GitHub"
]
}
],
"experience": [
{
"title": "Самостоятельные проекты и обучение",
"meta": "Продолжается",
"points": [
"Полнофункциональные backend-приложения на Python/FastAPI с асинхронным программированием.",
"Контейнеризация приложений в Docker, развёртывание в Kubernetes.",
"CI/CD-пайплайны на GitHub Actions для автоматизации тестирования и деплоя.",
"Администрирование Linux-серверов (VPS на Debian) и самостоятельно размещаемых сервисов.",
"Работа с базами данных: SQLite, Redis, PostgreSQL."
]
},
{
"title": "Командный опыт",
"meta": "Университетские и агентские проекты",
"points": [
"Руководил командами в университетских проектах и управлял командой в агентской среде.",
"Работал по Agile/Scrum: распределение задач и координация.",
"Постоянная коммуникация с участниками команды и стейкхолдерами."
]
}
],
"education": [
{
"period": "2026–2030",
"institution": "НИУ ВШЭ, Москва",
"program": "Бакалавриат: разработка игр и цифровых продуктов. Первый курс."
},
{
"period": "2023–2025",
"institution": "Университет Иннополис",
"program": "Data Structures and Artificial Intelligence (DSAI). Два года, высшее образование не завершено."
}
],
"languages": [
{
"name": "Русский",
"level": "Родной"
},
{
"name": "Английский",
"level": "C1"
}
],
"interests": [
"DevOps и Infrastructure as Code",
"высоконагруженные backend-системы",
"Linux и системное администрирование",
"компьютерная графика и разработка игр",
"open source",
"обучение по документации и через практику"
]
}
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Build the site and publish it at https://shanaty.ru/resume/ (Gitea/Caddy server).
#
# The text and project list are NOT baked into the build: the page reads /data/resume.json, which the
# server rebuilds every minute from data/resume.json in the Gitea repo plus the repos tagged `resume`.
# So redeploy only after changing code or styles; content changes just need a push to Gitea.
# The site is uploaded to a temp dir and swapped in, so it is never half-updated.
set -euo pipefail
cd "$(dirname "$0")"
HOST="${GT2GH_HOST:-root@135.106.198.222}"
KEY="${GT2GH_KEY:-$HOME/.ssh/MyGitea}"
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 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" "
set -e
cd $SITE_DIR
rm -rf .resume.new .resume.old
mkdir .resume.new
tar -x -C .resume.new --no-same-owner
chmod -R a+rX .resume.new
if [ -d resume ]; then mv resume .resume.old; fi
mv .resume.new resume
rm -rf .resume.old
"
echo "Deployed: https://shanaty.ru/resume/"
-112
View File
@@ -1,112 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ if .IsHome }}{{ .Site.Params.author }} — {{ .Site.Params.description }}{{ else }}{{ .Title }} | {{ .Site.Params.author }}{{ end }}</title>
{{ $css := resources.Get "css/main.css" }}
{{ if $css }}<link rel="stylesheet" href="{{ $css.RelPermalink }}">{{ end }}
</head>
<body class="bg-slate-950 text-slate-100 min-h-screen">
<div class="h-px bg-gradient-to-r from-blue-600 via-cyan-400 to-blue-600"></div>
<nav class="sticky top-0 z-10 bg-slate-950/80 backdrop-blur border-b border-slate-800/60 px-8 py-3 flex items-center justify-between">
<span class="text-sm font-mono text-slate-500 tracking-wide">{{ .Site.Params.author }}</span>
<button id="pdf-btn" onclick="downloadPDF()"
class="text-xs text-slate-500 hover:text-white transition-colors px-3 py-1.5 rounded border border-slate-700 hover:border-slate-500 cursor-pointer">
Download PDF
</button>
</nav>
<main id="resume-main" class="max-w-3xl mx-auto px-6 py-14">
{{ block "main" . }}{{ end }}
</main>
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js" defer></script>
<script>
function downloadPDF() {
const btn = document.getElementById('pdf-btn');
btn.textContent = 'Generating…';
btn.disabled = true;
html2pdf().set({
margin: [14, 18, 14, 18],
filename: 'Emil_Shanayev_Resume.pdf',
image: { type: 'png' },
html2canvas: {
scale: 2.5,
useCORS: true,
onclone: (doc) => {
// Inject a stylesheet — inline styles lose to !important in our CSS,
// but a later <style> tag wins the cascade.
const style = doc.createElement('style');
style.textContent = `
body {
background: #ffffff !important;
background-image: none !important;
color: #0f172a !important;
min-height: auto !important;
}
#resume-main {
max-width: none !important;
margin: 0 !important;
padding: 0 !important;
background: #ffffff !important;
}
h1 {
background-image: none !important;
-webkit-background-clip: unset !important;
background-clip: unset !important;
-webkit-text-fill-color: #0f172a !important;
color: #0f172a !important;
}
header { border-color: #e2e8f0 !important; }
header p { color: #475569 !important; }
header span {
background: #eff6ff !important;
border-color: #bfdbfe !important;
color: #1d4ed8 !important;
}
header a {
background: transparent !important;
border: none !important;
color: #1d4ed8 !important;
padding: 0 !important;
font-size: 0.8rem !important;
}
.resume-content h2 {
color: #1d4ed8 !important;
background: #eff6ff !important;
border-color: #1d4ed8 !important;
box-shadow: none !important;
margin-left: 0 !important;
border-radius: 0.25rem !important;
}
.resume-content h3 { color: #1e293b !important; }
.resume-content p { color: #334155 !important; }
.resume-content li { color: #334155 !important; }
.resume-content li::marker { color: #1d4ed8 !important; }
.resume-content strong { color: #0f172a !important; }
.resume-content a { color: #1d4ed8 !important; }
.resume-content code { background: #f1f5f9 !important; color: #1d4ed8 !important; }
.resume-content hr { border-color: #e2e8f0 !important; }
.skills-grid span {
background: #eff6ff !important;
border-color: #bfdbfe !important;
color: #1d4ed8 !important;
}
`;
doc.head.appendChild(style);
}
},
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' },
pagebreak: { mode: 'css' }
})
.from(document.getElementById('resume-main'))
.save()
.then(() => {
btn.textContent = 'Download PDF';
btn.disabled = false;
});
}
</script>
</body>
</html>
-6
View File
@@ -1,6 +0,0 @@
{{ define "main" }}
<article class="prose prose-invert max-w-none">
<h1 class="text-3xl font-bold mb-6">{{ .Title }}</h1>
{{ .Content }}
</article>
{{ end }}
-31
View File
@@ -1,31 +0,0 @@
{{ define "main" }}
<header class="mb-14 pb-10 border-b border-slate-800">
<div class="flex flex-wrap gap-2 mb-5">
<span class="px-2.5 py-1 rounded-full text-xs font-mono bg-blue-500/10 text-blue-400 border border-blue-500/20">DevOps</span>
<span class="px-2.5 py-1 rounded-full text-xs font-mono bg-blue-500/10 text-blue-400 border border-blue-500/20">Backend</span>
<span class="px-2.5 py-1 rounded-full text-xs font-mono bg-blue-500/10 text-blue-400 border border-blue-500/20">Go · Python</span>
<span class="px-2.5 py-1 rounded-full text-xs font-mono bg-blue-500/10 text-blue-400 border border-blue-500/20">Kubernetes</span>
</div>
<h1 class="text-5xl font-bold tracking-tight bg-gradient-to-br from-white via-slate-200 to-slate-500 bg-clip-text text-transparent">
{{ .Site.Params.author }}
</h1>
<p class="mt-3 text-slate-400 text-lg">{{ .Site.Params.description }}</p>
<div class="mt-6 flex flex-wrap gap-3">
{{ if .Site.Params.github }}
<a href="{{ .Site.Params.github }}"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-sm text-slate-300 hover:text-white transition-all border border-slate-700">
GitHub ↗
</a>
{{ end }}
{{ if .Site.Params.email }}
<a href="mailto:{{ .Site.Params.email }}"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-800/50 hover:bg-slate-700 text-sm text-slate-400 hover:text-white transition-all border border-slate-700/60">
{{ .Site.Params.email }}
</a>
{{ end }}
</div>
</header>
<article class="resume-content prose prose-invert max-w-none">
{{ .Content }}
</article>
{{ end }}
-5
View File
@@ -1,5 +0,0 @@
<div class="skills-grid">
{{ range .Params }}
<span>{{ . }}</span>
{{ end }}
</div>
+32
View File
@@ -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'
+39
View File
@@ -0,0 +1,39 @@
export type Span = 2 | 3 | 4 | 6
/**
* Column spans (out of 6) for `count` projects, so that every row is exactly full whatever the count:
* rows of two or three, a feature row first, and never a lone project in the last row (a single project
* spans the whole width). Two-project rows alternate 4+2 and 2+4 for rhythm.
*/
export function planSpans(count: number): Span[] {
if (count <= 0) return []
if (count === 1) return [6]
const sizes: number[] = []
const pattern = [2, 3, 3]
let remaining = count
for (let i = 0; remaining > 0; i++) {
const size = Math.min(pattern[i % pattern.length], remaining)
sizes.push(size)
remaining -= size
}
const last = sizes.length - 1
if (sizes[last] === 1) {
if (sizes[last - 1] === 3) {
sizes[last - 1] = 2
sizes[last] = 2
} else {
sizes[last - 1] = 3
sizes.pop()
}
}
const spans: Span[] = []
let pairs = 0
for (const size of sizes) {
if (size === 3) spans.push(2, 2, 2)
else spans.push(...(pairs++ % 2 === 0 ? ([4, 2] as const) : ([2, 4] as const)))
}
return spans
}
+47
View File
@@ -0,0 +1,47 @@
export type Contact = { label: string; value: string; href: string; note?: string }
export type Skill = { title: string; text: string; stack: string[] }
export type Experience = { title: string; meta?: string; points: string[] }
export type Education = { period: string; institution: string; program: string }
export type Link = { label: string; href: string }
export type Resume = {
name: string
eyebrow: string
role: string
bio: string
location: string
availability: string
contacts: Contact[]
skills: Skill[]
experience: Experience[]
education: Education[]
languages: { name: string; level: string }[]
interests: string[]
}
// A public Gitea repository tagged `resume`, merged with the optional notes from data/resume.json.
export type Demo = { url: string; width: number; height: number }
export type Project = {
name: string
title?: string
url: string
description?: string
kind?: string
highlights?: string[]
tech: string[]
links?: Link[]
homepage?: string
language?: string
stars?: number
updated?: string
rank?: number
// Image copied from `demo.*` in the repo root by the server sync; the size lets the layout reserve space.
demo?: Demo
}
// Where "the full portfolio" lives: Gitea's public list of repos with the portfolio topic, and how many there are.
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; resume_ru?: Resume; portfolio?: Portfolio; stats?: { repos?: number }; projects: Project[] }
+20
View File
@@ -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
}
+43
View File
@@ -0,0 +1,43 @@
'use client'
import { useEffect, useState } from 'react'
const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches
/** Scroll position of the page as 0..1. Updates at most once per frame; frozen when the user prefers reduced motion. */
export function useScrollProgress(): number {
const [progress, setProgress] = useState(0)
useEffect(() => {
if (reducedMotion()) return
let frame = 0
const update = () => {
frame = 0
const max = document.documentElement.scrollHeight - window.innerHeight
setProgress(max > 0 ? Math.min(1, Math.max(0, window.scrollY / max)) : 0)
}
const schedule = () => {
if (!frame) frame = requestAnimationFrame(update)
}
update()
window.addEventListener('scroll', schedule, { passive: true })
window.addEventListener('resize', schedule)
return () => {
window.removeEventListener('scroll', schedule)
window.removeEventListener('resize', schedule)
if (frame) cancelAnimationFrame(frame)
}
}, [])
return progress
}
/** True once the page has scrolled past the very top; only changes state when that flips. */
export function useScrolled(threshold = 8): boolean {
const [scrolled, setScrolled] = useState(false)
useEffect(() => {
const update = () => setScrolled(window.scrollY > threshold)
update()
window.addEventListener('scroll', update, { passive: true })
return () => window.removeEventListener('scroll', update)
}, [threshold])
return scrolled
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+11
View File
@@ -0,0 +1,11 @@
// BASE_PATH overrides the GitHub Pages default, e.g. BASE_PATH=/resume for https://shanaty.ru/resume/.
const basePath = process.env.BASE_PATH ?? '/Emil_Shanayev_Resume'
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
...(basePath && { basePath, assetPrefix: basePath }),
images: { unoptimized: true },
}
export default nextConfig
+804 -1454
View File
File diff suppressed because it is too large Load Diff
+14 -9
View File
@@ -1,16 +1,21 @@
{
"name": "resume-website",
"version": "1.0.0",
"name": "emil-shanayev-resume",
"version": "2.0.0",
"private": true,
"scripts": {
"build:css": "postcss assets/css/tailwind.css -o assets/css/main.css",
"build": "npm run build:css && hugo --minify"
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "15.5.21",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.19",
"autoprefixer": "^10.5.0",
"postcss": "^8.5.12",
"postcss-cli": "^11.0.1",
"tailwindcss": "^3.4.4"
"@types/node": "22.10.2",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"typescript": "5.7.2"
}
}
-6
View File
@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
-34
View File
@@ -1,34 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: 'class',
content: ['./layouts/**/*.html', './content/**/*.md', './assets/**/*.{js,ts}'],
theme: {
extend: {
colors: {
primary: '#2563eb',
secondary: '#112d44',
dark: {
100: '#0f172a',
200: '#1e293b',
300: '#334155',
400: '#475569',
500: '#607d8b',
600: '#304ffe',
700: '#24368a',
800: '#1d2951',
900: '#132743',
},
bg: {
default: '#ffffff',
primary: '#0f172a',
},
},
spacing: {
'5': '1.25rem',
'7': '1.75rem',
'9': '2.25rem',
},
},
},
plugins: [require('@tailwindcss/typography')],
}
-1
View File
@@ -1 +0,0 @@
# Identity Template
-1
View File
@@ -1 +0,0 @@
# Preferences Template
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
File diff suppressed because one or more lines are too long