feat(site): redesigned /download, releases and links moved from GitHub to Forgejo
- /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:
parent
fa08d50704
commit
2d96f28091
19 changed files with 360 additions and 143 deletions
77
frontend/src/features/download/ReleaseCard.tsx
Normal file
77
frontend/src/features/download/ReleaseCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
79
frontend/src/features/download/download.css
Normal file
79
frontend/src/features/download/download.css
Normal 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; }
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -89,6 +89,6 @@ export const landingRu = {
|
|||
downloadLead: 'Один .jar с нашего сервера. Кидаешь его в папку mods, запускаешь игру через Fabric, открываешь меню.',
|
||||
downloadCta: 'Скачать бесплатно',
|
||||
downloadNote: 'Последний релиз для Minecraft 26.2 на Fabric',
|
||||
releaseLink: 'Все версии на GitHub',
|
||||
releaseLink: 'Все версии',
|
||||
},
|
||||
} as const
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue