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;