генерация карточек из объекта добавление разделов чата и обо мне

This commit is contained in:
Илья Братских 2026-06-24 10:01:39 +03:00 committed by KBAHTNET
parent d86f7f1952
commit f37edb6d24
5 changed files with 224 additions and 115 deletions

14
assets/about.svg Normal file
View File

@ -0,0 +1,14 @@
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="160" height="160" rx="44" fill="url(#bg)"/>
<circle cx="80" cy="62" r="24" fill="white"/>
<path d="M42 124c6-28 20-42 38-42s32 14 38 42" fill="white"/>
<path d="M43 45c11-16 29-24 51-20" stroke="#FDE68A" stroke-width="8" stroke-linecap="round"/>
<path d="M111 37l9-12 9 12 14 4-9 11 1 15-15-6-15 6 1-15-9-11 14-4z" fill="#FDE68A"/>
<path d="M66 112h28" stroke="#0F172A" stroke-opacity=".32" stroke-width="8" stroke-linecap="round"/>
<defs>
<linearGradient id="bg" x1="18" y1="14" x2="140" y2="150" gradientUnits="userSpaceOnUse">
<stop stop-color="#22D3EE"/>
<stop offset="1" stop-color="#0F172A"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 781 B

17
assets/openspace.svg Normal file
View File

@ -0,0 +1,17 @@
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="160" height="160" rx="44" fill="url(#bg)"/>
<circle cx="56" cy="67" r="18" fill="white"/>
<circle cx="104" cy="67" r="18" fill="white" fill-opacity=".86"/>
<path d="M32 112c4-20 17-32 35-32s31 12 35 32" fill="white"/>
<path d="M72 112c4-20 17-32 35-32 13 0 24 7 30 20" fill="white" fill-opacity=".76"/>
<path d="M39 44h82c8 0 14 6 14 14v30c0 8-6 14-14 14h-12l-17 18v-18H39c-8 0-14-6-14-14V58c0-8 6-14 14-14z" fill="#EEF2FF" fill-opacity=".2"/>
<path d="M52 67h55M52 84h33" stroke="white" stroke-width="8" stroke-linecap="round"/>
<circle cx="120" cy="42" r="14" fill="#FDE68A"/>
<path d="M114 42h12M120 36v12" stroke="#7C3AED" stroke-width="4" stroke-linecap="round"/>
<defs>
<linearGradient id="bg" x1="18" y1="14" x2="140" y2="150" gradientUnits="userSpaceOnUse">
<stop stop-color="#A78BFA"/>
<stop offset="1" stop-color="#4F46E5"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1023 B

View File

@ -82,82 +82,20 @@
</div>
</section>
<section class="services cards-view" aria-label="Сервисы экосистемы">
<a
class="service-card"
href="https://nextcloud.bratskih.tech"
target="_blank"
rel="noopener noreferrer"
>
<span class="shine"></span>
<img src="assets/nextcloud.svg" alt="" loading="lazy" />
<div>
<p class="service-kicker">Cloud storage</p>
<h2>Nextcloud</h2>
<p>
Личное облако для файлов, заметок, календарей и синхронизации
между устройствами.
</p>
</div>
<span class="arrow" aria-hidden="true">→</span>
</a>
<section
class="services cards-view"
aria-label="Сервисы экосистемы"
data-cards-root
></section>
<a
class="service-card"
href="https://mail.bratskih.tech"
target="_blank"
rel="noopener noreferrer"
>
<span class="shine"></span>
<img src="assets/mail.svg" alt="" loading="lazy" />
<div>
<p class="service-kicker">Mailbox</p>
<h2>Mail</h2>
<section class="about-panel" id="about" aria-labelledby="about-title">
<p class="eyebrow">About</p>
<h2 id="about-title">Обо мне</h2>
<p>
Почтовый шлюз для рабочей переписки, уведомлений и управления
сообщениями.
Bratskih Tech — личное пространство для сервисов, экспериментов,
инфраструктуры и проектов. Здесь собраны основные точки входа в мою
цифровую экосистему.
</p>
</div>
<span class="arrow" aria-hidden="true">→</span>
</a>
<a
class="service-card"
href="https://test-net.bratskih.tech"
target="_blank"
rel="noopener noreferrer"
>
<span class="shine"></span>
<img src="assets/test-net.svg" alt="" loading="lazy" />
<div>
<p class="service-kicker">Lab network</p>
<h2>Test Net</h2>
<p>
Песочница для экспериментов, тестовых окружений и проверки новых
идей.
</p>
</div>
<span class="arrow" aria-hidden="true">→</span>
</a>
<a
class="service-card"
href="https://gitea.bratskih.tech"
target="_blank"
rel="noopener noreferrer"
>
<span class="shine"></span>
<img src="assets/gitea.svg" alt="" loading="lazy" />
<div>
<p class="service-kicker">Git forge</p>
<h2>Gitea</h2>
<p>
Домашняя кузница кода: репозитории, задачи, ревью и история
проектов.
</p>
</div>
<span class="arrow" aria-hidden="true">→</span>
</a>
</section>
</main>

183
script.js
View File

@ -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);
});
});
}

View File

@ -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 {