feat: HG mode - změna obrázků, animace v modálech
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 27s
CI / Build server (push) Successful in 29s
CI / Build client (push) Successful in 43s
CI / Playwright E2E tests (push) Successful in 2m2s
CI / Build and push Docker image (push) Successful in 45s
CI / Notify (push) Successful in 2s
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 27s
CI / Build server (push) Successful in 29s
CI / Build client (push) Successful in 43s
CI / Playwright E2E tests (push) Successful in 2m2s
CI / Build and push Docker image (push) Successful in 45s
CI / Notify (push) Successful in 2s
This commit is contained in:
1 parent
5147651e7c
commit
2a54a1d41f
7 files changed
+198
-2
No files matched your search
@@ -16,6 +16,7 @@ import './themes/hellgirl.scss';
|
|||||||
import { faCircleCheck, faNoteSticky, faTrashCan, faComment } from '@fortawesome/free-regular-svg-icons';
|
import { faCircleCheck, faNoteSticky, faTrashCan, faComment } from '@fortawesome/free-regular-svg-icons';
|
||||||
import { useSettings } from './context/settings';
|
import { useSettings } from './context/settings';
|
||||||
import Footer from './components/Footer';
|
import Footer from './components/Footer';
|
||||||
|
import HellGirlFlames from './components/HellGirlFlames';
|
||||||
import { faArrowUpRightFromSquare, faBasketShopping, faChainBroken, faChevronLeft, faChevronRight, faDharmachakra, faGear, faMoneyBillTransfer, faSatelliteDish, faSearch, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
|
import { faArrowUpRightFromSquare, faBasketShopping, faChainBroken, faChevronLeft, faChevronRight, faDharmachakra, faGear, faMoneyBillTransfer, faSatelliteDish, faSearch, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import Loader from './components/Loader';
|
import Loader from './components/Loader';
|
||||||
@@ -668,6 +669,7 @@ function App() {
|
|||||||
<div className="app-container">
|
<div className="app-container">
|
||||||
{easterEgg && eggImage && <img ref={eggRef} alt='' src={URL.createObjectURL(eggImage)} style={{ position: 'absolute', ...EASTER_EGG_STYLE, ...style, animationDuration: `${duration ?? EASTER_EGG_DEFAULT_DURATION}s` }} />}
|
{easterEgg && eggImage && <img ref={eggRef} alt='' src={URL.createObjectURL(eggImage)} style={{ position: 'absolute', ...EASTER_EGG_STYLE, ...style, animationDuration: `${duration ?? EASTER_EGG_DEFAULT_DURATION}s` }} />}
|
||||||
<Header choices={data?.choices} dayIndex={dayIndex} />
|
<Header choices={data?.choices} dayIndex={dayIndex} />
|
||||||
|
{settings?.styleTheme === 'hellgirl' && <HellGirlFlames />}
|
||||||
<div className='wrapper'>
|
<div className='wrapper'>
|
||||||
{data.todayDayIndex != null && data.todayDayIndex > 4 &&
|
{data.todayDayIndex != null && data.todayDayIndex > 4 &&
|
||||||
<Alert variant="info" className="mb-3">
|
<Alert variant="info" className="mb-3">
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Plamen stylu Hell Girl — hoří na černé přes obsah dialogu (.modal-content je relative a ve stylu
|
||||||
|
// Hell Girl ořezává přetečení), pak černá odezní a odhalí obsah.
|
||||||
|
.hg-burn {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background: #020204; // barva pozadí GIFu, aby kolem plamene nebyl vidět obdélník
|
||||||
|
pointer-events: none;
|
||||||
|
animation: hg-burn-reveal 1.5s ease-in forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hg-burn-flame {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
// Dlouhý dialog může přesahovat obrazovku — plamen držet ve viditelné části
|
||||||
|
top: min(50%, 45vh);
|
||||||
|
height: min(85%, 60vh);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hg-burn-reveal {
|
||||||
|
0%, 50% { opacity: 1; }
|
||||||
|
100% { opacity: 0; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { createSymbolMask, loadHellGirlImages } from "../utils/hellGirlImages";
|
||||||
|
import "./HellGirlFlames.scss";
|
||||||
|
|
||||||
|
/** Plamen vzplane a rozpadne se v jiskry za ~1,2 s, pak GIF drží černý snímek — mezitím se obsah vynoří z černé. */
|
||||||
|
const BURN_MS = 1500;
|
||||||
|
|
||||||
|
/** `target` = obsah dialogu, ve kterém se hoří. */
|
||||||
|
type Burn = { src: string, target: HTMLElement };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Doplňky stylu Hell Girl: při otevření dialogu jeho obsah zčerná, uprostřed jednou
|
||||||
|
* vzplane plamen („Ippen, shinde miru?") a obsah se pak z černé vynoří. Dále symbol ohně ze znělky
|
||||||
|
* u titulku dne (nastaví CSS proměnnou --hg-symbol, dokud se nenačte, zůstávají v titulku původní květy).
|
||||||
|
*/
|
||||||
|
const HellGirlFlames: React.FC = () => {
|
||||||
|
const [burning, setBurning] = useState<Burn>();
|
||||||
|
const flameBlobRef = useRef<Blob | undefined>(undefined);
|
||||||
|
|
||||||
|
const burn = (target: HTMLElement) => {
|
||||||
|
const blob = flameBlobRef.current;
|
||||||
|
if (!blob || window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return;
|
||||||
|
// Pokaždé nová URL — jinak by prohlížeč GIF (bez opakování) znovu nepřehrál
|
||||||
|
setBurning({ src: URL.createObjectURL(blob), target });
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!burning) return;
|
||||||
|
const timeout = setTimeout(() => setBurning(undefined), BURN_MS);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
URL.revokeObjectURL(burning.src);
|
||||||
|
};
|
||||||
|
}, [burning]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const root = document.documentElement;
|
||||||
|
loadHellGirlImages().then(images => {
|
||||||
|
if (!images || cancelled) return;
|
||||||
|
fetch(images.flame)
|
||||||
|
.then(r => r.ok ? r.blob() : undefined)
|
||||||
|
.then(blob => { flameBlobRef.current = blob; })
|
||||||
|
.catch(() => { /* bez plamenů */ });
|
||||||
|
createSymbolMask(images.symbol).then(mask => {
|
||||||
|
if (!mask || cancelled) return;
|
||||||
|
root.style.setProperty('--hg-symbol', `url(${mask})`);
|
||||||
|
root.setAttribute('data-hg-symbol', '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
root.style.removeProperty('--hg-symbol');
|
||||||
|
root.removeAttribute('data-hg-symbol');
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Dialogy (react-bootstrap) se vkládají přes portál přímo do <body>
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new MutationObserver(mutations => {
|
||||||
|
const modalContent = mutations
|
||||||
|
.flatMap(m => Array.from(m.addedNodes))
|
||||||
|
.map(node => node instanceof HTMLElement ? node.querySelector<HTMLElement>('.modal-content') : null)
|
||||||
|
.find(element => !!element);
|
||||||
|
if (modalContent) burn(modalContent);
|
||||||
|
});
|
||||||
|
observer.observe(document.body, { childList: true });
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!burning) return null;
|
||||||
|
return createPortal(
|
||||||
|
<div className="hg-burn" aria-hidden="true">
|
||||||
|
<img className="hg-burn-flame" src={burning.src} alt="" />
|
||||||
|
</div>,
|
||||||
|
burning.target,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HellGirlFlames;
|
||||||
@@ -198,6 +198,26 @@ $hg-thread: linear-gradient(90deg, transparent, #c8102e 12%, #e8344f 50%, #c8102
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Symbol ohně ze znělky místo květů (masku dodá HellGirlFlames, až se obrázek načte)
|
||||||
|
&[data-hg-symbol] .day-navigator .title {
|
||||||
|
&::before, &::after {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 1em;
|
||||||
|
width: 0.85em;
|
||||||
|
height: 0.85em;
|
||||||
|
margin: 0 14px;
|
||||||
|
vertical-align: -0.08em;
|
||||||
|
background: linear-gradient(180deg, var(--hg-dusk), var(--hg-higanbana) 45%, var(--hg-crimson-deep));
|
||||||
|
-webkit-mask: var(--hg-symbol) center / contain no-repeat;
|
||||||
|
mask: var(--hg-symbol) center / contain no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.text-muted::before, &.text-muted::after {
|
||||||
|
background: #b3a19d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Karty podniků ----------
|
// ---------- Karty podniků ----------
|
||||||
.restaurant-card {
|
.restaurant-card {
|
||||||
border: 1px solid var(--hg-card-border);
|
border: 1px solid var(--hg-card-border);
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/** Obrázky stylu Hell Girl (plamen + symbol ohně) — server je cachuje, viz server/src/confetti.ts. */
|
||||||
|
const HELLGIRL_IMAGES_URL = '/api/confetti/hellgirl';
|
||||||
|
|
||||||
|
export type HellGirlImages = { flame: string, symbol: string };
|
||||||
|
|
||||||
|
let imagesPromise: Promise<HellGirlImages | undefined> | undefined;
|
||||||
|
|
||||||
|
/** Načte URL obrázků (jednou za běh aplikace). Při chybě vrátí undefined a příště to zkusí znovu. */
|
||||||
|
export const loadHellGirlImages = (): Promise<HellGirlImages | undefined> => {
|
||||||
|
imagesPromise ??= fetch(HELLGIRL_IMAGES_URL)
|
||||||
|
.then(r => r.ok ? r.json() : undefined)
|
||||||
|
.then(images => images?.flame && images?.symbol ? images as HellGirlImages : undefined)
|
||||||
|
.catch(() => undefined)
|
||||||
|
.then(images => {
|
||||||
|
if (!images) imagesPromise = undefined;
|
||||||
|
return images;
|
||||||
|
});
|
||||||
|
return imagesPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Z obrázku symbolu (rudý tvar na bílém pozadí) udělá masku — průhlednost podle toho, jak
|
||||||
|
* moc pixel není bílý (zelený kanál). Barvu pak symbolu dodá CSS gradientem v paletě stylu.
|
||||||
|
*/
|
||||||
|
export const createSymbolMask = (src: string, size = 96): Promise<string | undefined> => new Promise(resolve => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = canvas.height = size;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return resolve(undefined);
|
||||||
|
ctx.drawImage(img, 0, 0, size, size);
|
||||||
|
const pixels = ctx.getImageData(0, 0, size, size);
|
||||||
|
const data = pixels.data;
|
||||||
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
|
data[i + 3] = 255 - data[i + 1];
|
||||||
|
data[i] = data[i + 1] = data[i + 2] = 0;
|
||||||
|
}
|
||||||
|
ctx.putImageData(pixels, 0, 0);
|
||||||
|
resolve(canvas.toDataURL('image/png'));
|
||||||
|
} catch {
|
||||||
|
resolve(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
img.onerror = () => resolve(undefined);
|
||||||
|
img.src = src;
|
||||||
|
});
|
||||||
+18
-1
@@ -278,6 +278,15 @@ export const UMA_CHARACTERS: { name: string, quote?: string, round?: boolean, im
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obrázky stylu Hell Girl — plamen (jednorázová animace při otevření dialogu a změně dne)
|
||||||
|
* a symbol ohně ze znělky (ozdoba u titulku dne). Cachují se stejně jako obrázky postav.
|
||||||
|
*/
|
||||||
|
const HELLGIRL_IMAGES = {
|
||||||
|
flame: 'https://www.jigokutsushin.it/images/flame.gif',
|
||||||
|
symbol: 'https://images.seeklogo.com/logo-png/6/1/hell-girl-fire-symbol-logo-png_seeklogo-66173.png',
|
||||||
|
};
|
||||||
|
|
||||||
/** Po neúspěšném stažení obrázku se další pokus smí provést až po této době. */
|
/** Po neúspěšném stažení obrázku se další pokus smí provést až po této době. */
|
||||||
const RETRY_AFTER_MS = 10 * 60 * 1000;
|
const RETRY_AFTER_MS = 10 * 60 * 1000;
|
||||||
const MAX_IMAGE_BYTES = 2 * 1024 * 1024;
|
const MAX_IMAGE_BYTES = 2 * 1024 * 1024;
|
||||||
@@ -290,7 +299,10 @@ const imageKey = (url: string) => `confetti_img_${imageId(url)}`;
|
|||||||
const imageUrl = (url: string) => `/api/confetti/image/${imageId(url)}`;
|
const imageUrl = (url: string) => `/api/confetti/image/${imageId(url)}`;
|
||||||
|
|
||||||
/** Povolené obrázky: ID → zdrojová URL (nic jiného server nestáhne). */
|
/** Povolené obrázky: ID → zdrojová URL (nic jiného server nestáhne). */
|
||||||
const ALLOWED_IMAGES = new Map(UMA_CHARACTERS.flatMap(c => c.icon ? [...c.images, c.icon] : c.images).map(url => [imageId(url), url]));
|
const ALLOWED_IMAGES = new Map([
|
||||||
|
...UMA_CHARACTERS.flatMap(c => c.icon ? [...c.images, c.icon] : c.images),
|
||||||
|
...Object.values(HELLGIRL_IMAGES),
|
||||||
|
].map(url => [imageId(url), url]));
|
||||||
const failedAt = new Map<string, number>();
|
const failedAt = new Map<string, number>();
|
||||||
const inFlight = new Map<string, Promise<CachedImage | undefined>>();
|
const inFlight = new Map<string, Promise<CachedImage | undefined>>();
|
||||||
|
|
||||||
@@ -305,6 +317,11 @@ export function getConfettiCharacters() {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Obrázky stylu Hell Girl pro klienta — vedou na cachující proxy tohoto serveru. */
|
||||||
|
export function getHellGirlImages() {
|
||||||
|
return { flame: imageUrl(HELLGIRL_IMAGES.flame), symbol: imageUrl(HELLGIRL_IMAGES.symbol) };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vrátí obrázek konfet dle jeho ID. Poprvé ho stáhne a uloží do storage, další
|
* Vrátí obrázek konfet dle jeho ID. Poprvé ho stáhne a uloží do storage, další
|
||||||
* požadavky (i při výpadku internetu) obslouží z cache. Stahují se jen URL ze seznamu výše.
|
* požadavky (i při výpadku internetu) obslouží z cache. Stahují se jen URL ze seznamu výše.
|
||||||
|
|||||||
+5
-1
@@ -29,7 +29,7 @@ import changelogRoutes from "./routes/changelogRoutes";
|
|||||||
import groupRoutes from "./routes/groupRoutes";
|
import groupRoutes from "./routes/groupRoutes";
|
||||||
import storeRoutes from "./routes/storeRoutes";
|
import storeRoutes from "./routes/storeRoutes";
|
||||||
import butterflyRoutes from "./routes/butterflyRoutes";
|
import butterflyRoutes from "./routes/butterflyRoutes";
|
||||||
import { getConfettiCharacters, getConfettiImage } from "./confetti";
|
import { getConfettiCharacters, getConfettiImage, getHellGirlImages } from "./confetti";
|
||||||
import { getTrustedRemoteUser, parseTrustedIps } from "./trustedHeaders";
|
import { getTrustedRemoteUser, parseTrustedIps } from "./trustedHeaders";
|
||||||
import { getGroupLimits } from "./limits";
|
import { getGroupLimits } from "./limits";
|
||||||
|
|
||||||
@@ -222,6 +222,10 @@ app.get("/api/confetti/umamusume", (_req, res) => {
|
|||||||
res.set('Cache-Control', 'no-cache').json(getConfettiCharacters());
|
res.set('Cache-Control', 'no-cache').json(getConfettiCharacters());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get("/api/confetti/hellgirl", (_req, res) => {
|
||||||
|
res.set('Cache-Control', 'no-cache').json(getHellGirlImages());
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/api/confetti/image/:id", async (req, res, next) => {
|
app.get("/api/confetti/image/:id", async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const id = req.params.id;
|
const id = req.params.id;
|
||||||
|
|||||||
Reference in new issue
Block a user