Website: a PS5Upload page for GitHub Pages, kept current from the releases

site/ is a static page in the app's own look (light and dark, the app's
screenshots): features, downloads for every platform with the visitor's
system first, the Docker command, news from the release notes, community
links. It reads the releases from GitHub's API live, falling back on
site/releases.json (scripts/site-releases.mjs), which the pages workflow
rewrites on every deploy: after each publish run, on release edits (promoting a
pre-release), on changes to site/, and daily.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
This commit is contained in:
phantomptrandClaude Opus 5.5 committed 2026-10-10 19:32:54 -07:00
1 parent 7026ed9bdb
commit 76baa41e16
14 files changed
+2513

No files matched your search

+62
View File
@@ -0,0 +1,62 @@
name: pages
# The website (site/) on GitHub Pages: https://phantomptr.github.io/ps5upload/
#
# The page reads GitHub's releases API live in the visitor's browser, so a new release shows
# up without a deploy. Each deploy also writes site/releases.json, the copy the page falls
# back on when the API can't be reached (offline, or GitHub's anonymous rate limit).
#
# When it deploys:
# - a change to site/ (or this file) on main;
# - after the publish workflow finishes. A release made by publish.yml does not fire the
# `release` event below: GitHub doesn't start workflows from its own token's actions;
# - a release published, edited or deleted by hand (promoting a pre-release is an edit);
# - once a day, so the snapshot never drifts far;
# - by hand (Actions → pages → Run workflow).
on:
push:
branches: [main]
paths: ["site/**", "scripts/site-releases.mjs", ".github/workflows/pages.yml"]
workflow_run:
workflows: [publish]
types: [completed]
release:
types: [published, edited, deleted]
schedule:
- cron: "17 6 * * *"
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-24.04
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/checkout@v7
with:
ref: main
- uses: actions/setup-node@v7
with:
node-version: 22
- name: Snapshot the releases (fallback for the page)
run: node scripts/site-releases.mjs
env:
GH_TOKEN: ${{ github.token }}
GITHUB_TOKEN: ${{ github.token }}
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v4
with:
path: site
- id: deployment
uses: actions/deploy-pages@v4
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env node
// Writes site/releases.json: the website's fallback copy of the newest releases, used when a
// visitor's browser can't reach GitHub's API (offline, or over its anonymous rate limit).
// Run by .github/workflows/pages.yml on every deploy and every published release; run it by
// hand to refresh a local preview: node scripts/site-releases.mjs
//
// Uses `gh` when it is installed and signed in (no rate limit worth worrying about), else
// GITHUB_TOKEN with fetch, else an anonymous fetch.
import { execFileSync } from "node:child_process";
import { writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
const REPO = "phantomptr/ps5upload";
const out = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "site", "releases.json");
/** Only what the page reads, so the snapshot stays small. */
const slim = (list) =>
list
.filter((r) => !r.draft)
.slice(0, 12)
.map((r) => ({
tag_name: r.tag_name,
prerelease: r.prerelease,
published_at: r.published_at,
html_url: r.html_url,
body: r.body,
assets: r.assets.map((a) => ({ name: a.name, browser_download_url: a.browser_download_url, size: a.size })),
}));
async function fetchReleases() {
try {
const json = execFileSync("gh", ["api", `repos/${REPO}/releases?per_page=12`], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
});
return JSON.parse(json);
} catch {
const headers = { Accept: "application/vnd.github+json" };
if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
const res = await fetch(`https://api.github.com/repos/${REPO}/releases?per_page=12`, { headers });
if (!res.ok) throw new Error(`GitHub answered ${res.status}`);
return res.json();
}
}
const list = slim(await fetchReleases());
writeFileSync(out, `${JSON.stringify(list, null, 1)}\n`);
console.log(`site/releases.json: ${list.length} releases, newest ${list[0]?.tag_name ?? "none"}`);
+300
View File
@@ -0,0 +1,300 @@
// PS5Upload website: live releases, the right download for this visitor, the news list, and
// the light/dark switch. No build step and no dependencies.
//
// Releases come from GitHub's API when the page loads, so a new release shows up without a
// redeploy. If that fails (offline, or GitHub's 60-requests-an-hour limit for anonymous
// visitors), releases.json is used: a snapshot the Pages workflow writes on every deploy and
// every published release (scripts/site-releases.mjs).
"use strict";
const REPO = "phantomptr/ps5upload";
const API = `https://api.github.com/repos/${REPO}/releases?per_page=12`;
const NEWS_COUNT = 4;
// ── Theme ──────────────────────────────────────────────────────────────────
const root = document.documentElement;
const systemDark = () => window.matchMedia("(prefers-color-scheme: dark)").matches;
const store = {
get: (k) => {
try {
return localStorage.getItem(k);
} catch {
return null;
}
},
set: (k, v) => {
try {
localStorage.setItem(k, v);
} catch {
/* private window: the choice lasts this visit */
}
},
};
function applyTheme(theme) {
if (theme) root.dataset.theme = theme;
else delete root.dataset.theme;
const dark = theme ? theme === "dark" : systemDark();
// The screenshots follow the page, not only the system.
document.querySelectorAll("[data-theme-src]").forEach((s) => {
s.media = theme ? "not all" : "(prefers-color-scheme: dark)";
});
document.querySelectorAll("img[data-theme-img]").forEach((img) => {
img.src = `assets/${img.dataset.themeImg}-${dark ? "dark" : "light"}.webp`;
});
}
applyTheme(store.get("ps5upload.site.theme"));
document.getElementById("theme").addEventListener("click", () => {
const dark = root.dataset.theme ? root.dataset.theme === "dark" : systemDark();
const next = dark ? "light" : "dark";
store.set("ps5upload.site.theme", next);
applyTheme(next);
});
// ── Copy buttons ───────────────────────────────────────────────────────────
document.addEventListener("click", async (e) => {
const btn = e.target.closest("[data-copy]");
if (!btn) return;
const text = document.getElementById(btn.dataset.copy)?.textContent ?? "";
try {
await navigator.clipboard.writeText(text);
btn.textContent = "Copied";
} catch {
btn.textContent = "Select and copy";
}
setTimeout(() => (btn.textContent = "Copy"), 1600);
});
// ── Releases ───────────────────────────────────────────────────────────────
/** What a release file is, from its name (scripts/gen-updater-manifest.py names them). */
const KINDS = [
{ re: /-mac-arm64\.dmg$/, os: "mac", label: "Apple Silicon", note: ".dmg" },
{ re: /-mac-x64\.dmg$/, os: "mac", label: "Intel", note: ".dmg" },
{ re: /-win-x64-setup\.exe$/, os: "windows", label: "x64 installer", note: ".exe" },
{ re: /-win-x64\.zip$/, os: "windows", label: "x64 portable", note: ".zip" },
{ re: /-win-arm64-setup\.exe$/, os: "windows", label: "ARM64 installer", note: ".exe" },
{ re: /-win-arm64\.zip$/, os: "windows", label: "ARM64 portable", note: ".zip" },
{ re: /-linux-x64\.deb$/, os: "linux", label: "x64 Debian, Ubuntu", note: ".deb" },
{ re: /-linux-x64\.rpm$/, os: "linux", label: "x64 Fedora, openSUSE", note: ".rpm" },
{ re: /-linux-x64\.zip$/, os: "linux", label: "x64 any distro", note: ".zip" },
{ re: /-linux-arm64\.deb$/, os: "linux", label: "ARM64 Debian, Ubuntu", note: ".deb" },
{ re: /-linux-arm64\.rpm$/, os: "linux", label: "ARM64 Fedora, openSUSE", note: ".rpm" },
{ re: /-linux-arm64\.zip$/, os: "linux", label: "ARM64 any distro", note: ".zip" },
{ re: /-android\.apk$/, os: "android", label: "Phones and tablets", note: ".apk" },
{ re: /^ps5upload-[\d.]+\.elf$/, os: "extra", label: "PS5 helper (payload)", note: ".elf" },
{ re: /^ps5upload-engine-/, os: "extra", label: null, note: "" },
];
const OS = {
mac: "macOS",
windows: "Windows",
linux: "Linux",
android: "Android",
};
function detectOs() {
const ua = navigator.userAgent;
if (/android/i.test(ua)) return "android";
if (/windows/i.test(ua)) return "windows";
if (/mac os x|macintosh/i.test(ua) && !/iphone|ipad/i.test(ua)) return "mac";
if (/linux|x11/i.test(ua)) return "linux";
return null;
}
const mb = (n) => (n >= 1e9 ? `${(n / 1e9).toFixed(1)} GB` : `${Math.max(1, Math.round(n / 1e6))} MB`);
const date = (iso) =>
iso ? new Date(iso).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }) : "";
function normalize(list) {
return (list || [])
.filter((r) => !r.draft)
.map((r) => ({
tag: r.tag_name,
prerelease: !!r.prerelease,
published: r.published_at,
body: r.body || "",
url: r.html_url,
assets: (r.assets || []).map((a) => ({ name: a.name, url: a.browser_download_url, size: a.size })),
}));
}
async function loadReleases() {
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), 6000);
try {
const res = await fetch(API, { signal: ctl.signal, headers: { Accept: "application/vnd.github+json" } });
if (!res.ok) throw new Error(String(res.status));
const list = normalize(await res.json());
if (list.length) return list;
throw new Error("empty");
} catch {
const res = await fetch("releases.json", { cache: "no-cache" });
return normalize(await res.json());
} finally {
clearTimeout(timer);
}
}
// ── Markdown, the little the release notes use ─────────────────────────────
const esc = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
function inline(s) {
return esc(s)
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2">$1</a>');
}
/** The start of a release's notes for the news list: everything up to its first `maxItems`
* bullets. `more` says whether anything was left out. */
function excerpt(md, maxItems = 6) {
const lines = md.split(/\r?\n/);
let items = 0;
for (let i = 0; i < lines.length; i++) {
if (lines[i].trim().startsWith("- ") && ++items > maxItems) {
return { md: lines.slice(0, i).join("\n"), more: true };
}
}
return { md, more: false };
}
/** Paragraphs and "- " bullets (with wrapped continuation lines); headings become bold lines. */
function markdown(md) {
const out = [];
let list = null;
let para = null;
const flush = () => {
if (para) out.push(`<p>${inline(para)}</p>`);
para = null;
};
for (const raw of md.split(/\r?\n/)) {
const line = raw.trim();
if (!line || line === "---") {
flush();
if (list) out.push(`<ul>${list.map((i) => `<li>${inline(i)}</li>`).join("")}</ul>`);
list = null;
continue;
}
if (line.startsWith("- ")) {
flush();
(list ??= []).push(line.slice(2));
} else if (list && /^\s{2,}/.test(raw)) {
list[list.length - 1] += ` ${line}`;
} else if (/^#{1,6}\s/.test(line)) {
flush();
out.push(`<p><strong>${inline(line.replace(/^#+\s*/, ""))}</strong></p>`);
} else {
para = para ? `${para} ${line}` : line;
}
}
flush();
if (list) out.push(`<ul>${list.map((i) => `<li>${inline(i)}</li>`).join("")}</ul>`);
return out.join("");
}
// ── Render ─────────────────────────────────────────────────────────────────
function renderDownloads(rel, mine) {
const groups = { mac: [], windows: [], linux: [], android: [], extra: [] };
for (const a of rel.assets) {
const i = KINDS.findIndex((k) => k.re.test(a.name));
if (i < 0) continue;
const k = KINDS[i];
groups[k.os].push({ ...a, order: i, label: k.label ?? a.name.replace(/^ps5upload-engine-[\d.]+-/, "Engine · "), note: k.note });
}
// KINDS order (x64 before ARM64, installer before portable), not GitHub's alphabetical one.
for (const g of Object.values(groups)) g.sort((a, b) => a.order - b.order);
const order = ["mac", "windows", "linux", "android"].sort((a, b) => (a === mine ? -1 : b === mine ? 1 : 0));
const el = document.getElementById("downloads");
el.innerHTML = order
.filter((os) => groups[os].length)
.map(
(os) => `
<div class="card dl${os === mine ? " mine" : ""}">
<div class="dl-head"><h3>${OS[os]}</h3>${os === mine ? '<span class="tag">Your system</span>' : ""}</div>
<div class="dl-files">
${groups[os]
.map(
(f) =>
`<a class="dl-file" href="${esc(f.url)}"><span>${esc(f.label)} <small>${esc(f.note)}</small></span><small>${mb(f.size)}</small></a>`,
)
.join("")}
</div>
</div>`,
)
.join("");
document.getElementById("extras").innerHTML = groups.extra
.map((f) => `<a class="btn small" href="${esc(f.url)}">${esc(f.label)} · ${mb(f.size)}</a>`)
.join("");
return groups;
}
function renderHero(rel, stable, groups, mine) {
const pre = rel.prerelease ? " · pre-release" : "";
document.getElementById("hero-version").textContent = `${rel.tag}${pre} · ${date(rel.published)}`;
const first = mine && groups[mine]?.[0];
const btn = document.getElementById("hero-download");
if (first) {
btn.href = first.url;
document.getElementById("hero-download-label").textContent = `Download for ${OS[mine]}`;
document.getElementById("hero-fine").innerHTML =
`${esc(first.label)} ${esc(first.note)} · ${mb(first.size)} · <a href="#download">Other platforms</a>`;
} else {
btn.href = "#download";
document.getElementById("hero-download-label").textContent = "Download";
}
document.getElementById("release-link").href = rel.url;
const lead = document.getElementById("download-lead");
lead.textContent = rel.prerelease
? `${rel.tag} is a pre-release: new and being tested.${stable ? ` The last stable release is ${stable.tag}.` : ""}`
: `${rel.tag}, released ${date(rel.published)}.`;
if (rel.prerelease && stable) {
lead.insertAdjacentHTML("beforeend", ` <a href="${esc(stable.url)}">Get ${esc(stable.tag)}</a>`);
}
document.getElementById("docker-cmd").textContent =
`docker run -d --name ps5upload --network host -v ps5upload-data:/data ghcr.io/${REPO.split("/")[0]}/ps5upload-engine-webui:${rel.tag.replace(/^v/, "")}`;
}
function renderNews(list) {
const el = document.getElementById("news-list");
const shown = list.slice(0, NEWS_COUNT);
el.innerHTML =
shown
.map(
(r) => `
<article class="card">
<div class="post-head">
<h3>${esc(r.tag)}</h3>
${r.prerelease ? '<span class="tag pre">Pre-release</span>' : '<span class="tag">Release</span>'}
<time datetime="${esc(r.published || "")}">${date(r.published)}</time>
</div>
<div class="post-body">${markdown(excerpt(r.body).md)}</div>
<a class="post-more" href="${esc(r.url)}">${excerpt(r.body).more ? "Read the rest, and downloads →" : "Downloads and full notes →"}</a>
</article>`,
)
.join("") +
(list.length > NEWS_COUNT
? `<div class="older">${list
.slice(NEWS_COUNT)
.map((r) => `<a class="btn small" href="${esc(r.url)}">${esc(r.tag)}</a>`)
.join("")}</div>`
: "");
}
(async () => {
try {
const list = await loadReleases();
if (!list.length) throw new Error("no releases");
const latest = list[0];
const stable = list.find((r) => !r.prerelease) ?? null;
const mine = detectOs();
const groups = renderDownloads(latest, mine);
renderHero(latest, stable && stable !== latest ? stable : null, groups, mine);
renderNews(list);
} catch {
const msg = `<p class="muted">Couldn't load the releases here. <a href="https://github.com/${REPO}/releases">See them on GitHub</a>.</p>`;
document.getElementById("downloads").innerHTML = msg;
document.getElementById("news-list").innerHTML = msg;
}
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+195
View File
@@ -0,0 +1,195 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PS5Upload — move games to your PS5, fast</title>
<meta name="description" content="PS5Upload sends files, installs packages and manages games on a jailbroken PS5 from your computer or phone. Free and open source, for macOS, Windows, Linux, Android and Docker." />
<meta property="og:title" content="PS5Upload" />
<meta property="og:description" content="Move games to your PS5, fast. Free and open source, for macOS, Windows, Linux, Android and Docker." />
<meta property="og:image" content="https://phantomptr.github.io/ps5upload/assets/home-dark.webp" />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="color-scheme" content="light dark" />
<link rel="icon" href="assets/favicon.png" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<a class="skip" href="#main">Skip to content</a>
<header class="top">
<div class="wrap top-row">
<a class="brand" href="#">
<img src="assets/logo.png" alt="" width="34" height="34" />
<span>PS5Upload</span>
</a>
<nav class="nav" aria-label="Sections">
<a href="#features">Features</a>
<a href="#download">Download</a>
<a href="#news">News</a>
<a href="#community">Community</a>
</nav>
<div class="top-actions">
<a class="icon-btn" href="https://github.com/phantomptr/ps5upload" aria-label="PS5Upload on GitHub" title="GitHub">
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M12 .5a11.5 11.5 0 0 0-3.64 22.41c.58.1.79-.25.79-.56v-2c-3.2.7-3.88-1.37-3.88-1.37-.53-1.33-1.28-1.69-1.28-1.69-1.05-.71.08-.7.08-.7 1.16.08 1.77 1.19 1.77 1.19 1.03 1.76 2.7 1.25 3.36.96.1-.75.4-1.25.73-1.54-2.56-.29-5.25-1.28-5.25-5.69 0-1.26.45-2.29 1.19-3.09-.12-.29-.52-1.46.11-3.05 0 0 .97-.31 3.17 1.18a11 11 0 0 1 5.77 0c2.2-1.49 3.17-1.18 3.17-1.18.63 1.59.23 2.76.11 3.05.74.8 1.19 1.83 1.19 3.09 0 4.42-2.7 5.39-5.27 5.68.41.36.78 1.06.78 2.14v3.17c0 .31.21.67.8.56A11.5 11.5 0 0 0 12 .5Z"/></svg>
</a>
<button class="icon-btn" id="theme" type="button" aria-label="Switch light or dark" title="Light or dark">
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg>
</button>
</div>
</div>
</header>
<main id="main">
<section class="hero wrap">
<div class="hero-text">
<p class="eyebrow" id="hero-version">Free and open source</p>
<h1>Move games to your PS5, fast.</h1>
<p class="lead">
PS5Upload sends files, installs packages and manages games on a jailbroken PS5,
from your computer or your phone. Encrypted, resumable transfers at the full speed of
your network.
</p>
<div class="hero-cta">
<a class="btn primary" id="hero-download" href="https://github.com/phantomptr/ps5upload/releases">
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M12 3v12m0 0-4-4m4 4 4-4M5 21h14"/></svg>
<span id="hero-download-label">Download</span>
</a>
<a class="btn" href="#download">All downloads</a>
</div>
<p class="fine" id="hero-fine">macOS · Windows · Linux · Android · Docker</p>
</div>
<figure class="shot hero-shot">
<picture>
<source srcset="assets/home-dark.webp" media="(prefers-color-scheme: dark)" data-theme-src="home" />
<img src="assets/home-light.webp" alt="The PS5Upload Home screen: upload, install, games, files, saves and convert, with the console's status beside them." width="1800" height="1125" data-theme-img="home" />
</picture>
</figure>
</section>
<section class="section wrap" id="features" aria-labelledby="features-title">
<h2 id="features-title">Everything between your computer and your PS5</h2>
<p class="section-lead">One app for the whole round trip, built on its own fast transfer protocol.</p>
<div class="grid features">
<article class="card">
<span class="ico"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 15V3m0 0L8 7m4-4 4 4M4 15v4a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-4"/></svg></span>
<h3>Fast uploads</h3>
<p>Folders, game images and archives go straight to the console over an encrypted, resumable connection. Thousands of small files are packed together so they don't crawl.</p>
</article>
<article class="card">
<span class="ico"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M21 8 12 3 3 8l9 5 9-5Zm-18 0v8l9 5 9-5V8M12 13v8"/></svg></span>
<h3>Install packages</h3>
<p>Stream a .pkg from your computer, a link or a NAS while it installs, or copy it first. Updates and DLC install from each game's own page.</p>
</article>
<article class="card">
<span class="ico"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h10M4 17h7m9-2v6m-3-3h6"/></svg></span>
<h3>Convert games</h3>
<p>Turn a game folder into a package, an exFAT image or a compressed .ffpfsc for ShadowMount+, with AMPR LZ4 asset packs to make images smaller.</p>
</article>
<article class="card">
<span class="ico"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 4v16M9 4v16M14 4l5 16"/></svg></span>
<h3>Your collection</h3>
<p>Every game on your drives and network shares, grouped by game with its updates, DLC and duplicates, and what each console is missing.</p>
</article>
<article class="card">
<span class="ico"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="2" y="6" width="20" height="12" rx="3"/><path d="M6 12h4M8 10v4M15 11h.01M18 13h.01"/></svg></span>
<h3>Games, saves and files</h3>
<p>Launch, stop and uninstall games, back up and restore saves, browse the console's drives, and copy straight from one PS5 to another.</p>
</article>
<article class="card">
<span class="ico"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="7" y="2" width="10" height="20" rx="2"/><path d="M11 18h2"/></svg></span>
<h3>Desktop, phone or browser</h3>
<p>Native apps for macOS, Windows, Linux and Android, and a Docker image that serves the same app as a web page from your NAS. In 21 languages.</p>
</article>
</div>
</section>
<section class="section wrap" aria-label="More screens">
<div class="grid screens">
<figure class="shot">
<picture>
<source srcset="assets/upload-dark.webp" media="(prefers-color-scheme: dark)" data-theme-src="upload" />
<img src="assets/upload-light.webp" alt="Upload: drop a file or folder and it is detected automatically." width="1400" height="875" loading="lazy" data-theme-img="upload" />
</picture>
<figcaption>Drop anything on Upload: what it is gets detected.</figcaption>
</figure>
<figure class="shot">
<picture>
<source srcset="assets/convert-dark.webp" media="(prefers-color-scheme: dark)" data-theme-src="convert" />
<img src="assets/convert-light.webp" alt="Convert: make a package or a game image from a game folder." width="1400" height="875" loading="lazy" data-theme-img="convert" />
</picture>
<figcaption>Convert a folder into a package or a ShadowMount+ image.</figcaption>
</figure>
</div>
</section>
<section class="section wrap" id="download" aria-labelledby="download-title">
<div class="section-head">
<div>
<h2 id="download-title">Download</h2>
<p class="section-lead" id="download-lead">The newest release for every platform.</p>
</div>
<a class="btn small" id="release-link" href="https://github.com/phantomptr/ps5upload/releases">Release notes</a>
</div>
<div class="grid downloads" id="downloads">
<p class="muted">Loading the latest release…</p>
</div>
<div class="card docker">
<div>
<h3>Docker (web UI on your NAS or server)</h3>
<p class="muted">Serves the full app as a web page on your network. Open port 19113 in a browser.</p>
</div>
<div class="copy">
<code id="docker-cmd">docker run -d --name ps5upload --network host -v ps5upload-data:/data ghcr.io/phantomptr/ps5upload-engine-webui:latest</code>
<button class="btn small" type="button" data-copy="docker-cmd">Copy</button>
</div>
</div>
<details class="more">
<summary>The PS5 helper and the engine on its own</summary>
<div id="extras" class="extras"></div>
</details>
</section>
<section class="section wrap" id="news" aria-labelledby="news-title">
<div class="section-head">
<h2 id="news-title">News</h2>
<a class="btn small" href="https://github.com/phantomptr/ps5upload/blob/main/CHANGELOG.md">Full changelog</a>
</div>
<div id="news-list" class="news">
<p class="muted">Loading releases…</p>
</div>
</section>
<section class="section wrap" id="community" aria-labelledby="community-title">
<h2 id="community-title">Community</h2>
<div class="grid community">
<a class="card link-card" href="https://discord.gg/fzK3xddtrM">
<h3>Discord</h3>
<p>Help, setup questions and ideas. Release announcements land here first.</p>
</a>
<a class="card link-card" href="https://github.com/phantomptr/ps5upload/issues">
<h3>Report a bug</h3>
<p>Open an issue on GitHub. The app's Bug report screen builds a report with logs to attach.</p>
</a>
<a class="card link-card" href="https://x.com/phantomptr">
<h3>X</h3>
<p>Follow @phantomptr for releases.</p>
</a>
<a class="card link-card" href="https://ko-fi.com/B0B81S0WUA">
<h3>Buy me a coffee</h3>
<p>PS5Upload is free. If it saves you time, a coffee keeps it going.</p>
</a>
</div>
</section>
</main>
<footer class="foot">
<div class="wrap foot-row">
<span>PS5Upload is free software under the GPL-3.0.</span>
<span class="muted">Not affiliated with Sony Interactive Entertainment. Use it on consoles you own.</span>
</div>
</footer>
<script src="app.js" defer></script>
</body>
</html>
+1370
View File
File diff suppressed because it is too large. Load diff
+537
View File
@@ -0,0 +1,537 @@
/* PS5Upload website. The app's look (client/src/index.css): a calm neutral page, white or
* graphite cards a step off it, one blue accent, the system typeface. Light and dark follow
* the visitor's system; the header button overrides it (data-theme on <html>). */
:root {
color-scheme: light;
--bg: #f5f5f7;
--card: #ffffff;
--card-2: rgb(0 0 0 / 0.03);
--line: rgb(0 0 0 / 0.1);
--line-strong: rgb(0 0 0 / 0.18);
--text: #1d1d1f;
--muted: #5f5f66;
--accent: #0064d2;
--accent-fill: #0071e3;
--accent-soft: rgb(0 113 227 / 0.1);
--good: #1a7f43;
--warn: #8a5200;
--shadow: 0 1px 2px rgb(0 0 0 / 0.05);
--shadow-big: 0 30px 80px -30px rgb(0 0 0 / 0.35), 0 0 0 0.5px rgb(0 0 0 / 0.1);
--radius: 14px;
--radius-big: 20px;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--bg: #1e1e20;
--card: #2a2a2c;
--card-2: rgb(255 255 255 / 0.04);
--line: rgb(255 255 255 / 0.1);
--line-strong: rgb(255 255 255 / 0.2);
--text: #f5f5f7;
--muted: #a1a1a8;
--accent: #4da3ff;
--accent-fill: #0a72e8;
--accent-soft: rgb(10 132 255 / 0.16);
--good: #32d074;
--warn: #ffb340;
--shadow: 0 1px 2px rgb(0 0 0 / 0.25);
--shadow-big: 0 30px 80px -30px rgb(0 0 0 / 0.8), 0 0 0 0.5px rgb(255 255 255 / 0.08);
}
}
:root[data-theme="dark"] {
color-scheme: dark;
--bg: #1e1e20;
--card: #2a2a2c;
--card-2: rgb(255 255 255 / 0.04);
--line: rgb(255 255 255 / 0.1);
--line-strong: rgb(255 255 255 / 0.2);
--text: #f5f5f7;
--muted: #a1a1a8;
--accent: #4da3ff;
--accent-fill: #0a72e8;
--accent-soft: rgb(10 132 255 / 0.16);
--good: #32d074;
--warn: #ffb340;
--shadow: 0 1px 2px rgb(0 0 0 / 0.25);
--shadow-big: 0 30px 80px -30px rgb(0 0 0 / 0.8), 0 0 0 0.5px rgb(255 255 255 / 0.08);
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
scroll-padding-top: 80px;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 16px/1.55 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans",
Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 6px;
}
h1,
h2,
h3 {
letter-spacing: -0.02em;
line-height: 1.1;
margin: 0;
}
code {
font: 0.85em ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
background: var(--card-2);
border: 1px solid var(--line);
border-radius: 6px;
padding: 0.1em 0.4em;
}
.muted {
color: var(--muted);
}
.skip {
position: absolute;
left: -999px;
}
.skip:focus {
left: 16px;
top: 12px;
z-index: 10;
background: var(--card);
padding: 8px 12px;
border-radius: 10px;
}
.wrap {
width: min(1180px, 100% - 32px);
margin-inline: auto;
}
/* ── Header ─────────────────────────────────────────────────────────────── */
.top {
position: sticky;
top: 0;
z-index: 5;
background: color-mix(in srgb, var(--bg) 82%, transparent);
-webkit-backdrop-filter: blur(20px) saturate(1.4);
backdrop-filter: blur(20px) saturate(1.4);
border-bottom: 1px solid var(--line);
}
.top-row {
display: flex;
align-items: center;
gap: 16px;
height: 60px;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
color: var(--text);
font-weight: 650;
font-size: 18px;
letter-spacing: -0.02em;
}
.brand:hover {
text-decoration: none;
}
.brand img {
border-radius: 9px;
}
.nav {
display: flex;
gap: 4px;
margin-inline-start: auto;
}
.nav a {
color: var(--text);
padding: 6px 12px;
border-radius: 999px;
font-size: 15px;
}
.nav a:hover {
background: var(--card-2);
text-decoration: none;
}
.top-actions {
display: flex;
gap: 6px;
}
.icon-btn {
display: grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: 999px;
border: 1px solid var(--line);
background: var(--card);
color: var(--text);
cursor: pointer;
}
.icon-btn svg {
width: 18px;
height: 18px;
}
/* ── Buttons ────────────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 44px;
padding: 0 20px;
border-radius: 999px;
border: 1px solid var(--line-strong);
background: var(--card);
color: var(--text);
font: inherit;
font-weight: 550;
cursor: pointer;
box-shadow: var(--shadow);
}
.btn:hover {
text-decoration: none;
background: var(--card-2);
}
.btn svg {
width: 18px;
height: 18px;
}
.btn.primary {
background: var(--accent-fill);
border-color: transparent;
color: #fff;
}
.btn.primary:hover {
filter: brightness(1.08);
}
.btn.small {
min-height: 34px;
padding: 0 14px;
font-size: 14px;
}
/* ── Hero ───────────────────────────────────────────────────────────────── */
.hero {
display: grid;
gap: 40px;
padding: 72px 0 32px;
text-align: center;
}
.eyebrow {
display: inline-block;
margin: 0 0 16px;
padding: 4px 12px;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
font-size: 14px;
font-weight: 600;
}
.hero h1 {
font-size: clamp(40px, 7vw, 76px);
font-weight: 750;
letter-spacing: -0.04em;
}
.lead {
max-width: 680px;
margin: 20px auto 0;
font-size: clamp(17px, 2vw, 21px);
color: var(--muted);
}
.hero-cta {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 12px;
margin-top: 28px;
}
.fine {
margin: 14px 0 0;
font-size: 14px;
color: var(--muted);
}
.shot {
margin: 0;
}
.shot img {
display: block;
width: 100%;
height: auto;
border-radius: var(--radius-big);
box-shadow: var(--shadow-big);
}
.shot figcaption {
margin-top: 14px;
color: var(--muted);
font-size: 15px;
text-align: center;
}
/* ── Sections ───────────────────────────────────────────────────────────── */
.section {
padding: 64px 0 8px;
}
.section h2 {
font-size: clamp(28px, 4vw, 40px);
font-weight: 700;
}
.section-lead {
margin: 10px 0 0;
color: var(--muted);
font-size: 18px;
}
.section-head {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
}
.grid {
display: grid;
gap: 16px;
margin-top: 28px;
}
.features {
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
}
.screens {
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 32px;
margin-top: 0;
}
.card {
background: var(--card);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 22px;
box-shadow: var(--shadow);
}
.card h3 {
font-size: 18px;
font-weight: 650;
}
.card p {
margin: 8px 0 0;
color: var(--muted);
font-size: 15px;
}
.ico {
display: grid;
place-items: center;
width: 42px;
height: 42px;
margin-bottom: 14px;
border-radius: 12px;
background: var(--accent-soft);
color: var(--accent);
}
.ico svg {
width: 21px;
height: 21px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
/* ── Downloads ──────────────────────────────────────────────────────────── */
.downloads {
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
}
.dl {
display: flex;
flex-direction: column;
gap: 12px;
}
.dl.mine {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.dl-head {
display: flex;
align-items: center;
gap: 10px;
}
.dl-head .tag {
margin-inline-start: auto;
}
.dl-files {
display: grid;
gap: 8px;
}
.dl-file {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border-radius: 10px;
background: var(--card-2);
border: 1px solid var(--line);
color: var(--text);
font-size: 15px;
}
.dl-file:hover {
border-color: var(--accent);
text-decoration: none;
}
.dl-file small {
color: var(--muted);
white-space: nowrap;
}
.tag {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
background: var(--accent-soft);
color: var(--accent);
white-space: nowrap;
}
.tag.pre {
background: color-mix(in srgb, var(--warn) 16%, transparent);
color: var(--warn);
}
.docker {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 16px;
}
.copy {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
max-width: 100%;
}
.copy code {
overflow-x: auto;
white-space: nowrap;
padding: 8px 12px;
max-width: 640px;
}
.more {
margin-top: 16px;
color: var(--muted);
}
.more summary {
cursor: pointer;
}
.extras {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
/* ── News ───────────────────────────────────────────────────────────────── */
.news {
display: grid;
gap: 16px;
margin-top: 28px;
}
.post-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.post-head h3 {
font-size: 22px;
}
.post-head time {
color: var(--muted);
font-size: 14px;
}
.post-body {
margin-top: 10px;
color: var(--text);
font-size: 15px;
}
.post-body p {
color: var(--text);
}
.post-body ul {
margin: 10px 0 0;
padding-inline-start: 20px;
}
.post-body li {
margin: 4px 0;
color: var(--muted);
}
.post-body li strong {
color: var(--text);
}
.post-more {
display: inline-block;
margin-top: 10px;
font-size: 15px;
}
.older {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
/* ── Community, footer ──────────────────────────────────────────────────── */
.community {
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
}
.link-card {
color: var(--text);
}
.link-card:hover {
text-decoration: none;
border-color: var(--accent);
}
.foot {
margin-top: 72px;
padding: 28px 0 40px;
border-top: 1px solid var(--line);
font-size: 14px;
}
.foot-row {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 8px;
}
@media (max-width: 720px) {
.nav {
display: none;
}
.top-actions {
margin-inline-start: auto;
}
.hero {
padding-top: 40px;
}
.docker {
flex-direction: column;
align-items: stretch;
}
.copy code {
flex: 1;
}
}