From f37edb6d24f3585cb3bb491c959da350f9f468b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=20=D0=91=D1=80=D0=B0=D1=82=D1=81?= =?UTF-8?q?=D0=BA=D0=B8=D1=85?= Date: Wed, 24 Jun 2026 10:01:39 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B3=D0=B5=D0=BD=D0=B5=D1=80=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=B5=D0=BA?= =?UTF-8?q?=20=D0=B8=D0=B7=20=D0=BE=D0=B1=D1=8A=D0=B5=D0=BA=D1=82=D0=B0=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=D0=BE=D0=B2=20=D1=87=D0=B0?= =?UTF-8?q?=D1=82=D0=B0=20=D0=B8=20=D0=BE=D0=B1=D0=BE=20=D0=BC=D0=BD=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/about.svg | 14 ++++ assets/openspace.svg | 17 ++++ index.html | 88 +++------------------ script.js | 183 ++++++++++++++++++++++++++++++++++--------- styles.css | 37 ++++++++- 5 files changed, 224 insertions(+), 115 deletions(-) create mode 100644 assets/about.svg create mode 100644 assets/openspace.svg diff --git a/assets/about.svg b/assets/about.svg new file mode 100644 index 0000000..19f603f --- /dev/null +++ b/assets/about.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/assets/openspace.svg b/assets/openspace.svg new file mode 100644 index 0000000..859e059 --- /dev/null +++ b/assets/openspace.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/index.html b/index.html index 7bc9fe0..f657e46 100644 --- a/index.html +++ b/index.html @@ -82,82 +82,20 @@ -
- - - -
-

Cloud storage

-

Nextcloud

-

- Личное облако для файлов, заметок, календарей и синхронизации - между устройствами. -

-
- -
+
- - - -
-

Mailbox

-

Mail

-

- Почтовый шлюз для рабочей переписки, уведомлений и управления - сообщениями. -

-
- -
- - - - -
-

Lab network

-

Test Net

-

- Песочница для экспериментов, тестовых окружений и проверки новых - идей. -

-
- -
- - - - -
-

Git forge

-

Gitea

-

- Домашняя кузница кода: репозитории, задачи, ревью и история - проектов. -

-
- -
+
+

About

+

Обо мне

+

+ Bratskih Tech — личное пространство для сервисов, экспериментов, + инфраструктуры и проектов. Здесь собраны основные точки входа в мою + цифровую экосистему. +

