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

This commit is contained in:
Ondřej Anděl committed 2026-10-12 14:34:55 +02:00
1 parent 5147651e7c
commit 2a54a1d41f
7 files changed
+198 -2

No files matched your search

+2
View File
@@ -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">
+24
View File
@@ -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; }
}
+81
View File
@@ -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;
+20
View File
@@ -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);
+48
View File
@@ -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;
});