feat: collapse background uploads into a progress circle and fit hero covers
This commit is contained in:
1 parent
69f71cb836
commit
db52a2b589
4 files changed
+174
-5
No files matched your search
+21
-1
@@ -595,15 +595,18 @@ input[type="file"] { padding: 0.4rem; }
|
||||
z-index: -1;
|
||||
background: linear-gradient(to bottom, transparent 20%, var(--bg) 100%);
|
||||
}
|
||||
.hero-inner { display: flex; gap: var(--space-5); align-items: flex-end; max-width: 1500px; margin: 0 auto; flex-wrap: wrap; }
|
||||
.hero-inner { display: flex; gap: var(--space-5); align-items: flex-start; max-width: 1500px; margin: 0 auto; flex-wrap: wrap; }
|
||||
.hero .cover.big {
|
||||
width: 380px;
|
||||
max-width: 100%;
|
||||
aspect-ratio: auto;
|
||||
border-radius: var(--radius-l);
|
||||
box-shadow: var(--shadow-3);
|
||||
border: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.hero .cover.big img { height: auto; object-fit: contain; }
|
||||
.hero .cover.big > span { width: 100%; aspect-ratio: 16 / 10; display: grid; place-items: center; }
|
||||
.hero-info { flex: 1; min-width: 280px; display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
.hero-info h1 { font-size: clamp(1.8rem, 4vw, 2.8rem); }
|
||||
.description { white-space: pre-wrap; color: var(--text-2); max-width: 70ch; }
|
||||
@@ -679,6 +682,23 @@ input[type="file"] { padding: 0.4rem; }
|
||||
.upload-floating { position: fixed; right: var(--space-4); bottom: var(--space-4); z-index: 50; width: min(360px, calc(100vw - 2rem)); padding: var(--space-4); background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-2); max-height: calc(100dvh - var(--nav-h) - 2rem); overflow-y: auto; }
|
||||
.upload-floating .upload-progress { padding: 0; border: 0; box-shadow: none; }
|
||||
.upload-floating > * + * { margin-top: var(--space-3); }
|
||||
.upload-minimize { position: absolute; top: var(--space-4); right: var(--space-4); }
|
||||
.upload-minimize + .upload-progress { margin-top: 0; }
|
||||
.upload-floating .upload-progress h2 { padding-right: 2rem; }
|
||||
.upload-floating.upload-floating-collapsed { width: auto; padding: 0; border: 0; background: transparent; box-shadow: none; overflow: visible; }
|
||||
.upload-bubble { position: relative; display: grid; place-items: center; width: 64px; height: 64px; padding: 0; border: 1px solid var(--border); border-radius: 50%; background: var(--surface); color: var(--accent); box-shadow: var(--shadow-2); cursor: pointer; }
|
||||
.upload-bubble:hover { background: var(--surface-2); }
|
||||
.upload-bubble.done { color: var(--ok); }
|
||||
.upload-bubble.failed { color: var(--danger); }
|
||||
.upload-ring { position: absolute; inset: 0; width: 100%; height: 100%; transform: rotate(-90deg); fill: none; stroke-width: 4; }
|
||||
.upload-ring-track { stroke: var(--surface-3); }
|
||||
.upload-ring-value { stroke: currentColor; stroke-linecap: round; transition: stroke-dasharray 0.2s; }
|
||||
.upload-ring.indeterminate { animation: upload-ring-spin 1.4s linear infinite; }
|
||||
.upload-bubble-value { display: grid; place-items: center; font-size: 0.75rem; font-weight: 700; }
|
||||
.upload-bubble-count { position: absolute; top: -3px; right: -3px; display: grid; place-items: center; min-width: 21px; height: 21px; padding: 0 4px; border: 1px solid var(--border); border-radius: 50%; background: var(--surface-2); color: var(--text); font-size: 0.7rem; }
|
||||
.upload-bubble-count.has-error { background: var(--danger); color: white; }
|
||||
@keyframes upload-ring-spin { to { transform: rotate(270deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { .upload-ring.indeterminate { animation: none; } }
|
||||
@media (max-width: 700px) {
|
||||
.upload-floating { bottom: calc(var(--tabbar-h) + env(safe-area-inset-bottom) + 1rem); max-height: calc(100dvh - var(--nav-h) - var(--tabbar-h) - env(safe-area-inset-bottom) - 2rem); }
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ const PATHS = {
|
||||
edit: "M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z",
|
||||
image: "M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Zm5.5 5.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM21 15l-5-5L5 21",
|
||||
plus: "M12 5v14M5 12h14",
|
||||
minus: "M5 12h14",
|
||||
back: "M19 12H5m7-7-7 7 7 7",
|
||||
chevron: "m9 6 6 6-6 6",
|
||||
check: "m5 12 5 5L20 7",
|
||||
|
||||
@@ -157,12 +157,48 @@ function FloatingUploads() {
|
||||
const { pathname } = useLocation();
|
||||
const { tasks } = useUploads();
|
||||
const { task: translation } = useTranslationRun();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
// on the upload page the uploads are listed in the page itself
|
||||
const uploads = tasks.length > 0 && pathname.replace(/\/$/, "") !== "/games/new";
|
||||
if (!uploads && !translation) return null;
|
||||
return <aside className="upload-floating" aria-label="Фоновые задачи">
|
||||
{uploads && <UploadProgress />}
|
||||
<TranslationProgress />
|
||||
{uploads && <Link to="/games/new" className="btn btn-primary">К загрузкам</Link>}
|
||||
const active = uploads ? tasks.find(task => ["creating", "uploading", "processing"].includes(task.status)) : undefined;
|
||||
const finished = uploads ? tasks.find(task => task.status === "failed") ?? tasks[0] : undefined;
|
||||
const current = active ?? finished;
|
||||
const translationActive = translation?.status === "running" || translation?.status === "stopping";
|
||||
const showTranslation = !active && translationActive || !current;
|
||||
const failed = showTranslation ? translation?.status === "failed" : current?.status === "failed";
|
||||
const done = showTranslation ? translation?.status === "done" || translation?.status === "stopped" : current?.status === "ready";
|
||||
const unknown = showTranslation
|
||||
? !!translationActive && !translation?.progress.total
|
||||
: current?.status === "creating" || current?.status === "processing" || current?.status === "uploading" && current.progress >= 1;
|
||||
const percentage = showTranslation && translation
|
||||
? Math.min(100, Math.round((translation.progress.done + translation.progress.failed) / (translation.progress.total || 1) * 100))
|
||||
: Math.max(0, Math.min(100, Math.round((current?.progress ?? 0) * 100)));
|
||||
const label = failed ? "Ошибка фоновой задачи" : done ? "Фоновые задачи завершены"
|
||||
: unknown ? "Обработка на сервере" : `Прогресс: ${percentage}%`;
|
||||
const count = (uploads ? tasks.length : 0) + (translation ? 1 : 0);
|
||||
const hasError = tasks.some(task => task.status === "failed") || translation?.status === "failed";
|
||||
const circumference = 2 * Math.PI * 25;
|
||||
return <aside className={`upload-floating${collapsed ? " upload-floating-collapsed" : ""}`} aria-label="Фоновые задачи">
|
||||
<button type="button" className={collapsed ? `upload-bubble${failed ? " failed" : done ? " done" : ""}` : "btn btn-ghost btn-icon upload-minimize"}
|
||||
aria-expanded={!collapsed} aria-label={collapsed ? `Развернуть фоновые задачи. ${label}` : "Свернуть фоновые задачи"}
|
||||
title={collapsed ? `${label}. Нажмите, чтобы развернуть` : "Свернуть в круг прогресса"}
|
||||
onClick={() => setCollapsed(value => !value)}>
|
||||
{collapsed ? <>
|
||||
<svg viewBox="0 0 64 64" className={`upload-ring${unknown ? " indeterminate" : ""}`} aria-hidden="true">
|
||||
<circle cx="32" cy="32" r="25" className="upload-ring-track" />
|
||||
<circle cx="32" cy="32" r="25" className="upload-ring-value"
|
||||
strokeDasharray={`${circumference * (unknown ? 0.25 : percentage / 100)} ${circumference}`} />
|
||||
</svg>
|
||||
<span className="upload-bubble-value">{failed ? <Icon name="alert" size={20} /> : done ? <Icon name="check" size={22} />
|
||||
: unknown ? <Icon name="clock" size={20} /> : `${percentage}%`}</span>
|
||||
{(count > 1 || hasError && !failed) && <span className={`upload-bubble-count${hasError ? " has-error" : ""}`}>{count}</span>}
|
||||
</> : <Icon name="minus" size={18} />}
|
||||
</button>
|
||||
{!collapsed && <>
|
||||
{uploads && <UploadProgress />}
|
||||
<TranslationProgress />
|
||||
{uploads && <Link to="/games/new" className="btn btn-primary">К загрузкам</Link>}
|
||||
</>}
|
||||
</aside>;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { test } from "node:test";
|
||||
import { build } from "esbuild";
|
||||
|
||||
test("floating uploads collapse, keep live progress across navigation and show processing and failure", {
|
||||
skip: !process.env.PGAMES_PLAYWRIGHT_PATH,
|
||||
}, async () => {
|
||||
const { chromium } = await import(pathToFileURL(process.env.PGAMES_PLAYWRIGHT_PATH + "/index.mjs").href);
|
||||
const compiled = await build({stdin: {contents: `
|
||||
import {createElement as h} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import {MemoryRouter, Link, Routes, Route} from 'react-router-dom';
|
||||
import {AuthProvider} from './auth'; import {UiProvider} from './ui/Ui';
|
||||
import {TranslationRunProvider} from './translator/runner';
|
||||
import {UploadProvider, useUploads} from './uploads'; import {GamePage} from './pages/GamePage';
|
||||
function Harness() { const uploads = useUploads(); return h('div', null,
|
||||
h('button', {onClick: () => uploads.start({gameId: 1, file: new File(['archive'], 'MIST.zip')})}, 'Начать'),
|
||||
h(Link, {to: '/games/1'}, 'Первая игра'), h(Link, {to: '/games/2'}, 'Вторая игра'),
|
||||
h(Routes, null, h(Route, {path: '/games/:gameId', element: h(GamePage)}))); }
|
||||
createRoot(document.querySelector('main')).render(h(MemoryRouter, {initialEntries: ['/games/1']},
|
||||
h(UiProvider, null, h(AuthProvider, null, h(TranslationRunProvider, null, h(UploadProvider, null, h(Harness)))))));`,
|
||||
resolveDir: fileURLToPath(new URL("../src/", import.meta.url)), loader: "tsx"},
|
||||
bundle: true, write: false, format: "iife", platform: "browser", logLevel: "silent"});
|
||||
const css = readFileSync(new URL("../src/styles.css", import.meta.url), "utf8");
|
||||
let ready = false;
|
||||
const server = createServer((req, res) => {
|
||||
if (req.url === "/bundle.js") { res.setHeader("Content-Type", "application/javascript"); res.end(compiled.outputFiles[0].text); return; }
|
||||
if (req.url === "/styles.css") { res.setHeader("Content-Type", "text/css"); res.end(css); return; }
|
||||
if (req.url === "/blobs/banner.webp") {
|
||||
res.setHeader("Content-Type", "image/svg+xml");
|
||||
res.end('<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="500"><rect width="1200" height="500" fill="#273d5c"/><rect width="80" height="500" fill="#f90"/><rect x="1120" width="80" height="500" fill="#0b9"/><text x="600" y="290" text-anchor="middle" fill="white" font-size="100">FULL BANNER</text></svg>'); return;
|
||||
}
|
||||
if (req.url === "/ready") { ready = true; res.end("ok"); return; }
|
||||
if (req.url.startsWith("/api/")) {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
if (req.url === "/api/auth/me") res.end(JSON.stringify({id: 1, permissions: ["games.play"]}));
|
||||
else if (req.url.startsWith("/api/versions/")) res.end(JSON.stringify({id: 1, status: ready ? "ready" : "converting", progress: "Обработка"}));
|
||||
else res.end(JSON.stringify({id: Number(req.url.split("/").at(-1)), title: req.url.endsWith("/2") ? "No cover" : "Mist",
|
||||
tags: Array.from({length: 30}, (_, i) => `tag-${i}`), description: Array(8).fill("Long description of the game and its story.").join("\n\n"),
|
||||
cover_hash: req.url.endsWith("/2") ? null : "banner.webp", gallery_hashes: [], versions: [], main_version_id: null}));
|
||||
return;
|
||||
}
|
||||
res.setHeader("Content-Type", "text/html");
|
||||
res.end('<!doctype html><meta name="viewport" content="width=device-width, initial-scale=1"><link rel="stylesheet" href="/styles.css"><main></main><script src="/bundle.js"></script>');
|
||||
});
|
||||
await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch({headless: true, executablePath: process.env.PGAMES_CHROMIUM_EXECUTABLE});
|
||||
const page = await browser.newPage({colorScheme: "dark", viewport: {width: 1906, height: 910}});
|
||||
page.setDefaultTimeout(10000);
|
||||
await page.addInitScript(() => {
|
||||
window.XMLHttpRequest = class {
|
||||
upload = {};
|
||||
open() {}
|
||||
setRequestHeader() {}
|
||||
send() { window.uploadRequest = this; }
|
||||
};
|
||||
window.uploadProgress = value => window.uploadRequest.upload.onprogress({lengthComputable: true, loaded: value, total: 100});
|
||||
window.uploadComplete = failed => {
|
||||
const xhr = window.uploadRequest;
|
||||
xhr.status = failed ? 500 : 201;
|
||||
xhr.responseText = JSON.stringify(failed ? {detail: "Test failure"} : {id: 1, status: "queued"});
|
||||
xhr.onload();
|
||||
};
|
||||
});
|
||||
await page.goto(`http://127.0.0.1:${server.address().port}`);
|
||||
await page.locator(".hero .cover img").evaluate(image => image.decode());
|
||||
const layout = await page.locator(".hero-inner").evaluate(element => {
|
||||
const image = element.querySelector(".cover img"), cover = element.querySelector(".cover"), info = element.querySelector(".hero-info");
|
||||
return {ratio: image.getBoundingClientRect().width / image.getBoundingClientRect().height,
|
||||
naturalRatio: image.naturalWidth / image.naturalHeight, topOffset: cover.getBoundingClientRect().top - info.getBoundingClientRect().top};
|
||||
});
|
||||
assert.ok(Math.abs(layout.ratio - layout.naturalRatio) < 0.01);
|
||||
assert.ok(Math.abs(layout.topOffset) < 1);
|
||||
await page.getByRole("button", {name: "Начать", exact: true}).click();
|
||||
await page.waitForFunction(() => !!window.uploadRequest);
|
||||
await page.evaluate(() => window.uploadProgress(38));
|
||||
await page.getByText("Загрузка архива… 38%", {exact: true}).waitFor();
|
||||
await page.getByRole("button", {name: "Свернуть фоновые задачи", exact: true}).click();
|
||||
await page.getByRole("button", {name: "Развернуть фоновые задачи. Прогресс: 38%", exact: true}).waitFor();
|
||||
await page.evaluate(() => window.uploadProgress(65));
|
||||
await page.getByRole("button", {name: "Развернуть фоновые задачи. Прогресс: 65%", exact: true}).waitFor();
|
||||
await page.getByRole("link", {name: "Вторая игра", exact: true}).click();
|
||||
await page.getByRole("heading", {name: "No cover", exact: true}).waitFor();
|
||||
assert.equal(await page.locator(".hero .cover").evaluate(element => element.getBoundingClientRect().height > 0), true);
|
||||
await page.getByRole("button", {name: "Развернуть фоновые задачи. Прогресс: 65%", exact: true}).press("Enter");
|
||||
await page.getByText("Загрузка архива… 65%", {exact: true}).waitFor();
|
||||
await page.getByRole("button", {name: "Свернуть фоновые задачи", exact: true}).click();
|
||||
await page.evaluate(() => window.uploadComplete(false));
|
||||
await page.getByRole("button", {name: "Развернуть фоновые задачи. Обработка на сервере", exact: true}).waitFor();
|
||||
await page.request.get(`http://127.0.0.1:${server.address().port}/ready`);
|
||||
await page.getByRole("button", {name: "Развернуть фоновые задачи. Фоновые задачи завершены", exact: true}).waitFor();
|
||||
await page.getByRole("link", {name: "Первая игра", exact: true}).click();
|
||||
await page.getByRole("heading", {name: "Mist", exact: true}).waitFor();
|
||||
for (const width of [1906, 375]) {
|
||||
await page.setViewportSize({width, height: 910});
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
|
||||
if (process.env.PGAMES_F95_SCREENSHOT_DIR) await page.screenshot({path: `${process.env.PGAMES_F95_SCREENSHOT_DIR}/hero-progress-${width}.png`, fullPage: true});
|
||||
}
|
||||
await page.getByRole("button", {name: "Начать", exact: true}).click();
|
||||
await page.evaluate(() => window.uploadComplete(true));
|
||||
await page.getByRole("button", {name: "Развернуть фоновые задачи. Ошибка фоновой задачи", exact: true}).click();
|
||||
await page.getByText("Test failure", {exact: true}).waitFor();
|
||||
} finally {
|
||||
await browser?.close();
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user