chore(frontend): add time braker
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
David Fencl
2025-11-26 21:13:10 +01:00
parent 62f1d9e6d8
commit 188c7d4bc6
10 changed files with 969 additions and 82 deletions

View File

@@ -1,17 +1,28 @@
import { useState } from 'react'
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
import LoadingScreen from './components/LoadingScreen'
import Home from './components/Home'
import Navbar from './components/Navbar'
import TimeBreaker from './components/TimeBreaker'
import { type Language } from './translations'
import './App.css'
function App() {
const [isLoading, setIsLoading] = useState(true)
const [language, setLanguage] = useState<Language>('en')
return (
<>
{isLoading ? (
<LoadingScreen onLoaded={() => setIsLoading(false)} />
) : (
<Home />
<Router>
<Navbar language={language} setLanguage={setLanguage} />
<Routes>
<Route path="/" element={<Home language={language} />} />
<Route path="/time-breaker" element={<TimeBreaker language={language} />} />
</Routes>
</Router>
)}
</>
)

View File

@@ -1,92 +1,17 @@
import React, { useState } from 'react';
import logo from '../assets/logo.jpg';
import React from 'react';
import { FaFacebookF, FaInstagram, FaLinkedinIn } from 'react-icons/fa';
import { SiTypescript, SiReact, SiJavascript } from 'react-icons/si';
import { translations, type Language } from '../translations';
type Language = 'en' | 'cs';
interface HomeProps {
language: Language;
}
const translations = {
en: {
home: 'Home',
about: 'About',
contact: 'Contact',
welcome: 'WELCOME TO MY WORLD',
hello: "Hello, I'm",
job: 'a Developer.',
desc: "I use animation as a third dimension by which to simplify experiences and guiding through each and every interaction. I'm not adding motion just to spruce things up, but doing it in ways that matter.",
findMe: 'FIND WITH ME',
bestSkill: 'BEST SKILL ON',
aboutMe: 'About Me',
aboutDesc: 'I build accessible, pixel-perfect, and performant web experiences. Passionate about technology and design.',
getInTouch: 'Get In Touch',
contactDesc: 'Interested in working together?',
emailMe: 'Email me',
},
cs: {
home: 'Domů',
about: 'O mně',
contact: 'Kontakt',
welcome: 'VÍTEJTE V MÉM SVĚTĚ',
hello: "Ahoj, jsem",
job: 'Vývojář.',
desc: "Používám animaci jako třetí rozměr, kterým zjednodušuji zážitky a provázím každou interakcí. Nepřidávám pohyb jen pro efekt, ale dělám to způsoby, které mají smysl.",
findMe: 'NAJDETE MĚ NA',
bestSkill: 'DOVEDNOSTI',
aboutMe: 'O mně',
aboutDesc: 'Tvořím přístupné, pixel-perfect a výkonné webové zážitky. Vášnivý pro technologie a design.',
getInTouch: 'Napište mi',
contactDesc: 'Máte zájem o spolupráci?',
emailMe: 'Napište mi',
}
};
const Home: React.FC = () => {
const [language, setLanguage] = useState<Language>('en');
const Home: React.FC<HomeProps> = ({ language }) => {
const t = translations[language];
const handleNavClick = (id: string, e: React.MouseEvent) => {
e.preventDefault();
const element = document.getElementById(id);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
}
};
return (
<div className="home-container fade-in">
<nav className="navbar">
<div className="nav-content">
<div
className="logo-container"
onClick={(e) => handleNavClick('home', e)}
style={{ cursor: 'pointer' }}
>
<img src={logo} alt="David Fencl Logo" className="nav-logo" />
</div>
<div className="nav-right">
<div className="language-switcher">
<button
className={`lang-btn ${language === 'en' ? 'active' : ''}`}
onClick={() => setLanguage('en')}
>
EN
</button>
<span className="lang-separator">/</span>
<button
className={`lang-btn ${language === 'cs' ? 'active' : ''}`}
onClick={() => setLanguage('cs')}
>
CZ
</button>
</div>
<div className="links">
<a href="#about" onClick={(e) => handleNavClick('about', e)}>{t.about}</a>
<a href="#contact" onClick={(e) => handleNavClick('contact', e)}>{t.contact}</a>
</div>
</div>
</div>
</nav>
<section id="home" className="hero fade-in">
<div className="hero-content">
<div className="hero-text">
@@ -143,3 +68,4 @@ const Home: React.FC = () => {
};
export default Home;

81
src/components/Navbar.tsx Normal file
View File

@@ -0,0 +1,81 @@
import React from 'react';
import logo from '../assets/logo.jpg';
import { translations, type Language } from '../translations';
import { Link, useLocation, useNavigate } from 'react-router-dom';
interface NavbarProps {
language: Language;
setLanguage: (lang: Language) => void;
}
const Navbar: React.FC<NavbarProps> = ({ language, setLanguage }) => {
const t = translations[language];
const location = useLocation();
const navigate = useNavigate();
const handleNavClick = (id: string, e: React.MouseEvent) => {
e.preventDefault();
if (location.pathname !== '/') {
navigate('/');
// Wait for navigation then scroll
setTimeout(() => {
const element = document.getElementById(id);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
}
}, 100);
} else {
const element = document.getElementById(id);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
}
}
};
const handleHomeClick = (e: React.MouseEvent) => {
e.preventDefault();
if (location.pathname !== '/') {
navigate('/');
} else {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
};
return (
<nav className="navbar">
<div className="nav-content">
<div
className="logo-container"
onClick={handleHomeClick}
style={{ cursor: 'pointer' }}
>
<img src={logo} alt="David Fencl Logo" className="nav-logo" />
</div>
<div className="nav-right">
<div className="language-switcher">
<button
className={`lang-btn ${language === 'en' ? 'active' : ''}`}
onClick={() => setLanguage('en')}
>
EN
</button>
<span className="lang-separator">/</span>
<button
className={`lang-btn ${language === 'cs' ? 'active' : ''}`}
onClick={() => setLanguage('cs')}
>
CZ
</button>
</div>
<div className="links">
<Link to="/time-breaker">{t.timeBreaker}</Link>
<a href="#about" onClick={(e) => handleNavClick('about', e)}>{t.about}</a>
<a href="#contact" onClick={(e) => handleNavClick('contact', e)}>{t.contact}</a>
</div>
</div>
</div>
</nav>
);
};
export default Navbar;

View File

@@ -0,0 +1,162 @@
.timer-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
max-width: 500px;
margin: 0 auto;
padding: 2rem;
background-color: rgba(30, 32, 36, 0.5);
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
}
.timer-display {
font-size: 5rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
margin: 2rem 0;
padding: 1rem 2rem;
border-radius: 8px;
background-color: rgba(0, 0, 0, 0.2);
color: var(--text-color);
transition: background-color 0.3s ease, color 0.3s ease;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}
.timer-display.time-up {
background-color: rgba(244, 67, 54, 0.2);
color: #f44336;
animation: flash 1s infinite;
border: 1px solid rgba(244, 67, 54, 0.5);
}
@keyframes flash {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.8;
}
}
.controls-section {
display: flex;
flex-direction: column;
gap: 2rem;
width: 100%;
}
.slider-container {
width: 100%;
padding: 0 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.time-slider {
-webkit-appearance: none;
width: 100%;
height: 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.1);
outline: none;
cursor: pointer;
}
.time-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--accent-color);
cursor: pointer;
transition: transform 0.2s;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
}
.time-slider::-webkit-slider-thumb:hover {
transform: scale(1.1);
background: #747bff;
}
.time-slider::-moz-range-thumb {
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--accent-color);
cursor: pointer;
border: none;
transition: transform 0.2s;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
}
.time-slider::-moz-range-thumb:hover {
transform: scale(1.1);
background: #747bff;
}
.slider-labels {
display: flex;
justify-content: space-between;
color: var(--secondary-color);
font-size: 0.8rem;
font-weight: 500;
}
.main-controls {
display: flex;
justify-content: center;
gap: 1.5rem;
}
.control-btn {
min-width: 120px;
font-size: 1.1rem;
padding: 0.8rem 2rem;
text-transform: uppercase;
letter-spacing: 1px;
}
.start-btn {
background-color: var(--accent-color);
color: white;
box-shadow: 0 4px 15px rgba(100, 108, 255, 0.3);
}
.start-btn:hover {
background-color: #747bff;
box-shadow: 0 6px 20px rgba(100, 108, 255, 0.4);
transform: translateY(-2px);
}
.reset-btn {
background-color: transparent;
border: 1px solid var(--secondary-color);
color: var(--secondary-color);
}
.reset-btn:hover {
border-color: var(--text-color);
color: var(--text-color);
background-color: rgba(255, 255, 255, 0.05);
}
@media (max-width: 480px) {
.timer-display {
font-size: 3.5rem;
}
.control-btn {
min-width: 100px;
padding: 0.7rem 1.5rem;
font-size: 1rem;
}
}

View File

@@ -0,0 +1,163 @@
import React, { useState, useEffect, useRef } from 'react';
import { translations, type Language } from '../translations';
import './TimeBreaker.css';
interface TimeBreakerProps {
language: Language;
}
const DEFAULT_TIME_MINUTES = 22;
const MAX_TIME_MINUTES = 180;
const SECONDS_PER_MINUTE = 60;
const TimeBreaker: React.FC<TimeBreakerProps> = ({ language }) => {
const t = translations[language];
const [timeLeft, setTimeLeft] = useState(DEFAULT_TIME_MINUTES * SECONDS_PER_MINUTE);
const [isRunning, setIsRunning] = useState(false);
const [isTimeUp, setIsTimeUp] = useState(false);
const audioContextRef = useRef<AudioContext | null>(null);
const intervalRef = useRef<number | null>(null);
// Format time as MM:SS
const formatTime = (seconds: number): string => {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
};
// Update document title
useEffect(() => {
document.title = `${formatTime(timeLeft)} - ${t.timeBreaker}`;
return () => {
document.title = 'David Fencl - IT Consulting';
};
}, [timeLeft, t.timeBreaker]);
// Timer logic
useEffect(() => {
if (isRunning && timeLeft > 0) {
intervalRef.current = window.setInterval(() => {
setTimeLeft((prev) => {
if (prev <= 1) {
handleTimeUp();
return 0;
}
return prev - 1;
});
}, 1000);
} else if (timeLeft === 0 && isRunning) {
handleTimeUp();
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [isRunning]);
const handleTimeUp = () => {
setIsRunning(false);
setIsTimeUp(true);
playAlarm();
// Play alarm 3 times
setTimeout(playAlarm, 1000);
setTimeout(playAlarm, 2000);
};
const playAlarm = () => {
if (!audioContextRef.current) {
audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
}
const ctx = audioContextRef.current;
if (!ctx) return;
// Create oscillator
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.type = 'sine';
oscillator.frequency.setValueAtTime(880, ctx.currentTime); // A5
oscillator.frequency.exponentialRampToValueAtTime(440, ctx.currentTime + 0.5); // Drop to A4
gainNode.gain.setValueAtTime(0.5, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.5);
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
oscillator.start();
oscillator.stop(ctx.currentTime + 0.5);
};
const handleStart = () => {
if (timeLeft === 0) return;
setIsRunning(true);
setIsTimeUp(false);
};
const handlePause = () => {
setIsRunning(false);
};
const handleReset = () => {
setIsRunning(false);
setIsTimeUp(false);
setTimeLeft(DEFAULT_TIME_MINUTES * SECONDS_PER_MINUTE);
};
return (
<div className="home-container fade-in">
<section className="hero">
<div className="timer-container">
<h1 style={{ marginBottom: '1rem' }}>{t.timeBreaker}</h1>
<div className={`timer-display ${isTimeUp ? 'time-up' : ''}`}>
{formatTime(timeLeft)}
</div>
<div className="controls-section">
<div className="slider-container">
<input
type="range"
min="0"
max="180"
value={Math.ceil(timeLeft / 60)}
onChange={(e) => {
const minutes = parseInt(e.target.value, 10);
setTimeLeft(minutes * 60);
if (minutes > 0) setIsTimeUp(false);
}}
className="time-slider"
/>
<div className="slider-labels">
<span>0m</span>
<span>180m</span>
</div>
</div>
<div className="main-controls">
{!isRunning ? (
<button className="btn control-btn start-btn" onClick={handleStart}>
{timeLeft > 0 && timeLeft < DEFAULT_TIME_MINUTES * SECONDS_PER_MINUTE ? 'Resume' : 'Start'}
</button>
) : (
<button className="btn control-btn start-btn" onClick={handlePause}>
Pause
</button>
)}
<button className="btn control-btn reset-btn" onClick={handleReset}>
Reset
</button>
</div>
</div>
</div>
</section>
</div>
);
};
export default TimeBreaker;

38
src/translations.ts Normal file
View File

@@ -0,0 +1,38 @@
export type Language = 'en' | 'cs';
export const translations = {
en: {
home: 'Home',
about: 'About',
contact: 'Contact',
timeBreaker: 'Time-Breaker',
welcome: 'WELCOME TO MY WORLD',
hello: "Hello, I'm",
job: 'a Developer.',
desc: "I use animation as a third dimension by which to simplify experiences and guiding through each and every interaction. I'm not adding motion just to spruce things up, but doing it in ways that matter.",
findMe: 'FIND WITH ME',
bestSkill: 'BEST SKILL ON',
aboutMe: 'About Me',
aboutDesc: 'I build accessible, pixel-perfect, and performant web experiences. Passionate about technology and design.',
getInTouch: 'Get In Touch',
contactDesc: 'Interested in working together?',
emailMe: 'Email me',
},
cs: {
home: 'Domů',
about: 'O mně',
contact: 'Kontakt',
timeBreaker: 'Time-Breaker',
welcome: 'VÍTEJTE V MÉM SVĚTĚ',
hello: "Ahoj, jsem",
job: 'Vývojář.',
desc: "Používám animaci jako třetí rozměr, kterým zjednodušuji zážitky a provázím každou interakcí. Nepřidávám pohyb jen pro efekt, ale dělám to způsoby, které mají smysl.",
findMe: 'NAJDETE MĚ NA',
bestSkill: 'DOVEDNOSTI',
aboutMe: 'O mně',
aboutDesc: 'Tvořím přístupné, pixel-perfect a výkonné webové zážitky. Vášnivý pro technologie a design.',
getInTouch: 'Napište mi',
contactDesc: 'Máte zájem o spolupráci?',
emailMe: 'Napište mi',
}
};