diff --git a/client/src/App.tsx b/client/src/App.tsx
index 9c53486..885ce9d 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -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() {
{easterEgg && eggImage &&
})
}
+ {settings?.styleTheme === 'hellgirl' &&
}
{data.todayDayIndex != null && data.todayDayIndex > 4 &&
diff --git a/client/src/components/HellGirlFlames.scss b/client/src/components/HellGirlFlames.scss
new file mode 100644
index 0000000..cdc07c9
--- /dev/null
+++ b/client/src/components/HellGirlFlames.scss
@@ -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; }
+}
diff --git a/client/src/components/HellGirlFlames.tsx b/client/src/components/HellGirlFlames.tsx
new file mode 100644
index 0000000..2923e7e
--- /dev/null
+++ b/client/src/components/HellGirlFlames.tsx
@@ -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();
+ const flameBlobRef = useRef(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
+ useEffect(() => {
+ const observer = new MutationObserver(mutations => {
+ const modalContent = mutations
+ .flatMap(m => Array.from(m.addedNodes))
+ .map(node => node instanceof HTMLElement ? node.querySelector('.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(
+
+

+
,
+ burning.target,
+ );
+};
+
+export default HellGirlFlames;
diff --git a/client/src/themes/hellgirl.scss b/client/src/themes/hellgirl.scss
index 66a7752..650a266 100644
--- a/client/src/themes/hellgirl.scss
+++ b/client/src/themes/hellgirl.scss
@@ -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);
diff --git a/client/src/utils/hellGirlImages.ts b/client/src/utils/hellGirlImages.ts
new file mode 100644
index 0000000..7ac0b27
--- /dev/null
+++ b/client/src/utils/hellGirlImages.ts
@@ -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 | 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 => {
+ 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 => 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;
+});
diff --git a/server/src/confetti.ts b/server/src/confetti.ts
index 59818eb..43bd604 100644
--- a/server/src/confetti.ts
+++ b/server/src/confetti.ts
@@ -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();
const inFlight = new Map>();
@@ -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.
diff --git a/server/src/index.ts b/server/src/index.ts
index 728a012..eabfc4e 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -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;