feat(download): two-button page with the jar served by our backend
This commit is contained in:
parent
54af71589a
commit
fc440f0c91
11 changed files with 105 additions and 37 deletions
|
|
@ -4,14 +4,18 @@ const DEFAULT_REPO = 'loki5512344/LoVisual-'
|
||||||
export const GITHUB_REPO: string = import.meta.env.VITE_GITHUB_REPO || DEFAULT_REPO
|
export const GITHUB_REPO: string = import.meta.env.VITE_GITHUB_REPO || DEFAULT_REPO
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One-click download of the newest build. Served directly from our own origin (not a
|
* One-click download of the newest build. Served by our own gateway
|
||||||
* github.com redirect) so the button keeps working even where GitHub itself is blocked.
|
* (`GET /downloads/lovisual.jar`, reached as `/api/...` through the site origin's
|
||||||
* The file at `/downloads/lovisual.jar` on the server is NOT rebuilt automatically —
|
* nginx), so the button keeps working even where GitHub itself is blocked.
|
||||||
* whoever cuts a mod release has to `./gradlew build` and re-upload it there by hand
|
* The file on the server is NOT rebuilt automatically — whoever cuts a mod
|
||||||
* (this is a separate Gradle project from the site, deliberately not wired into
|
* release has to `./gradlew build` and re-upload it there by hand (the mod is a
|
||||||
|
* separate Gradle project from the site, deliberately not wired into
|
||||||
* `backend/deploy/deploy.sh`).
|
* `backend/deploy/deploy.sh`).
|
||||||
*/
|
*/
|
||||||
export const DOWNLOAD_URL = '/downloads/lovisual.jar'
|
export const DOWNLOAD_URL = '/api/downloads/lovisual.jar'
|
||||||
|
|
||||||
|
/** Releases page — the secondary, GitHub-hosted path (shown behind a ribbon for now). */
|
||||||
|
export const GITHUB_RELEASES_URL = `https://github.com/${GITHUB_REPO}/releases`
|
||||||
|
|
||||||
/** A published GitHub release, narrowed to the fields the download page renders. */
|
/** A published GitHub release, narrowed to the fields the download page renders. */
|
||||||
export type Release = {
|
export type Release = {
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,12 @@ import type { downloadRu } from './ru'
|
||||||
export const downloadEn = {
|
export const downloadEn = {
|
||||||
nav: 'Download',
|
nav: 'Download',
|
||||||
button: 'Download for free',
|
button: 'Download for free',
|
||||||
hint: 'One .jar straight from GitHub',
|
hint: 'One .jar straight from our server',
|
||||||
versionHint: 'v{version} · {size}',
|
versionHint: 'v{version} · {size}',
|
||||||
page: {
|
page: {
|
||||||
title: 'Download LoVisual',
|
title: 'Download LoVisual',
|
||||||
|
githubButton: 'Code on GitHub',
|
||||||
|
githubSoon: 'temporarily unavailable',
|
||||||
lead: 'A free visuals mod for Minecraft 26.2 on Fabric. One file: no installers, no ads.',
|
lead: 'A free visuals mod for Minecraft 26.2 on Fabric. One file: no installers, no ads.',
|
||||||
reqTitle: 'What you need',
|
reqTitle: 'What you need',
|
||||||
reqMc: 'Minecraft',
|
reqMc: 'Minecraft',
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
export const downloadRu = {
|
export const downloadRu = {
|
||||||
nav: 'Скачать',
|
nav: 'Скачать',
|
||||||
button: 'Скачать бесплатно',
|
button: 'Скачать бесплатно',
|
||||||
hint: 'Один .jar прямо с GitHub',
|
hint: 'Один .jar прямо с нашего сервера',
|
||||||
versionHint: 'v{version} · {size}',
|
versionHint: 'v{version} · {size}',
|
||||||
page: {
|
page: {
|
||||||
title: 'Скачать LoVisual',
|
title: 'Скачать LoVisual',
|
||||||
|
githubButton: 'Код на GitHub',
|
||||||
|
githubSoon: 'временно не работает',
|
||||||
lead: 'Бесплатный визуальный мод для Minecraft 26.2 на Fabric. Один файл: без установщиков и без рекламы.',
|
lead: 'Бесплатный визуальный мод для Minecraft 26.2 на Fabric. Один файл: без установщиков и без рекламы.',
|
||||||
reqTitle: 'Что понадобится',
|
reqTitle: 'Что понадобится',
|
||||||
reqMc: 'Minecraft',
|
reqMc: 'Minecraft',
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { afterEach, expect, test, vi } from 'vitest'
|
import { afterEach, expect, test, vi } from 'vitest'
|
||||||
import { DownloadButton } from '../DownloadButton'
|
import { DownloadButton } from '../DownloadButton'
|
||||||
import { ReleaseNotes } from '../ReleaseNotes'
|
import { ReleaseNotes } from '../ReleaseNotes'
|
||||||
|
import DownloadPage from '../../../pages/download/DownloadPage'
|
||||||
|
|
||||||
function withQuery(ui: React.ReactElement) {
|
function withQuery(ui: React.ReactElement) {
|
||||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||||
|
|
@ -20,13 +21,28 @@ const release = (body: string) => ({
|
||||||
|
|
||||||
afterEach(() => vi.restoreAllMocks())
|
afterEach(() => vi.restoreAllMocks())
|
||||||
|
|
||||||
test('DownloadButton links straight to the latest lovisual.jar', () => {
|
test('DownloadButton links straight to the latest lovisual.jar on our own backend', () => {
|
||||||
const { container } = withQuery(<DownloadButton />)
|
const { container } = withQuery(<DownloadButton />)
|
||||||
const link = container.querySelector('a')
|
const link = container.querySelector('a')
|
||||||
expect(link?.getAttribute('href')).toBe('/downloads/lovisual.jar')
|
expect(link?.getAttribute('href')).toBe('/api/downloads/lovisual.jar')
|
||||||
expect(link?.hasAttribute('download')).toBe(true)
|
expect(link?.hasAttribute('download')).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('the page offers both paths: our jar and a GitHub button locked behind a ribbon', async () => {
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('offline'))
|
||||||
|
withQuery(<DownloadPage />)
|
||||||
|
|
||||||
|
const direct = await screen.findByRole('link', { name: 'Скачать бесплатно' })
|
||||||
|
expect(direct).toHaveAttribute('href', '/api/downloads/lovisual.jar')
|
||||||
|
expect(direct).toHaveAttribute('download')
|
||||||
|
|
||||||
|
// The GitHub path is visible but not a link, and says why.
|
||||||
|
const gh = screen.getByText('Код на GitHub')
|
||||||
|
expect(gh.closest('a')).toBeNull()
|
||||||
|
expect(gh).toHaveAttribute('aria-disabled', 'true')
|
||||||
|
expect(screen.getByText('временно не работает')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
test('ReleaseNotes renders Markdown but never raw HTML from a release body', async () => {
|
test('ReleaseNotes renders Markdown but never raw HTML from a release body', async () => {
|
||||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ export const landingEn = {
|
||||||
headlineAccent: "you'll fall in love with",
|
headlineAccent: "you'll fall in love with",
|
||||||
lead: 'A free Minecraft 26.2 mod: wings, particles, your own HUD, weather and fifty-odd other visual modules. Not a cheat, just pretty.',
|
lead: 'A free Minecraft 26.2 mod: wings, particles, your own HUD, weather and fifty-odd other visual modules. Not a cheat, just pretty.',
|
||||||
download: 'Download for free',
|
download: 'Download for free',
|
||||||
downloadHint: 'One .jar from GitHub: drop it in mods and play',
|
downloadHint: 'One .jar from our server: drop it in mods and play',
|
||||||
register: 'Create account',
|
register: 'Create account',
|
||||||
facts: {
|
facts: {
|
||||||
modules: 'modules',
|
modules: 'modules',
|
||||||
|
|
@ -89,7 +89,7 @@ export const landingEn = {
|
||||||
q5: 'How do I move my settings to another PC?',
|
q5: 'How do I move my settings to another PC?',
|
||||||
a5: 'Save your config to a cloud slot, then load it on the other PC with the slot’s code.',
|
a5: 'Save your config to a cloud slot, then load it on the other PC with the slot’s code.',
|
||||||
downloadTitle: 'Grab it and jump in',
|
downloadTitle: 'Grab it and jump in',
|
||||||
downloadLead: 'One .jar from GitHub. Drop it in your mods folder, launch the game with Fabric, open the menu.',
|
downloadLead: 'One .jar from our server. Drop it in your mods folder, launch the game with Fabric, open the menu.',
|
||||||
downloadCta: 'Download for free',
|
downloadCta: 'Download for free',
|
||||||
downloadNote: 'Latest release for Minecraft 26.2 on Fabric',
|
downloadNote: 'Latest release for Minecraft 26.2 on Fabric',
|
||||||
releaseLink: 'All versions on GitHub',
|
releaseLink: 'All versions on GitHub',
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ export const landingRu = {
|
||||||
headlineAccent: 'в которые влюбляешься',
|
headlineAccent: 'в которые влюбляешься',
|
||||||
lead: 'Бесплатный мод на Minecraft 26.2: крылья, партиклы, свой HUD, погода и ещё полсотни визуальных модулей. Не чит, просто красиво.',
|
lead: 'Бесплатный мод на Minecraft 26.2: крылья, партиклы, свой HUD, погода и ещё полсотни визуальных модулей. Не чит, просто красиво.',
|
||||||
download: 'Скачать бесплатно',
|
download: 'Скачать бесплатно',
|
||||||
downloadHint: 'Один .jar с GitHub: кидаешь в mods и играешь',
|
downloadHint: 'Один .jar с нашего сервера: кидаешь в mods и играешь',
|
||||||
register: 'Создать аккаунт',
|
register: 'Создать аккаунт',
|
||||||
facts: {
|
facts: {
|
||||||
modules: 'модулей',
|
modules: 'модулей',
|
||||||
|
|
@ -86,7 +86,7 @@ export const landingRu = {
|
||||||
q5: 'Как перенести настройки на другой комп?',
|
q5: 'Как перенести настройки на другой комп?',
|
||||||
a5: 'Сохрани конфиг в облачный слот, а на другом компе загрузи его по коду слота.',
|
a5: 'Сохрани конфиг в облачный слот, а на другом компе загрузи его по коду слота.',
|
||||||
downloadTitle: 'Качай и заходи в игру',
|
downloadTitle: 'Качай и заходи в игру',
|
||||||
downloadLead: 'Один .jar с GitHub. Кидаешь его в папку mods, запускаешь игру через Fabric, открываешь меню.',
|
downloadLead: 'Один .jar с нашего сервера. Кидаешь его в папку mods, запускаешь игру через Fabric, открываешь меню.',
|
||||||
downloadCta: 'Скачать бесплатно',
|
downloadCta: 'Скачать бесплатно',
|
||||||
downloadNote: 'Последний релиз для Minecraft 26.2 на Fabric',
|
downloadNote: 'Последний релиз для Minecraft 26.2 на Fabric',
|
||||||
releaseLink: 'Все версии на GitHub',
|
releaseLink: 'Все версии на GitHub',
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { DOWNLOAD_URL, GITHUB_REPO } from '@features/download/api'
|
import { Link } from 'react-router'
|
||||||
|
import { GITHUB_RELEASES_URL } from '@features/download/api'
|
||||||
import '@features/landing/styles/landing.css'
|
import '@features/landing/styles/landing.css'
|
||||||
import '@features/landing/styles/sections.css'
|
import '@features/landing/styles/sections.css'
|
||||||
|
|
||||||
|
|
@ -40,12 +41,12 @@ export function FaqDownload() {
|
||||||
<h2 id="download-title" className="lv-download-title">{t('faq.downloadTitle')}</h2>
|
<h2 id="download-title" className="lv-download-title">{t('faq.downloadTitle')}</h2>
|
||||||
<p className="max-w-[46ch] text-lg text-snow/85">{t('faq.downloadLead')}</p>
|
<p className="max-w-[46ch] text-lg text-snow/85">{t('faq.downloadLead')}</p>
|
||||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-4">
|
<div className="flex flex-wrap items-center gap-x-6 gap-y-4">
|
||||||
<a href={DOWNLOAD_URL} download className="lv-cta-primary lv-cta-lg">
|
<Link to="/download" className="lv-cta-primary lv-cta-lg">
|
||||||
<DownloadIcon />
|
<DownloadIcon />
|
||||||
{t('faq.downloadCta')}
|
{t('faq.downloadCta')}
|
||||||
</a>
|
</Link>
|
||||||
<a
|
<a
|
||||||
href={`https://github.com/${GITHUB_REPO}/releases`}
|
href={GITHUB_RELEASES_URL}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="text-snow underline underline-offset-4"
|
className="text-snow underline underline-offset-4"
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { MotionConfig, motion } from 'motion/react'
|
import { MotionConfig, motion } from 'motion/react'
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Link } from 'react-router'
|
||||||
import { ClickGuiWindow } from '@features/clickgui/ClickGuiWindow'
|
import { ClickGuiWindow } from '@features/clickgui/ClickGuiWindow'
|
||||||
import { DOWNLOAD_URL } from '@features/download/api'
|
|
||||||
import { PRESETS } from '@features/themes/presets.generated'
|
import { PRESETS } from '@features/themes/presets.generated'
|
||||||
import { parseArgb } from '@features/themes/types'
|
import { parseArgb } from '@features/themes/types'
|
||||||
import { useActiveTheme } from '@features/themes/useActiveTheme'
|
import { useActiveTheme } from '@features/themes/useActiveTheme'
|
||||||
|
|
@ -65,10 +65,10 @@ export function Hero() {
|
||||||
{t('hero.lead')}
|
{t('hero.lead')}
|
||||||
</motion.p>
|
</motion.p>
|
||||||
<motion.div {...rise(0.24)} className="flex flex-wrap items-center gap-3">
|
<motion.div {...rise(0.24)} className="flex flex-wrap items-center gap-3">
|
||||||
<a href={DOWNLOAD_URL} download className="lv-cta-primary lv-cta-lg">
|
<Link to="/download" className="lv-cta-primary lv-cta-lg">
|
||||||
<DownloadIcon />
|
<DownloadIcon />
|
||||||
{t('hero.download')}
|
{t('hero.download')}
|
||||||
</a>
|
</Link>
|
||||||
<a href="/register" className="lv-cta-quiet-lg">
|
<a href="/register" className="lv-cta-quiet-lg">
|
||||||
{t('hero.register')}
|
{t('hero.register')}
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -59,3 +59,20 @@ test('cloud section speaks in player terms in both languages', async () => {
|
||||||
unmount()
|
unmount()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('hero download CTA goes to the page with both download buttons', async () => {
|
||||||
|
vi.stubGlobal('matchMedia', (q: string) => ({ matches: q.includes('reduce'), addEventListener() {}, removeEventListener() {} }))
|
||||||
|
mockFetch({ 'POST /auth/refresh': () => json({}, 401) })
|
||||||
|
const router = createMemoryRouter([{ path: '/', element: <Hero /> }])
|
||||||
|
// Explicit ru: an earlier test in this file re-inits the default instance in English.
|
||||||
|
render(
|
||||||
|
<I18nextProvider i18n={initI18n('ru')}>
|
||||||
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
|
<SessionProvider><RouterProvider router={router} /></SessionProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</I18nextProvider>,
|
||||||
|
)
|
||||||
|
const cta = await screen.findByRole('link', { name: 'Скачать бесплатно' })
|
||||||
|
expect(cta).toHaveAttribute('href', '/download')
|
||||||
|
expect(cta).not.toHaveAttribute('download')
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { DownloadButton } from '@features/download/DownloadButton'
|
import { DownloadButton } from '@features/download/DownloadButton'
|
||||||
import { ReleaseNotes } from '@features/download/ReleaseNotes'
|
import { ReleaseNotes } from '@features/download/ReleaseNotes'
|
||||||
import { GITHUB_REPO, fetchReleases, releaseSize } from '@features/download/api'
|
import { GITHUB_RELEASES_URL, fetchReleases, releaseSize } from '@features/download/api'
|
||||||
import { PageTitle } from '@shared/layout/PageTitle'
|
import { PageTitle } from '@shared/layout/PageTitle'
|
||||||
|
|
||||||
// Requirement versions mirror mod/gradle.properties (the mod's build source of truth).
|
// Requirement versions mirror mod/gradle.properties (the mod's build source of truth).
|
||||||
|
|
@ -12,6 +12,25 @@ const REQUIREMENTS = [
|
||||||
{ key: 'reqApi', value: '0.152.2+26.2' },
|
{ key: 'reqApi', value: '0.152.2+26.2' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The second download path, kept visible but locked: GitHub releases are
|
||||||
|
* temporarily off (see TODO.md «Скачивание»), so the button is not a link and
|
||||||
|
* carries a ribbon saying so.
|
||||||
|
*/
|
||||||
|
function GithubButtonOnHold() {
|
||||||
|
const { t } = useTranslation('download')
|
||||||
|
return (
|
||||||
|
<span className="relative inline-flex">
|
||||||
|
<span aria-disabled="true" className="lv-cta-quiet-lg cursor-not-allowed opacity-60">
|
||||||
|
{t('page.githubButton')}
|
||||||
|
</span>
|
||||||
|
<span className="pointer-events-none absolute left-1/2 top-1/2 w-[calc(100%+1.5rem)] -translate-x-1/2 -translate-y-1/2 -rotate-[7deg] rounded-sm bg-ember px-3 py-1 text-center font-code text-xs font-bold uppercase text-abyss shadow-lg">
|
||||||
|
{t('page.githubSoon')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function DownloadPage() {
|
export default function DownloadPage() {
|
||||||
const { t } = useTranslation('download')
|
const { t } = useTranslation('download')
|
||||||
const { data } = useQuery({ queryKey: ['releases'], queryFn: fetchReleases, staleTime: 10 * 60 * 1000 })
|
const { data } = useQuery({ queryKey: ['releases'], queryFn: fetchReleases, staleTime: 10 * 60 * 1000 })
|
||||||
|
|
@ -22,12 +41,14 @@ export default function DownloadPage() {
|
||||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-12 px-6 py-16">
|
<div className="mx-auto flex w-full max-w-3xl flex-col gap-12 px-6 py-16">
|
||||||
<header className="flex flex-col gap-4">
|
<header className="flex flex-col gap-4">
|
||||||
<PageTitle lead={t('page.lead')}>{t('page.title')}</PageTitle>
|
<PageTitle lead={t('page.lead')}>{t('page.title')}</PageTitle>
|
||||||
<DownloadButton
|
<div className="mt-2 flex flex-wrap items-start gap-x-6 gap-y-5">
|
||||||
size="lg"
|
<DownloadButton
|
||||||
className="mt-2"
|
size="lg"
|
||||||
version={latest ? latest.tag_name.replace(/^v/, '') : undefined}
|
version={latest ? latest.tag_name.replace(/^v/, '') : undefined}
|
||||||
bytes={latest ? releaseSize(latest) : undefined}
|
bytes={latest ? releaseSize(latest) : undefined}
|
||||||
/>
|
/>
|
||||||
|
<GithubButtonOnHold />
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="flex flex-col gap-3">
|
<section className="flex flex-col gap-3">
|
||||||
|
|
@ -61,7 +82,7 @@ export default function DownloadPage() {
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<h2 className="font-display text-xl font-bold uppercase text-snow">{t('notes.title')}</h2>
|
<h2 className="font-display text-xl font-bold uppercase text-snow">{t('notes.title')}</h2>
|
||||||
<a
|
<a
|
||||||
href={`https://github.com/${GITHUB_REPO}/releases`}
|
href={GITHUB_RELEASES_URL}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="text-sm text-ice underline-offset-4 hover:underline"
|
className="text-sm text-ice underline-offset-4 hover:underline"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Link } from 'react-router'
|
import { Link } from 'react-router'
|
||||||
import { DOWNLOAD_URL, GITHUB_REPO } from '@features/download/api'
|
import { GITHUB_REPO } from '@features/download/api'
|
||||||
import { LanguageSwitch } from '../i18n/LanguageSwitch'
|
import { LanguageSwitch } from '../i18n/LanguageSwitch'
|
||||||
|
|
||||||
export function Footer() {
|
export function Footer() {
|
||||||
|
|
@ -10,9 +10,10 @@ export function Footer() {
|
||||||
{ to: '/configs', label: t('nav.myConfigs') },
|
{ to: '/configs', label: t('nav.myConfigs') },
|
||||||
{ to: '/themes', label: t('nav.themes') },
|
{ to: '/themes', label: t('nav.themes') },
|
||||||
]
|
]
|
||||||
const project = [
|
const project: { label: string; to?: string; href?: string }[] = [
|
||||||
{ href: `https://github.com/${GITHUB_REPO}`, label: t('footer.github') },
|
{ href: `https://github.com/${GITHUB_REPO}`, label: t('footer.github') },
|
||||||
{ href: DOWNLOAD_URL, label: t('footer.download') },
|
// In-site: the download page is where both download paths (ours + GitHub) live.
|
||||||
|
{ to: '/download', label: t('footer.download') },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -33,11 +34,15 @@ export function Footer() {
|
||||||
|
|
||||||
<nav aria-label={t('footer.sectionProject')} className="flex min-w-0 flex-col gap-2">
|
<nav aria-label={t('footer.sectionProject')} className="flex min-w-0 flex-col gap-2">
|
||||||
<span className="font-display text-sm font-bold text-snow">{t('footer.sectionProject')}</span>
|
<span className="font-display text-sm font-bold text-snow">{t('footer.sectionProject')}</span>
|
||||||
{project.map((i) => (
|
{project.map((i) =>
|
||||||
<a key={i.label} href={i.href} target="_blank" rel="noreferrer" className="text-sm text-frost hover:text-snow">
|
i.to ? (
|
||||||
{i.label}
|
<Link key={i.label} to={i.to} className="text-sm text-frost hover:text-snow">{i.label}</Link>
|
||||||
</a>
|
) : (
|
||||||
))}
|
<a key={i.label} href={i.href} target="_blank" rel="noreferrer" className="text-sm text-frost hover:text-snow">
|
||||||
|
{i.label}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue