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