Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/pull_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ jobs:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18.x
node-version: 22.x
- run: npm install
- run: npm run lint
- run: npm run build
Expand All @@ -21,7 +21,7 @@ jobs:
# - uses: actions/checkout@v3
# - uses: actions/setup-node@v3
# with:
# node-version: 18.x
# node-version: 22.x
# - run: npm install
# - name: unit test
# run: npm run test:unit
Expand All @@ -32,7 +32,7 @@ jobs:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18.x
node-version: 22.x
- run: npm install
- name: e2e testing
run: npm run test:e2e
8 changes: 4 additions & 4 deletions .github/workflows/push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ jobs:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18.x
node-version: 22.x
- run: npm install
- run: npm run lint
- run: npm run build
Expand All @@ -21,7 +21,7 @@ jobs:
# - uses: actions/checkout@v3
# - uses: actions/setup-node@v3
# with:
# node-version: 18.x
# node-version: 22.x
# - run: npm install
# - name: unit test
# run: npm run test:unit
Expand All @@ -32,7 +32,7 @@ jobs:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18.x
node-version: 22.x
- run: npm install
- name: unit test
run: npm run test:e2e
Expand All @@ -44,7 +44,7 @@ jobs:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18.x
node-version: 22.x
- name: Install plugins
run: |
npm install @semantic-release/commit-analyzer -D
Expand Down
32 changes: 10 additions & 22 deletions app/(home)/DownloadButtonClient.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,12 @@
'use client';
import LinkButton from "../common/uiLibrary/linkButton";
import {useEffect, useState} from "react";
import getDownloadLink from "../../utils/platform";
import {GITHUB_RELEASES_URL} from "../../utils/urlContants";
import {BiSolidDownload} from "react-icons/bi";


import LinkButton from '../common/uiLibrary/linkButton';
import { BiSolidDownload } from 'react-icons/bi';

const DownloadButtonClient = () => {
const [downloadLink, setDownloadLink] = useState(GITHUB_RELEASES_URL);

useEffect(() => {
getDownloadLink().then((link) => setDownloadLink(link));
}, []);

return (
<div className={'flex flex-col lg:flex-row justify-center mt-8 gap-4'}>
<LinkButton className="shadow-2xl" filled={true} text={'Download'} linkTo={downloadLink} iconRight={BiSolidDownload} />
</div>
)
}

export default DownloadButtonClient;
return (
<div className={'flex flex-col lg:flex-row justify-center mt-8 gap-4'}>
<LinkButton className="shadow-2xl" filled={true} text={'Download'} linkTo={'/download'} iconRight={BiSolidDownload} />
</div>
);
};

export default DownloadButtonClient;
15 changes: 15 additions & 0 deletions app/download/fetchLatestRelease.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import GithubReleaseResponse from '../../types/githubReleaseResponse';
import { LAUNCHER_RELEASES_API } from '../../utils/urlContants';

async function fetchLatestRelease(): Promise<GithubReleaseResponse> {
const response = await fetch(LAUNCHER_RELEASES_API, {
next: { revalidate: 3600 },
headers: { Accept: 'application/vnd.github+json' },
});
if (!response.ok) {
throw new Error(`GitHub releases API responded ${response.status}`);
}
return response.json();
}

export default fetchLatestRelease;
53 changes: 53 additions & 0 deletions app/download/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import React from 'react';
import { headers } from 'next/headers';
import PageHeading from '../common/uiLibrary/PageHeading';
import { LauncherRelease } from '../../types/launcherRelease';
import { GITHUB_RELEASES_URL, LAUNCHER_REPO_URL } from '../../utils/urlContants';
import mapLauncherRelease from '../../utils/launcherRelease';
import { detectPlatform } from '../../utils/detectPlatform';
import fetchLatestRelease from './fetchLatestRelease';
import DownloadExperience from '../../components/organisms/DownloadExperience';
import QuietExternalLink from '../../components/atoms/QuietExternalLink';