diff --git a/script.js b/script.js index 52af398..154d22f 100644 --- a/script.js +++ b/script.js @@ -1,5 +1,58 @@ const STORAGE_KEY = "bratskih-hub-preferences"; +const serviceCards = [ + { + title: "Nextcloud", + kicker: "Cloud storage", + description: + "Личное облако для файлов, заметок, календарей и синхронизации между устройствами.", + url: "https://nextcloud.bratskih.tech", + image: "assets/nextcloud.svg", + }, + { + title: "Mail", + kicker: "Mailbox", + description: + "Почтовый шлюз для рабочей переписки, уведомлений и управления сообщениями.", + url: "https://mail.bratskih.tech", + image: "assets/mail.svg", + }, + { + title: "Test Net", + kicker: "Lab network", + description: + "Песочница для экспериментов, тестовых окружений и проверки новых идей.", + url: "https://test-net.bratskih.tech", + image: "assets/test-net.svg", + }, + { + title: "Gitea", + kicker: "Git forge", + description: + "Домашняя кузница кода: репозитории, задачи, ревью и история проектов.", + url: "https://gitea.bratskih.tech", + image: "assets/gitea.svg", + }, + { + title: "BratskihTech openspace", + kicker: "Discussion room", + description: + "Комната для обсуждений, созвонов и быстрых встреч вокруг идей Bratskih Tech.", + url: "https://cloud.bratskih.tech/call/n49bjhdu", + image: "assets/openspace.svg", + }, + { + title: "Обо мне", + kicker: "Profile", + description: + "Короткий раздел о владельце экосистемы, проектах и принципах домашней инфраструктуры.", + url: "#about", + image: "assets/about.svg", + target: "self", + variant: "about", + }, +]; + const defaultPreferences = { theme: window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark", view: "cards", @@ -11,13 +64,14 @@ const preferences = { ...readPreferences(), }; -const services = document.querySelector(".services"); +const services = document.querySelector("[data-cards-root]"); const themeButtons = document.querySelectorAll("[data-theme]"); const viewButtons = document.querySelectorAll("[data-view]"); const accentButtons = document.querySelectorAll("[data-accent]"); -const cards = document.querySelectorAll(".service-card"); +renderCards(serviceCards); applyPreferences(preferences); +attachCardInteractions(); themeButtons.forEach((button) => { button.addEventListener("click", () => { @@ -43,43 +97,6 @@ accentButtons.forEach((button) => { }); }); -cards.forEach((card) => { - let leaveTimer; - - card.addEventListener("pointerenter", () => { - window.clearTimeout(leaveTimer); - card.classList.add("is-hovered"); - }); - - card.addEventListener("pointermove", (event) => { - window.clearTimeout(leaveTimer); - card.classList.add("is-hovered"); - - const rect = card.getBoundingClientRect(); - const x = event.clientX - rect.left; - const y = event.clientY - rect.top; - const xPercent = clamp((x / rect.width) * 100, 8, 92); - const yPercent = clamp((y / rect.height) * 100, 8, 92); - const rotateY = ((xPercent - 50) / 50) * 2.6; - const rotateX = ((50 - yPercent) / 50) * 2.6; - - card.style.setProperty("--card-x", `${xPercent}%`); - card.style.setProperty("--card-y", `${yPercent}%`); - card.style.setProperty("--tilt-x", `${rotateX}deg`); - card.style.setProperty("--tilt-y", `${rotateY}deg`); - }); - - card.addEventListener("pointerleave", () => { - leaveTimer = window.setTimeout(() => { - card.classList.remove("is-hovered"); - card.style.setProperty("--card-x", "50%"); - card.style.setProperty("--card-y", "50%"); - card.style.setProperty("--tilt-x", "0deg"); - card.style.setProperty("--tilt-y", "0deg"); - }, 80); - }); -}); - function applyPreferences(nextPreferences) { document.documentElement.dataset.theme = nextPreferences.theme; document.documentElement.dataset.accent = nextPreferences.accent; @@ -115,3 +132,91 @@ function savePreferences(nextPreferences) { function clamp(value, min, max) { return Math.min(Math.max(value, min), max); } + +function renderCards(cards) { + services.replaceChildren(...cards.map(createCardElement)); +} + +function createCardElement(card) { + const link = document.createElement("a"); + link.className = "service-card"; + link.href = card.url; + + if (card.variant) { + link.dataset.variant = card.variant; + } + + if (card.target !== "self") { + link.target = "_blank"; + link.rel = "noopener noreferrer"; + } + + const shine = document.createElement("span"); + shine.className = "shine"; + + const image = document.createElement("img"); + image.src = card.image; + image.alt = ""; + image.loading = "lazy"; + + const content = document.createElement("div"); + + const kicker = document.createElement("p"); + kicker.className = "service-kicker"; + kicker.textContent = card.kicker; + + const title = document.createElement("h2"); + title.textContent = card.title; + + const description = document.createElement("p"); + description.textContent = card.description; + + const arrow = document.createElement("span"); + arrow.className = "arrow"; + arrow.setAttribute("aria-hidden", "true"); + arrow.textContent = card.target === "self" ? "↓" : "→"; + + content.append(kicker, title, description); + link.append(shine, image, content, arrow); + + return link; +} + +function attachCardInteractions() { + document.querySelectorAll(".service-card").forEach((card) => { + let leaveTimer; + + card.addEventListener("pointerenter", () => { + window.clearTimeout(leaveTimer); + card.classList.add("is-hovered"); + }); + + card.addEventListener("pointermove", (event) => { + window.clearTimeout(leaveTimer); + card.classList.add("is-hovered"); + + const rect = card.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + const xPercent = clamp((x / rect.width) * 100, 8, 92); + const yPercent = clamp((y / rect.height) * 100, 8, 92); + const rotateY = ((xPercent - 50) / 50) * 2.6; + const rotateX = ((50 - yPercent) / 50) * 2.6; + + card.style.setProperty("--card-x", `${xPercent}%`); + card.style.setProperty("--card-y", `${yPercent}%`); + card.style.setProperty("--tilt-x", `${rotateX}deg`); + card.style.setProperty("--tilt-y", `${rotateY}deg`); + }); + + card.addEventListener("pointerleave", () => { + leaveTimer = window.setTimeout(() => { + card.classList.remove("is-hovered"); + card.style.setProperty("--card-x", "50%"); + card.style.setProperty("--card-y", "50%"); + card.style.setProperty("--tilt-x", "0deg"); + card.style.setProperty("--tilt-y", "0deg"); + }, 80); + }); + }); +} diff --git a/styles.css b/styles.css index 2515288..5fa5997 100644 --- a/styles.css +++ b/styles.css @@ -49,6 +49,10 @@ html[data-accent="rose"] { box-sizing: border-box; } +html { + scroll-behavior: smooth; +} + body { min-height: 100vh; margin: 0; @@ -257,7 +261,7 @@ h1 { .services { display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px; } @@ -306,6 +310,12 @@ h1 { outline: none; } +.service-card[data-variant="about"] { + background: + linear-gradient(135deg, rgba(var(--accent-rgb), 0.2), transparent 58%), + var(--panel); +} + .service-card.is-hovered::before, .service-card:focus-visible::before { opacity: 1; @@ -388,6 +398,31 @@ h1 { justify-self: end; } +.about-panel { + margin-top: 18px; + padding: clamp(26px, 5vw, 44px); + border: 1px solid var(--border); + border-radius: 32px; + background: var(--panel); + box-shadow: var(--shadow); + backdrop-filter: blur(22px); +} + +.about-panel h2 { + margin: 10px 0 12px; + font-size: clamp(2rem, 5vw, 4.3rem); + line-height: 0.96; + letter-spacing: -0.07em; +} + +.about-panel p:not(.eyebrow) { + max-width: 780px; + margin: 0; + color: var(--muted); + font-size: 1.05rem; + line-height: 1.75; +} + @media (max-width: 1040px) { .hero, .services {