Compare commits

...
3 Commits
Author SHA1 Message Date
cacto 01eec92d48 Merge dev в main: редизайн + RU/EN
Deploy / deploy (push) Failing after 1s
2026-08-09 21:56:44 +05:00
cacto 17fd3bae8d Merge feature/matrix-redesign-i18n в dev 2026-08-09 21:43:05 +05:00
cacto a1b123dd13 Редизайн под новую Matrix-стилистику + RU/EN переключатель языка
Новая шапка (sticky, blur, мигающий курсор), нумерованные секции резюме
с полным опытом работы и новым разделом «Образование», карточки
проектов в стиле drwxr-xr-x, более яркий дождь на фоне. Добавлен
i18n-модуль (frontend/js/i18n.js) с переключателем EN/RU: резюме и
проекты полностью переведены, в игре переведены статичные подписи
интерфейса лобби и боя (данные от сервера остаются на русском).
2026-08-09 21:42:57 +05:00
13 changed files with 658 additions and 186 deletions
+20
View File
@@ -1,3 +1,17 @@
.page-heading {
font-size: 1.7rem;
font-weight: 700;
color: var(--color-accent);
margin: 0 0 0.4rem;
text-shadow: 0 0 20px var(--color-accent-dim);
}
.page-subheading {
color: var(--color-silver);
font-size: 0.9rem;
margin: 0 0 2rem;
}
.project-grid {
list-style: none;
margin: 0;
@@ -22,6 +36,8 @@
border-bottom: var(--border);
color: var(--color-silver);
font-size: 0.8rem;
white-space: nowrap;
overflow-x: auto;
}
.project-card__bar span {
@@ -33,6 +49,10 @@
flex-shrink: 0;
}
.project-card__perms {
color: var(--color-accent-dim);
}
.project-card__body {
padding: 1.25rem 1.5rem;
display: flex;
+69 -7
View File
@@ -18,20 +18,34 @@
.hero-text .tagline {
margin: 0.75rem 0 0;
color: var(--color-silver);
color: var(--color-accent-dim);
max-width: 34ch;
}
.status-line {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--color-accent-dim);
margin: 0 0 0.75rem;
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--color-accent);
box-shadow: 0 0 8px var(--color-accent-dim);
animation: blink 1.6s step-end infinite;
}
section {
margin: 3rem 0;
}
section > h2 {
font-size: 1rem;
font-weight: 400;
margin-bottom: 1.25rem;
}
.about p {
max-width: 68ch;
}
@@ -93,6 +107,54 @@ section > h2 {
color: var(--color-accent-dim);
}
.skill-group {
margin-bottom: 1.25rem;
}
.skill-group:last-child {
margin-bottom: 0;
}
.skill-group__title {
color: var(--color-silver);
font-size: 0.78rem;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 0.6rem;
}
.edu-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.edu-item__year {
color: var(--color-silver);
font-size: 0.8rem;
}
.edu-item__degree {
color: var(--color-fg);
font-weight: 600;
margin: 0.15rem 0;
}
.edu-item__school {
color: var(--color-silver);
font-size: 0.9rem;
}
.edu-item__detail {
color: var(--color-silver);
font-size: 0.83rem;
opacity: 0.85;
margin-top: 0.1rem;
}
@media (max-width: 640px) {
.hero {
flex-direction: column;
+60 -4
View File
@@ -48,7 +48,7 @@ button:focus-visible {
.container {
max-width: 860px;
margin: 0 auto;
padding: 2.5rem 1.5rem 4rem;
padding: 2rem 1.5rem 4rem;
}
/* --- фон: canvas цифрового дождя --- */
@@ -75,15 +75,26 @@ body::before {
);
}
/* --- шапка --- */
/* --- шапка: прилипает к верху, слегка размыта поверх дождя --- */
.site-header {
position: sticky;
top: 0;
z-index: 10;
border-bottom: var(--border);
background: rgba(10, 14, 12, 0.6);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.site-header__inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding-bottom: 1.5rem;
border-bottom: var(--border);
flex-wrap: wrap;
max-width: 860px;
margin: 0 auto;
padding: 1.1rem 1.5rem;
}
.logo {
@@ -101,8 +112,18 @@ body::before {
filter: drop-shadow(0 0 4px var(--color-accent-dim));
}
@keyframes blink {
0%, 49% { opacity: 1; }
50%, 100% { opacity: 0; }
}
.logo .cursor {
animation: blink 1s step-end infinite;
}
.nav-links {
display: flex;
align-items: center;
gap: 1.5rem;
list-style: none;
margin: 0;
@@ -118,6 +139,23 @@ body::before {
color: var(--color-accent);
}
.lang-btn {
background: transparent;
border: 1px solid var(--color-accent-dim);
color: var(--color-fg);
font-family: var(--font-mono);
font-size: 0.75rem;
letter-spacing: 0.06em;
padding: 0.3rem 0.7rem;
cursor: pointer;
}
.lang-btn:hover {
border-color: var(--color-accent);
color: var(--color-accent);
box-shadow: 0 0 6px var(--color-accent-dim);
}
/* --- терминальный префикс перед заголовками --- */
.prompt::before {
content: 'cacto@matrix:~$ ';
@@ -208,3 +246,21 @@ body::before {
padding: 0.15rem 0.6rem;
font-size: 0.8rem;
}
/* --- нумерованный заголовок секции (01 / whoami, 02 / experience...) --- */
.section-heading {
display: flex;
align-items: baseline;
gap: 0.75rem;
font-size: 1rem;
font-weight: 400;
border-bottom: var(--border);
padding-bottom: 0.6rem;
margin-bottom: 1.5rem;
color: var(--color-fg);
}
.section-heading__num {
color: var(--color-accent-dim);
font-size: 0.85rem;
}
+7 -4
View File
@@ -11,19 +11,22 @@
<body>
<canvas id="matrix-rain"></canvas>
<div class="container">
<header class="site-header">
<header class="site-header">
<div class="site-header__inner">
<a href="/" class="logo" aria-label="cacto — на главную">
<img src="/assets/cactus-logo.svg" width="20" height="20" alt="" />
cacto
cacto<span class="cursor">_</span>
</a>
<ul class="nav-links">
<li><a href="/">whoami</a></li>
<li><a href="/projects">ls projects/</a></li>
<li><a href="/game" aria-current="page">./battlecity</a></li>
<li><button id="lang-toggle" class="lang-btn" type="button">EN</button></li>
</ul>
</header>
</div>
</header>
<div class="container">
<main id="game-root">
<h2 class="prompt">./battlecity</h2>
<div id="lobby"></div>
+9 -110
View File
@@ -12,127 +12,26 @@
<body>
<canvas id="matrix-rain"></canvas>
<div class="container">
<header class="site-header">
<header class="site-header">
<div class="site-header__inner">
<a href="/" class="logo" aria-label="cacto — на главную">
<img src="/assets/cactus-logo.svg" width="20" height="20" alt="" />
cacto
cacto<span class="cursor">_</span>
</a>
<ul class="nav-links">
<li><a href="/" aria-current="page">whoami</a></li>
<li><a href="/projects">ls projects/</a></li>
<li><a href="/game">./battlecity</a></li>
<li><button id="lang-toggle" class="lang-btn" type="button">EN</button></li>
</ul>
</header>
</div>
</header>
<main>
<section class="hero">
<pre class="ascii-cactus" aria-hidden="true">
, ,
|\_/|
.--| |--.
( | | )
&#96;--| |--&#39;
| |
.--| |--.
( | | )
&#96;--| |--&#39;
| |
| |
__| |__
(_________)</pre>
<div class="hero-text">
<h1><span class="glitch" data-text="Гаммель Дмитрий">Гаммель Дмитрий</span></h1>
<p class="role">PHP / Go backend-разработчик</p>
<p class="tagline">Живучий, колючий, не требует много воды. Специализация — сложные запросы, legacy-код, архитектура.</p>
</div>
</section>
<section class="about">
<h2 class="prompt">cat about.txt</h2>
<div class="term-window">
<div class="term-window__bar"><span></span><span></span><span></span></div>
<div class="term-window__body">
<p>
Backend-разработчик с 6-летним коммерческим опытом на PHP: сложные запросы к базам данных,
работа с legacy-кодом, архитектурные решения. Спроектировал аналитический контур на ClickHouse,
ускоривший формирование отчётности в 15–20 раз; неофициально возглавлял разработку модуля
документооборота в банковской IT-инфраструктуре.
</p>
<p>
Начинал с электротехники и АСУТП — писал управляющий софт для автоматизированных систем.
Путь «от электрического сигнала до строчки кода» помогает быстро разбираться в сложных
legacy-системах и нестандартных задачах.
</p>
<p>
Сейчас изучаю Go и продолжаю искать позицию Middle+/Senior backend-разработчика.
</p>
</div>
</div>
</section>
<section class="experience">
<h2 class="prompt">ls -la experience/</h2>
<ol class="timeline">
<li class="timeline-item">
<div class="timeline-item__meta">июль 2022 — март 2026 · Екатеринбург</div>
<h3 class="timeline-item__role">PHP-разработчик — ООО «ЭТП ГПБ» (дочернее АО «Газпромбанк»)</h3>
<ul>
<li>Поддерживал legacy на Zend Framework, участвовал в переходе на Symfony; PHP 7.4 → 8.0.</li>
<li>Спроектировал аналитический контур отчётности на ClickHouse — сократил время формирования отчётов с 3–4 минут до 10–15 секунд.</li>
<li>Интеграции с внутренними банковскими сервисами (проверка ЭП, машиночитаемые доверенности), очереди RabbitMQ.</li>
<li>Неофициально возглавлял разработку нового модуля документооборота: архитектура, код-ревью, MVP.</li>
</ul>
</li>
<li class="timeline-item">
<div class="timeline-item__meta">март 2021 — июль 2022 · фриланс/субподряд</div>
<h3 class="timeline-item__role">PHP-разработчик — kupisever.ru</h3>
<ul>
<li>Доработка бэкенда B2B-платформы (доска объявлений) на Yii2, стек PHP/PostgreSQL/RabbitMQ/Docker.</li>
<li>Модуль email-рассылок с очередями сообщений и конструктором писем.</li>
<li>Модуль категорий объявлений — админка и клиентское меню.</li>
</ul>
</li>
<li class="timeline-item">
<div class="timeline-item__meta">июль 2020 — март 2021 · Курган</div>
<h3 class="timeline-item__role">PHP-разработчик (с обязанностями руководителя группы) — ИстВуд</h3>
<ul>
<li>Интернет-магазины на 1С-Битрикс, интеграции с 1С и платёжными системами.</li>
<li>Фактически руководил командой из 3 разработчиков: задачи, код-ревью, технические собеседования.</li>
</ul>
</li>
</ol>
</section>
<section class="skills">
<h2 class="prompt">skills --list</h2>
<ul class="tag-list">
<li class="tag">PHP8</li>
<li class="tag">Symfony</li>
<li class="tag">Zend Framework</li>
<li class="tag">Yii2</li>
<li class="tag">PostgreSQL</li>
<li class="tag">MySQL</li>
<li class="tag">ClickHouse</li>
<li class="tag">RabbitMQ</li>
<li class="tag">Docker</li>
<li class="tag">REST API</li>
<li class="tag">PHPUnit</li>
<li class="tag">ООП</li>
<li class="tag">Go (изучаю)</li>
</ul>
</section>
<section class="contact">
<h2 class="prompt">cat contact.txt</h2>
<ul class="contact-list">
<li><a href="mailto:cactozzz93@gmail.com">cactozzz93@gmail.com</a></li>
<li><a href="https://t.me/Cactozz">t.me/Cactozz</a></li>
</ul>
</section>
</main>
<div class="container">
<main id="resume-root"></main>
</div>
<script type="module" src="/js/matrix-rain.js"></script>
<script type="module" src="/js/resume.js"></script>
</body>
</html>
+54 -15
View File
@@ -1,6 +1,38 @@
import { ClientMessage, ServerMessage } from './protocol.js';
import { returnToLobbyContainer, leaveRoomAndReturn } from './lobby.js';
import { playShot, playWallHit, playExplosion, playWin, playLose } from './audio.js';
import { getLang } from '../i18n.js';
const STR = {
ru: {
controlsHint: 'WASD / стрелки + пробел тоже работают',
fire: 'огонь',
titles: { win: 'ПОБЕДА', lose: 'ПОРАЖЕНИЕ', draw: 'НИЧЬЯ' },
rematch: 'играть ещё',
toLobby: 'в лобби',
},
en: {
controlsHint: 'WASD / arrow keys + space also work',
fire: 'fire',
titles: { win: 'VICTORY', lose: 'DEFEAT', draw: 'DRAW' },
rematch: 'play again',
toLobby: 'to lobby',
},
};
function t() {
return STR[getLang()];
}
// Регистрируем один раз на модуль: обновляем статичные подписи в уже
// отрисованной панели управления и оверлей победы/поражения, если он открыт.
window.addEventListener('cacto:langchange', () => {
const hint = document.querySelector('.battle-controls__hint');
if (hint) hint.textContent = t().controlsHint;
const fireBtn = document.querySelector('.fire-btn');
if (fireBtn) fireBtn.textContent = t().fire;
if (lastOverPayload) renderOverlay(lastOverPayload);
});
const TILE_PX = 32;
// Quintus транслирует и вращает контекст сам (Sprite.render -> matrix.setContextTransform)
@@ -282,6 +314,23 @@ function onState(payload) {
}
}
let lastOverPayload = null;
function renderOverlay(payload) {
const title = t().titles[payload.result] || payload.result.toUpperCase();
const overlay = document.getElementById('battle-result-overlay');
overlay.innerHTML = `
<div class="battle-result battle-result--${payload.result}">
<h2 class="glitch" data-text="${title}">${title}</h2>
<p>${payload.reason}</p>
<div class="battle-result__actions">
<button data-action="rematch">${t().rematch}</button>
<button data-action="to-lobby">${t().toLobby}</button>
</div>
</div>`;
overlay.style.display = 'flex';
}
function onOver(payload) {
polling = false;
if (payload.result === 'win') {
@@ -289,19 +338,8 @@ function onOver(payload) {
} else {
playLose();
}
const titles = { win: 'ПОБЕДА', lose: 'ПОРАЖЕНИЕ', draw: 'НИЧЬЯ' };
const title = titles[payload.result] || payload.result.toUpperCase();
const overlay = document.getElementById('battle-result-overlay');
overlay.innerHTML = `
<div class="battle-result battle-result--${payload.result}">
<h2 class="glitch" data-text="${title}">${title}</h2>
<p>${payload.reason}</p>
<div class="battle-result__actions">
<button data-action="rematch">играть ещё</button>
<button data-action="to-lobby">в лобби</button>
</div>
</div>`;
overlay.style.display = 'flex';
lastOverPayload = payload;
renderOverlay(payload);
}
function onOverlayClick(e) {
@@ -310,6 +348,7 @@ function onOverlayClick(e) {
const overlay = document.getElementById('battle-result-overlay');
overlay.style.display = 'none';
overlay.innerHTML = '';
lastOverPayload = null;
if (btn.dataset.action === 'rematch') {
returnToLobbyContainer();
} else {
@@ -331,14 +370,14 @@ export function startBattle(networkInstance, payload, ownPlayerId) {
<div class="battle-layout">
<div id="battle-canvas-slot"></div>
<div class="battle-controls">
<p class="battle-controls__hint">WASD / стрелки + пробел тоже работают</p>
<p class="battle-controls__hint">${t().controlsHint}</p>
<div class="dpad">
<button class="dpad__up" data-input="up">&uarr;</button>
<button class="dpad__left" data-input="left">&larr;</button>
<button class="dpad__right" data-input="right">&rarr;</button>
<button class="dpad__down" data-input="down">&darr;</button>
</div>
<button class="fire-btn" data-input="fire">огонь</button>
<button class="fire-btn" data-input="fire">${t().fire}</button>
</div>
</div>
<div id="battle-result-overlay"></div>`;
+56 -14
View File
@@ -2,9 +2,49 @@ import { Network, ServerMessage } from './network.js';
import { ClientMessage } from './protocol.js';
import { startBattle } from './battle.js';
import { unlockAudio } from './audio.js';
import { getLang } from '../i18n.js';
const container = document.getElementById('lobby');
// Статичные подписи UI лобби переводим; данные из сервера (ники, room id,
// текст ошибок) остаются как есть — они генерируются на сервере только на русском.
const STR = {
ru: {
connecting: 'подключение...',
join: 'войти',
createCoop: 'создать комнату (coop)',
createPvp: 'создать комнату (pvp)',
refresh: 'обновить список',
empty: 'пока пусто — создай комнату',
ready: 'готов',
readySuffix: ' — готов',
waitingForSecond: 'жду второго игрока (1/2) — открой комнату во второй вкладке/другим человеком',
soloHint: 'можно начать соло, второй игрок сможет подключиться позже',
room: 'комната',
mode: 'режим:',
leave: 'выйти в лобби',
},
en: {
connecting: 'connecting...',
join: 'join',
createCoop: 'create room (coop)',
createPvp: 'create room (pvp)',
refresh: 'refresh list',
empty: 'nothing here yet — create a room',
ready: 'ready',
readySuffix: ' — ready',
waitingForSecond: 'waiting for a second player (1/2) — open the room in another tab or have someone else join',
soloHint: 'you can start solo, a second player can join later',
room: 'room',
mode: 'mode:',
leave: 'leave to lobby',
},
};
function t() {
return STR[getLang()];
}
let net;
let myPlayerId = null;
let rooms = [];
@@ -33,7 +73,7 @@ function roomRow(room) {
<span class="tag">${room.mode}</span>
<span class="room-list__count">${room.players}/${room.max_players}</span>
<span class="room-list__state">${room.state}</span>
<button data-action="join" data-room-id="${room.id}" ${full || room.state !== 'waiting' || !connected ? 'disabled' : ''}>войти</button>
<button data-action="join" data-room-id="${room.id}" ${full || room.state !== 'waiting' || !connected ? 'disabled' : ''}>${t().join}</button>
</li>`;
}
@@ -46,18 +86,18 @@ function renderLobbyView() {
container.innerHTML = `
<div class="lobby-panel">
${errorBanner()}
${connected ? '' : '<p class="lobby-error">подключение...</p>'}
${connected ? '' : `<p class="lobby-error">${t().connecting}</p>`}
<div class="lobby-panel__row">
<label class="prompt-inline">nickname</label>
<input id="nickname-input" type="text" value="${escapeHtml(nickname)}" maxlength="20" ${dis} />
</div>
<div class="lobby-panel__row">
<button data-action="create" data-mode="coop" ${dis}>создать комнату (coop)</button>
<button data-action="create" data-mode="pvp" ${dis}>создать комнату (pvp)</button>
<button data-action="refresh" ${dis}>обновить список</button>
<button data-action="create" data-mode="coop" ${dis}>${t().createCoop}</button>
<button data-action="create" data-mode="pvp" ${dis}>${t().createPvp}</button>
<button data-action="refresh" ${dis}>${t().refresh}</button>
</div>
<ul class="room-list">
${rooms.length === 0 ? '<li class="room-list__empty">пока пусто — создай комнату</li>' : rooms.map(roomRow).join('')}
${rooms.length === 0 ? `<li class="room-list__empty">${t().empty}</li>` : rooms.map(roomRow).join('')}
</ul>
</div>`;
}
@@ -65,24 +105,24 @@ function renderLobbyView() {
function renderRoomView() {
const full = currentRoom.players.length >= 2;
let statusLine = '';
let readyBtn = '<button data-action="ready">готов</button>';
let readyBtn = `<button data-action="ready">${t().ready}</button>`;
if (currentRoom.mode === 'pvp' && !full) {
statusLine = '<p class="lobby-error">жду второго игрока (1/2) — открой комнату во второй вкладке/другим человеком</p>';
readyBtn = '<button data-action="ready" disabled>готов</button>';
statusLine = `<p class="lobby-error">${t().waitingForSecond}</p>`;
readyBtn = `<button data-action="ready" disabled>${t().ready}</button>`;
} else if (currentRoom.mode === 'coop' && !full) {
statusLine = '<p class="lobby-error">можно начать соло, второй игрок сможет подключиться позже</p>';
statusLine = `<p class="lobby-error">${t().soloHint}</p>`;
}
container.innerHTML = `
<div class="lobby-panel">
${errorBanner()}
<h3 class="prompt">room ${currentRoom.name}</h3>
<p>режим: <span class="tag">${currentRoom.mode}</span></p>
<h3 class="prompt">${t().room} ${currentRoom.name}</h3>
<p>${t().mode} <span class="tag">${currentRoom.mode}</span></p>
<ul class="room-players">
${currentRoom.players.map((p) => `<li>${escapeHtml(p.nickname)}${p.ready ? ' — готов' : ''}</li>`).join('')}
${currentRoom.players.map((p) => `<li>${escapeHtml(p.nickname)}${p.ready ? t().readySuffix : ''}</li>`).join('')}
</ul>
${statusLine}
${readyBtn}
<button data-action="leave">выйти в лобби</button>
<button data-action="leave">${t().leave}</button>
</div>`;
}
@@ -180,6 +220,8 @@ export function initLobby() {
render();
});
window.addEventListener('cacto:langchange', render);
render();
return net;
}
+4
View File
@@ -1,4 +1,8 @@
// TODO: инициализация Quintus и игрового цикла рендера — этапы 4-5
import { initLobby } from './lobby.js';
import { initLangToggle } from '../i18n.js';
initLobby();
// lobby.js/battle.js сами слушают cacto:langchange и перерисовывают свои
// статичные подписи — здесь достаточно включить саму кнопку в шапке.
initLangToggle();
+30
View File
@@ -0,0 +1,30 @@
const KEY = 'cacto-lang';
export function getLang() {
return localStorage.getItem(KEY) === 'en' ? 'en' : 'ru';
}
export function setLang(lang) {
localStorage.setItem(KEY, lang);
document.documentElement.lang = lang;
}
// Вешает обработчик на кнопку #lang-toggle в шапке и синхронизирует её
// подпись (показываем язык, НА который переключит клик, а не текущий).
export function initLangToggle(onChange) {
document.documentElement.lang = getLang();
const btn = document.getElementById('lang-toggle');
if (!btn) return;
const sync = () => {
btn.textContent = getLang() === 'ru' ? 'EN' : 'RU';
};
sync();
btn.addEventListener('click', () => {
setLang(getLang() === 'ru' ? 'en' : 'ru');
sync();
window.dispatchEvent(new CustomEvent('cacto:langchange', { detail: getLang() }));
onChange?.(getLang());
});
}
+6 -3
View File
@@ -5,8 +5,6 @@ if (canvas && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
const chars = 'アカサタナハマヤラワ0123456789ABCDEFcacto$#@%&*'.split('');
const fontSize = 15;
const bg = getComputedStyle(document.documentElement).getPropertyValue('--color-bg').trim();
const accent = getComputedStyle(document.documentElement).getPropertyValue('--color-accent').trim();
const silver = getComputedStyle(document.documentElement).getPropertyValue('--color-silver').trim();
let columns = 0;
let drops = [];
@@ -28,7 +26,11 @@ if (canvas && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
const x = i * fontSize;
const y = drops[i] * fontSize;
ctx.fillStyle = Math.random() > 0.98 ? silver : accent;
// «голова» капли — редкая, чуть ярче, со свечением
const isHead = Math.random() > 0.94;
ctx.shadowColor = 'rgba(100, 255, 160, 0.9)';
ctx.shadowBlur = isHead ? 10 : 0;
ctx.fillStyle = isHead ? '#d2ffe1' : Math.random() > 0.98 ? '#9fa3a0' : '#00ff41';
ctx.fillText(char, x, y);
if (y > canvas.height && Math.random() > 0.975) {
@@ -37,6 +39,7 @@ if (canvas && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
drops[i]++;
}
}
ctx.shadowBlur = 0;
}
setup();
+69 -24
View File
@@ -1,39 +1,84 @@
const projects = [
{
name: 'home_automatization',
title: 'Home Automation Platform',
description:
'Микросервисная платформа умного дома на Go и Laravel. Первая зона — гроубокс (полив, свет, климат), дальше — весь дом.',
tags: ['Go', 'Laravel', 'Docker', 'RabbitMQ', 'ClickHouse', 'gRPC', 'MQTT'],
url: 'https://git.cactoz.su/cacto/home_automatization',
},
{
name: 'cactoz.su',
title: 'cactoz.su — этот сайт',
description:
'Личный сайт-визитка: резюме, проекты и мультиплеерный клон BattleCity. Тема — Матрица.',
tags: ['HTML/CSS/JS', 'C++', 'uWebSockets', 'Quintus.js', 'nginx', 'Docker'],
url: 'https://git.cactoz.su/cacto/cactoz.su',
},
];
import { getLang, initLangToggle } from './i18n.js';
const list = document.getElementById('project-list');
const DATA = {
ru: {
heading: 'ls projects/',
subheading: 'Пара вещей, которые я собрал сам, от идеи до продакшена.',
projects: [
{
name: 'home_automatization',
title: 'Home Automation Platform',
description:
'Микросервисная платформа умного дома на Go и Laravel. Первая зона — гроубокс (полив, свет, климат), дальше — весь дом.',
tags: ['Go', 'Laravel', 'Docker', 'RabbitMQ', 'ClickHouse', 'gRPC', 'MQTT'],
url: 'https://git.cactoz.su/cacto/home_automatization',
linkLabel: 'открыть',
},
{
name: 'cactoz.su',
title: 'cactoz.su — этот сайт',
description:
'Личный сайт-визитка: резюме, проекты и мультиплеерный клон BattleCity. Тема — Матрица.',
tags: ['HTML/CSS/JS', 'C++', 'uWebSockets', 'Quintus.js', 'nginx', 'Docker'],
url: 'https://git.cactoz.su/cacto/cactoz.su',
linkLabel: 'открыть',
},
],
},
en: {
heading: 'ls projects/',
subheading: 'A couple of things I built myself, start to finish.',
projects: [
{
name: 'home_automatization',
title: 'Home Automation Platform',
description:
'A microservice smart-home platform on Go and Laravel. First zone is a grow box (watering, lighting, climate), the whole house comes next.',
tags: ['Go', 'Laravel', 'Docker', 'RabbitMQ', 'ClickHouse', 'gRPC', 'MQTT'],
url: 'https://git.cactoz.su/cacto/home_automatization',
linkLabel: 'open',
},
{
name: 'cactoz.su',
title: 'cactoz.su — this site',
description:
'A personal portfolio site: résumé, projects and a multiplayer BattleCity clone. Matrix theme throughout.',
tags: ['HTML/CSS/JS', 'C++', 'uWebSockets', 'Quintus.js', 'nginx', 'Docker'],
url: 'https://git.cactoz.su/cacto/cactoz.su',
linkLabel: 'open',
},
],
},
};
if (list) {
list.innerHTML = projects
function render() {
const t = DATA[getLang()];
document.title = `${t.heading} — cacto`;
const heading = document.getElementById('projects-heading');
const subheading = document.getElementById('projects-subheading');
if (heading) heading.textContent = t.heading;
if (subheading) subheading.textContent = t.subheading;
const list = document.getElementById('project-list');
if (!list) return;
list.innerHTML = t.projects
.map(
(p) => `
<li class="project-card">
<div class="project-card__bar"><span></span>${p.name}/README.md</div>
<div class="project-card__bar"><span></span><span class="project-card__perms">drwxr-xr-x</span> ${p.name}</div>
<div class="project-card__body">
<h3 class="project-card__title">${p.title}</h3>
<p class="project-card__desc">${p.description}</p>
<ul class="tag-list">
${p.tags.map((t) => `<li class="tag">${t}</li>`).join('')}
${p.tags.map((tag) => `<li class="tag">${tag}</li>`).join('')}
</ul>
<a class="project-card__link" href="${p.url}" target="_blank" rel="noopener">${p.url.replace('https://', '')}</a>
<a class="project-card__link" href="${p.url}" target="_blank" rel="noopener">${p.linkLabel}: ${p.url.replace('https://', '')}</a>
</div>
</li>`
)
.join('');
}
render();
initLangToggle(render);
+265
View File
@@ -0,0 +1,265 @@
import { getLang, initLangToggle } from './i18n.js';
const DATA = {
ru: {
name: 'Гаммель Дмитрий',
title: 'PHP-разработчик (Backend, Middle+/Senior)',
tagline: 'От электрического сигнала до строчки кода.',
statusLabel: 'STATUS: OPEN TO WORK',
aboutHeading: 'cat about.txt',
about: [
'PHP-разработчик с 6-летним коммерческим опытом, специализация — backend, сложные запросы к базам данных, работа с legacy-кодом. Прошёл путь от рядового разработчика до архитектурных и лидерских задач: спроектировал аналитический контур на ClickHouse, ускоривший формирование отчётности в 15-20 раз, неофициально возглавлял разработку модуля документооборота в банковской IT-инфраструктуре, вырос от специалиста без опыта в PHP до фактического руководителя команды за 3 месяца работы в digital-агентстве.',
'Разносторонний технический бэкграунд: начинал с электротехники и АСУТП, писал управляющий софт для автоматизированных систем — например, для фонтана, который до сих пор работает в центре Кургана под моей программой. Такой путь даёт понимание логики «от электрического сигнала до строчки кода» — это помогает быстро разбираться в сложных legacy-системах и нестандартных задачах.',
'Разностороннее образование — от электротехники до начального музыкального и театрального — помогает находить нестандартные решения и легко находить общий язык с людьми разного склада.',
],
experienceHeading: 'ls -la experience/',
experience: [
{
period: 'март 2026 — настоящее время', duration: '5 месяцев', location: 'Екатеринбург',
role: 'Программист АСУТП и КИПиА', company: 'Уралэнергоаква',
bullets: ['Временный период вне основной специальности после завершения проекта в ЭТП ГПБ. Продолжаю развиваться как PHP-разработчик, активно ищу позицию по профилю backend-разработки.'],
},
{
period: 'июль 2022 — март 2026', duration: '3 года 9 месяцев', location: 'Екатеринбург',
role: 'PHP-разработчик', company: 'ООО «ЭТП ГПБ» (дочернее АО «Газпромбанк»)',
bullets: [
'Разрабатывал и поддерживал внутреннюю систему электронного документооборота для согласования и контроля исполнения договоров.',
'Поддерживал и развивал legacy-проект на Zend Framework, участвовал в архитектурном переходе на Symfony; PHP 7.4 → 8.0.',
'Переписывал построители запросов на «чистый» SQL с оптимизацией под PostgreSQL, внедрял сервисный слой архитектуры.',
'Спроектировал и реализовал аналитический контур отчётности на ClickHouse — сократил время формирования отчётов с 3–4 минут до 10–15 секунд.',
'Реализовал интеграции с внутренними банковскими сервисами, включая проверку электронной подписи и машиночитаемых доверенностей.',
'Работал с очередями сообщений (RabbitMQ) для асинхронной обработки задач.',
'Неофициально возглавлял разработку нового модуля документооборота: планировал архитектуру, проводил код-ревью, довёл проект до MVP.',
'Работал по Scrum: месячные спринты, таск-трекер YouTrack, оценка задач методом Planning Poker.',
],
},
{
period: 'март 2021 — июль 2022', duration: '1 год 5 месяцев', location: 'удалённо',
role: 'PHP-разработчик', company: 'фриланс/субподряд · kupisever.ru',
bullets: [
'Дорабатывал бэкенд действующей B2B-платформы (доска объявлений) на Yii2 в качестве субподрядчика, работал самостоятельно на удалённом проекте. Стек: PHP, Yii2, PostgreSQL, RabbitMQ, Docker.',
'Разработал модуль email-рассылок с очередями сообщений (RabbitMQ): веб-форма конструктора писем с текстовым редактором и гибкой настройкой параметров рассылки.',
'Реализовал модуль категорий объявлений — административную часть (бэкенд) и клиентское меню (фронтенд).',
],
},
{
period: 'июль 2020 — март 2021', duration: '9 месяцев', location: 'Курган',
role: 'PHP-разработчик (с обязанностями руководителя группы)', company: 'ИстВуд',
bullets: [
'Интернет-магазины на 1С-Битрикс: вёрстка, интеграция готовых модулей, доработка бизнес-логики под задачи клиентов.',
'Реализовал интеграции с 1С (обмен товарными каталогами и заказами) и платёжными системами (эквайринг).',
'Реализовал нестандартный механизм автоматического получения и обновления каталога товаров (несколько тысяч позиций, ежесуточное обновление) для интернет-магазина на кастомном шаблоне вне типовых решений Bitrix.',
'Разработал функционал онлайн-записи на приём к врачу для сайта частной клиники; реализовал интеграцию с фискальным регистратором.',
'Участвовал во внедрении Битрикс24: настройка бизнес-процессов и цепочек согласования закупок.',
'Фактически руководил командой из 3 разработчиков: распределял задачи, контролировал качество реализации, проводил технические собеседования.',
],
},
{
period: 'октябрь 2019 — февраль 2020', duration: '5 месяцев', location: 'Курган',
role: 'Техник по телекоммуникациям', company: 'Урал-М',
bullets: ['Настройка и обслуживание систем СКУД, видеонаблюдения, радиоканальной связи; работа с 1С.'],
},
{
period: 'апрель 2014 — июль 2018', duration: '4 года 4 месяца', location: 'Курган',
role: 'Техник-электрик', company: 'Кирпичный завод, Мясокомбинат, Хлебозавод',
bullets: ['Электромонтажные и наладочные работы на производственных предприятиях.'],
},
],
skillsHeading: 'skills --list',
skillGroups: [
{ title: 'backend', items: ['PHP8', 'PHP7', 'Symfony', 'Zend Framework', 'Yii2', 'PostgreSQL', 'MySQL', 'ClickHouse', 'SQL', 'RabbitMQ', 'Docker', 'Git', 'REST API', 'PHPUnit', 'Composer', 'ООП', 'Go (изучаю)'] },
{ title: 'electrical / automation', items: ['АСУТП', 'КИПиА', 'СКУД', 'Видеонаблюдение', 'Радиоканальная связь', 'Электромонтаж', '1С'] },
{ title: 'soft', items: ['Оптимизация SQL-запросов', 'Обучение и адаптация junior-разработчиков', 'Быстрая обучаемость'] },
],
educationHeading: 'cat education.txt',
education: [
{ year: '2013', degree: 'Неоконченное высшее', school: 'Тюменский государственный нефтегазовый университет (ТюмГНГУ)', detail: 'Институт кибернетики, информатики и связи, Электроэнергетика и электротехника' },
{ year: '2011', degree: 'Свидетельство о квалификации «Электромонтёр 3 разряда»', school: 'ФГБОУ ВПО «Тюменский государственный университет»', detail: '' },
],
contactHeading: 'cat contact.txt',
contact: { email: 'cactozzz93@gmail.com', telegram: 't.me/Cactozz', telegramUrl: 'https://t.me/Cactozz' },
},
en: {
name: 'Dmitry Gammel',
title: 'PHP Developer (Backend, Middle+/Senior)',
tagline: 'From electrical signal to line of code.',
statusLabel: 'STATUS: OPEN TO WORK',
aboutHeading: 'cat about.txt',
about: [
"PHP developer with 6 years of commercial experience, specializing in backend: complex database queries, legacy code, architecture. Grew from a line developer into architecture and leadership work — designed a ClickHouse analytics pipeline that sped up reporting 15-20x, informally led a new document-workflow module in a bank's IT infrastructure, and went from a PHP beginner to de facto team lead within 3 months at a digital agency.",
"Broad technical background: started in electrical engineering and industrial automation (ACS/KIPiA), writing control software for automated systems — including a fountain that still runs in the center of Kurgan on my program. That path gives an understanding of the full chain, from electrical signal to line of code, which helps me get up to speed fast in complex legacy systems and unfamiliar problems.",
'A varied education — from electrical engineering to early music and theater — helps find unconventional solutions and get along with people of very different backgrounds.',
],
experienceHeading: 'ls -la experience/',
experience: [
{
period: 'Mar 2026 — present', duration: '5 months', location: 'Yekaterinburg',
role: 'ACS/KIPiA Programmer (industrial automation)', company: 'Uralenergoakva',
bullets: ["A temporary role outside my core specialty after wrapping up the ETP GPB project. Keep growing as a PHP developer and actively looking for a backend position."],
},
{
period: 'Jul 2022 — Mar 2026', duration: '3 years 9 months', location: 'Yekaterinburg',
role: 'PHP Developer', company: 'ETP GPB LLC (subsidiary of Gazprombank)',
bullets: [
'Built and maintained an internal document-workflow system for contract approval and execution tracking.',
'Maintained a legacy Zend Framework project and took part in the architectural move to Symfony; PHP 7.4 → 8.0.',
'Rewrote query builders as raw SQL optimized for PostgreSQL, introduced a service-layer architecture.',
'Designed and built a ClickHouse analytics pipeline — cut report generation time from 34 minutes to 1015 seconds.',
'Built integrations with internal banking services, including e-signature and machine-readable power-of-attorney verification.',
'Worked with message queues (RabbitMQ) for async task processing.',
'Informally led development of a new document-workflow module: planned the architecture, ran code review, took it to MVP.',
'Worked in Scrum: monthly sprints, YouTrack, Planning Poker estimation.',
],
},
{
period: 'Mar 2021 — Jul 2022', duration: '1 year 5 months', location: 'Remote',
role: 'PHP Developer', company: 'Freelance / subcontract · kupisever.ru',
bullets: [
'Worked as a subcontractor on the backend of a live B2B classifieds platform on Yii2, working independently on a remote project. Stack: PHP, Yii2, PostgreSQL, RabbitMQ, Docker.',
'Built an email-campaign module with message queues (RabbitMQ): a web-based email builder with a text editor and flexible send settings.',
'Built the listing-categories module — both the admin backend and the client-facing menu.',
],
},
{
period: 'Jul 2020 — Mar 2021', duration: '9 months', location: 'Kurgan',
role: 'PHP Developer (acting team lead)', company: 'IstWood',
bullets: [
'Built and maintained online stores on 1C-Bitrix: markup, module integration, business-logic customization.',
'Built integrations with 1C (catalog and order exchange) and payment systems (acquiring).',
"Built a custom mechanism for automatically fetching and updating a product catalog (several thousand SKUs, daily updates) on a custom template outside Bitrix's standard tooling.",
'Built online doctor-appointment booking for a private clinic site; integrated a fiscal register for automatic receipts.',
'Took part in rolling out Bitrix24: configured business processes and procurement approval chains.',
'Acting lead of a 3-developer team: assigned tasks, reviewed quality, ran technical interviews.',
],
},
{
period: 'Oct 2019 — Feb 2020', duration: '5 months', location: 'Kurgan',
role: 'Telecom Technician', company: 'Ural-M',
bullets: ['Set up and maintained access-control, video-surveillance and radio-link systems; worked with 1C.'],
},
{
period: 'Apr 2014 — Jul 2018', duration: '4 years 4 months', location: 'Kurgan',
role: 'Electrical Technician', company: 'Brick factory, meat plant, bakery',
bullets: ['Electrical installation and commissioning work at manufacturing plants.'],
},
],
skillsHeading: 'skills --list',
skillGroups: [
{ title: 'backend', items: ['PHP8', 'PHP7', 'Symfony', 'Zend Framework', 'Yii2', 'PostgreSQL', 'MySQL', 'ClickHouse', 'SQL', 'RabbitMQ', 'Docker', 'Git', 'REST API', 'PHPUnit', 'Composer', 'OOP', 'Go (learning)'] },
{ title: 'electrical / automation', items: ['Industrial automation (ACS)', 'KIPiA', 'Access control', 'Video surveillance', 'Radio links', 'Electrical installation', '1C'] },
{ title: 'soft', items: ['SQL query optimization', 'Mentoring junior developers', 'Fast learner'] },
],
educationHeading: 'cat education.txt',
education: [
{ year: '2013', degree: 'Incomplete higher education', school: 'Tyumen State Oil and Gas University', detail: 'Institute of Cybernetics, Informatics and Communications — Power Engineering and Electrical Engineering' },
{ year: '2011', degree: 'Qualification certificate, Electrician grade 3', school: 'Tyumen State University', detail: '' },
],
contactHeading: 'cat contact.txt',
contact: { email: 'cactozzz93@gmail.com', telegram: 't.me/Cactozz', telegramUrl: 'https://t.me/Cactozz' },
},
};
const ASCII_CACTUS = ` , ,
|\\_/|
.--| |--.
( | | )
\`--| |--'
| |
.--| |--.
( | | )
\`--| |--'
| |
| |
__| |__
(_________)`;
function renderExperience(list) {
return list
.map(
(job) => `
<li class="timeline-item">
<div class="timeline-item__meta">${job.period} · ${job.duration} · ${job.location}</div>
<h3 class="timeline-item__role">${job.role}${job.company}</h3>
<ul>${job.bullets.map((b) => `<li>${b}</li>`).join('')}</ul>
</li>`
)
.join('');
}
function renderSkillGroups(groups) {
return groups
.map(
(g) => `
<div class="skill-group">
<div class="skill-group__title">${g.title}</div>
<ul class="tag-list">${g.items.map((i) => `<li class="tag">${i}</li>`).join('')}</ul>
</div>`
)
.join('');
}
function renderEducation(list) {
return list
.map(
(e) => `
<li class="edu-item">
<div class="edu-item__year">${e.year}</div>
<div class="edu-item__degree">${e.degree}</div>
<div class="edu-item__school">${e.school}</div>
${e.detail ? `<div class="edu-item__detail">${e.detail}</div>` : ''}
</li>`
)
.join('');
}
function render() {
const t = DATA[getLang()];
document.title = `cacto — ${t.name}, ${t.title}`;
document.getElementById('resume-root').innerHTML = `
<section class="hero">
<pre class="ascii-cactus" aria-hidden="true">${ASCII_CACTUS}</pre>
<div class="hero-text">
<div class="status-line"><span class="status-dot"></span>${t.statusLabel}</div>
<h1><span class="glitch" data-text="${t.name}">${t.name}</span></h1>
<p class="role">${t.title}</p>
<p class="tagline">${t.tagline}</p>
</div>
</section>
<section class="about">
<h2 class="section-heading"><span class="section-heading__num">01</span>${t.aboutHeading}</h2>
<div class="term-window">
<div class="term-window__bar"><span></span><span></span><span></span></div>
<div class="term-window__body">
${t.about.map((p) => `<p>${p}</p>`).join('')}
</div>
</div>
</section>
<section class="experience">
<h2 class="section-heading"><span class="section-heading__num">02</span>${t.experienceHeading}</h2>
<ol class="timeline">${renderExperience(t.experience)}</ol>
</section>
<section class="skills">
<h2 class="section-heading"><span class="section-heading__num">03</span>${t.skillsHeading}</h2>
${renderSkillGroups(t.skillGroups)}
</section>
<section class="education">
<h2 class="section-heading"><span class="section-heading__num">04</span>${t.educationHeading}</h2>
<ul class="edu-list">${renderEducation(t.education)}</ul>
</section>
<section class="contact">
<h2 class="section-heading"><span class="section-heading__num">05</span>${t.contactHeading}</h2>
<ul class="contact-list">
<li><a href="mailto:${t.contact.email}">${t.contact.email}</a></li>
<li><a href="${t.contact.telegramUrl}">${t.contact.telegram}</a></li>
</ul>
</section>`;
}
render();
initLangToggle(render);
+9 -5
View File
@@ -12,22 +12,26 @@
<body>
<canvas id="matrix-rain"></canvas>
<div class="container">
<header class="site-header">
<header class="site-header">
<div class="site-header__inner">
<a href="/" class="logo" aria-label="cacto — на главную">
<img src="/assets/cactus-logo.svg" width="20" height="20" alt="" />
cacto
cacto<span class="cursor">_</span>
</a>
<ul class="nav-links">
<li><a href="/">whoami</a></li>
<li><a href="/projects" aria-current="page">ls projects/</a></li>
<li><a href="/game">./battlecity</a></li>
<li><button id="lang-toggle" class="lang-btn" type="button">EN</button></li>
</ul>
</header>
</div>
</header>
<div class="container">
<main>
<section>
<h2 class="prompt">ls projects/</h2>
<h1 id="projects-heading" class="page-heading"></h1>
<p id="projects-subheading" class="page-subheading"></p>
<ul id="project-list" class="project-grid"></ul>
</section>
</main>