feat: import F95 screenshot galleries with AVIF support
CI / frontend (push) Failing after 19s
CI / backend (push) Failing after 41s

This commit is contained in:
mrleo1nid committed 2026-10-26 17:51:19 +03:00
1 parent 5b911d1e3b
commit 69f71cb836
21 files changed
+513 -33

No files matched your search

+1
View File
@@ -47,6 +47,7 @@ export interface Game {
tags: string[];
engine: string;
cover_hash: string | null;
gallery_hashes: string[];
main_version_id: number | null;
created_at: string;
ready_versions?: number;
+9 -2
View File
@@ -10,6 +10,7 @@ import { LanguageMenu } from "../ui/LanguageMenu";
import { aggregateLanguages, versionLanguages } from "./languages";
import { useUi } from "../ui/Ui";
import { useUploads } from "../uploads";
import { GameGallery, mergeGallery, type GalleryImage } from "../ui/GameGallery";
const STATUS: Record<Version["status"], string> = {
uploading: "Загружается",
@@ -224,6 +225,7 @@ export function GamePage() {
</div>
</div>
{!editing && <GameGallery images={(game.gallery_hashes ?? []).map(hash => ({ hash }))} />}
<div className="section-title" style={{ marginTop: "var(--space-5)" }}>
<h2>Версии</h2>
<span className="count">{game.versions.length}</span>
@@ -365,6 +367,8 @@ function Report({ report }: { report: Record<string, any> | null }) {
function GameEditor({ game, onDone }: { game: GameDetail; onDone: (changed: boolean) => void }) {
const [coverHash, setCoverHash] = useState<string | null>(null);
const [coverPreview, setCoverPreview] = useState<string | null>(null);
const [gallery, setGallery] = useState<GalleryImage[]>((game.gallery_hashes ?? []).map(hash => ({ hash })));
const [importing, setImporting] = useState(false);
const [title, setTitle] = useState(game.title);
const [description, setDescription] = useState(game.description);
const [tags, setTags] = useState(game.tags.join(", "));
@@ -374,6 +378,7 @@ function GameEditor({ game, onDone }: { game: GameDetail; onDone: (changed: bool
await api.patch(`/api/games/${game.id}`, {
title, description, tags: tags.split(",").map((t) => t.trim()).filter(Boolean),
...(coverHash ? { cover_hash: coverHash } : {}),
gallery_hashes: gallery.map(image => image.hash),
});
onDone(true);
} catch (e) {
@@ -382,20 +387,22 @@ function GameEditor({ game, onDone }: { game: GameDetail; onDone: (changed: bool
};
return (
<form className="editor" onSubmit={(e) => { e.preventDefault(); void save(); }}>
<F95Fetch onLoaded={(m) => {
<F95Fetch onBusyChange={setImporting} onLoaded={(m) => {
setTitle(m.title);
setDescription(m.description);
setTags(m.tags.join(", "));
setCoverHash(m.cover_hash);
setCoverPreview(m.cover_preview);
setGallery(items => mergeGallery(items, m.gallery ?? []));
}} />
{coverHash && <F95CoverPreview source={coverPreview} editing onRemove={() => { setCoverHash(null); setCoverPreview(null); }} />}
<GameGallery images={gallery} onRemove={hash => setGallery(items => items.filter(image => image.hash !== hash))} />
<label className="field"><span>Название</span><input value={title} onChange={(e) => setTitle(e.target.value)} /></label>
<label className="field"><span>Описание</span><textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={5} /></label>
<label className="field"><span>Теги</span><input value={tags} onChange={(e) => setTags(e.target.value)} placeholder="через запятую: романтика, драма" /></label>
{error && <ErrorNote>{error}</ErrorNote>}
<div className="actions">
<button className="btn btn-primary" disabled={!title.trim()}>Сохранить</button>
<button className="btn btn-primary" disabled={!title.trim() || importing || gallery.length > 48}>Сохранить</button>
<button type="button" className="btn" onClick={() => onDone(false)}>Отмена</button>
</div>
</form>
+9 -3
View File
@@ -5,6 +5,7 @@ import { UploadProgress, useUploads } from "../uploads";
import { ErrorNote, PageHeader } from "../ui/Bits";
import { F95CoverPreview, F95Fetch } from "../ui/F95Fetch";
import { Icon } from "../ui/Icon";
import { GameGallery, type GalleryImage } from "../ui/GameGallery";
const ARCHIVE = /\.(zip|7z|rar|tar|gz|tgz|bz2|xz)$/i;
@@ -15,6 +16,8 @@ export function NewGame() {
const [tags, setTags] = useState("");
const [coverHash, setCoverHash] = useState<string | null>(null);
const [coverPreview, setCoverPreview] = useState<string | null>(null);
const [gallery, setGallery] = useState<GalleryImage[]>([]);
const [importing, setImporting] = useState(false);
const [file, setFile] = useState<File | null>(null);
const [over, setOver] = useState(false);
const { start } = useUploads();
@@ -39,13 +42,14 @@ export function NewGame() {
e.preventDefault();
if (!file) return;
setError("");
start({ file, title, description, coverHash, tags: tags.split(",").map((t) => t.trim()).filter(Boolean) });
start({ file, title, description, coverHash, galleryHashes: gallery.map(image => image.hash), tags: tags.split(",").map((t) => t.trim()).filter(Boolean) });
setFile(null);
setTitle("");
setDescription("");
setTags("");
setCoverHash(null);
setCoverPreview(null);
setGallery([]);
};
return (
@@ -73,14 +77,16 @@ export function NewGame() {
)}
</label>
<F95Fetch onLoaded={(m) => {
<F95Fetch onBusyChange={setImporting} onLoaded={(m) => {
setTitle(m.title);
setDescription(m.description);
setTags(m.tags.join(", "));
setCoverHash(m.cover_hash);
setCoverPreview(m.cover_preview);
setGallery(m.gallery ?? []);
}} />
{coverHash && <F95CoverPreview source={coverPreview} onRemove={() => { setCoverHash(null); setCoverPreview(null); }} />}
<GameGallery images={gallery} onRemove={hash => setGallery(items => items.filter(image => image.hash !== hash))} />
<div className="form-row game-metadata-row">
<label className="field">
<span>Название</span>
@@ -99,7 +105,7 @@ export function NewGame() {
{error && <ErrorNote>{error}</ErrorNote>}
<div className="actions">
<button className="btn btn-primary btn-lg" disabled={!file}>
<button className="btn btn-primary btn-lg" disabled={!file || importing}>
<Icon name="upload" size={20} />Загрузить
</button>
<button type="button" className="btn btn-ghost" onClick={() => navigate(-1)}>Отмена</button>
+9
View File
@@ -730,6 +730,15 @@ input[type="file"] { padding: 0.4rem; }
.f95-cover-image img { width: 100%; height: 100%; object-fit: contain; }
.f95-cover-info { min-width: 0; display: flex; flex-direction: column; gap: 0.4rem; align-items: flex-start; }
.game-metadata-row { align-items: flex-start; }
.game-gallery { min-width: 0; margin-block: var(--space-3); }
.game-gallery-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(150px, 100%), 1fr)); gap: var(--space-3); }
.game-gallery-item { min-width: 0; display: flex; flex-direction: column; align-items: flex-start; }
.game-gallery-thumb { width: 100%; aspect-ratio: 16 / 10; padding: 0; border: 1px solid var(--border); border-radius: var(--radius-s); overflow: hidden; background: var(--surface-2); cursor: zoom-in; }
.game-gallery-thumb img { width: 100%; height: 100%; object-fit: cover; }
.game-gallery-viewer { width: min(1100px, 94vw); max-height: 94dvh; padding: var(--space-3); border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); color: var(--text); }
.game-gallery-viewer::backdrop { background: rgba(0, 0, 0, 0.85); }
.game-gallery-viewer > img { display: block; width: 100%; max-height: 75dvh; object-fit: contain; }
.game-gallery-toolbar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 0.5rem; margin-bottom: var(--space-3); }
@media (max-width: 520px) {
.f95-cover { align-items: flex-start; flex-direction: column; }
.game-metadata-row > .field { flex-basis: 100%; min-width: 0; }
+9 -3
View File
@@ -1,6 +1,7 @@
import { useEffect, useId, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { api } from "../api";
import type { GalleryImage } from "./GameGallery";
export interface F95Meta {
url: string;
@@ -10,6 +11,8 @@ export interface F95Meta {
cover_hash: string | null;
cover_preview: string | null;
cover_warning: string | null;
gallery: GalleryImage[];
gallery_warning: string | null;
auth_required: boolean;
metadata_available: boolean;
}
@@ -18,7 +21,7 @@ interface CookieValues { xf_user: string; xf_session: string; cf_clearance: stri
const EMPTY_COOKIES: CookieValues = { xf_user: "", xf_session: "", cf_clearance: "" };
/** Imports editable metadata and a locally cached cover from an F95zone thread. */
export function F95Fetch({ onLoaded }: { onLoaded: (m: F95Meta) => void }) {
export function F95Fetch({ onLoaded, onBusyChange }: { onLoaded: (m: F95Meta) => void; onBusyChange?: (busy: boolean) => void }) {
const [url, setUrl] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
@@ -28,6 +31,7 @@ export function F95Fetch({ onLoaded }: { onLoaded: (m: F95Meta) => void }) {
const [agent, setAgent] = useState("");
const [result, setResult] = useState<F95Meta | null>(null);
const [notice, setNotice] = useState("");
useEffect(() => { onBusyChange?.(busy); }, [busy, onBusyChange]);
const decline = () => {
setNeedsAuth(false);
@@ -96,13 +100,15 @@ export function F95Fetch({ onLoaded }: { onLoaded: (m: F95Meta) => void }) {
</button>
</div>
<span className="hint">Заполнит поля по теме игры. Изменения сохранятся после отправки формы.</span>
{busy && <div className="note note-info" role="status">Загружаем данные и обложку из F95zone…</div>}
{!busy && result?.metadata_available && <div className={`note ${result.cover_warning ? "note-info" : "note-ok"}`} role="status">
{busy && <div className="note note-info" role="status">Загружаем данные, обложку и галерею из F95zone…</div>}
{!busy && result?.metadata_available && <div className={`note ${result.cover_warning || result.gallery_warning ? "note-info" : "note-ok"}`} role="status">
<div><b>Данные из F95zone загружены</b>
<p className="small">{result.cover_hash && result.cover_preview
? "Название, описание и теги заполнены, обложка подготовлена. Проверьте результат перед сохранением."
: "Название, описание и теги заполнены. Обложку получить не удалось."}</p>
{result.cover_warning && <p className="small">{result.cover_warning}</p>}
{!!result.gallery?.length && <p className="small">Изображений для галереи: {result.gallery.length}.</p>}
{result.gallery_warning && <p className="small">{result.gallery_warning}</p>}
</div>
</div>}
{!busy && !result?.metadata_available && warning && <div className="note note-info" role="status">{warning}</div>}
+59
View File
@@ -0,0 +1,59 @@
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
export interface GalleryImage { hash: string; preview?: string }
export function mergeGallery(current: GalleryImage[], incoming: GalleryImage[]): GalleryImage[] {
const images = new Map(current.map(image => [image.hash, image]));
for (const image of incoming) images.set(image.hash, image);
return [...images.values()];
}
export function GameGallery({ images, onRemove }: { images: GalleryImage[]; onRemove?: (hash: string) => void }) {
const [active, setActive] = useState<string | null>(null);
const index = images.findIndex(image => image.hash === active);
if (!images.length) return null;
return <section className="game-gallery" aria-label="Галерея игры">
<div className="section-title"><h2>Галерея</h2><span className="count">{images.length}</span></div>
{onRemove && <p className="hint">Изображения сохранятся вместе с игрой. Ненужные кадры можно убрать.</p>}
{onRemove && images.length > 48 && <p className="note note-error" role="alert">В галерее можно сохранить до 48 изображений. Уберите лишние кадры.</p>}
<div className="game-gallery-grid">
{images.map((image, i) => <div className="game-gallery-item" key={image.hash}>
<button type="button" className="game-gallery-thumb" onClick={() => setActive(image.hash)} aria-label={`Открыть изображение ${i + 1}`}>
<img src={image.preview ?? `/blobs/${image.hash}`} alt={`Кадр ${i + 1}`} loading="lazy" />
</button>
{onRemove && <button type="button" className="btn btn-ghost btn-sm" onClick={() => onRemove(image.hash)} aria-label={`Убрать изображение ${i + 1}`}>Убрать</button>}
</div>)}
</div>
{index >= 0 && createPortal(<GalleryViewer images={images} index={index}
onSelect={i => setActive(images[i].hash)} onClose={() => setActive(null)} />, document.body)}
</section>;
}
function GalleryViewer({ images, index, onSelect, onClose }: {
images: GalleryImage[]; index: number; onSelect: (index: number) => void; onClose: () => void;
}) {
const dialog = useRef<HTMLDialogElement>(null);
useEffect(() => {
dialog.current?.showModal();
const overflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = overflow; };
}, []);
const move = (step: number) => onSelect((index + step + images.length) % images.length);
const image = images[index];
return <dialog ref={dialog} className="game-gallery-viewer" aria-label="Просмотр галереи"
onCancel={onClose} onClick={e => { if (e.target === e.currentTarget) onClose(); }}
onKeyDown={e => {
if (e.key === "ArrowLeft") { e.preventDefault(); move(-1); }
if (e.key === "ArrowRight") { e.preventDefault(); move(1); }
}}>
<div className="game-gallery-toolbar">
<button type="button" className="btn" onClick={() => move(-1)} disabled={images.length < 2}>Назад</button>
<span aria-live="polite">{index + 1} / {images.length}</span>
<button type="button" className="btn" onClick={() => move(1)} disabled={images.length < 2}>Далее</button>
<button type="button" className="btn" onClick={onClose}>Закрыть</button>
</div>
<img src={image.preview ?? `/blobs/${image.hash}`} alt={`Кадр ${index + 1}`} />
</dialog>;
}
+2
View File
@@ -25,6 +25,7 @@ interface UploadInput {
description?: string;
tags?: string[];
coverHash?: string | null;
galleryHashes?: string[];
}
interface UploadContextValue {
@@ -57,6 +58,7 @@ export function UploadProvider({ children }: { children: ReactNode }) {
title: input.title?.trim() || file.name.replace(/\.(zip|7z|rar|tar.*)$/i, ""),
description: input.description ?? "", tags: input.tags ?? [],
cover_hash: input.coverHash ?? null,
gallery_hashes: input.galleryHashes ?? [],
});
gameId = game.id;
}
+29 -2
View File
@@ -144,16 +144,28 @@ test("new game shows an unsaved F95 cover and aligns metadata fields", {
bundle: true, write: false, format: "iife", platform: "browser", logLevel: "silent"});
const css = readFileSync(new URL("../src/styles.css", import.meta.url), "utf8");
let blobRequests = 0;
const server = createServer((req, res) => {
let createdGame;
const server = createServer(async (req, res) => {
if (req.url === "/styles.css") { res.setHeader("Content-Type", "text/css"); res.end(css); return; }
if (req.url === "/bundle.js") { res.setHeader("Content-Type", "application/javascript"); res.end(compiled.outputFiles[0].text); return; }
if (req.url.startsWith("/blobs/")) { blobRequests++; res.writeHead(404); res.end(); return; }
if (req.url === "/api/games" && req.method === "POST") {
let body = "";
for await (const chunk of req) body += chunk;
createdGame = JSON.parse(body);
res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify({id: 1})); return;
}
if (req.url.startsWith("/api/games/1/upload")) {
res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify({id: 1, status: "ready"})); return;
}
if (req.url === "/api/metadata/f95") {
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({url: "https://f95zone.to/threads/example.123/", title: "Mist", description: "A story",
tags: ["adventure", "survival"], cover_hash: "a".repeat(64) + ".webp",
cover_preview: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jCTkAAAAASUVORK5CYII=",
cover_warning: null, auth_required: false, metadata_available: true})); return;
cover_warning: null, auth_required: false, metadata_available: true,
gallery: ["b", "c"].map(char => ({hash: char.repeat(64) + ".webp",
preview: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jCTkAAAAASUVORK5CYII="}))})); 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>');
@@ -170,6 +182,15 @@ test("new game shows an unsaved F95 cover and aligns metadata fields", {
await page.getByRole("button", {name: "Подтянуть", exact: true}).click();
await page.getByText("Данные из F95zone загружены", {exact: true}).waitFor();
await page.getByRole("img", {name: "Обложка игры из F95zone"}).evaluate(image => image.decode());
const gallery = page.getByRole("region", {name: "Галерея игры"});
await gallery.getByRole("button", {name: "Открыть изображение 1", exact: true}).click();
const viewer = page.getByRole("dialog", {name: "Просмотр галереи"});
await viewer.waitFor();
await viewer.getByRole("img", {name: "Кадр 1", exact: true}).evaluate(image => image.decode());
await viewer.press("ArrowRight");
await viewer.getByRole("img", {name: "Кадр 2", exact: true}).waitFor();
await viewer.press("Escape");
await viewer.waitFor({state: "detached"});
assert.equal(blobRequests, 0, "unsaved covers must not use a route that requires a saved game");
assert.equal(await page.getByRole("textbox", {name: "Название", exact: true}).inputValue(), "Mist");
assert.equal(await page.getByRole("textbox", {name: /^Теги/}).inputValue(), "adventure, survival");
@@ -186,6 +207,12 @@ test("new game shows an unsaved F95 cover and aligns metadata fields", {
await page.getByRole("button", {name: "Убрать обложку", exact: true}).click();
assert.equal(await page.getByRole("img", {name: "Обложка игры из F95zone"}).count(), 0);
assert.equal(await page.getByRole("textbox", {name: "Название", exact: true}).inputValue(), "Mist");
await gallery.getByRole("button", {name: "Убрать изображение 1", exact: true}).click();
assert.equal(await gallery.getByRole("img").count(), 1);
await page.getByRole("button", {name: "Загрузить", exact: true}).click();
await page.getByText("Готово", {exact: true}).waitFor();
assert.deepEqual(createdGame.gallery_hashes, ["c".repeat(64) + ".webp"]);
assert.equal(createdGame.title, "Mist");
} finally {
await browser?.close();
await new Promise(resolve => server.close(resolve));