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 { useSettings } from './context/settings';
|
||||
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 { useNavigate } from 'react-router-dom';
|
||||
import Loader from './components/Loader';
|
||||
@@ -668,6 +669,7 @@ function App() {
|
||||
<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` }} />}
|
||||
<Header choices={data?.choices} dayIndex={dayIndex} />
|
||||
{settings?.styleTheme === 'hellgirl' && <HellGirlFlames />}
|
||||
<div className='wrapper'>
|
||||
{data.todayDayIndex != null && data.todayDayIndex > 4 &&
|
||||
<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ů ----------
|
||||
.restaurant-card {
|
||||
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ě. */
|
||||
const RETRY_AFTER_MS = 10 * 60 * 1000;
|
||||
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)}`;
|
||||
|
||||
/** 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 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ší
|
||||
* 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 storeRoutes from "./routes/storeRoutes";
|
||||
import butterflyRoutes from "./routes/butterflyRoutes";
|
||||
import { getConfettiCharacters, getConfettiImage } from "./confetti";
|
||||
import { getConfettiCharacters, getConfettiImage, getHellGirlImages } from "./confetti";
|
||||
import { getTrustedRemoteUser, parseTrustedIps } from "./trustedHeaders";
|
||||
import { getGroupLimits } from "./limits";
|
||||
|
||||
@@ -222,6 +222,10 @@ app.get("/api/confetti/umamusume", (_req, res) => {
|
||||
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) => {
|
||||
try {
|
||||
const id = req.params.id;
|
||||
|
||||
Reference in new issue
Block a user