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
|
||||
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
|
||||
|
|
|
|||
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).
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
**[⬇ 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`.
|
||||
|
||||
### Лицензия
|
||||
|
|
|
|||
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;
|
||||
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;
|
||||
|
|
|
|||
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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export const commonRu = {
|
|||
about: 'Бесплатный визуальный мод для Minecraft 26.2 на Fabric.',
|
||||
sectionProduct: 'Продукт',
|
||||
sectionProject: 'Проект',
|
||||
github: 'GitHub',
|
||||
github: 'Исходный код',
|
||||
download: 'Скачать',
|
||||
legalTitle: 'Правовое',
|
||||
disclaimer: 'Не связано с Mojang или Microsoft.',
|
||||
|
|
|
|||
|
|
@ -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') },
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue