| 1 | import { downloadPaneFromURL, downloadURLForPane } from "./download-link.js"; |
| 2 | import { |
| 3 | cliReleaseModel, |
| 4 | cliUpgradeCommand, |
| 5 | desktopGitHubReleaseModel, |
| 6 | desktopReleaseModel, |
| 7 | fetchFirstJSON, |
| 8 | releaseVersionLabel, |
| 9 | } from "./release-channels.js"; |
| 10 | import { initTheme } from "./theme.js"; |
| 11 | |
| 12 | // Reasonix site — vanilla interactions |
| 13 | (function () { |
| 14 | initTheme(); |
| 15 | const motionOK = () => |
| 16 | document.body.dataset.motion === "rich" && |
| 17 | !window.matchMedia("(prefers-reduced-motion: reduce)").matches; |
| 18 | |
| 19 | const nav = document.querySelector(".nav"); |
| 20 | if (nav) { |
| 21 | const onScroll = () => nav.classList.toggle("scrolled", window.scrollY > 12); |
| 22 | window.addEventListener("scroll", onScroll, { passive: true }); |
| 23 | onScroll(); |
| 24 | } |
| 25 | |
| 26 | const revealEls = Array.from(document.querySelectorAll(".reveal")); |
| 27 | const inView = (el, factor) => |
| 28 | el.getBoundingClientRect().top < window.innerHeight * (factor || 0.95); |
| 29 | |
| 30 | const term = document.querySelector(".term"); |
| 31 | const lines = Array.from(document.querySelectorAll(".term-body .tl")); |
| 32 | let played = false; |
| 33 | const playTerm = () => { |
| 34 | if (played) return; |
| 35 | played = true; |
| 36 | const fire = () => document.dispatchEvent(new CustomEvent("rx:term-played")); |
| 37 | if (!motionOK()) { |
| 38 | lines.forEach((l) => l.classList.add("on")); |
| 39 | fire(); |
| 40 | return; |
| 41 | } |
| 42 | lines.forEach((l, i) => setTimeout(() => l.classList.add("on"), 350 + i * 520)); |
| 43 | setTimeout(fire, 350 + Math.max(0, lines.length - 2) * 520); |
| 44 | }; |
| 45 | |
| 46 | let sweepQueued = false; |
| 47 | const sweep = () => { |
| 48 | sweepQueued = false; |
| 49 | revealEls.forEach((el) => { |
| 50 | if (!el.classList.contains("in") && inView(el, 0.95)) el.classList.add("in"); |
| 51 | }); |
| 52 | if (term && !played && inView(term, 0.85)) playTerm(); |
| 53 | }; |
| 54 | const queueSweep = () => { |
| 55 | if (sweepQueued) return; |
| 56 | sweepQueued = true; |
| 57 | requestAnimationFrame(sweep); |
| 58 | }; |
| 59 | window.addEventListener("scroll", queueSweep, { passive: true }); |
| 60 | window.addEventListener("resize", queueSweep, { passive: true }); |
| 61 | window.addEventListener("load", queueSweep); |
| 62 | sweep(); |
| 63 | setTimeout(sweep, 400); |
| 64 | |
| 65 | /* contributors marquee — duplicate the server-rendered set for a seamless loop */ |
| 66 | document.querySelectorAll(".crew-row").forEach((row) => { |
| 67 | const set = row.querySelector(".crew-set"); |
| 68 | if (set) row.appendChild(set.cloneNode(true)); |
| 69 | }); |
| 70 | |
| 71 | /* download / channel tabs */ |
| 72 | const tabs = Array.from(document.querySelectorAll(".dl-tab")); |
| 73 | const panes = Array.from(document.querySelectorAll(".dl-pane")); |
| 74 | const activatePane = (name) => { |
| 75 | tabs.forEach((b) => { |
| 76 | const active = b.dataset.pane === name; |
| 77 | b.classList.toggle("active", active); |
| 78 | b.setAttribute("aria-selected", active ? "true" : "false"); |
| 79 | b.tabIndex = active ? 0 : -1; |
| 80 | }); |
| 81 | panes.forEach((p) => { |
| 82 | const active = p.dataset.pane === name; |
| 83 | p.classList.toggle("active", active); |
| 84 | p.hidden = !active; |
| 85 | }); |
| 86 | }; |
| 87 | tabs.forEach((tab) => { |
| 88 | tab.addEventListener("click", () => { |
| 89 | activatePane(tab.dataset.pane); |
| 90 | reflectPaneURL(tab.dataset.pane); |
| 91 | }); |
| 92 | tab.addEventListener("keydown", (event) => { |
| 93 | const current = tabs.indexOf(tab); |
| 94 | let next = -1; |
| 95 | if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (current + 1) % tabs.length; |
| 96 | else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (current - 1 + tabs.length) % tabs.length; |
| 97 | else if (event.key === "Home") next = 0; |
| 98 | else if (event.key === "End") next = tabs.length - 1; |
| 99 | if (next < 0) return; |
| 100 | event.preventDefault(); |
| 101 | const nextTab = tabs[next]; |
| 102 | activatePane(nextTab.dataset.pane); |
| 103 | reflectPaneURL(nextTab.dataset.pane); |
| 104 | nextTab.focus(); |
| 105 | }); |
| 106 | }); |
| 107 | |
| 108 | /* OS detection — hero download button + card badge + highlight */ |
| 109 | const ua = navigator.userAgent; |
| 110 | const os = /Windows/i.test(ua) ? "win" : /Mac|iPhone|iPad/i.test(ua) ? "mac" : /Linux|X11/i.test(ua) ? "linux" : "mac"; |
| 111 | const osNames = { mac: "macOS", win: "Windows", linux: "Linux" }; |
| 112 | document.querySelectorAll("[data-os-dl] .os-name").forEach((s) => (s.textContent = osNames[os])); |
| 113 | const osCard = document.querySelector('.os-card[data-os="' + os + '"]'); |
| 114 | if (osCard) { |
| 115 | osCard.classList.add("detected"); |
| 116 | const chip = document.createElement("span"); |
| 117 | chip.className = "os-chip"; |
| 118 | chip.innerHTML = '<span class="l-en">your OS</span><span class="l-zh">当前系统</span>'; |
| 119 | osCard.appendChild(chip); |
| 120 | } |
| 121 | |
| 122 | const flashOSCard = () => { |
| 123 | if (!osCard) return; |
| 124 | osCard.classList.remove("flash"); |
| 125 | void osCard.offsetWidth; |
| 126 | setTimeout(() => osCard.classList.add("flash"), 450); |
| 127 | setTimeout(() => osCard.classList.remove("flash"), 2600); |
| 128 | }; |
| 129 | |
| 130 | const requestedPane = downloadPaneFromURL(window.location.href); |
| 131 | const legacyChannelURL = new URL(window.location.href); |
| 132 | if (legacyChannelURL.searchParams.has("channel")) { |
| 133 | legacyChannelURL.searchParams.delete("channel"); |
| 134 | window.history.replaceState(null, "", legacyChannelURL.href); |
| 135 | } |
| 136 | if (requestedPane) { |
| 137 | activatePane(requestedPane); |
| 138 | if (requestedPane === "desktop") flashOSCard(); |
| 139 | requestAnimationFrame(() => { |
| 140 | document.getElementById("start")?.scrollIntoView({ block: "start" }); |
| 141 | queueSweep(); |
| 142 | }); |
| 143 | } |
| 144 | |
| 145 | /* links that deep-link into a specific download tab */ |
| 146 | document.querySelectorAll("[data-goto]").forEach((a) => { |
| 147 | a.addEventListener("click", (event) => { |
| 148 | event.preventDefault(); |
| 149 | activatePane(a.dataset.goto); |
| 150 | reflectPaneURL(a.dataset.goto); |
| 151 | if (a.hasAttribute("data-os-dl")) flashOSCard(); |
| 152 | document.getElementById("start")?.scrollIntoView({ block: "start" }); |
| 153 | setTimeout(queueSweep, 500); |
| 154 | }); |
| 155 | }); |
| 156 | |
| 157 | /* language switch */ |
| 158 | const LANG_KEY = "reasonix-lang"; |
| 159 | const langBtns = Array.from(document.querySelectorAll(".lang-switch button")); |
| 160 | const setLang = (l, alignHash) => { |
| 161 | document.body.dataset.lang = l; |
| 162 | document.documentElement.lang = l === "zh" ? "zh-CN" : "en"; |
| 163 | const t = document.body.dataset[l === "zh" ? "titleZh" : "titleEn"]; |
| 164 | if (t) document.title = t; |
| 165 | langBtns.forEach((b) => b.classList.toggle("active", b.dataset.lang === l)); |
| 166 | try { localStorage.setItem(LANG_KEY, l); } catch (e) {} |
| 167 | if (alignHash && window.location.hash) { |
| 168 | const target = document.getElementById(window.location.hash.slice(1)); |
| 169 | if (target) requestAnimationFrame(() => target.scrollIntoView({ block: "start" })); |
| 170 | } |
| 171 | }; |
| 172 | langBtns.forEach((b) => b.addEventListener("click", () => setLang(b.dataset.lang))); |
| 173 | let savedLang = ""; |
| 174 | try { savedLang = localStorage.getItem(LANG_KEY) || ""; } catch (e) {} |
| 175 | const requestedLang = new URLSearchParams(window.location.search).get("lang"); |
| 176 | const initialLang = requestedLang === "zh" || requestedLang === "en" |
| 177 | ? requestedLang |
| 178 | : savedLang || ((navigator.language || "").toLowerCase().startsWith("zh") ? "zh" : "en"); |
| 179 | setLang(initialLang, true); |
| 180 | |
| 181 | /* docs scrollspy */ |
| 182 | const sideLinks = Array.from(document.querySelectorAll(".docs-side a[href^='#']")); |
| 183 | if (sideLinks.length) { |
| 184 | const targets = sideLinks |
| 185 | .map((a) => document.getElementById(a.getAttribute("href").slice(1))) |
| 186 | .filter(Boolean) |
| 187 | // Sidebar links are grouped editorially, so their order differs from the |
| 188 | // page order. The spy below picks the last section past the 140px line, |
| 189 | // which is only correct when targets are sorted in document order. |
| 190 | .sort((a, b) => |
| 191 | a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1); |
| 192 | const setActive = (id) => |
| 193 | sideLinks.forEach((a) => { |
| 194 | const on = a.getAttribute("href") === "#" + id; |
| 195 | a.classList.toggle("active", on); |
| 196 | if (on) a.setAttribute("aria-current", "true"); |
| 197 | else a.removeAttribute("aria-current"); |
| 198 | }); |
| 199 | // While a click smooth-scrolls to a section, pin the highlight to it so it |
| 200 | // doesn't sweep through every section scrolled past on the way. scrollend |
| 201 | // releases the pin when the scroll settles — on arrival or when the user |
| 202 | // takes over (wheel, touch, keyboard, scrollbar). Browsers without |
| 203 | // scrollend just skip pinning: correct destination, no sweep suppression. |
| 204 | let pinned = null; |
| 205 | const spy = () => { |
| 206 | if (pinned) return; |
| 207 | let current = targets[0]; |
| 208 | for (const t of targets) if (t.getBoundingClientRect().top < 140) current = t; |
| 209 | if (current) setActive(current.id); |
| 210 | }; |
| 211 | if ("onscrollend" in window) { |
| 212 | const ids = new Set(targets.map((t) => t.id)); |
| 213 | document.querySelectorAll("a[href^='#']").forEach((a) => { |
| 214 | const id = a.getAttribute("href").slice(1); |
| 215 | if (ids.has(id)) a.addEventListener("click", () => { pinned = id; setActive(id); }); |
| 216 | }); |
| 217 | window.addEventListener("scrollend", () => { pinned = null; spy(); }, { passive: true }); |
| 218 | } |
| 219 | window.addEventListener("scroll", spy, { passive: true }); |
| 220 | spy(); |
| 221 | } |
| 222 | |
| 223 | /* copy-to-clipboard */ |
| 224 | document.querySelectorAll("[data-copy]").forEach((btn) => { |
| 225 | btn.addEventListener("click", () => { |
| 226 | const text = btn.getAttribute("data-copy"); |
| 227 | const done = () => { |
| 228 | btn.classList.add("copied"); |
| 229 | const prev = btn.textContent; |
| 230 | btn.textContent = "Copied"; |
| 231 | setTimeout(() => { btn.classList.remove("copied"); btn.textContent = prev; }, 1600); |
| 232 | }; |
| 233 | if (navigator.clipboard && navigator.clipboard.writeText) { |
| 234 | navigator.clipboard.writeText(text).then(done).catch(done); |
| 235 | } else done(); |
| 236 | }); |
| 237 | }); |
| 238 | |
| 239 | /* public official releases */ |
| 240 | const releaseModels = { desktop: null, cli: null }; |
| 241 | const releasesPage = "https://github.com/esengine/DeepSeek-Reasonix/releases"; |
| 242 | const reflectPaneURL = (surface) => { |
| 243 | const nextURL = downloadURLForPane(window.location.href, surface, ""); |
| 244 | if (nextURL) window.history.replaceState(null, "", nextURL); |
| 245 | }; |
| 246 | |
| 247 | // npm / Homebrew / generic product chips track the CLI stable line only. |
| 248 | // Desktop and CLI download panes render their own versions via |
| 249 | // [data-release-version="<surface>"]; never let Desktop and CLI race on .rxv. |
| 250 | const updateCLIPackageVersion = (model) => { |
| 251 | if (!model) return; |
| 252 | document.querySelectorAll(".rxv").forEach((element) => { element.textContent = releaseVersionLabel(model); }); |
| 253 | document.querySelectorAll("a.rxnotes").forEach((link) => { |
| 254 | link.href = new URL("changelog/v" + model.displayVersion + "/", window.location.origin + "/").href; |
| 255 | }); |
| 256 | }; |
| 257 | |
| 258 | // Never synthesize public artifact URLs. If every required asset is not |
| 259 | // attested by live release data, fall back to the release list instead of a |
| 260 | // plausible-looking URL that may 404. |
| 261 | const fallbackReleaseURL = () => releasesPage; |
| 262 | |
| 263 | const renderReleaseSurface = (surface) => { |
| 264 | const model = releaseModels[surface]; |
| 265 | document.querySelectorAll('[data-release-version="' + surface + '"]').forEach((element) => { |
| 266 | element.textContent = releaseVersionLabel(model); |
| 267 | }); |
| 268 | document.querySelectorAll('[data-release-notes="' + surface + '"]').forEach((link) => { |
| 269 | const path = model?.changelogURL ? new URL(model.changelogURL).pathname : "changelog/"; |
| 270 | link.href = new URL(path, window.location.origin + "/").href; |
| 271 | }); |
| 272 | |
| 273 | const assetAttribute = "data-" + surface + "-asset"; |
| 274 | document.querySelectorAll("[" + assetAttribute + "]").forEach((link) => { |
| 275 | const asset = link.getAttribute(assetAttribute); |
| 276 | const target = model?.assets?.[asset] || fallbackReleaseURL(); |
| 277 | link.href = target; |
| 278 | if (target === releasesPage) link.removeAttribute("download"); |
| 279 | else link.setAttribute("download", ""); |
| 280 | }); |
| 281 | |
| 282 | if (surface === "cli") { |
| 283 | const command = cliUpgradeCommand(); |
| 284 | document.querySelectorAll('[data-release-command="cli"]').forEach((element) => { element.textContent = command; }); |
| 285 | document.querySelectorAll('.release-upgrade-command [data-copy]').forEach((button) => { button.dataset.copy = command; }); |
| 286 | } |
| 287 | }; |
| 288 | |
| 289 | renderReleaseSurface("desktop"); |
| 290 | renderReleaseSurface("cli"); |
| 291 | if (requestedPane) reflectPaneURL(requestedPane); |
| 292 | |
| 293 | fetchFirstJSON([ |
| 294 | "https://dl.reasonix.io/latest/latest.json", |
| 295 | "https://crash.reasonix.io/v1/desktop/releases/stable/latest.json", |
| 296 | ], fetch, (manifest) => Boolean(desktopReleaseModel(manifest))) |
| 297 | .then((manifest) => desktopReleaseModel(manifest)) |
| 298 | .catch(() => fetchFirstJSON( |
| 299 | ["https://api.github.com/repos/esengine/DeepSeek-Reasonix/releases/latest"], |
| 300 | fetch, |
| 301 | (release) => Boolean(desktopGitHubReleaseModel(release)), |
| 302 | ).then(desktopGitHubReleaseModel)) |
| 303 | .then((model) => { |
| 304 | if (!model) return; |
| 305 | releaseModels.desktop = model; |
| 306 | renderReleaseSurface("desktop"); |
| 307 | }) |
| 308 | .catch(() => {}); |
| 309 | |
| 310 | let githubCLIReleases; |
| 311 | const fallbackCLIReleases = () => { |
| 312 | githubCLIReleases ??= fetchFirstJSON([ |
| 313 | "https://api.github.com/repos/esengine/DeepSeek-Reasonix/releases?per_page=100", |
| 314 | ]).catch(() => null); |
| 315 | return githubCLIReleases; |
| 316 | }; |
| 317 | fetchFirstJSON( |
| 318 | ["https://crash.reasonix.io/v1/cli/releases/stable/latest.json"], |
| 319 | fetch, |
| 320 | (payload) => Boolean(cliReleaseModel(Array.isArray(payload) ? payload : [payload])), |
| 321 | ) |
| 322 | .catch(() => fallbackCLIReleases()) |
| 323 | .then((payload) => { |
| 324 | const releases = Array.isArray(payload) ? payload : payload ? [payload] : []; |
| 325 | const model = cliReleaseModel(releases); |
| 326 | if (!model) return; |
| 327 | releaseModels.cli = model; |
| 328 | updateCLIPackageVersion(model); |
| 329 | renderReleaseSurface("cli"); |
| 330 | }) |
| 331 | .catch(() => {}); |
| 332 | })(); |
| 333 |