265 lines
8.3 KiB
JavaScript
265 lines
8.3 KiB
JavaScript
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",
|
||
accent: "violet",
|
||
};
|
||
const availableViews = ["cards", "list", "compact-cards", "compact-list"];
|
||
|
||
const preferences = {
|
||
...defaultPreferences,
|
||
...readPreferences(),
|
||
};
|
||
|
||
const services = document.querySelector("[data-cards-root]");
|
||
const settingsToggle = document.querySelector("[data-settings-toggle]");
|
||
const settingsPanel = document.querySelector("[data-settings-panel]");
|
||
const settingsOverlay = document.querySelector("[data-settings-overlay]");
|
||
const settingsClose = document.querySelector("[data-settings-close]");
|
||
const themeButtons = document.querySelectorAll("[data-theme]");
|
||
const viewButtons = document.querySelectorAll("[data-view]");
|
||
const accentButtons = document.querySelectorAll("[data-accent]");
|
||
|
||
renderCards(serviceCards);
|
||
applyPreferences(preferences);
|
||
attachCardInteractions();
|
||
|
||
settingsToggle.addEventListener("click", () => {
|
||
const isOpen = settingsPanel.classList.toggle("is-open");
|
||
settingsOverlay.classList.toggle("is-open", isOpen);
|
||
settingsToggle.setAttribute("aria-expanded", String(isOpen));
|
||
});
|
||
|
||
settingsClose.addEventListener("click", closeSettingsPanel);
|
||
settingsOverlay.addEventListener("click", closeSettingsPanel);
|
||
|
||
document.addEventListener("keydown", (event) => {
|
||
if (event.key === "Escape") {
|
||
closeSettingsPanel();
|
||
}
|
||
});
|
||
|
||
document.addEventListener("click", (event) => {
|
||
if (!settingsPanel.classList.contains("is-open")) {
|
||
return;
|
||
}
|
||
|
||
if (settingsPanel.contains(event.target) || settingsToggle.contains(event.target)) {
|
||
return;
|
||
}
|
||
|
||
closeSettingsPanel();
|
||
});
|
||
|
||
themeButtons.forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
preferences.theme = button.dataset.theme;
|
||
applyPreferences(preferences);
|
||
savePreferences(preferences);
|
||
});
|
||
});
|
||
|
||
viewButtons.forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
preferences.view = button.dataset.view;
|
||
applyPreferences(preferences);
|
||
savePreferences(preferences);
|
||
});
|
||
});
|
||
|
||
accentButtons.forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
preferences.accent = button.dataset.accent;
|
||
applyPreferences(preferences);
|
||
savePreferences(preferences);
|
||
});
|
||
});
|
||
|
||
function applyPreferences(nextPreferences) {
|
||
if (!availableViews.includes(nextPreferences.view)) {
|
||
nextPreferences.view = defaultPreferences.view;
|
||
}
|
||
|
||
document.documentElement.dataset.theme = nextPreferences.theme;
|
||
document.documentElement.dataset.accent = nextPreferences.accent;
|
||
|
||
services.classList.remove("cards-view", "list-view", "compact-cards-view", "compact-list-view");
|
||
services.classList.add(`${nextPreferences.view}-view`);
|
||
|
||
setActiveButton(themeButtons, "theme", nextPreferences.theme);
|
||
setActiveButton(viewButtons, "view", nextPreferences.view);
|
||
setActiveButton(accentButtons, "accent", nextPreferences.accent);
|
||
}
|
||
|
||
function setActiveButton(buttons, key, value) {
|
||
buttons.forEach((button) => {
|
||
const isActive = button.dataset[key] === value;
|
||
button.classList.toggle("is-active", isActive);
|
||
button.setAttribute("aria-pressed", String(isActive));
|
||
});
|
||
}
|
||
|
||
function readPreferences() {
|
||
try {
|
||
return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {};
|
||
} catch {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
function savePreferences(nextPreferences) {
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify(nextPreferences));
|
||
}
|
||
|
||
function closeSettingsPanel() {
|
||
settingsPanel.classList.remove("is-open");
|
||
settingsOverlay.classList.remove("is-open");
|
||
settingsToggle.setAttribute("aria-expanded", "false");
|
||
}
|
||
|
||
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);
|
||
});
|
||
});
|
||
}
|