export const metadata = {
title: 'Download · Unitystation',
description: 'Download the Pudu Launcher and start playing Unitystation.',
};

const DownloadPage = async () => {
let release: LauncherRelease | null = null;
try {
release = mapLauncherRelease(await fetchLatestRelease());
} catch {
release = null;
}

// Detect the platform on the server from the request so the right OS is in the
// first paint. The client effect only re-runs when this comes back unknown.
const userAgent = (await headers()).get('user-agent') ?? undefined;
const platform = detectPlatform(userAgent);
const initialOs = platform.os === 'unknown' ? undefined : platform.os;

return (
<div className="flex flex-col items-center text-center px-4 py-12">
<PageHeading isCentered>Download Pudu Launcher</PageHeading>
<p className="max-w-xl text-gray-400">
The Pudu Launcher keeps your game up to date and gets you into a round quickly.
We&apos;ve highlighted the build for your system below.
</p>

<DownloadExperience
release={release}
fallbackUrl={GITHUB_RELEASES_URL}
initialOs={initialOs}
initialLinuxFamily={platform.linuxFamily}
/>

<QuietExternalLink href={LAUNCHER_REPO_URL} className="mt-10">
View the launcher source on GitHub
</QuietExternalLink>
</div>
);
};

export default DownloadPage;
16 changes: 16 additions & 0 deletions components/atoms/AltDownloadLink.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { DownloadVariant } from '../../types/launcherRelease';

// Outline Button tokens for secondary downloads, so alternatives read as real
// buttons (not decorative cards) while staying quieter than the primary action.
function AltDownloadLink({ variant }: { variant: DownloadVariant }) {
return (
<a
href={variant.url}
className="uppercase flex items-center justify-center gap-2 py-2 px-4 bg-gray-800 bg-opacity-30 border-2 border-gray-800 text-white transition-colors hover:bg-gray-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-900"
>
{variant.label} · {variant.sizeMB} MB
</a>
);
}

export default AltDownloadLink;
33 changes: 33 additions & 0 deletions components/atoms/CopyableCommand.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
'use client';
import { useState } from 'react';
import { FiCopy, FiCheck } from 'react-icons/fi';

function CopyableCommand({ command }: { command: string }) {
const [copied, setCopied] = useState(false);

const onCopy = async () => {
try {
await navigator.clipboard.writeText(command);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard unavailable (e.g. insecure context); the command stays selectable.
}
};

return (
<div className="flex items-center justify-center gap-2 normal-case">
<code className="text-xs text-gray-300 bg-gray-800 rounded px-2 py-1 select-all">{command}</code>
<button
type="button"
onClick={onCopy}
aria-label={copied ? 'Command copied' : 'Copy install command'}
className="p-1 rounded text-gray-400 transition-colors hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400"
>
{copied ? <FiCheck className="w-4 h-4" aria-hidden /> : <FiCopy className="w-4 h-4" aria-hidden />}
</button>
</div>
);
}

export default CopyableCommand;
12 changes: 12 additions & 0 deletions components/atoms/CornerBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import Capsule from '../../app/common/uiLibrary/capsule';

// The parent must be positioned (`relative`) for the absolute corner placement.
function CornerBadge({ text }: { text: string }) {
return (
<span className="absolute -top-2 right-2 normal-case">
<Capsule text={text} colour="blue" />
</span>
);
}

export default CornerBadge;
18 changes: 18 additions & 0 deletions components/atoms/HeroDownloadLink.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { BiSolidDownload } from 'react-icons/bi';
import { DownloadVariant } from '../../types/launcherRelease';

