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 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 * One-click download of the newest build. Served by our own gateway
* github.com redirect) so the button keeps working even where GitHub itself is blocked. * (`GET /downloads/lovisual.jar`, reached as `/api/...` through the site origin's
* The file at `/downloads/lovisual.jar` on the server is NOT rebuilt automatically — * nginx), so the button keeps working even where GitHub itself is blocked.
* whoever cuts a mod release has to `./gradlew build` and re-upload it there by hand * The file on the server is NOT rebuilt automatically — whoever cuts a mod
* (this is a separate Gradle project from the site, deliberately not wired into * 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`). * `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. */ /** A published GitHub release, narrowed to the fields the download page renders. */
export type Release = { export type Release = {

View file

@ -4,10 +4,12 @@ import type { downloadRu } from './ru'
export const downloadEn = { export const downloadEn = {
nav: 'Download', nav: 'Download',
button: 'Download for free', button: 'Download for free',
hint: 'One .jar straight from GitHub', hint: 'One .jar straight from our server',
versionHint: 'v{version} · {size}', versionHint: 'v{version} · {size}',
page: { page: {
title: 'Download LoVisual', 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.', lead: 'A free visuals mod for Minecraft 26.2 on Fabric. One file: no installers, no ads.',
reqTitle: 'What you need', reqTitle: 'What you need',
reqMc: 'Minecraft', reqMc: 'Minecraft',

View file

@ -1,10 +1,12 @@
export const downloadRu = { export const downloadRu = {
nav: 'Скачать', nav: 'Скачать',
button: 'Скачать бесплатно', button: 'Скачать бесплатно',
hint: 'Один .jar прямо с GitHub', hint: 'Один .jar прямо с нашего сервера',
versionHint: 'v{version} · {size}', versionHint: 'v{version} · {size}',
page: { page: {
title: 'Скачать LoVisual', title: 'Скачать LoVisual',
githubButton: 'Код на GitHub',
githubSoon: 'временно не работает',
lead: 'Бесплатный визуальный мод для Minecraft 26.2 на Fabric. Один файл: без установщиков и без рекламы.', lead: 'Бесплатный визуальный мод для Minecraft 26.2 на Fabric. Один файл: без установщиков и без рекламы.',
reqTitle: 'Что понадобится', reqTitle: 'Что понадобится',
reqMc: 'Minecraft', reqMc: 'Minecraft',

View file

@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { afterEach, expect, test, vi } from 'vitest' import { afterEach, expect, test, vi } from 'vitest'
import { DownloadButton } from '../DownloadButton' import { DownloadButton } from '../DownloadButton'
import { ReleaseNotes } from '../ReleaseNotes' import { ReleaseNotes } from '../ReleaseNotes'
import DownloadPage from '../../../pages/download/DownloadPage'
function withQuery(ui: React.ReactElement) { function withQuery(ui: React.ReactElement) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
@ -20,13 +21,28 @@ const release = (body: string) => ({
afterEach(() => vi.restoreAllMocks()) 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 { container } = withQuery(<DownloadButton />)
const link = container.querySelector('a') 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) 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 () => { test('ReleaseNotes renders Markdown but never raw HTML from a release body', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue({ vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true, ok: true,

View file

@ -8,7 +8,7 @@ export const landingEn = {
headlineAccent: "you'll fall in love with", 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.', 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', 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', register: 'Create account',
facts: { facts: {
modules: 'modules', modules: 'modules',
@ -89,7 +89,7 @@ export const landingEn = {
q5: 'How do I move my settings to another PC?', 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.', 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', 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', downloadCta: 'Download for free',
downloadNote: 'Latest release for Minecraft 26.2 on Fabric', downloadNote: 'Latest release for Minecraft 26.2 on Fabric',
releaseLink: 'All versions on GitHub', releaseLink: 'All versions on GitHub',

View file

@ -5,7 +5,7 @@ export const landingRu = {
headlineAccent: 'в которые влюбляешься', headlineAccent: 'в которые влюбляешься',
lead: 'Бесплатный мод на Minecraft 26.2: крылья, партиклы, свой HUD, погода и ещё полсотни визуальных модулей. Не чит, просто красиво.', lead: 'Бесплатный мод на Minecraft 26.2: крылья, партиклы, свой HUD, погода и ещё полсотни визуальных модулей. Не чит, просто красиво.',
download: 'Скачать бесплатно', download: 'Скачать бесплатно',
downloadHint: 'Один .jar с GitHub: кидаешь в mods и играешь', downloadHint: 'Один .jar с нашего сервера: кидаешь в mods и играешь',
register: 'Создать аккаунт', register: 'Создать аккаунт',
facts: { facts: {
modules: 'модулей', modules: 'модулей',
@ -86,7 +86,7 @@ export const landingRu = {
q5: 'Как перенести настройки на другой комп?', q5: 'Как перенести настройки на другой комп?',
a5: 'Сохрани конфиг в облачный слот, а на другом компе загрузи его по коду слота.', a5: 'Сохрани конфиг в облачный слот, а на другом компе загрузи его по коду слота.',
downloadTitle: 'Качай и заходи в игру', downloadTitle: 'Качай и заходи в игру',
downloadLead: 'Один .jar с GitHub. Кидаешь его в папку mods, запускаешь игру через Fabric, открываешь меню.', downloadLead: 'Один .jar с нашего сервера. Кидаешь его в папку mods, запускаешь игру через Fabric, открываешь меню.',
downloadCta: 'Скачать бесплатно', downloadCta: 'Скачать бесплатно',
downloadNote: 'Последний релиз для Minecraft 26.2 на Fabric', downloadNote: 'Последний релиз для Minecraft 26.2 на Fabric',
releaseLink: 'Все версии на GitHub', releaseLink: 'Все версии на GitHub',

View file

@ -1,5 +1,6 @@
import { useTranslation } from 'react-i18next' 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/landing.css'
import '@features/landing/styles/sections.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> <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> <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"> <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 /> <DownloadIcon />
{t('faq.downloadCta')} {t('faq.downloadCta')}
</a> </Link>
<a <a
href={`https://github.com/${GITHUB_REPO}/releases`} href={GITHUB_RELEASES_URL}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="text-snow underline underline-offset-4" className="text-snow underline underline-offset-4"

View file

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

View file

@ -59,3 +59,20 @@ test('cloud section speaks in player terms in both languages', async () => {
unmount() 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')
})

View file

@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { DownloadButton } from '@features/download/DownloadButton' import { DownloadButton } from '@features/download/DownloadButton'
import { ReleaseNotes } from '@features/download/ReleaseNotes' 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' import { PageTitle } from '@shared/layout/PageTitle'
// Requirement versions mirror mod/gradle.properties (the mod's build source of truth). // 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' }, { key: 'reqApi', value: '0.152.2+26.2' },
] as const ] 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 (
<span className="relative inline-flex">
<span aria-disabled="true" className="lv-cta-quiet-lg cursor-not-allowed opacity-60">
{t('page.githubButton')}
</span>
<span className="pointer-events-none absolute left-1/2 top-1/2 w-[calc(100%+1.5rem)] -translate-x-1/2 -translate-y-1/2 -rotate-[7deg] rounded-sm bg-ember px-3 py-1 text-center font-code text-xs font-bold uppercase text-abyss shadow-lg">
{t('page.githubSoon')}
</span>
</span>
)
}
export default function DownloadPage() { export default function DownloadPage() {
const { t } = useTranslation('download') const { t } = useTranslation('download')
const { data } = useQuery({ queryKey: ['releases'], queryFn: fetchReleases, staleTime: 10 * 60 * 1000 }) const { data } = useQuery({ queryKey: ['releases'], queryFn: fetchReleases, staleTime: 10 * 60 * 1000 })
@ -22,12 +41,14 @@ export default function DownloadPage() {
<div className="mx-auto flex w-full max-w-3xl flex-col gap-12 px-6 py-16"> <div className="mx-auto flex w-full max-w-3xl flex-col gap-12 px-6 py-16">
<header className="flex flex-col gap-4"> <header className="flex flex-col gap-4">
<PageTitle lead={t('page.lead')}>{t('page.title')}</PageTitle> <PageTitle lead={t('page.lead')}>{t('page.title')}</PageTitle>
<div className="mt-2 flex flex-wrap items-start gap-x-6 gap-y-5">
<DownloadButton <DownloadButton
size="lg" size="lg"
className="mt-2"
version={latest ? latest.tag_name.replace(/^v/, '') : undefined} version={latest ? latest.tag_name.replace(/^v/, '') : undefined}
bytes={latest ? releaseSize(latest) : undefined} bytes={latest ? releaseSize(latest) : undefined}
/> />
<GithubButtonOnHold />
</div>
</header> </header>
<section className="flex flex-col gap-3"> <section className="flex flex-col gap-3">
@ -61,7 +82,7 @@ export default function DownloadPage() {
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<h2 className="font-display text-xl font-bold uppercase text-snow">{t('notes.title')}</h2> <h2 className="font-display text-xl font-bold uppercase text-snow">{t('notes.title')}</h2>
<a <a
href={`https://github.com/${GITHUB_REPO}/releases`} href={GITHUB_RELEASES_URL}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="text-sm text-ice underline-offset-4 hover:underline" className="text-sm text-ice underline-offset-4 hover:underline"

View file

@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Link } from 'react-router' import { Link } from 'react-router'
import { DOWNLOAD_URL, GITHUB_REPO } from '@features/download/api' import { GITHUB_REPO } from '@features/download/api'
import { LanguageSwitch } from '../i18n/LanguageSwitch' import { LanguageSwitch } from '../i18n/LanguageSwitch'
export function Footer() { export function Footer() {
@ -10,9 +10,10 @@ export function Footer() {
{ to: '/configs', label: t('nav.myConfigs') }, { to: '/configs', label: t('nav.myConfigs') },
{ to: '/themes', label: t('nav.themes') }, { to: '/themes', label: t('nav.themes') },
] ]
const project = [ const project: { label: string; to?: string; href?: string }[] = [
{ href: `https://github.com/${GITHUB_REPO}`, label: t('footer.github') }, { href: `https://github.com/${GITHUB_REPO}`, label: t('footer.github') },
{ href: DOWNLOAD_URL, label: t('footer.download') }, // In-site: the download page is where both download paths (ours + GitHub) live.
{ to: '/download', label: t('footer.download') },
] ]
return ( return (
@ -33,11 +34,15 @@ export function Footer() {
<nav aria-label={t('footer.sectionProject')} className="flex min-w-0 flex-col gap-2"> <nav aria-label={t('footer.sectionProject')} className="flex min-w-0 flex-col gap-2">
<span className="font-display text-sm font-bold text-snow">{t('footer.sectionProject')}</span> <span className="font-display text-sm font-bold text-snow">{t('footer.sectionProject')}</span>
{project.map((i) => ( {project.map((i) =>
i.to ? (
<Link key={i.label} to={i.to} className="text-sm text-frost hover:text-snow">{i.label}</Link>
) : (
<a key={i.label} href={i.href} target="_blank" rel="noreferrer" className="text-sm text-frost hover:text-snow"> <a key={i.label} href={i.href} target="_blank" rel="noreferrer" className="text-sm text-frost hover:text-snow">
{i.label} {i.label}
</a> </a>
))} ),
)}
</nav> </nav>
</div> </div>