diff --git a/frontend/src/features/download/api.ts b/frontend/src/features/download/api.ts
index 93370e8..50f4719 100644
--- a/frontend/src/features/download/api.ts
+++ b/frontend/src/features/download/api.ts
@@ -4,14 +4,18 @@ const DEFAULT_REPO = 'loki5512344/LoVisual-'
export const GITHUB_REPO: string = import.meta.env.VITE_GITHUB_REPO || DEFAULT_REPO
/**
- * One-click download of the newest build. Served directly from our own origin (not a
- * github.com redirect) so the button keeps working even where GitHub itself is blocked.
- * The file at `/downloads/lovisual.jar` on the server is NOT rebuilt automatically —
- * whoever cuts a mod release has to `./gradlew build` and re-upload it there by hand
- * (this is a separate Gradle project from the site, deliberately not wired into
+ * One-click download of the newest build. Served by our own gateway
+ * (`GET /downloads/lovisual.jar`, reached as `/api/...` through the site origin's
+ * nginx), so the button keeps working even where GitHub itself is blocked.
+ * The file on the server is NOT rebuilt automatically — whoever cuts a mod
+ * release has to `./gradlew build` and re-upload it there by hand (the mod is a
+ * separate Gradle project from the site, deliberately not wired into
* `backend/deploy/deploy.sh`).
*/
-export const DOWNLOAD_URL = '/downloads/lovisual.jar'
+export const DOWNLOAD_URL = '/api/downloads/lovisual.jar'
+
+/** Releases page — the secondary, GitHub-hosted path (shown behind a ribbon for now). */
+export const GITHUB_RELEASES_URL = `https://github.com/${GITHUB_REPO}/releases`
/** A published GitHub release, narrowed to the fields the download page renders. */
export type Release = {
diff --git a/frontend/src/features/download/i18n/en.ts b/frontend/src/features/download/i18n/en.ts
index ff38d81..b6789d3 100644
--- a/frontend/src/features/download/i18n/en.ts
+++ b/frontend/src/features/download/i18n/en.ts
@@ -4,10 +4,12 @@ import type { downloadRu } from './ru'
export const downloadEn = {
nav: 'Download',
button: 'Download for free',
- hint: 'One .jar straight from GitHub',
+ hint: 'One .jar straight from our server',
versionHint: 'v{version} · {size}',
page: {
title: 'Download LoVisual',
+ githubButton: 'Code on GitHub',
+ githubSoon: 'temporarily unavailable',
lead: 'A free visuals mod for Minecraft 26.2 on Fabric. One file: no installers, no ads.',
reqTitle: 'What you need',
reqMc: 'Minecraft',
diff --git a/frontend/src/features/download/i18n/ru.ts b/frontend/src/features/download/i18n/ru.ts
index f985746..47e5702 100644
--- a/frontend/src/features/download/i18n/ru.ts
+++ b/frontend/src/features/download/i18n/ru.ts
@@ -1,10 +1,12 @@
export const downloadRu = {
nav: 'Скачать',
button: 'Скачать бесплатно',
- hint: 'Один .jar прямо с GitHub',
+ hint: 'Один .jar прямо с нашего сервера',
versionHint: 'v{version} · {size}',
page: {
title: 'Скачать LoVisual',
+ githubButton: 'Код на GitHub',
+ githubSoon: 'временно не работает',
lead: 'Бесплатный визуальный мод для Minecraft 26.2 на Fabric. Один файл: без установщиков и без рекламы.',
reqTitle: 'Что понадобится',
reqMc: 'Minecraft',
diff --git a/frontend/src/features/download/tests/download.test.tsx b/frontend/src/features/download/tests/download.test.tsx
index 92579b0..c9d1ca5 100644
--- a/frontend/src/features/download/tests/download.test.tsx
+++ b/frontend/src/features/download/tests/download.test.tsx
@@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { afterEach, expect, test, vi } from 'vitest'
import { DownloadButton } from '../DownloadButton'
import { ReleaseNotes } from '../ReleaseNotes'
+import DownloadPage from '../../../pages/download/DownloadPage'
function withQuery(ui: React.ReactElement) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
@@ -20,13 +21,28 @@ const release = (body: string) => ({
afterEach(() => vi.restoreAllMocks())
-test('DownloadButton links straight to the latest lovisual.jar', () => {
+test('DownloadButton links straight to the latest lovisual.jar on our own backend', () => {
const { container } = withQuery()
const link = container.querySelector('a')
- expect(link?.getAttribute('href')).toBe('/downloads/lovisual.jar')
+ expect(link?.getAttribute('href')).toBe('/api/downloads/lovisual.jar')
expect(link?.hasAttribute('download')).toBe(true)
})
+test('the page offers both paths: our jar and a GitHub button locked behind a ribbon', async () => {
+ vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('offline'))
+ withQuery()
+
+ const direct = await screen.findByRole('link', { name: 'Скачать бесплатно' })
+ expect(direct).toHaveAttribute('href', '/api/downloads/lovisual.jar')
+ expect(direct).toHaveAttribute('download')
+
+ // The GitHub path is visible but not a link, and says why.
+ const gh = screen.getByText('Код на GitHub')
+ expect(gh.closest('a')).toBeNull()
+ expect(gh).toHaveAttribute('aria-disabled', 'true')
+ expect(screen.getByText('временно не работает')).toBeInTheDocument()
+})
+
test('ReleaseNotes renders Markdown but never raw HTML from a release body', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
diff --git a/frontend/src/features/landing/i18n/en.ts b/frontend/src/features/landing/i18n/en.ts
index fac36c4..0d2f905 100644
--- a/frontend/src/features/landing/i18n/en.ts
+++ b/frontend/src/features/landing/i18n/en.ts
@@ -8,7 +8,7 @@ export const landingEn = {
headlineAccent: "you'll fall in love with",
lead: 'A free Minecraft 26.2 mod: wings, particles, your own HUD, weather and fifty-odd other visual modules. Not a cheat, just pretty.',
download: 'Download for free',
- downloadHint: 'One .jar from GitHub: drop it in mods and play',
+ downloadHint: 'One .jar from our server: drop it in mods and play',
register: 'Create account',
facts: {
modules: 'modules',
@@ -89,7 +89,7 @@ export const landingEn = {
q5: 'How do I move my settings to another PC?',
a5: 'Save your config to a cloud slot, then load it on the other PC with the slot’s code.',
downloadTitle: 'Grab it and jump in',
- downloadLead: 'One .jar from GitHub. Drop it in your mods folder, launch the game with Fabric, open the menu.',
+ downloadLead: 'One .jar from our server. Drop it in your mods folder, launch the game with Fabric, open the menu.',
downloadCta: 'Download for free',
downloadNote: 'Latest release for Minecraft 26.2 on Fabric',
releaseLink: 'All versions on GitHub',
diff --git a/frontend/src/features/landing/i18n/ru.ts b/frontend/src/features/landing/i18n/ru.ts
index 117082a..bf3d1a0 100644
--- a/frontend/src/features/landing/i18n/ru.ts
+++ b/frontend/src/features/landing/i18n/ru.ts
@@ -5,7 +5,7 @@ export const landingRu = {
headlineAccent: 'в которые влюбляешься',
lead: 'Бесплатный мод на Minecraft 26.2: крылья, партиклы, свой HUD, погода и ещё полсотни визуальных модулей. Не чит, просто красиво.',
download: 'Скачать бесплатно',
- downloadHint: 'Один .jar с GitHub: кидаешь в mods и играешь',
+ downloadHint: 'Один .jar с нашего сервера: кидаешь в mods и играешь',
register: 'Создать аккаунт',
facts: {
modules: 'модулей',
@@ -86,7 +86,7 @@ export const landingRu = {
q5: 'Как перенести настройки на другой комп?',
a5: 'Сохрани конфиг в облачный слот, а на другом компе загрузи его по коду слота.',
downloadTitle: 'Качай и заходи в игру',
- downloadLead: 'Один .jar с GitHub. Кидаешь его в папку mods, запускаешь игру через Fabric, открываешь меню.',
+ downloadLead: 'Один .jar с нашего сервера. Кидаешь его в папку mods, запускаешь игру через Fabric, открываешь меню.',
downloadCta: 'Скачать бесплатно',
downloadNote: 'Последний релиз для Minecraft 26.2 на Fabric',
releaseLink: 'Все версии на GitHub',
diff --git a/frontend/src/features/landing/sections/explore/FaqDownload.tsx b/frontend/src/features/landing/sections/explore/FaqDownload.tsx
index 9426f6b..48f9952 100644
--- a/frontend/src/features/landing/sections/explore/FaqDownload.tsx
+++ b/frontend/src/features/landing/sections/explore/FaqDownload.tsx
@@ -1,5 +1,6 @@
import { useTranslation } from 'react-i18next'
-import { DOWNLOAD_URL, GITHUB_REPO } from '@features/download/api'
+import { Link } from 'react-router'
+import { GITHUB_RELEASES_URL } from '@features/download/api'
import '@features/landing/styles/landing.css'
import '@features/landing/styles/sections.css'
@@ -40,12 +41,12 @@ export function FaqDownload() {
{t('faq.downloadTitle')}
{t('faq.downloadLead')}
-
+
{t('faq.downloadCta')}
-
+
-
+
{t('hero.download')}
-
+
{t('hero.register')}
diff --git a/frontend/src/features/landing/tests/landing.test.tsx b/frontend/src/features/landing/tests/landing.test.tsx
index 215c0cb..7faa645 100644
--- a/frontend/src/features/landing/tests/landing.test.tsx
+++ b/frontend/src/features/landing/tests/landing.test.tsx
@@ -59,3 +59,20 @@ test('cloud section speaks in player terms in both languages', async () => {
unmount()
}
})
+
+test('hero download CTA goes to the page with both download buttons', async () => {
+ vi.stubGlobal('matchMedia', (q: string) => ({ matches: q.includes('reduce'), addEventListener() {}, removeEventListener() {} }))
+ mockFetch({ 'POST /auth/refresh': () => json({}, 401) })
+ const router = createMemoryRouter([{ path: '/', element: }])
+ // Explicit ru: an earlier test in this file re-inits the default instance in English.
+ render(
+
+
+
+
+ ,
+ )
+ const cta = await screen.findByRole('link', { name: 'Скачать бесплатно' })
+ expect(cta).toHaveAttribute('href', '/download')
+ expect(cta).not.toHaveAttribute('download')
+})
diff --git a/frontend/src/pages/download/DownloadPage.tsx b/frontend/src/pages/download/DownloadPage.tsx
index f2acea7..0fd4a72 100644
--- a/frontend/src/pages/download/DownloadPage.tsx
+++ b/frontend/src/pages/download/DownloadPage.tsx
@@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { DownloadButton } from '@features/download/DownloadButton'
import { ReleaseNotes } from '@features/download/ReleaseNotes'
-import { GITHUB_REPO, fetchReleases, releaseSize } from '@features/download/api'
+import { GITHUB_RELEASES_URL, fetchReleases, releaseSize } from '@features/download/api'
import { PageTitle } from '@shared/layout/PageTitle'
// Requirement versions mirror mod/gradle.properties (the mod's build source of truth).
@@ -12,6 +12,25 @@ const REQUIREMENTS = [
{ key: 'reqApi', value: '0.152.2+26.2' },
] as const
+/**
+ * The second download path, kept visible but locked: GitHub releases are
+ * temporarily off (see TODO.md «Скачивание»), so the button is not a link and
+ * carries a ribbon saying so.
+ */
+function GithubButtonOnHold() {
+ const { t } = useTranslation('download')
+ return (
+
+
+ {t('page.githubButton')}
+
+
+ {t('page.githubSoon')}
+
+
+ )
+}
+
export default function DownloadPage() {
const { t } = useTranslation('download')
const { data } = useQuery({ queryKey: ['releases'], queryFn: fetchReleases, staleTime: 10 * 60 * 1000 })
@@ -22,12 +41,14 @@ export default function DownloadPage() {
{t('page.title')}
-
+
+
+
+
@@ -61,7 +82,7 @@ export default function DownloadPage() {