feat(site): redesigned /download, releases and links moved from GitHub to Forgejo
Some checks failed
CI / backend (cargo test + clippy) (push) Has been cancelled
CI / frontend (lint + test + build) (push) Has been cancelled
CI / mod (gradle test + checkFolderLimit) (push) Has been cancelled

- /download: the jar as an in-game item (pixel heart in an inventory slot, vanilla-style tooltip with version, size and SHA-256, copy button), spec list, three install steps, release timeline
- releases read through a cached nginx proxy (/api/releases): Forgejo's API has no CORS headers
- README, footer and landing links point to git.wihuk.pro/boba/LoVisual (the GitHub repo they used does not exist)
- release workflow publishes through the Forgejo API and ships lovisual.jar.sha256
- fix i18n interpolation placeholders ({{x}}) in the download strings
This commit is contained in:
loki5512344 2026-10-09 22:15:55 +02:00
parent fa08d50704
commit 2d96f28091
Signed by: boba
GPG key ID: 253067914055423B
19 changed files with 360 additions and 143 deletions

View file

@ -17,6 +17,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up JDK 25
uses: actions/setup-java@v4
@ -46,11 +48,28 @@ jobs:
echo "version=$version" >>"$GITHUB_OUTPUT"
echo "Packaged $jar as lovisual-$version.jar and lovisual.jar"
- name: Publish the GitHub release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
generate_release_notes: true
files: |
lovisual-${{ steps.jar.outputs.version }}.jar
lovisual.jar
# The repository lives on Forgejo, so the release goes through its API.
# One-time setup: Settings > Actions > Secrets > RELEASE_TOKEN (an API
# token with write access to this repository's releases).
- name: Publish the Forgejo release
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
TAG: ${{ github.ref_name }}
VERSION: ${{ steps.jar.outputs.version }}
run: |
set -euo pipefail
test -n "$TOKEN" || { echo "RELEASE_TOKEN secret is missing"; exit 1; }
sha256sum lovisual.jar > lovisual.jar.sha256
prev="$(git describe --tags --abbrev=0 "$TAG^" 2>/dev/null || true)"
range="${prev:+$prev..}$TAG"
notes="$(git log --no-merges --pretty='- %s' "$range" | head -60)"
body="$(python3 -c 'import json,sys; print(json.dumps({"tag_name":sys.argv[1],"name":sys.argv[1],"body":sys.argv[2]}))' "$TAG" "$notes")"
id="$(curl -fsS -X POST "$API/releases" -H "Authorization: token $TOKEN" \
-H 'Content-Type: application/json' -d "$body" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')"
for f in "lovisual-$VERSION.jar" lovisual.jar lovisual.jar.sha256; do
curl -fsS -X POST "$API/releases/$id/assets?name=$f" -H "Authorization: token $TOKEN" \
-F "attachment=@$f" >/dev/null
echo "uploaded $f"
done

View file