// Mirrors the filled Button/LinkButton tokens (gray-800, square, uppercase) so the
// primary download reads as part of the site rather than a one-off blue CTA.
function HeroDownloadLink({ variant }: { variant: DownloadVariant }) {
return (
<a
href={variant.url}
className="uppercase flex items-center justify-center gap-3 py-3 px-6 text-lg bg-gray-800 border-2 border-transparent text-white transition-colors hover:bg-gray-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-900"
>
<BiSolidDownload className="w-7 h-7" aria-hidden />
<span>Download · {variant.label}</span>
</a>
);
}

export default HeroDownloadLink;
23 changes: 23 additions & 0 deletions components/atoms/QuietExternalLink.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { ReactNode } from 'react';

type Props = {
href: string;
children: ReactNode;
className?: string;
};

// Opens in a new tab so it never pulls the user away from what they were doing.
function QuietExternalLink({ href, children, className = '' }: Props) {
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className={`text-xs text-gray-400 underline transition-colors hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400 rounded-sm ${className}`}
>
{children}
</a>
);
}

export default QuietExternalLink;
34 changes: 34 additions & 0 deletions components/molecules/OsSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
'use client';
import { OsKey } from '../../types/launcherRelease';
import Capsule from '../../app/common/uiLibrary/capsule';
import { OS_META } from '../osMeta';

type Props = {
options: OsKey[];
detected: OsKey | null;
onSelect: (os: OsKey) => void;
};

export default function OsSwitcher({ options, detected, onSelect }: Props) {
return (
<div className="flex items-center gap-4 text-sm text-gray-400">
<span>On a different OS?</span>
{options.map((os) => {
const { label, Icon } = OS_META[os];
return (
<button
key={os}
type="button"
onClick={() => onSelect(os)}
aria-label={`Show ${label} downloads`}
className="inline-flex items-center gap-1.5 px-2 py-1 rounded uppercase text-gray-400 transition-colors hover:text-white hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-900"
>
<Icon className="w-4 h-4" aria-hidden />
<span>{label}</span>
{os === detected && <Capsule text="Detected" colour="blue" />}
</button>
);
})}
</div>
);
}
39 changes: 39 additions & 0 deletions components/molecules/PackageOption.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { ReactNode } from 'react';
import CornerBadge from '../atoms/CornerBadge';

// One shared row style so every option lines up at the same width and weight.
const BASE =
'relative w-full flex flex-col items-center justify-center gap-2 py-3 px-4 text-sm uppercase ' +
'border-2 border-gray-800 bg-gray-800 bg-opacity-30 text-white transition-colors';

// Added when the row is itself the action (a download link).
const LINK =
'hover:bg-gray-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400 ' +
'focus-visible:ring-offset-2 focus-visible:ring-offset-gray-900';

type Props = {
recommended?: boolean;
/** When set, the whole row is the download link; otherwise it is a container. */
href?: string;
children: ReactNode;
};

export default function PackageOption({ recommended, href, children }: Props) {
const badge = recommended ? <CornerBadge text="Recommended" /> : null;

if (href) {
return (
<a href={href} className={`${BASE} ${LINK}`}>
{children}
{badge}
</a>
);
}

return (
<div className={BASE}>
{children}
{badge}
</div>
);
}
25 changes: 25 additions & 0 deletions components/molecules/PlatformHeading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { OsKey } from '../../types/launcherRelease';
import Capsule from '../../app/common/uiLibrary/capsule';
import { OS_META } from '../osMeta';

type Props = {
os: OsKey;
version: string;
detected: boolean;
};

export default function PlatformHeading({ os, version, detected }: Props) {
const { label, Icon } = OS_META[os];
return (
<div className="flex items-center gap-4">
<Icon className="w-12 h-12 text-white" aria-hidden />
<div className="flex flex-col items-start gap-1">
<span className="text-3xl font-extrabold leading-none text-white">{label}</span>
<span className="text-xs uppercase tracking-wide text-gray-400">
Pudu Launcher · {version}
</span>
</div>
{detected && <Capsule text="Detected" colour="blue" />}
</div>
);
}
Loading
Loading