feat: letní vzhled
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 21s
CI / Build server (push) Successful in 25s
CI / Build client (push) Successful in 37s
CI / Playwright E2E tests (push) Successful in 1m20s
CI / Build and push Docker image (push) Successful in 40s
CI / Notify (push) Successful in 2s

This commit is contained in:
2026-07-07 13:29:28 +02:00
parent 464b14d63a
commit a708eda421
8 changed files with 395 additions and 10 deletions
+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<g stroke="#1B3F73" stroke-width="1.5" stroke-linejoin="round">
<path fill="#4A8FE7" d="M48,32 C30,10 8,14 8,34 C8,50 30,52 48,50 Z"/>
<path fill="#4A8FE7" d="M52,32 C70,10 92,14 92,34 C92,50 70,52 52,50 Z"/>
<path fill="#4A8FE7" d="M47,50 C34,54 16,58 18,74 C20,86 40,84 47,66 Z"/>
<path fill="#4A8FE7" d="M53,50 C66,54 84,58 82,74 C80,86 60,84 53,66 Z"/>
</g>
<g fill="#BBD7FF" stroke="none">
<circle cx="20" cy="30" r="4"/>
<circle cx="80" cy="30" r="4"/>
<circle cx="30" cy="70" r="3"/>
<circle cx="70" cy="70" r="3"/>
</g>
<g stroke="#17233B" stroke-width="1.6" fill="none" stroke-linecap="round">
<path d="M49,28 C44,18 40,14 36,11"/>
<path d="M51,28 C56,18 60,14 64,11"/>
</g>
<g fill="#17233B" stroke="none">
<circle cx="36" cy="11" r="1.8"/>
<circle cx="64" cy="11" r="1.8"/>
<rect x="47.5" y="24" width="5" height="52" rx="2.5"/>
<circle cx="50" cy="24" r="4.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<g stroke="#7A3B00" stroke-width="1.5" stroke-linejoin="round">
<path fill="#F59E1E" d="M48,32 C30,10 8,14 8,34 C8,50 30,52 48,50 Z"/>
<path fill="#F59E1E" d="M52,32 C70,10 92,14 92,34 C92,50 70,52 52,50 Z"/>
<path fill="#F59E1E" d="M47,50 C34,54 16,58 18,74 C20,86 40,84 47,66 Z"/>
<path fill="#F59E1E" d="M53,50 C66,54 84,58 82,74 C80,86 60,84 53,66 Z"/>
</g>
<g fill="#FFE0A3" stroke="none">
<circle cx="20" cy="30" r="4"/>
<circle cx="80" cy="30" r="4"/>
<circle cx="30" cy="70" r="3"/>
<circle cx="70" cy="70" r="3"/>
</g>
<g stroke="#2B1A0B" stroke-width="1.6" fill="none" stroke-linecap="round">
<path d="M49,28 C44,18 40,14 36,11"/>
<path d="M51,28 C56,18 60,14 64,11"/>
</g>
<g fill="#2B1A0B" stroke="none">
<circle cx="36" cy="11" r="1.8"/>
<circle cx="64" cy="11" r="1.8"/>
<rect x="47.5" y="24" width="5" height="52" rx="2.5"/>
<circle cx="50" cy="24" r="4.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<g stroke="#8A2255" stroke-width="1.5" stroke-linejoin="round">
<path fill="#F06B9B" d="M48,32 C30,10 8,14 8,34 C8,50 30,52 48,50 Z"/>
<path fill="#F06B9B" d="M52,32 C70,10 92,14 92,34 C92,50 70,52 52,50 Z"/>
<path fill="#F06B9B" d="M47,50 C34,54 16,58 18,74 C20,86 40,84 47,66 Z"/>
<path fill="#F06B9B" d="M53,50 C66,54 84,58 82,74 C80,86 60,84 53,66 Z"/>
</g>
<g fill="#FFD1E3" stroke="none">
<circle cx="20" cy="30" r="4"/>
<circle cx="80" cy="30" r="4"/>
<circle cx="30" cy="70" r="3"/>
<circle cx="70" cy="70" r="3"/>
</g>
<g stroke="#3B0E24" stroke-width="1.6" fill="none" stroke-linecap="round">
<path d="M49,28 C44,18 40,14 36,11"/>
<path d="M51,28 C56,18 60,14 64,11"/>
</g>
<g fill="#3B0E24" stroke="none">
<circle cx="36" cy="11" r="1.8"/>
<circle cx="64" cy="11" r="1.8"/>
<rect x="47.5" y="24" width="5" height="52" rx="2.5"/>
<circle cx="50" cy="24" r="4.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<g stroke="#8A6D00" stroke-width="1.5" stroke-linejoin="round">
<path fill="#FFD23F" d="M48,32 C30,10 8,14 8,34 C8,50 30,52 48,50 Z"/>
<path fill="#FFD23F" d="M52,32 C70,10 92,14 92,34 C92,50 70,52 52,50 Z"/>
<path fill="#FFD23F" d="M47,50 C34,54 16,58 18,74 C20,86 40,84 47,66 Z"/>
<path fill="#FFD23F" d="M53,50 C66,54 84,58 82,74 C80,86 60,84 53,66 Z"/>
</g>
<g fill="#FFF3C4" stroke="none">
<circle cx="20" cy="30" r="4"/>
<circle cx="80" cy="30" r="4"/>
<circle cx="30" cy="70" r="3"/>
<circle cx="70" cy="70" r="3"/>
</g>
<g stroke="#3A2E00" stroke-width="1.6" fill="none" stroke-linecap="round">
<path d="M49,28 C44,18 40,14 36,11"/>
<path d="M51,28 C56,18 60,14 64,11"/>
</g>
<g fill="#3A2E00" stroke="none">
<circle cx="36" cy="11" r="1.8"/>
<circle cx="64" cy="11" r="1.8"/>
<rect x="47.5" y="24" width="5" height="52" rx="2.5"/>
<circle cx="50" cy="24" r="4.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+3 -9
View File
@@ -1,7 +1,7 @@
import { Routes, Route } from "react-router-dom"; import { Routes, Route } from "react-router-dom";
import { ProvideSettings } from "./context/settings"; import { ProvideSettings } from "./context/settings";
// import Snowfall from "react-snowfall"; import FlyingButterflies, { BUTTERFLY_PRESETS } from "./FlyingButterflies";
import { SnowOverlay } from 'react-snow-overlay'; import "./FlyingButterflies.scss";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import { SocketContext, socket } from "./context/socket"; import { SocketContext, socket } from "./context/socket";
import StatsPage from "./pages/StatsPage"; import StatsPage from "./pages/StatsPage";
@@ -34,13 +34,7 @@ export default function AppRoutes() {
<ProvideSettings> <ProvideSettings>
<SocketContext.Provider value={socket}> <SocketContext.Provider value={socket}>
<> <>
{/* <Snowfall style={{ <FlyingButterflies numButterflies={BUTTERFLY_PRESETS.NORMAL} />
zIndex: 2,
position: 'fixed',
width: '100vw',
height: '100vh'
}} /> */}
<SnowOverlay color={'rgba(240, 240, 240, 0.9)'} disabledOnSingleCpuDevices={true} />
<App /> <App />
</> </>
<ToastContainer /> <ToastContainer />
+56
View File
@@ -0,0 +1,56 @@
.flying-butterflies {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
pointer-events: none;
z-index: 2;
overflow: hidden;
}
.butterfly-scene {
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 100%;
// Vnější element pozici a natočení nastavuje JS přes transform
> div {
position: absolute;
top: 0;
left: 0;
width: 34px;
height: 34px;
will-change: transform;
}
}
// Vnitřní element se sprite motýla máchá křídly (scaleX kolem svislé osy těla)
.butterfly-sprite {
width: 100%;
height: 100%;
background-repeat: no-repeat;
background-position: center;
background-size: contain;
animation: butterfly-flap 0.45s ease-in-out infinite;
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.15));
}
@keyframes butterfly-flap {
0%,
100% {
transform: scaleX(1);
}
50% {
transform: scaleX(0.35);
}
}
// Ohleduplnost k uživatelům, kteří nechtějí pohyb
@media (prefers-reduced-motion: reduce) {
.butterfly-sprite {
animation: none;
}
}
+238
View File
@@ -0,0 +1,238 @@
import React, { useEffect, useRef, useCallback } from 'react';
// Různé barevné varianty motýlů
const BUTTERFLY_VARIANTS = [
'butterfly-orange.svg', // Oranžová (monarcha)
'butterfly-blue.svg', // Modrá
'butterfly-yellow.svg', // Žlutá (otakárek)
'butterfly-pink.svg', // Růžová
] as const;
interface ButterflyData {
/** Vnější element nese pozici, natočení a velikost */
el: HTMLDivElement;
/** Vnitřní element nese CSS animaci mávání křídly */
sprite: HTMLDivElement;
x: number;
y: number;
/** Směr letu vodorovně: +1 doprava, -1 doleva */
dir: number;
/** Vodorovná rychlost (px/snímek) */
speed: number;
/** Velikost (měřítko sprite) */
size: number;
// Dvě sinusovky pro přirozené zvlněné stoupání a klesání
bobFreq1: number;
bobPhase1: number;
bobAmp1: number;
bobFreq2: number;
bobPhase2: number;
bobAmp2: number;
}
interface FlyingButterfliesProps {
/** Počet poletujících motýlů (výchozí: 9) */
numButterflies?: number;
/** CSS třída pro kontejner (výchozí: 'flying-butterflies') */
className?: string;
/** Barevné varianty motýlů k použití (výchozí: všechny) */
butterflyVariants?: readonly string[];
}
class ButterflyScene {
private viewport: HTMLElement;
private world: HTMLDivElement;
private butterflies: ButterflyData[] = [];
private numButterflies: number;
private variants: readonly string[];
private width: number;
private height: number;
private timer: number = 0;
private animationId: number | null = null;
private handleResize: () => void;
// Základní velikost sprite v px (dále se násobí náhodným měřítkem)
private static readonly BASE_SIZE = 34;
constructor(el: HTMLElement, numButterflies: number = 9, variants: readonly string[] = BUTTERFLY_VARIANTS) {
this.viewport = el;
this.world = document.createElement('div');
this.numButterflies = numButterflies;
this.variants = variants;
this.width = this.viewport.offsetWidth;
this.height = this.viewport.offsetHeight;
this.handleResize = () => {
this.width = this.viewport.offsetWidth;
this.height = this.viewport.offsetHeight;
};
}
// Nastaví motýlovi nové náhodné parametry a umístí ho na okraj obrazovky
private resetButterfly = (b: ButterflyData): void => {
// Vletí zleva nebo zprava
b.dir = Math.random() > 0.5 ? 1 : -1;
b.speed = Math.random() * 1.1 + 0.7; // 0.7 1.8 px/snímek
// Na začátku (timer 0) rozprostřeme motýly po celé šířce, ať není obrazovka prázdná
if (this.timer === 0) {
b.x = Math.random() * this.width;
} else {
b.x = b.dir === 1 ? -40 : this.width + 40;
}
b.y = Math.random() * (this.height - 60) + 30;
b.size = Math.random() * 0.6 + 0.6; // 0.6 1.2
// Dvě sinusovky s různou frekvencí → nepravidelné, měkké křivky letu
b.bobFreq1 = Math.random() * 0.02 + 0.03;
b.bobPhase1 = Math.random() * Math.PI * 2;
b.bobAmp1 = Math.random() * 0.8 + 0.6;
b.bobFreq2 = Math.random() * 0.015 + 0.01;
b.bobPhase2 = Math.random() * Math.PI * 2;
b.bobAmp2 = Math.random() * 0.6 + 0.4;
// Náhodná varianta a rychlost mávání křídly
const variant = this.variants[Math.floor(Math.random() * this.variants.length)];
b.sprite.style.backgroundImage = `url(${variant})`;
b.sprite.style.animationDuration = `${Math.random() * 0.25 + 0.3}s`; // 0.3 0.55 s
};
private updateButterfly = (b: ButterflyData): void => {
const vx = b.dir * b.speed;
// Svislá rychlost jako součet dvou sinusovek přirozené plachtění
const vy =
Math.sin(this.timer * b.bobFreq1 + b.bobPhase1) * b.bobAmp1 +
Math.sin(this.timer * b.bobFreq2 + b.bobPhase2) * b.bobAmp2;
b.x += vx;
b.y += vy;
// Sprite natočíme po směru letu (motýl je nakreslený hlavou nahoru)
const angle = Math.atan2(vy, vx) * (180 / Math.PI) + 90;
b.el.style.transform = `translate(${b.x}px, ${b.y}px) rotate(${angle}deg) scale(${b.size})`;
// Reset po opuštění obrazovky (s rezervou na natočení)
if (
(b.dir === 1 && b.x > this.width + 50) ||
(b.dir === -1 && b.x < -50) ||
b.y < -60 ||
b.y > this.height + 60
) {
this.resetButterfly(b);
}
};
public init = (): void => {
this.butterflies = [];
this.world.innerHTML = '';
this.world.className = 'butterfly-scene';
for (let i = 0; i < this.numButterflies; i++) {
const el = document.createElement('div');
const sprite = document.createElement('div');
sprite.className = 'butterfly-sprite';
el.appendChild(sprite);
const b: ButterflyData = {
el,
sprite,
x: 0,
y: 0,
dir: 1,
speed: 1,
size: 1,
bobFreq1: 0,
bobPhase1: 0,
bobAmp1: 0,
bobFreq2: 0,
bobPhase2: 0,
bobAmp2: 0,
};
this.resetButterfly(b);
this.butterflies.push(b);
this.world.appendChild(el);
}
this.viewport.appendChild(this.world);
window.addEventListener('resize', this.handleResize);
};
public render = (): void => {
for (let i = 0; i < this.butterflies.length; i++) {
this.updateButterfly(this.butterflies[i]);
}
this.timer++;
this.animationId = requestAnimationFrame(this.render);
};
public destroy = (): void => {
if (this.animationId) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
if (this.world && this.world.parentNode) {
this.world.parentNode.removeChild(this.world);
}
window.removeEventListener('resize', this.handleResize);
};
}
/**
* Komponenta pro zobrazení poletujících motýlů na pozadí stránky.
* Motýli plachtí vodorovně po měkkých zvlněných křivkách a mávají křídly.
*
* @param numButterflies - Počet motýlů (výchozí: 9)
* @param className - CSS třída pro kontejner (výchozí: 'flying-butterflies')
* @param butterflyVariants - Barevné varianty motýlů (výchozí: všechny)
*/
const FlyingButterflies: React.FC<FlyingButterfliesProps> = ({
numButterflies = 9,
className = 'flying-butterflies',
butterflyVariants = BUTTERFLY_VARIANTS,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const sceneRef = useRef<ButterflyScene | null>(null);
const initialize = useCallback(() => {
if (containerRef.current) {
sceneRef.current = new ButterflyScene(containerRef.current, numButterflies, butterflyVariants);
sceneRef.current.init();
sceneRef.current.render();
}
}, [numButterflies, butterflyVariants]);
useEffect(() => {
initialize();
return () => {
if (sceneRef.current) {
sceneRef.current.destroy();
sceneRef.current = null;
}
};
}, [initialize]);
return <div ref={containerRef} className={className} />;
};
// Přednastavení množství motýlů pro různé účely
export const BUTTERFLY_PRESETS = {
LIGHT: 5, // Pár motýlů
NORMAL: 9, // Standardní množství
HEAVY: 16, // Rušná letní louka
} as const;
// Přednastavené barevné kombinace
export const BUTTERFLY_COLOR_THEMES = {
ALL: BUTTERFLY_VARIANTS,
WARM: ['butterfly-orange.svg', 'butterfly-yellow.svg', 'butterfly-pink.svg'] as const,
COOL: ['butterfly-blue.svg'] as const,
} as const;
export default FlyingButterflies;
+2 -1
View File
@@ -1,5 +1,6 @@
[ [
"Oprava: uložení preferovaného času odchodu i u podniku bez načteného menu", "Oprava: uložení preferovaného času odchodu i u podniku bez načteného menu",
"Oprava: načítání denního menu podniku TechTower po změně jejich webu", "Oprava: načítání denního menu podniku TechTower po změně jejich webu",
"Oprava: rychlé klikání na šipky přepínání dní už nepřeskočí mimo pracovní týden" "Oprava: rychlé klikání na šipky přepínání dní už nepřeskočí mimo pracovní týden",
"Letní vzhled: místo padajícího sněhu teď po stránce poletují motýli"
] ]