|
| 1 | +document.documentElement.classList.add("motion-ready"); |
| 2 | + |
| 3 | +const RELEASE_URL = "https://github.com/monogram-android/monogram/releases"; |
| 4 | +const RELEASES_API_URL = "https://api.github.com/repos/monogram-android/monogram/releases?per_page=10"; |
| 5 | +const LANG_KEY = "monogram-site-language"; |
| 6 | +const translations = window.MONOGRAM_TRANSLATIONS || {}; |
| 7 | + |
| 8 | +const root = document.documentElement; |
| 9 | +const topBar = document.querySelector(".top-bar"); |
| 10 | +const revealNodes = document.querySelectorAll("[data-reveal]"); |
| 11 | +const latestReleaseNodes = document.querySelectorAll("[data-latest-release]"); |
| 12 | +const releaseMeta = document.querySelector(".release-meta"); |
| 13 | +const releaseVersionNodes = document.querySelectorAll("[data-release-version]"); |
| 14 | +const releaseDateNodes = document.querySelectorAll("[data-release-date]"); |
| 15 | +const releaseChannelNodes = document.querySelectorAll("[data-release-channel]"); |
| 16 | +const langButtons = document.querySelectorAll("[data-lang]"); |
| 17 | + |
| 18 | +let currentLang = "en"; |
| 19 | +let currentRelease = null; |
| 20 | +let hasAnimatedRelease = false; |
| 21 | + |
| 22 | +function safeStorageGet(key) { |
| 23 | + try { |
| 24 | + return localStorage.getItem(key); |
| 25 | + } catch { |
| 26 | + return null; |
| 27 | + } |
| 28 | +} |
| 29 | + |
| 30 | +function safeStorageSet(key, value) { |
| 31 | + try { |
| 32 | + localStorage.setItem(key, value); |
| 33 | + } catch { |
| 34 | + // Ignore storage failures. |
| 35 | + } |
| 36 | +} |
| 37 | + |
| 38 | +function detectPreferredLanguage() { |
| 39 | + const browserLanguage = (navigator.language || navigator.userLanguage || "en").toLowerCase(); |
| 40 | + |
| 41 | + if (browserLanguage.startsWith("ru")) { |
| 42 | + return "ru"; |
| 43 | + } |
| 44 | + |
| 45 | + if (browserLanguage.startsWith("zh")) { |
| 46 | + return "zh"; |
| 47 | + } |
| 48 | + |
| 49 | + return "en"; |
| 50 | +} |
| 51 | + |
| 52 | +function getDictionary(lang) { |
| 53 | + return translations[lang] || translations.en; |
| 54 | +} |
| 55 | + |
| 56 | +function updateTopBar() { |
| 57 | + if (!topBar) { |
| 58 | + return; |
| 59 | + } |
| 60 | + |
| 61 | + topBar.dataset.elevated = window.scrollY > 10 ? "true" : "false"; |
| 62 | +} |
| 63 | + |
| 64 | +function setLatestReleaseTargets(url) { |
| 65 | + latestReleaseNodes.forEach((node) => { |
| 66 | + if (node instanceof HTMLAnchorElement) { |
| 67 | + node.href = url || RELEASE_URL; |
| 68 | + } |
| 69 | + }); |
| 70 | +} |
| 71 | + |
| 72 | +function formatReleaseDate(value, lang) { |
| 73 | + const date = new Date(value); |
| 74 | + |
| 75 | + if (Number.isNaN(date.getTime())) { |
| 76 | + return ""; |
| 77 | + } |
| 78 | + |
| 79 | + const localeMap = { |
| 80 | + en: "en-US", |
| 81 | + ru: "ru-RU", |
| 82 | + zh: "zh-CN" |
| 83 | + }; |
| 84 | + |
| 85 | + return new Intl.DateTimeFormat(localeMap[lang] || "en-US", { |
| 86 | + day: "numeric", |
| 87 | + month: "long", |
| 88 | + year: "numeric" |
| 89 | + }).format(date); |
| 90 | +} |
| 91 | + |
| 92 | +function applyTranslations(lang) { |
| 93 | + const dictionary = getDictionary(lang); |
| 94 | + |
| 95 | + document.querySelectorAll("[data-i18n]").forEach((node) => { |
| 96 | + const key = node.getAttribute("data-i18n"); |
| 97 | + |
| 98 | + if (key && dictionary[key]) { |
| 99 | + node.textContent = dictionary[key]; |
| 100 | + } |
| 101 | + }); |
| 102 | + |
| 103 | + document.querySelectorAll("[data-i18n-attr]").forEach((node) => { |
| 104 | + const pairs = node.getAttribute("data-i18n-attr").split(","); |
| 105 | + |
| 106 | + pairs.forEach((pair) => { |
| 107 | + const [attribute, key] = pair.split(":").map((part) => part.trim()); |
| 108 | + |
| 109 | + if (attribute && key && dictionary[key]) { |
| 110 | + node.setAttribute(attribute, dictionary[key]); |
| 111 | + } |
| 112 | + }); |
| 113 | + }); |
| 114 | + |
| 115 | + root.lang = lang === "zh" ? "zh-CN" : lang; |
| 116 | + document.title = dictionary["meta.title"]; |
| 117 | +} |
| 118 | + |
| 119 | +function renderRelease() { |
| 120 | + if (!releaseMeta || !currentRelease) { |
| 121 | + return; |
| 122 | + } |
| 123 | + |
| 124 | + const dictionary = getDictionary(currentLang); |
| 125 | + const formattedDate = formatReleaseDate(currentRelease.publishedAt, currentLang); |
| 126 | + const stateKey = currentRelease.prerelease ? "release.preview" : "release.latest"; |
| 127 | + |
| 128 | + releaseVersionNodes.forEach((node) => { |
| 129 | + node.textContent = currentRelease.version; |
| 130 | + }); |
| 131 | + |
| 132 | + releaseDateNodes.forEach((node) => { |
| 133 | + node.textContent = formattedDate; |
| 134 | + node.setAttribute("datetime", currentRelease.publishedAt); |
| 135 | + }); |
| 136 | + |
| 137 | + releaseChannelNodes.forEach((node) => { |
| 138 | + node.textContent = dictionary[stateKey]; |
| 139 | + }); |
| 140 | +} |
| 141 | + |
| 142 | +function setReleaseVisibility(isVisible) { |
| 143 | + if (!releaseMeta) { |
| 144 | + return; |
| 145 | + } |
| 146 | + |
| 147 | + releaseMeta.hidden = !isVisible; |
| 148 | + |
| 149 | + if (isVisible) { |
| 150 | + releaseMeta.classList.add("is-visible"); |
| 151 | + |
| 152 | + if (!hasAnimatedRelease) { |
| 153 | + releaseMeta.classList.remove("is-appearing"); |
| 154 | + void releaseMeta.offsetWidth; |
| 155 | + releaseMeta.classList.add("is-appearing"); |
| 156 | + hasAnimatedRelease = true; |
| 157 | + } |
| 158 | + } |
| 159 | +} |
| 160 | + |
| 161 | +function updateLanguageButtons(lang) { |
| 162 | + langButtons.forEach((button) => { |
| 163 | + const isActive = button.dataset.lang === lang; |
| 164 | + button.classList.toggle("is-active", isActive); |
| 165 | + button.setAttribute("aria-pressed", isActive ? "true" : "false"); |
| 166 | + }); |
| 167 | +} |
| 168 | + |
| 169 | +function applyLanguage(lang) { |
| 170 | + currentLang = translations[lang] ? lang : "en"; |
| 171 | + applyTranslations(currentLang); |
| 172 | + renderRelease(); |
| 173 | + updateLanguageButtons(currentLang); |
| 174 | + safeStorageSet(LANG_KEY, currentLang); |
| 175 | +} |
| 176 | + |
| 177 | +function setupLanguageSwitcher() { |
| 178 | + langButtons.forEach((button) => { |
| 179 | + button.addEventListener("click", () => { |
| 180 | + applyLanguage(button.dataset.lang || "en"); |
| 181 | + }); |
| 182 | + }); |
| 183 | +} |
| 184 | + |
| 185 | +function setupRevealAnimations() { |
| 186 | + if (!("IntersectionObserver" in window) || window.matchMedia("(prefers-reduced-motion: reduce)").matches) { |
| 187 | + revealNodes.forEach((node) => node.classList.add("is-visible")); |
| 188 | + return; |
| 189 | + } |
| 190 | + |
| 191 | + const observer = new IntersectionObserver( |
| 192 | + (entries, activeObserver) => { |
| 193 | + entries.forEach((entry) => { |
| 194 | + if (!entry.isIntersecting) { |
| 195 | + return; |
| 196 | + } |
| 197 | + |
| 198 | + entry.target.classList.add("is-visible"); |
| 199 | + activeObserver.unobserve(entry.target); |
| 200 | + }); |
| 201 | + }, |
| 202 | + { |
| 203 | + threshold: 0.18, |
| 204 | + rootMargin: "0px 0px -8% 0px" |
| 205 | + } |
| 206 | + ); |
| 207 | + |
| 208 | + revealNodes.forEach((node) => observer.observe(node)); |
| 209 | +} |
| 210 | + |
| 211 | +async function loadLatestRelease() { |
| 212 | + try { |
| 213 | + const response = await fetch(RELEASES_API_URL, { |
| 214 | + headers: { |
| 215 | + Accept: "application/vnd.github+json", |
| 216 | + "X-GitHub-Api-Version": "2026-03-10" |
| 217 | + } |
| 218 | + }); |
| 219 | + |
| 220 | + if (!response.ok) { |
| 221 | + throw new Error(`GitHub API returned ${response.status}`); |
| 222 | + } |
| 223 | + |
| 224 | + const releases = await response.json(); |
| 225 | + |
| 226 | + if (!Array.isArray(releases)) { |
| 227 | + throw new Error("GitHub API returned an unexpected payload."); |
| 228 | + } |
| 229 | + |
| 230 | + const release = releases |
| 231 | + .filter((item) => item && !item.draft && item.published_at) |
| 232 | + .sort((left, right) => new Date(right.published_at) - new Date(left.published_at))[0]; |
| 233 | + |
| 234 | + if (!release) { |
| 235 | + throw new Error("No published releases were found."); |
| 236 | + } |
| 237 | + |
| 238 | + currentRelease = { |
| 239 | + version: (release.tag_name || release.name || "").replace(/^v/i, ""), |
| 240 | + publishedAt: release.published_at, |
| 241 | + prerelease: Boolean(release.prerelease) |
| 242 | + }; |
| 243 | + |
| 244 | + setLatestReleaseTargets(release.html_url || RELEASE_URL); |
| 245 | + setReleaseVisibility(true); |
| 246 | + renderRelease(); |
| 247 | + } catch (error) { |
| 248 | + currentRelease = null; |
| 249 | + setLatestReleaseTargets(RELEASE_URL); |
| 250 | + setReleaseVisibility(false); |
| 251 | + console.warn("Unable to load latest release metadata.", error); |
| 252 | + } |
| 253 | +} |
| 254 | + |
| 255 | +updateTopBar(); |
| 256 | +setupLanguageSwitcher(); |
| 257 | +setupRevealAnimations(); |
| 258 | +setLatestReleaseTargets(RELEASE_URL); |
| 259 | +setReleaseVisibility(false); |
| 260 | +applyLanguage(safeStorageGet(LANG_KEY) || detectPreferredLanguage()); |
| 261 | +loadLatestRelease(); |
| 262 | + |
| 263 | +window.addEventListener("scroll", updateTopBar, { passive: true }); |
0 commit comments