@ -6,13 +6,13 @@ Open-source Minecraft utility client **and** the platform around it - website, c
Developed and maintained by [loki5512344](https://github.com/loki5512344).
![Release](https://img.shields.io/github/v/release/loki5512344/LoVisual-?style=flat-square&color=blue)
![Release](https://img.shields.io/gitea/v/release/boba/LoVisual?gitea_url=https%3A%2F%2Fgit.wihuk.pro&style=flat-square&color=blue)
![Java](https://img.shields.io/badge/Java-25+-orange?style=flat-square&logo=openjdk&logoColor=white)
![Rust](https://img.shields.io/badge/Rust-backend-red?style=flat-square&logo=rust&logoColor=white)
![React](https://img.shields.io/badge/Frontend-React%20%2B%20Vite-61dafb?style=flat-square&logo=react&logoColor=white)
![License](https://img.shields.io/badge/license-GPLv3-blue?style=flat-square&logo=gnu&logoColor=white)
**[⬇ Download the mod](https://github.com/loki5512344/LoVisual-/releases/latest/download/lovisual.jar)** · [Releases](https://github.com/loki5512344/LoVisual-/releases)
**[⬇ Download the mod](https://git.wihuk.pro/boba/LoVisual/releases/latest/download/lovisual.jar)** · [Releases](https://git.wihuk.pro/boba/LoVisual/releases)
[English](#english) | [Русский](#русский)
@ -83,8 +83,8 @@ cargo build
cargo test
```
Releases are cut by pushing a `v*` tag - GitHub Actions builds the mod jar and publishes it to
[Releases](https://github.com/loki5512344/LoVisual-/releases) as both `lovisual-<version>.jar`
Releases are cut by pushing a `v*` tag - Forgejo Actions builds the mod jar and publishes it to
[Releases](https://git.wihuk.pro/boba/LoVisual/releases) as both `lovisual-<version>.jar`
and a stable `lovisual.jar` link.
### License
@ -155,8 +155,8 @@ cargo build
cargo test
```
Релизы создаются пушем тега `v*` - GitHub Actions собирает jar мода и публикует в
[Releases](https://github.com/loki5512344/LoVisual-/releases) как `lovisual-<версия>.jar`
Релизы создаются пушем тега `v*` - Forgejo Actions собирает jar мода и публикует в
[Releases](https://git.wihuk.pro/boba/LoVisual/releases) как `lovisual-<версия>.jar`
и по стабильной ссылке `lovisual.jar`.
### Лицензия

View file

@ -0,0 +1,2 @@
# Cache zone for the /api/releases proxy (see nginx-visual.loki-code.dev.conf).
proxy_cache_path /var/cache/nginx/lv_releases levels=1 keys_zone=lv_releases:1m max_size=10m inactive=1h use_temp_path=off;

View file

@ -3,6 +3,21 @@ server {
root /var/www/visual.loki-code.dev/html;
index index.html;
# Forgejo's API sends no CORS headers, so the download page reads the
# release list through this cached proxy (5 min) instead of from the browser.
location = /api/releases {
proxy_pass https://git.wihuk.pro/api/v1/repos/boba/LoVisual/releases?limit=10;
proxy_ssl_server_name on;
proxy_set_header Host git.wihuk.pro;
proxy_set_header Authorization "";
proxy_set_header Cookie "";
proxy_hide_header Set-Cookie;
proxy_cache lv_releases;
proxy_cache_valid 200 5m;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503;
add_header Cache-Control "public, max-age=60" always;
}
location /api/ {
proxy_pass http://127.0.0.1:8080/;
proxy_http_version 1.1;

View file

@ -0,0 +1,77 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from '@shared/i18n/react'
import { CHECKSUM_URL, DOWNLOAD_URL, fetchChecksum, formatSize, type Release, releaseSize } from './api'
import './download.css'
/** Pixel heart from the logo, one rect per cell so it stays crisp at any size. */
const HEART = ['.XX.XX.', 'XXXXXXX', 'XXXXXXX', '.XXXXX.', '..XXX..', '...X...']
function PixelHeart() {
const cells = HEART.flatMap((row, y) =>
[...row].flatMap((c, x) => (c === 'X' ? [{ x, y, hi: x + y < 3 && x < 3 }] : [])),
)
return (
<svg viewBox="0 0 7 6" shapeRendering="crispEdges" aria-hidden="true">
<defs>
<clipPath id="lv-heart-clip">
{cells.map((c) => <rect key={`${c.x}-${c.y}`} x={c.x} y={c.y} width="1" height="1" />)}
</clipPath>
</defs>
{cells.map((c) => (
<rect key={`${c.x}-${c.y}`} x={c.x} y={c.y} width="1" height="1" fill={c.hi ? '#f472b6' : '#be185d'} />
))}
<g clipPath="url(#lv-heart-clip)">
<rect className="lv-glint" x="0" y="-1" width="2" height="8" fill="#fff" opacity="0.55" />
</g>
</svg>
)
}
/**
* The download as an in-game item: heart in an inventory slot, a vanilla-style
* tooltip with version, size and SHA-256, and the download button underneath.
*/
export function ReleaseCard({ release }: { release?: Release }) {
const { t, i18n } = useTranslation('download')
const [copied, setCopied] = useState(false)
const { data: hash } = useQuery({ queryKey: ['checksum'], queryFn: fetchChecksum, staleTime: 10 * 60 * 1000 })
const version = release?.tag_name.replace(/^v/, '')
const bytes = release ? releaseSize(release) : 0
const copy = () => {
if (!hash) return
void navigator.clipboard?.writeText(hash).then(() => {
setCopied(true)
window.setTimeout(() => setCopied(false), 1800)
})
}
return (
<div className="lv-item">
<div className="flex items-start gap-4">
<div className="lv-slot"><PixelHeart /></div>
<div className="lv-tip flex-1">
<div className="lv-tip-name">LoVisual{version ? ` ${version}` : ''}</div>
<div>{t('card.platform')}</div>
{bytes > 0 ? <div>{t('card.size', { size: formatSize(bytes, i18n.language) })}</div> : null}
{hash ? (
<div className="mt-1">
<span className="lv-tip-key">SHA-256</span>
<div className="lv-tip-hash">{hash}</div>
</div>
) : null}
</div>
</div>
<div className="flex flex-wrap items-center gap-x-5 gap-y-3">
<a href={DOWNLOAD_URL} download className="lv-cta-primary lv-cta-lg">{t('button')}</a>
{hash ? (
<button type="button" onClick={copy} className="text-sm text-ice underline-offset-4 hover:underline">
{copied ? t('card.copied') : t('card.copyHash')}
</button>
) : null}
<a href={CHECKSUM_URL} className="sr-only">{t('card.checksumFile')}</a>
</div>
</div>
)
}

View file

@ -2,10 +2,12 @@ import { useQuery } from '@tanstack/react-query'
import { useTranslation } from '@shared/i18n/react'
import { Markdown } from '@shared/ui/Markdown'
import { fetchReleases } from './api'
import './download.css'
/**
* Changelog feed. Markdown bodies go through our own renderer, which builds React
* elements and never raw HTML, so a `<script>` or `onerror=` in a release body stays inert text.
* Changelog timeline, newest first. Markdown bodies go through our own renderer,
* which builds React elements and never raw HTML, so a `<script>` or `onerror=` in
* a release body stays inert text.
*/
export function ReleaseNotes() {
const { t, i18n } = useTranslation('download')
@ -21,23 +23,29 @@ export function ReleaseNotes() {
)
}
const date = new Intl.DateTimeFormat(i18n.language, { dateStyle: 'medium' })
const date = new Intl.DateTimeFormat(i18n.language, { dateStyle: 'long' })
return (
<ol className="flex flex-col gap-8">
{data.map((r) => (
<li key={r.tag_name} className="flex flex-col gap-2">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h3 className="font-display text-xl font-bold text-snow">{r.name || r.tag_name}</h3>
<time className="font-code text-xs text-frost" dateTime={r.published_at}>
{date.format(new Date(r.published_at))}
</time>
</div>
<div className="lv-markdown text-[0.95rem] leading-relaxed text-snow/85">
<Markdown>{r.body}</Markdown>
</div>
</li>
))}
<ol className="lv-rail flex flex-col gap-10">
{data.map((r, i) => {
const downloads = r.assets.reduce((sum, a) => sum + a.download_count, 0)
return (
<li key={r.tag_name} className="relative flex flex-col gap-2">
<span className="lv-dot" data-latest={i === 0} aria-hidden="true" />
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h3 className="font-display text-xl font-bold text-snow">{r.name || r.tag_name}</h3>
{i === 0 ? <span className="rounded-full bg-ice/15 px-2 py-0.5 text-xs font-bold text-ice">{t('notes.latest')}</span> : null}
<time className="font-code text-xs text-frost" dateTime={r.published_at}>
{date.format(new Date(r.published_at))}
</time>
{downloads > 0 ? <span className="font-code text-xs text-frost">{t('notes.downloads', { count: downloads })}</span> : null}
</div>
<div className="lv-markdown text-[0.95rem] leading-relaxed text-snow/85">
<Markdown>{r.body}</Markdown>
</div>
</li>
)
})}
</ol>
)
}

View file

@ -1,23 +1,29 @@
const DEFAULT_REPO = 'loki5512344/LoVisual-'
const DEFAULT_REPO = 'boba/LoVisual'
/** `owner/name` of the GitHub repository that publishes mod releases. */
export const GITHUB_REPO: string = import.meta.env.VITE_GITHUB_REPO || DEFAULT_REPO
/** `owner/name` of the Forgejo repository that publishes mod releases. */
export const FORGEJO_REPO: string = import.meta.env.VITE_FORGEJO_REPO || DEFAULT_REPO
/** Project home and its releases page (the repository lives on our own Forgejo). */
export const SOURCE_URL = `https://git.wihuk.pro/${FORGEJO_REPO}`
export const RELEASES_URL = `${SOURCE_URL}/releases`
/**
* 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`).
* nginx). The file on the server is NOT rebuilt automatically: whoever cuts a mod
* release re-uploads `lovisual.jar` and `lovisual.jar.sha256` there by hand
* (the mod is a separate Gradle project, deliberately not part of `deploy.sh`).
*/
export const DOWNLOAD_URL = '/api/downloads/lovisual.jar'
export const CHECKSUM_URL = '/api/downloads/lovisual.jar.sha256'
/** Releases page — the secondary, GitHub-hosted path (shown behind a ribbon for now). */
export const GITHUB_RELEASES_URL = `https://github.com/${GITHUB_REPO}/releases`
/**
* Forgejo's API sends no CORS headers, so the browser reads releases through
* our own nginx (`location = /api/releases`, cached for a few minutes).
*/
export const RELEASES_API = '/api/releases'
/** A published GitHub release, narrowed to the fields the download page renders. */
/** A published release, narrowed to the fields the download page renders. */
export type Release = {
tag_name: string
name: string
@ -27,15 +33,21 @@ export type Release = {
assets: { name: string; size: number; download_count: number }[]
}
/** Ten most recent releases from the public GitHub API (unauthenticated, ~60 req/h/IP). */
/** The most recent releases, newest first. */
export async function fetchReleases(): Promise<Release[]> {
const res = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases?per_page=10`, {
headers: { accept: 'application/vnd.github+json' },
})
if (!res.ok) throw new Error(`GitHub API responded ${res.status}`)
const res = await fetch(RELEASES_API, { headers: { accept: 'application/json' } })
if (!res.ok) throw new Error(`Releases API responded ${res.status}`)
return (await res.json()) as Release[]
}
/** SHA-256 of the served jar (`sha256sum` output), or null when it is not published. */
export async function fetchChecksum(): Promise<string | null> {
const res = await fetch(CHECKSUM_URL)
if (!res.ok) return null
const hash = (await res.text()).trim().split(/\s+/)[0]?.toLowerCase() ?? ''
return /^[0-9a-f]{64}$/.test(hash) ? hash : null
}
/** Total bytes of `.jar` assets in a release — the download button's size hint. */
export function releaseSize(release: Release): number {
return release.assets.reduce((sum, a) => (a.name.endsWith('.jar') ? sum + a.size : sum), 0)

View file

@ -0,0 +1,79 @@
/* Download page: the jar shown as an in-game item with a tooltip, plus the release timeline. */
.lv-item {
position: relative;
display: flex;
flex-direction: column;
gap: 1.25rem;
padding: 1.5rem;
border-radius: var(--radius-panel, 10px);
background:
radial-gradient(120% 90% at 50% 0%, color-mix(in srgb, var(--color-ice) 16%, transparent), transparent 62%),
rgb(255 255 255 / 0.03);
box-shadow: 0 0 0 1px rgb(255 255 255 / 0.08) inset;
}
/* The inventory slot behind the heart. */
.lv-slot {
width: 5.5rem;
height: 5.5rem;
padding: 0.9rem;
flex: none;
background: #8b8b8b;
border: 3px solid;
border-color: #373737 #fff #fff #373737;
box-shadow: 0 0 0 3px #1c1c1c;
}
.lv-slot svg { display: block; width: 100%; height: 100%; }
.lv-glint { animation: lv-glint 3.2s linear infinite; }
@keyframes lv-glint {
from { transform: translateX(-140%) skewX(-20deg); }
to { transform: translateX(240%) skewX(-20deg); }
}
/* Vanilla tooltip: near-black plate with a purple frame. */
.lv-tip {
min-width: 0;
padding: 0.75rem 0.9rem;
font-family: var(--font-code, monospace);
font-size: 0.82rem;
line-height: 1.55;
color: #aaa;
background: rgb(16 0 16 / 0.94);
border: 2px solid;
border-image: linear-gradient(180deg, #5000ff, #28007f) 1;
}
.lv-tip-name { color: #ffd34d; font-weight: 700; font-size: 0.95rem; }
.lv-tip-key { color: #5555ff; }
.lv-tip-hash { color: #fff; word-break: break-all; }
/* Releases timeline: a rail with one dot per release. */
.lv-rail { position: relative; padding-left: 1.75rem; }
.lv-rail::before {
content: '';
position: absolute;
left: 0.42rem;
top: 0.4rem;
bottom: 0.4rem;
width: 2px;
background: linear-gradient(180deg, var(--color-ice), rgb(255 255 255 / 0.08));
}
.lv-dot {
position: absolute;
left: -1.75rem;
top: 0.45rem;
width: 0.9rem;
height: 0.9rem;
border-radius: 999px;
background: var(--color-abyss);
border: 2px solid rgb(255 255 255 / 0.25);
}
.lv-dot[data-latest='true'] {
border-color: var(--color-ice);
background: var(--color-ice);
box-shadow: 0 0 14px var(--page-accent-glow, var(--color-ice));
}
@media (prefers-reduced-motion: reduce) {
.lv-glint { animation: none; opacity: 0; }
}

View file

@ -5,11 +5,17 @@ export const downloadEn = {
nav: 'Download',
button: 'Download for free',
hint: 'One .jar straight from our server',
versionHint: 'v{version} · {size}',
versionHint: 'v{{version}} · {{size}}',
card: {
platform: 'Minecraft 26.2 · Fabric',
size: 'Size: {{size}}',
copyHash: 'Copy SHA-256',
copied: 'Copied',
checksumFile: 'Checksum file',
},
page: {
title: 'Download LoVisual',
githubButton: 'Code on GitHub',
githubSoon: 'temporarily unavailable',
source: 'Source code',
lead: 'A free visuals mod for Minecraft 26.2 on Fabric. One file: no installers, no ads.',
reqTitle: 'What you need',
reqMc: 'Minecraft',
@ -24,8 +30,9 @@ export const downloadEn = {
},
notes: {
title: "What's new",
allReleases: 'All releases on GitHub',
allReleases: 'All releases',
latest: 'Latest',
empty: 'The changelog will appear once the first public release is out.',
downloads: '{count} downloads',
downloads: '{{count}} downloads',
},
} satisfies Translation<typeof downloadRu>

View file

@ -2,11 +2,17 @@ export const downloadRu = {
nav: 'Скачать',
button: 'Скачать бесплатно',
hint: 'Один .jar прямо с нашего сервера',
versionHint: 'v{version} · {size}',
versionHint: 'v{{version}} · {{size}}',
card: {
platform: 'Minecraft 26.2 · Fabric',
size: 'Размер: {{size}}',
copyHash: 'Скопировать SHA-256',
copied: 'Скопировано',
checksumFile: 'Файл с контрольной суммой',
},
page: {
title: 'Скачать LoVisual',
githubButton: 'Код на GitHub',
githubSoon: 'временно не работает',
source: 'Исходный код',
lead: 'Бесплатный визуальный мод для Minecraft 26.2 на Fabric. Один файл: без установщиков и без рекламы.',
reqTitle: 'Что понадобится',
reqMc: 'Minecraft',
@ -15,14 +21,15 @@ export const downloadRu = {
reqOptionalTitle: 'По желанию для максимального FPS',
reqOptional: 'Sodium и Iris: мод не требует их, но дружит с ними.',
installTitle: 'Установка в три шага',
step1: 'Скачай lovisual.jar кнопкой выше.',
step2: 'Положи файл в папку mods (создай её, если нет).',
step3: 'Запусти Minecraft с профилем Fabric, мод активен сразу.',
step1: 'Скачайте lovisual.jar кнопкой выше.',
step2: 'Положите файл в папку mods (создайте её, если нет).',
step3: 'Запустите Minecraft с профилем Fabric, мод активен сразу.',
},
notes: {
title: 'Что нового',
allReleases: 'Все релизы на GitHub',
allReleases: 'Все релизы',
latest: 'Последний',
empty: 'Список изменений появится, когда выйдет первый публичный релиз.',
downloads: '{count} загрузок',
downloads: '{{count}} загрузок',
},
} as const

View file

@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react'
import { render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { afterEach, expect, test, vi } from 'vitest'
import { DownloadButton } from '../DownloadButton'
@ -15,7 +15,7 @@ const release = (body: string) => ({
name: 'v0.1.1',
published_at: '2026-09-01T00:00:00Z',
body,
html_url: 'https://github.com/x/y/releases/tag/v0.1.1',
html_url: 'https://git.wihuk.pro/boba/LoVisual/releases/tag/v0.1.1',
assets: [{ name: 'lovisual.jar', size: 4_400_000, download_count: 12 }],
})
@ -28,7 +28,7 @@ test('DownloadButton links straight to the latest lovisual.jar on our own backen
expect(link?.hasAttribute('download')).toBe(true)
})
test('the page offers both paths: our jar and a GitHub button locked behind a ribbon', async () => {
test('the page shows the jar as an item card and links to releases and source on our Forgejo', async () => {
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('offline'))
withQuery(<DownloadPage />)
@ -36,11 +36,33 @@ test('the page offers both paths: our jar and a GitHub button locked behind a ri
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()
expect(screen.getByRole('link', { name: 'Все релизы' })).toHaveAttribute(
'href',
'https://git.wihuk.pro/boba/LoVisual/releases',
)
expect(screen.getByRole('link', { name: 'Исходный код' })).toHaveAttribute(
'href',
'https://git.wihuk.pro/boba/LoVisual',
)
// GitHub is gone from the page entirely.
expect(document.body.innerHTML).not.toContain('github.com')
})
test('the release card shows the SHA-256 of the served jar and can copy it', async () => {
const hash = 'a'.repeat(63) + 'b'
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
const url = String(input)
if (url.endsWith('.sha256')) return { ok: true, status: 200, text: async () => `${hash} lovisual.jar\n` } as unknown as Response
return { ok: true, status: 200, json: async () => [release('x')] } as unknown as Response
})
const write = vi.fn().mockResolvedValue(undefined)
Object.assign(navigator, { clipboard: { writeText: write } })
withQuery(<DownloadPage />)
expect(await screen.findByText(hash)).toBeInTheDocument()
expect(await screen.findByText(/LoVisual 0\.1\.1/)).toBeInTheDocument()
screen.getByRole('button', { name: 'Скопировать SHA-256' }).click()
await waitFor(() => expect(write).toHaveBeenCalledWith(hash))
})
test('ReleaseNotes renders Markdown but never raw HTML from a release body', async () => {

View file

@ -92,6 +92,6 @@ export const landingEn = {
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',
releaseLink: 'All versions',
},
} satisfies Translation<typeof landingRu>

View file

@ -89,6 +89,6 @@ export const landingRu = {
downloadLead: 'Один .jar с нашего сервера. Кидаешь его в папку mods, запускаешь игру через Fabric, открываешь меню.',
downloadCta: 'Скачать бесплатно',
downloadNote: 'Последний релиз для Minecraft 26.2 на Fabric',
releaseLink: 'Все версии на GitHub',
releaseLink: 'Все версии',
},
} as const

View file

@ -1,6 +1,6 @@
import { useTranslation } from '@shared/i18n/react'
import { Link } from 'react-router'
import { GITHUB_RELEASES_URL } from '@features/download/api'
import { RELEASES_URL } from '@features/download/api'
import '@features/landing/styles/landing.css'
import '@features/landing/styles/sections.css'
@ -46,7 +46,7 @@ export function FaqDownload() {
{t('faq.downloadCta')}
</Link>
<a
href={GITHUB_RELEASES_URL}
href={RELEASES_URL}
target="_blank"
rel="noreferrer"
className="text-snow underline underline-offset-4"

View file

@ -1,8 +1,8 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from '@shared/i18n/react'
import { DownloadButton } from '@features/download/DownloadButton'
import { ReleaseCard } from '@features/download/ReleaseCard'
import { ReleaseNotes } from '@features/download/ReleaseNotes'
import { GITHUB_RELEASES_URL, fetchReleases, releaseSize } from '@features/download/api'
import { RELEASES_URL, SOURCE_URL, fetchReleases } from '@features/download/api'
import { PageTitle } from '@shared/layout/PageTitle'
// Requirement versions mirror mod/gradle.properties (the mod's build source of truth).
@ -12,67 +12,36 @@ 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 (
<span className="relative inline-flex">
<span aria-disabled="true" className="lv-cta-quiet-lg cursor-not-allowed opacity-60">
{t('page.githubButton')}
</span>
{/* Band sits on the top edge: the ribbon stays on top of the button
without hiding its label. */}
<span className="pointer-events-none absolute left-1/2 top-0 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>
)
}
const STEPS = ['1', '2', '3'] as const
export default function DownloadPage() {
const { t } = useTranslation('download')
const { data } = useQuery({ queryKey: ['releases'], queryFn: fetchReleases, staleTime: 10 * 60 * 1000 })
const latest = data?.[0]
const steps = ['1', '2', '3'] as const
return (
<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">
<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
size="lg"
version={latest ? latest.tag_name.replace(/^v/, '') : undefined}
bytes={latest ? releaseSize(latest) : undefined}
/>
<GithubButtonOnHold />
<div className="mx-auto flex w-full max-w-5xl flex-col gap-16 px-6 py-16">
<section className="grid items-center gap-10 lg:grid-cols-[1.05fr_0.95fr]">
<div className="flex flex-col gap-8">
<PageTitle lead={t('page.lead')}>{t('page.title')}</PageTitle>
<dl className="grid grid-cols-[auto_1fr] gap-x-6 gap-y-3 sm:gap-x-8">
{REQUIREMENTS.map((r) => (
<div key={r.key} className="contents">
<dt className="text-frost">{t(`page.${r.key}`)}</dt>
<dd className="font-code text-snow">{r.value}</dd>
</div>
))}
<dt className="text-frost">{t('page.reqOptionalTitle')}</dt>
<dd className="text-snow/85">{t('page.reqOptional')}</dd>
</dl>
</div>
</header>
<section className="flex flex-col gap-3">
<h2 className="font-display text-xl font-bold uppercase text-snow">{t('page.reqTitle')}</h2>
<ul className="grid gap-2 sm:grid-cols-3">
{REQUIREMENTS.map((r) => (
<li key={r.key} className="rounded-panel border border-white/10 bg-black/30 p-4">
<span className="block text-xs text-frost">{t(`page.${r.key}`)}</span>
<span className="font-code text-sm text-snow">{r.value}</span>
</li>
))}
</ul>
<p className="text-sm text-frost">
<strong className="text-snow">{t('page.reqOptionalTitle')}</strong> {t('page.reqOptional')}
</p>
<ReleaseCard release={data?.[0]} />
</section>
<section className="flex flex-col gap-3">
<section className="flex flex-col gap-6">
<h2 className="font-display text-xl font-bold uppercase text-snow">{t('page.installTitle')}</h2>
<ol className="flex flex-col gap-2">
{steps.map((s) => (
<li key={s} className="flex items-baseline gap-3">
<ol className="grid gap-6 md:grid-cols-3">
{STEPS.map((s) => (
<li key={s} className="flex flex-col gap-3 border-t border-white/10 pt-4">
<span className="lv-step-num">{s}</span>
<span className="text-snow/90">{t(`page.step${s}`)}</span>
</li>
@ -80,17 +49,17 @@ export default function DownloadPage() {
</ol>
</section>
<section className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<section className="flex flex-col gap-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<h2 className="font-display text-xl font-bold uppercase text-snow">{t('notes.title')}</h2>
<a
href={GITHUB_RELEASES_URL}
target="_blank"
rel="noreferrer"
className="text-sm text-ice underline-offset-4 hover:underline"
>
{t('notes.allReleases')}
</a>
<span className="flex gap-5 text-sm">
<a href={RELEASES_URL} target="_blank" rel="noreferrer" className="text-ice underline-offset-4 hover:underline">
{t('notes.allReleases')}
</a>
<a href={SOURCE_URL} target="_blank" rel="noreferrer" className="text-ice underline-offset-4 hover:underline">
{t('page.source')}
</a>
</span>
</div>
<ReleaseNotes />
</section>

View file

@ -36,7 +36,7 @@ export const commonEn = {
about: 'A free visuals mod for Minecraft 26.2 on Fabric.',
sectionProduct: 'Product',
sectionProject: 'Project',
github: 'GitHub',
github: 'Source code',
download: 'Download',
legalTitle: 'Legal',
disclaimer: 'Not affiliated with Mojang or Microsoft.',

View file

@ -34,7 +34,7 @@ export const commonRu = {
about: 'Бесплатный визуальный мод для Minecraft 26.2 на Fabric.',
sectionProduct: 'Продукт',
sectionProject: 'Проект',
github: 'GitHub',
github: 'Исходный код',
download: 'Скачать',
legalTitle: 'Правовое',
disclaimer: 'Не связано с Mojang или Microsoft.',

View file

@ -1,6 +1,6 @@
import { useTranslation } from '@shared/i18n/react'
import { Link } from 'react-router'
import { GITHUB_REPO } from '@features/download/api'
import { SOURCE_URL } from '@features/download/api'
import { LanguageSwitch } from '../i18n/LanguageSwitch'
export function Footer() {
@ -11,8 +11,8 @@ export function Footer() {
{ to: '/themes', label: t('nav.themes') },
]
const project: { label: string; to?: string; href?: string }[] = [
{ href: `https://github.com/${GITHUB_REPO}`, label: t('footer.github') },
// In-site: the download page is where both download paths (ours + GitHub) live.
{ href: SOURCE_URL, label: t('footer.github') },
// In-site: the download page is where both download paths (the jar and the release history) live.
{ to: '/download', label: t('footer.download') },
]

View file

@ -106,12 +106,12 @@ test('the header language toggle switches the UI to English and back', async ()
expect(await screen.findByRole('link', { name: 'Войти' }, { timeout: 3000 })).toBeInTheDocument()
})
test('footer carries the github link and the disclaimer', async () => {
test('footer carries the source link on our Forgejo and the disclaimer', async () => {
mockFetch({ 'POST /auth/refresh': () => json({}, 401) })
renderApp(routes, '/')
expect(await screen.findByText('Не связано с Mojang или Microsoft.')).toBeInTheDocument()
const gh = screen.getByRole('link', { name: 'GitHub' })
expect(gh.getAttribute('href')).toMatch(/^https:\/\/github\.com\//)
const src = screen.getByRole('link', { name: 'Исходный код' })
expect(src.getAttribute('href')).toBe('https://git.wihuk.pro/boba/LoVisual')
})
test('page title renders one h1 with an optional lead', () => {