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
35
.github/workflows/release.yml
vendored
35
.github/workflows/release.yml
vendored
|
|
@ -17,6 +17,8 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up JDK 25
|
- name: Set up JDK 25
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
|
|
@ -46,11 +48,28 @@ jobs:
|
||||||
echo "version=$version" >>"$GITHUB_OUTPUT"
|
echo "version=$version" >>"$GITHUB_OUTPUT"
|
||||||
echo "Packaged $jar as lovisual-$version.jar and lovisual.jar"
|
echo "Packaged $jar as lovisual-$version.jar and lovisual.jar"
|
||||||
|
|
||||||
- name: Publish the GitHub release
|
# The repository lives on Forgejo, so the release goes through its API.
|
||||||
uses: softprops/action-gh-release@v2
|
# One-time setup: Settings > Actions > Secrets > RELEASE_TOKEN (an API
|
||||||
with:
|
# token with write access to this repository's releases).
|
||||||
tag_name: ${{ github.ref_name }}
|
- name: Publish the Forgejo release
|
||||||
generate_release_notes: true
|
env:
|
||||||
files: |
|
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
lovisual-${{ steps.jar.outputs.version }}.jar
|
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
lovisual.jar
|
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
|
||||||
|
|
|
||||||
12
README.md
12
README.md
|
|
@ -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).
|
Developed and maintained by [loki5512344](https://github.com/loki5512344).
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
**[⬇ 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) | [Русский](#русский)
|
[English](#english) | [Русский](#русский)
|
||||||
|
|
||||||
|
|
@ -83,8 +83,8 @@ cargo build
|
||||||
cargo test
|
cargo test
|
||||||
```
|
```
|
||||||
|
|
||||||
Releases are cut by pushing a `v*` tag - GitHub Actions builds the mod jar and publishes it to
|
Releases are cut by pushing a `v*` tag - Forgejo Actions builds the mod jar and publishes it to
|
||||||
[Releases](https://github.com/loki5512344/LoVisual-/releases) as both `lovisual-<version>.jar`
|
[Releases](https://git.wihuk.pro/boba/LoVisual/releases) as both `lovisual-<version>.jar`
|
||||||
and a stable `lovisual.jar` link.
|
and a stable `lovisual.jar` link.
|
||||||
|
|
||||||
### License
|
### License
|
||||||
|
|
@ -155,8 +155,8 @@ cargo build
|
||||||
cargo test
|
cargo test
|
||||||
```
|
```
|
||||||
|
|
||||||
Релизы создаются пушем тега `v*` - GitHub Actions собирает jar мода и публикует в
|
Релизы создаются пушем тега `v*` - Forgejo Actions собирает jar мода и публикует в
|
||||||
[Releases](https://github.com/loki5512344/LoVisual-/releases) как `lovisual-<версия>.jar`
|
[Releases](https://git.wihuk.pro/boba/LoVisual/releases) как `lovisual-<версия>.jar`
|
||||||
и по стабильной ссылке `lovisual.jar`.
|
и по стабильной ссылке `lovisual.jar`.
|
||||||
|
|
||||||
### Лицензия
|
### Лицензия
|
||||||
|
|
|
||||||
2
backend/deploy/nginx-lovisual-releases-cache.conf
Normal file
2
backend/deploy/nginx-lovisual-releases-cache.conf
Normal 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;
|
||||||
|
|
@ -3,6 +3,21 @@ server {
|
||||||
root /var/www/visual.loki-code.dev/html;
|
root /var/www/visual.loki-code.dev/html;
|
||||||
index index.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/ {
|
location /api/ {
|
||||||
proxy_pass http://127.0.0.1:8080/;
|
proxy_pass http://127.0.0.1:8080/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|
|
||||||
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 { useTranslation } from '@shared/i18n/react'
|
||||||
import { Markdown } from '@shared/ui/Markdown'
|
import { Markdown } from '@shared/ui/Markdown'
|
||||||
import { fetchReleases } from './api'
|
import { fetchReleases } from './api'
|
||||||
|
import './download.css'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Changelog feed. Markdown bodies go through our own renderer, which builds React
|
* Changelog timeline, newest first. Markdown bodies go through our own renderer,
|
||||||
* elements and never raw HTML, so a `<script>` or `onerror=` in a release body stays inert text.
|
* which builds React elements and never raw HTML, so a `<script>` or `onerror=` in
|
||||||
|
* a release body stays inert text.
|
||||||
*/
|
*/
|
||||||
export function ReleaseNotes() {
|
export function ReleaseNotes() {
|
||||||
const { t, i18n } = useTranslation('download')
|
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 (
|
return (
|
||||||
<ol className="flex flex-col gap-8">
|
<ol className="lv-rail flex flex-col gap-10">
|
||||||
{data.map((r) => (
|
{data.map((r, i) => {
|
||||||
<li key={r.tag_name} className="flex flex-col gap-2">
|
const downloads = r.assets.reduce((sum, a) => sum + a.download_count, 0)
|
||||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
return (
|
||||||
<h3 className="font-display text-xl font-bold text-snow">{r.name || r.tag_name}</h3>
|
<li key={r.tag_name} className="relative flex flex-col gap-2">
|
||||||
<time className="font-code text-xs text-frost" dateTime={r.published_at}>
|
<span className="lv-dot" data-latest={i === 0} aria-hidden="true" />
|
||||||
{date.format(new Date(r.published_at))}
|
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||||
</time>
|
<h3 className="font-display text-xl font-bold text-snow">{r.name || r.tag_name}</h3>
|
||||||
</div>
|
{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}
|
||||||
<div className="lv-markdown text-[0.95rem] leading-relaxed text-snow/85">
|
<time className="font-code text-xs text-frost" dateTime={r.published_at}>
|
||||||
<Markdown>{r.body}</Markdown>
|
{date.format(new Date(r.published_at))}
|
||||||
</div>
|
</time>
|
||||||
</li>
|
{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>
|
</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. */
|
/** `owner/name` of the Forgejo repository that publishes mod releases. */
|
||||||
export const GITHUB_REPO: string = import.meta.env.VITE_GITHUB_REPO || DEFAULT_REPO
|
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
|
* One-click download of the newest build. Served by our own gateway
|
||||||
* (`GET /downloads/lovisual.jar`, reached as `/api/...` through the site origin's
|
* (`GET /downloads/lovisual.jar`, reached as `/api/...` through the site origin's
|
||||||
* nginx), so the button keeps working even where GitHub itself is blocked.
|
* nginx). The file on the server is NOT rebuilt automatically: whoever cuts a mod
|
||||||
* 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
|
||||||
* release has to `./gradlew build` and re-upload it there by hand (the mod is a
|
* (the mod is a separate Gradle project, deliberately not part of `deploy.sh`).
|
||||||
* separate Gradle project from the site, deliberately not wired into
|
|
||||||
* `backend/deploy/deploy.sh`).
|
|
||||||
*/
|
*/
|
||||||
export const DOWNLOAD_URL = '/api/downloads/lovisual.jar'
|
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 = {
|
export type Release = {
|
||||||
tag_name: string
|
tag_name: string
|
||||||
name: string
|
name: string
|
||||||
|
|
@ -27,15 +33,21 @@ export type Release = {
|
||||||
assets: { name: string; size: number; download_count: number }[]
|
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[]> {
|
export async function fetchReleases(): Promise<Release[]> {
|
||||||
const res = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases?per_page=10`, {
|
const res = await fetch(RELEASES_API, { headers: { accept: 'application/json' } })
|
||||||
headers: { accept: 'application/vnd.github+json' },
|
if (!res.ok) throw new Error(`Releases API responded ${res.status}`)
|
||||||
})
|
|
||||||
if (!res.ok) throw new Error(`GitHub API responded ${res.status}`)
|
|
||||||
return (await res.json()) as Release[]
|
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. */
|
/** Total bytes of `.jar` assets in a release — the download button's size hint. */
|
||||||
export function releaseSize(release: Release): number {
|
export function releaseSize(release: Release): number {
|
||||||
return release.assets.reduce((sum, a) => (a.name.endsWith('.jar') ? sum + a.size : sum), 0)
|
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',
|
nav: 'Download',
|
||||||
button: 'Download for free',
|
button: 'Download for free',
|
||||||
hint: 'One .jar straight from our server',
|
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: {
|
page: {
|
||||||
title: 'Download LoVisual',
|
title: 'Download LoVisual',
|
||||||
githubButton: 'Code on GitHub',
|
source: 'Source code',
|
||||||
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',
|
||||||
|
|
@ -24,8 +30,9 @@ export const downloadEn = {
|
||||||
},
|
},
|
||||||
notes: {
|
notes: {
|
||||||
title: "What's new",
|
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.',
|
empty: 'The changelog will appear once the first public release is out.',
|
||||||
downloads: '{count} downloads',
|
downloads: '{{count}} downloads',
|
||||||
},
|
},
|
||||||
} satisfies Translation<typeof downloadRu>
|
} satisfies Translation<typeof downloadRu>
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,17 @@ export const downloadRu = {
|
||||||
nav: 'Скачать',
|
nav: 'Скачать',
|
||||||
button: 'Скачать бесплатно',
|
button: 'Скачать бесплатно',
|
||||||
hint: 'Один .jar прямо с нашего сервера',
|
hint: 'Один .jar прямо с нашего сервера',
|
||||||
versionHint: 'v{version} · {size}',
|
versionHint: 'v{{version}} · {{size}}',
|
||||||
|
card: {
|
||||||
|
platform: 'Minecraft 26.2 · Fabric',
|
||||||
|
size: 'Размер: {{size}}',
|
||||||
|
copyHash: 'Скопировать SHA-256',
|
||||||
|
copied: 'Скопировано',
|
||||||
|
checksumFile: 'Файл с контрольной суммой',
|
||||||
|
},
|
||||||
page: {
|
page: {
|
||||||
title: 'Скачать LoVisual',
|
title: 'Скачать LoVisual',
|
||||||
githubButton: 'Код на GitHub',
|
source: 'Исходный код',
|
||||||
githubSoon: 'временно не работает',
|
|
||||||
lead: 'Бесплатный визуальный мод для Minecraft 26.2 на Fabric. Один файл: без установщиков и без рекламы.',
|
lead: 'Бесплатный визуальный мод для Minecraft 26.2 на Fabric. Один файл: без установщиков и без рекламы.',
|
||||||
reqTitle: 'Что понадобится',
|
reqTitle: 'Что понадобится',
|
||||||
reqMc: 'Minecraft',
|
reqMc: 'Minecraft',
|
||||||
|
|
@ -15,14 +21,15 @@ export const downloadRu = {
|
||||||
reqOptionalTitle: 'По желанию для максимального FPS',
|
reqOptionalTitle: 'По желанию для максимального FPS',
|
||||||
reqOptional: 'Sodium и Iris: мод не требует их, но дружит с ними.',
|
reqOptional: 'Sodium и Iris: мод не требует их, но дружит с ними.',
|
||||||
installTitle: 'Установка в три шага',
|
installTitle: 'Установка в три шага',
|
||||||
step1: 'Скачай lovisual.jar кнопкой выше.',
|
step1: 'Скачайте lovisual.jar кнопкой выше.',
|
||||||
step2: 'Положи файл в папку mods (создай её, если нет).',
|
step2: 'Положите файл в папку mods (создайте её, если нет).',
|
||||||
step3: 'Запусти Minecraft с профилем Fabric, мод активен сразу.',
|
step3: 'Запустите Minecraft с профилем Fabric, мод активен сразу.',
|
||||||
},
|
},
|
||||||
notes: {
|
notes: {
|
||||||
title: 'Что нового',
|
title: 'Что нового',
|
||||||
allReleases: 'Все релизы на GitHub',
|
allReleases: 'Все релизы',
|
||||||
|
latest: 'Последний',
|
||||||
empty: 'Список изменений появится, когда выйдет первый публичный релиз.',
|
empty: 'Список изменений появится, когда выйдет первый публичный релиз.',
|
||||||
downloads: '{count} загрузок',
|
downloads: '{{count}} загрузок',
|
||||||
},
|
},
|
||||||
} as const
|
} 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 { 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'
|
||||||
|
|
@ -15,7 +15,7 @@ const release = (body: string) => ({
|
||||||
name: 'v0.1.1',
|
name: 'v0.1.1',
|
||||||
published_at: '2026-09-01T00:00:00Z',
|
published_at: '2026-09-01T00:00:00Z',
|
||||||
body,
|
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 }],
|
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)
|
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'))
|
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('offline'))
|
||||||
withQuery(<DownloadPage />)
|
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('href', '/api/downloads/lovisual.jar')
|
||||||
expect(direct).toHaveAttribute('download')
|
expect(direct).toHaveAttribute('download')
|
||||||
|
|
||||||
// The GitHub path is visible but not a link, and says why.
|
expect(screen.getByRole('link', { name: 'Все релизы' })).toHaveAttribute(
|
||||||
const gh = screen.getByText('Код на GitHub')
|
'href',
|
||||||
expect(gh.closest('a')).toBeNull()
|
'https://git.wihuk.pro/boba/LoVisual/releases',
|
||||||
expect(gh).toHaveAttribute('aria-disabled', 'true')
|
)
|
||||||
expect(screen.getByText('временно не работает')).toBeInTheDocument()
|
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 () => {
|
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.',
|
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',
|
||||||
},
|
},
|
||||||
} satisfies Translation<typeof landingRu>
|
} satisfies Translation<typeof landingRu>
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,6 @@ export const landingRu = {
|
||||||
downloadLead: 'Один .jar с нашего сервера. Кидаешь его в папку mods, запускаешь игру через Fabric, открываешь меню.',
|
downloadLead: 'Один .jar с нашего сервера. Кидаешь его в папку mods, запускаешь игру через Fabric, открываешь меню.',
|
||||||
downloadCta: 'Скачать бесплатно',
|
downloadCta: 'Скачать бесплатно',
|
||||||
downloadNote: 'Последний релиз для Minecraft 26.2 на Fabric',
|
downloadNote: 'Последний релиз для Minecraft 26.2 на Fabric',
|
||||||
releaseLink: 'Все версии на GitHub',
|
releaseLink: 'Все версии',
|
||||||
},
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useTranslation } from '@shared/i18n/react'
|
import { useTranslation } from '@shared/i18n/react'
|
||||||
import { Link } from 'react-router'
|
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/landing.css'
|
||||||
import '@features/landing/styles/sections.css'
|
import '@features/landing/styles/sections.css'
|
||||||
|
|
||||||
|
|
@ -46,7 +46,7 @@ export function FaqDownload() {
|
||||||
{t('faq.downloadCta')}
|
{t('faq.downloadCta')}
|
||||||
</Link>
|
</Link>
|
||||||
<a
|
<a
|
||||||
href={GITHUB_RELEASES_URL}
|
href={RELEASES_URL}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="text-snow underline underline-offset-4"
|
className="text-snow underline underline-offset-4"
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useTranslation } from '@shared/i18n/react'
|
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 { 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'
|
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,67 +12,36 @@ const REQUIREMENTS = [
|
||||||
{ key: 'reqApi', value: '0.152.2+26.2' },
|
{ key: 'reqApi', value: '0.152.2+26.2' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
/**
|
const STEPS = ['1', '2', '3'] 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 })
|
||||||
const latest = data?.[0]
|
|
||||||
const steps = ['1', '2', '3'] as const
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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-5xl flex-col gap-16 px-6 py-16">
|
||||||
<header className="flex flex-col gap-4">
|
<section className="grid items-center gap-10 lg:grid-cols-[1.05fr_0.95fr]">
|
||||||
<PageTitle lead={t('page.lead')}>{t('page.title')}</PageTitle>
|
<div className="flex flex-col gap-8">
|
||||||
<div className="mt-2 flex flex-wrap items-start gap-x-6 gap-y-5">
|
<PageTitle lead={t('page.lead')}>{t('page.title')}</PageTitle>
|
||||||
<DownloadButton
|
<dl className="grid grid-cols-[auto_1fr] gap-x-6 gap-y-3 sm:gap-x-8">
|
||||||
size="lg"
|
{REQUIREMENTS.map((r) => (
|
||||||
version={latest ? latest.tag_name.replace(/^v/, '') : undefined}
|
<div key={r.key} className="contents">
|
||||||
bytes={latest ? releaseSize(latest) : undefined}
|
<dt className="text-frost">{t(`page.${r.key}`)}</dt>
|
||||||
/>
|
<dd className="font-code text-snow">{r.value}</dd>
|
||||||
<GithubButtonOnHold />
|
</div>
|
||||||
|
))}
|
||||||
|
<dt className="text-frost">{t('page.reqOptionalTitle')}</dt>
|
||||||
|
<dd className="text-snow/85">{t('page.reqOptional')}</dd>
|
||||||
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
<ReleaseCard release={data?.[0]} />
|
||||||
|
|
||||||
<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>
|
|
||||||
</section>
|
</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>
|
<h2 className="font-display text-xl font-bold uppercase text-snow">{t('page.installTitle')}</h2>
|
||||||
<ol className="flex flex-col gap-2">
|
<ol className="grid gap-6 md:grid-cols-3">
|
||||||
{steps.map((s) => (
|
{STEPS.map((s) => (
|
||||||
<li key={s} className="flex items-baseline gap-3">
|
<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="lv-step-num">{s}</span>
|
||||||
<span className="text-snow/90">{t(`page.step${s}`)}</span>
|
<span className="text-snow/90">{t(`page.step${s}`)}</span>
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -80,17 +49,17 @@ export default function DownloadPage() {
|
||||||
</ol>
|
</ol>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="flex flex-col gap-4">
|
<section className="flex flex-col gap-6">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<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>
|
<h2 className="font-display text-xl font-bold uppercase text-snow">{t('notes.title')}</h2>
|
||||||
<a
|
<span className="flex gap-5 text-sm">
|
||||||
href={GITHUB_RELEASES_URL}
|
<a href={RELEASES_URL} target="_blank" rel="noreferrer" className="text-ice underline-offset-4 hover:underline">
|
||||||
target="_blank"
|
{t('notes.allReleases')}
|
||||||
rel="noreferrer"
|
</a>
|
||||||
className="text-sm text-ice underline-offset-4 hover:underline"
|
<a href={SOURCE_URL} target="_blank" rel="noreferrer" className="text-ice underline-offset-4 hover:underline">
|
||||||
>
|
{t('page.source')}
|
||||||
{t('notes.allReleases')}
|
</a>
|
||||||
</a>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<ReleaseNotes />
|
<ReleaseNotes />
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ export const commonEn = {
|
||||||
about: 'A free visuals mod for Minecraft 26.2 on Fabric.',
|
about: 'A free visuals mod for Minecraft 26.2 on Fabric.',
|
||||||
sectionProduct: 'Product',
|
sectionProduct: 'Product',
|
||||||
sectionProject: 'Project',
|
sectionProject: 'Project',
|
||||||
github: 'GitHub',
|
github: 'Source code',
|
||||||
download: 'Download',
|
download: 'Download',
|
||||||
legalTitle: 'Legal',
|
legalTitle: 'Legal',
|
||||||
disclaimer: 'Not affiliated with Mojang or Microsoft.',
|
disclaimer: 'Not affiliated with Mojang or Microsoft.',
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ export const commonRu = {
|
||||||
about: 'Бесплатный визуальный мод для Minecraft 26.2 на Fabric.',
|
about: 'Бесплатный визуальный мод для Minecraft 26.2 на Fabric.',
|
||||||
sectionProduct: 'Продукт',
|
sectionProduct: 'Продукт',
|
||||||
sectionProject: 'Проект',
|
sectionProject: 'Проект',
|
||||||
github: 'GitHub',
|
github: 'Исходный код',
|
||||||
download: 'Скачать',
|
download: 'Скачать',
|
||||||
legalTitle: 'Правовое',
|
legalTitle: 'Правовое',
|
||||||
disclaimer: 'Не связано с Mojang или Microsoft.',
|
disclaimer: 'Не связано с Mojang или Microsoft.',
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useTranslation } from '@shared/i18n/react'
|
import { useTranslation } from '@shared/i18n/react'
|
||||||
import { Link } from 'react-router'
|
import { Link } from 'react-router'
|
||||||
import { GITHUB_REPO } from '@features/download/api'
|
import { SOURCE_URL } from '@features/download/api'
|
||||||
import { LanguageSwitch } from '../i18n/LanguageSwitch'
|
import { LanguageSwitch } from '../i18n/LanguageSwitch'
|
||||||
|
|
||||||
export function Footer() {
|
export function Footer() {
|
||||||
|
|
@ -11,8 +11,8 @@ export function Footer() {
|
||||||
{ to: '/themes', label: t('nav.themes') },
|
{ to: '/themes', label: t('nav.themes') },
|
||||||
]
|
]
|
||||||
const project: { label: string; to?: string; href?: string }[] = [
|
const project: { label: string; to?: string; href?: string }[] = [
|
||||||
{ href: `https://github.com/${GITHUB_REPO}`, label: t('footer.github') },
|
{ href: SOURCE_URL, label: t('footer.github') },
|
||||||
// In-site: the download page is where both download paths (ours + GitHub) live.
|
// In-site: the download page is where both download paths (the jar and the release history) live.
|
||||||
{ to: '/download', label: t('footer.download') },
|
{ to: '/download', label: t('footer.download') },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
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) })
|
mockFetch({ 'POST /auth/refresh': () => json({}, 401) })
|
||||||
renderApp(routes, '/')
|
renderApp(routes, '/')
|
||||||
expect(await screen.findByText('Не связано с Mojang или Microsoft.')).toBeInTheDocument()
|
expect(await screen.findByText('Не связано с Mojang или Microsoft.')).toBeInTheDocument()
|
||||||
const gh = screen.getByRole('link', { name: 'GitHub' })
|
const src = screen.getByRole('link', { name: 'Исходный код' })
|
||||||
expect(gh.getAttribute('href')).toMatch(/^https:\/\/github\.com\//)
|
expect(src.getAttribute('href')).toBe('https://git.wihuk.pro/boba/LoVisual')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('page title renders one h1 with an optional lead', () => {
|
test('page title renders one h1 with an optional lead', () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue