feat(download): two-button page with the jar served by our backend

This commit is contained in:
loki5512344 2026-09-29 18:39:40 +02:00
parent 54af71589a
commit fc440f0c91
Signed by: boba
GPG key ID: 253067914055423B
11 changed files with 105 additions and 37 deletions

View file

@ -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 = {

View file

@ -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',

View file

@ -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',

View file

@ -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(<DownloadButton />)
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(<DownloadPage />)
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,

View file

@ -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',

View file

@ -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',

View file

@ -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() {
<h2 id="download-title" className="lv-download-title">{t('faq.downloadTitle')}</h2>
<p className="max-w-[46ch] text-lg text-snow/85">{t('faq.downloadLead')}</p>
<div className="flex flex-wrap items-center gap-x-6 gap-y-4">
<a href={DOWNLOAD_URL} download className="lv-cta-primary lv-cta-lg">
<Link to="/download" className="lv-cta-primary lv-cta-lg">
<DownloadIcon />
{t('faq.downloadCta')}
</a>
</Link>
<a
href={`https://github.com/${GITHUB_REPO}/releases`}
href={GITHUB_RELEASES_URL}
target="_blank"
rel="noreferrer"
className="text-snow underline underline-offset-4"

View file

@ -1,8 +1,8 @@
import { MotionConfig, motion } from 'motion/react'
import { useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router'
import { ClickGuiWindow } from '@features/clickgui/ClickGuiWindow'
import { DOWNLOAD_URL } from '@features/download/api'
import { PRESETS } from '@features/themes/presets.generated'
import { parseArgb } from '@features/themes/types'
import { useActiveTheme } from '@features/themes/useActiveTheme'
@ -65,10 +65,10 @@ export function Hero() {
{t('hero.lead')}
</motion.p>
<motion.div {...rise(0.24)} className="flex flex-wrap items-center gap-3">
<a href={DOWNLOAD_URL} download className="lv-cta-primary lv-cta-lg">
<Link to="/download" className="lv-cta-primary lv-cta-lg">
<DownloadIcon />
{t('hero.download')}
</a>
</Link>
<a href="/register" className="lv-cta-quiet-lg">
{t('hero.register')}
</a>

View file

@ -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: <Hero /> }])
// Explicit ru: an earlier test in this file re-inits the default instance in English.
render(
<I18nextProvider i18n={initI18n('ru')}>
<QueryClientProvider client={new QueryClient()}>
<SessionProvider><RouterProvider router={router} /></SessionProvider>
</QueryClientProvider>
</I18nextProvider>,
)
const cta = await screen.findByRole('link', { name: 'Скачать бесплатно' })
expect(cta).toHaveAttribute('href', '/download')
expect(cta).not.toHaveAttribute('download')
})