Compare commits

...

8 Commits

Author SHA1 Message Date
David Fencl
583075a0fd fix: use MAX_TIME_MINUTES constant to resolve unused variable error
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-11 19:28:29 +01:00
David Fencl
065d57a74f fix(docker): implement multi-stage build and serve dist folder
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-11 19:12:53 +01:00
David Fencl
188c7d4bc6 chore(frontend): add time braker
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-26 21:13:10 +01:00
David Fencl
62f1d9e6d8 chore(frontend): fix favicon
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-26 20:52:46 +01:00
David Fencl
2e8cc06e56 chore(frontend): fix favicon 2025-11-26 20:52:42 +01:00
David Fencl
2eb34cba7b chore(frontend): fix favicon
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-26 20:22:51 +01:00
David Fencl
ba9885f1cb chore(frontend): app v1
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-26 20:09:57 +01:00
David Fencl
f52bfe3efe chore(frontend):bracha test
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-15 14:11:59 +01:00
31 changed files with 5042 additions and 31 deletions

23
.gitignore vendored
View File

@@ -1 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea .idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -1,22 +1,27 @@
#FROM php:8.0-apache # Stage 1: Build the React Application
FROM node:20 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Serve the application with Apache
FROM --platform=linux/amd64 php:8.4-apache FROM --platform=linux/amd64 php:8.4-apache
WORKDIR /var/www/html WORKDIR /var/www/html
#COPY index.php index.php # Enable necessary Apache modules
RUN a2enmod rewrite headers
RUN mkdir /app
COPY vhost.conf /etc/apache2/sites-available/000-default.conf COPY vhost.conf /etc/apache2/sites-available/000-default.conf
# COPY adf/saveData.php /var/www/html/saveData.php # Copy the built application from the build stage
COPY --from=build /app/dist /app/dist
WORKDIR /app # Ensure correct permissions
RUN chown -R www-data:www-data /app
COPY . . # Keep original PHP extensions just in case
# ADD extras/dockerstart.sh /usr/local/servicemix/bin/
# RUN chmod 755 /usr/local/bin/dockerstart.sh
RUN chown -R www-data:www-data /app && a2enmod rewrite
RUN docker-php-ext-install mysqli pdo pdo_mysql RUN docker-php-ext-install mysqli pdo pdo_mysql
EXPOSE 80 EXPOSE 80

73
README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

Binary file not shown.

Before

Width:  |  Height:  |  Size: 314 KiB

View File

@@ -1,22 +1,13 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <link rel="icon" type="image/jpeg" href="/logo.jpg" />
<title>DAVISFE</title> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Davis Fencl</title>
</head> </head>
<body> <body>
<b>Davidův</b> <div id="root"></div>
<b>úžasný</b> <script type="module" src="/src/main.tsx"></script>
<p>server</p>
<p>test</p>
<p>test2</p>
<p>test2</p>
<p>test2</p>
<p>test3</p>
<p>test4</p>
<p>test5</p>
<img src="gif/hulkhogan-hulk.gif" alt="Hulk Hogan gif">
</body> </body>
</html> </html>

3288
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "test",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-icons": "^5.5.0",
"react-router-dom": "^7.9.6"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

BIN
public/logo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

42
src/App.css Normal file
View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

31
src/App.tsx Normal file
View File

@@ -0,0 +1,31 @@
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)} />
) : (
<Router>
<Navbar language={language} setLanguage={setLanguage} />
<Routes>
<Route path="/" element={<Home language={language} />} />
<Route path="/time-breaker" element={<TimeBreaker language={language} />} />
</Routes>
</Router>
)}
</>
)
}
export default App

BIN
src/assets/logo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

1
src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

71
src/components/Home.tsx Normal file
View File

@@ -0,0 +1,71 @@
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';
interface HomeProps {
language: Language;
}
const Home: React.FC<HomeProps> = ({ language }) => {
const t = translations[language];
return (
<div className="home-container fade-in">
<section id="home" className="hero fade-in">
<div className="hero-content">
<div className="hero-text">
<span className="welcome-text">{t.welcome}</span>
<h1>{t.hello} <span className="highlight">Davis</span></h1>
<h2 className="job-title">{t.job}</h2>
<p className="description">
{t.desc}
</p>
<div className="hero-footer">
<div className="socials">
<span className="footer-label">{t.findMe}</span>
<div className="icon-group">
<button className="icon-btn"><FaFacebookF /></button>
<button className="icon-btn"><FaInstagram /></button>
<button className="icon-btn"><FaLinkedinIn /></button>
</div>
</div>
<div className="skills">
<span className="footer-label">{t.bestSkill}</span>
<div className="icon-group">
<button className="icon-btn"><SiTypescript /></button>
<button className="icon-btn"><SiReact /></button>
<button className="icon-btn"><SiJavascript /></button>
</div>
</div>
</div>
</div>
<div className="hero-image-container">
<div className="hero-image-placeholder">
<span>Photo</span>
</div>
</div>
</div>
</section>
<section id="about" className="section about fade-in">
<h2>{t.aboutMe}</h2>
<p>
{t.aboutDesc}
</p>
</section>
<section id="contact" className="section contact fade-in">
<h2>{t.getInTouch}</h2>
<p>
{t.contactDesc} <a href="mailto:hello@example.com">{t.emailMe}</a>.
</p>
</section>
</div>
);
};
export default Home;

View File

@@ -0,0 +1,30 @@
import React, { useEffect, useState } from 'react';
import '../index.css';
interface LoadingScreenProps {
onLoaded: () => void;
}
const LoadingScreen: React.FC<LoadingScreenProps> = ({ onLoaded }) => {
const [fading, setFading] = useState(false);
useEffect(() => {
const timer = setTimeout(() => {
setFading(true);
setTimeout(onLoaded, 500); // Wait for fade out animation
}, 2500); // Show loading screen for 2.5 seconds
return () => clearTimeout(timer);
}, [onLoaded]);
return (
<div className={`loading-screen ${fading ? 'fade-out' : ''}`}>
<div className="loader-content">
<div className="spinner"></div>
<h1 className="loading-text">Welcome</h1>
</div>
</div>
);
};
export default LoadingScreen;

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={MAX_TIME_MINUTES}
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>{MAX_TIME_MINUTES}m</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;

366
src/index.css Normal file
View File

@@ -0,0 +1,366 @@
:root {
--bg-color: #0f0f0f;
--text-color: #f0f0f0;
--accent-color: #646cff;
--secondary-color: #a0a0a0;
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: dark;
color: var(--text-color);
background-color: var(--bg-color);
scroll-behavior: smooth;
}
html {
scroll-padding-top: 100px;
/* Offset for sticky header */
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
#root {
width: 100%;
margin: 0 auto;
text-align: center;
}
/* Loading Screen */
.loading-screen {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--bg-color);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
transition: opacity 0.5s ease-out;
}
.loading-screen.fade-out {
opacity: 0;
pointer-events: none;
}
.loader-content {
display: flex;
flex-direction: column;
align-items: center;
}
.spinner {
width: 50px;
height: 50px;
border: 4px solid rgba(255, 255, 255, 0.1);
border-left-color: var(--accent-color);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 20px;
}
.loading-text {
font-size: 1.5rem;
letter-spacing: 2px;
text-transform: uppercase;
animation: pulse 2s infinite;
}
/* Home */
.home-container {
width: 100%;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.fade-in {
animation: fadeIn 1s ease-in;
}
.navbar {
position: sticky;
top: 0;
z-index: 100;
background-color: rgba(15, 15, 15, 0.85);
backdrop-filter: blur(10px);
display: flex;
justify-content: center;
padding: 0.8rem 8rem;
align-items: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.nav-content {
width: 100%;
max-width: 1200px;
display: flex;
justify-content: space-between;
align-items: center;
}
.nav-logo {
height: 120px;
width: auto;
border-radius: 4px;
}
.links a {
margin-left: 2rem;
text-decoration: none;
color: var(--secondary-color);
transition: color 0.3s;
}
.links a:hover {
color: var(--accent-color);
}
.hero {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
padding: 4rem 8rem;
padding-top: 10vh;
text-align: left;
}
.hero-content {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
width: 100%;
max-width: 1200px;
gap: 4rem;
}
.hero-text {
flex: 1;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.welcome-text {
font-size: 0.9rem;
letter-spacing: 3px;
color: var(--secondary-color);
text-transform: uppercase;
margin-bottom: 1rem;
}
.hero h1 {
font-size: 3.5rem;
margin: 0;
line-height: 1.2;
font-weight: 700;
}
.highlight {
color: var(--accent-color);
}
.job-title {
font-size: 3.5rem;
margin: 0;
line-height: 1.2;
font-weight: 700;
color: var(--text-color);
}
.description {
font-size: 1rem;
line-height: 1.8;
color: var(--secondary-color);
max-width: 600px;
margin-top: 1rem;
}
.hero-footer {
display: flex;
justify-content: space-between;
margin-top: 4rem;
gap: 2rem;
flex-wrap: wrap;
}
.footer-label {
display: block;
font-size: 0.8rem;
letter-spacing: 2px;
color: var(--secondary-color);
margin-bottom: 1rem;
text-transform: uppercase;
}
.icon-group {
display: flex;
gap: 1rem;
}
.icon-btn {
width: 54px;
height: 54px;
background: linear-gradient(145deg, #1e2024, #23272b);
box-shadow: 10px 10px 19px #1c1e22, -10px -10px 19px #262a2e;
border: none;
border-radius: 6px;
color: var(--text-color);
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
justify-content: center;
align-items: center;
font-size: 1.3rem;
}
.icon-btn:hover {
background: linear-gradient(145deg, #23272b, #1e2024);
transform: translateY(-2px);
color: var(--accent-color);
}
.hero-image-container {
flex: 0.8;
display: flex;
justify-content: center;
align-items: center;
}
.hero-image-placeholder {
width: 100%;
aspect-ratio: 3/4;
background: linear-gradient(145deg, #1e2024, #23272b);
box-shadow: 10px 10px 19px #1c1e22, -10px -10px 19px #262a2e;
border-radius: 20px;
display: flex;
justify-content: center;
align-items: center;
color: var(--secondary-color);
font-size: 1.2rem;
border: 1px solid rgba(255, 255, 255, 0.05);
}
.nav-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.5rem;
}
.language-switcher {
display: flex;
gap: 0.5rem;
align-items: center;
font-size: 0.9rem;
color: var(--secondary-color);
}
.lang-separator {
opacity: 0.5;
}
.lang-btn {
background: none;
border: none;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
opacity: 0.5;
transition: opacity 0.3s, color 0.3s;
padding: 0;
color: var(--secondary-color);
}
.lang-btn:hover {
opacity: 1;
color: var(--accent-color);
}
.lang-btn.active {
opacity: 1;
color: var(--text-color);
}
@media (max-width: 1024px) {
.hero-content {
flex-direction: column-reverse;
align-items: center;
text-align: center;
}
.hero-text {
align-items: center;
}
.hero-footer {
justify-content: center;
}
.hero-image-container {
width: 80%;
margin-bottom: 2rem;
}
}
.section {
padding: 4rem;
}
.about {
background-color: #1a1a1a;
}
/* Animations */
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes pulse {
0% {
opacity: 0.5;
}
50% {
opacity: 1;
}
100% {
opacity: 0.5;
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 768px) {
.navbar {
padding: 1rem;
}
.hero h1 {
font-size: 2.5rem;
}
}

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

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',
}
};

View File

@@ -1,2 +0,0 @@
test
test2

28
tsconfig.app.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
tsconfig.node.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -3,13 +3,22 @@ LoadModule headers_module /usr/lib/apache2/modules/mod_headers.so
<VirtualHost *:80> <VirtualHost *:80>
ServerName localhost ServerName localhost
DocumentRoot /app DocumentRoot /app/dist
<Directory "/app"> <Directory "/app/dist">
Options Indexes FollowSymLinks Includes execCGI Options Indexes FollowSymLinks Includes execCGI
AllowOverride All AllowOverride All
Require all granted Require all granted
allow from all allow from all
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
</IfModule>
</Directory> </Directory>
ErrorLog ${APACHE_LOG_DIR}/error.log ErrorLog ${APACHE_LOG_DIR}/error.log

7
vite.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})

View File

@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Work Break Timer</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="container">
<h1 class="title">Work Break Timer</h1>
<div class="timer-display" id="timerDisplay">22:00</div>
<div class="controls-section">
<div class="adjustment-buttons">
<button class="btn adjust-btn" data-minutes="1">+1 min</button>
<button class="btn adjust-btn" data-minutes="5">+5 min</button>
<button class="btn adjust-btn" data-minutes="10">+10 min</button>
<button class="btn adjust-btn" data-minutes="30">+30 min</button>
</div>
<div class="main-controls">
<button class="btn control-btn start-btn" id="startBtn">Start</button>
<button class="btn control-btn reset-btn" id="resetBtn">Reset</button>
</div>
</div>
</div>
<!-- Simple beep sound using data URI to avoid external dependencies if possible,
but for better browser compatibility across strict policies, we might need user interaction first.
The requirements said "simple HTML5 audio element or generated beep".
I'll use a generated beep in JS or a simple oscillator, but for the HTML structure,
I'll leave a placeholder or just handle it in JS.
Let's stick to handling it in JS with AudioContext for a generated beep as it's cleaner than a file.
But the plan mentioned an audio element. I'll add a hidden one just in case I want to use a file later,
but I'll primarily use JS for the beep as it's "pure" and doesn't require assets. -->
<script src="main.js"></script>
</body>
</html>

149
work-break-timer/main.js Normal file
View File

@@ -0,0 +1,149 @@
/**
* Work Break Timer Logic
*/
// Constants
const DEFAULT_TIME_MINUTES = 22;
const MAX_TIME_MINUTES = 180;
const SECONDS_PER_MINUTE = 60;
// State
let timeLeft = DEFAULT_TIME_MINUTES * SECONDS_PER_MINUTE; // in seconds
let isRunning = false;
let intervalId = null;
let audioContext = null;
// DOM Elements
const timerDisplay = document.getElementById('timerDisplay');
const startBtn = document.getElementById('startBtn');
const resetBtn = document.getElementById('resetBtn');
const adjustButtons = document.querySelectorAll('.adjust-btn');
/**
* Formats seconds into MM:SS string
*/
function formatTime(seconds) {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
}
/**
* Updates the timer display
*/
function updateDisplay() {
timerDisplay.textContent = formatTime(timeLeft);
// Update document title
document.title = `${formatTime(timeLeft)} - Work Timer`;
}
/**
* Plays a simple beep sound using AudioContext
*/
function playAlarm() {
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
// Create oscillator
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.type = 'sine';
oscillator.frequency.setValueAtTime(880, audioContext.currentTime); // A5
oscillator.frequency.exponentialRampToValueAtTime(440, audioContext.currentTime + 0.5); // Drop to A4
gainNode.gain.setValueAtTime(0.5, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.5);
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.start();
oscillator.stop(audioContext.currentTime + 0.5);
}
/**
* Handles the timer tick
*/
function onTick() {
if (timeLeft > 0) {
timeLeft--;
updateDisplay();
}
else {
// Time is up
pauseTimer();
timerDisplay.classList.add('time-up');
playAlarm();
// Play alarm 3 times
setTimeout(playAlarm, 1000);
setTimeout(playAlarm, 2000);
}
}
/**
* Starts or resumes the timer
*/
function startTimer() {
if (timeLeft === 0) {
// If time is 0, reset to default before starting?
// Or just do nothing? Requirement says: "Start from currently set time".
// If currently set is 0, we can't really start.
// Let's assume if 0, we reset to default for convenience, or just return.
// Let's just return to be safe, or maybe the user wants to add time first.
if (timeLeft === 0)
return;
}
if (!isRunning) {
isRunning = true;
startBtn.textContent = 'Pause';
timerDisplay.classList.remove('time-up');
// Use window.setInterval to avoid TypeScript confusion with NodeJS.Timeout
intervalId = window.setInterval(onTick, 1000);
}
else {
// Pause
pauseTimer();
}
}
/**
* Pauses the timer
*/
function pauseTimer() {
isRunning = false;
startBtn.textContent = 'Resume';
if (intervalId !== null) {
clearInterval(intervalId);
intervalId = null;
}
}
/**
* Resets the timer
*/
function resetTimer() {
pauseTimer();
timeLeft = DEFAULT_TIME_MINUTES * SECONDS_PER_MINUTE;
startBtn.textContent = 'Start';
timerDisplay.classList.remove('time-up');
updateDisplay();
document.title = 'Work Break Timer';
}
/**
* Adjusts the time by adding minutes
*/
function adjustTime(minutes) {
const newTime = timeLeft + (minutes * SECONDS_PER_MINUTE);
const maxSeconds = MAX_TIME_MINUTES * SECONDS_PER_MINUTE;
if (newTime <= maxSeconds) {
timeLeft = newTime;
updateDisplay();
// If timer finished and we add time, remove the alarm style
if (timeLeft > 0) {
timerDisplay.classList.remove('time-up');
}
}
else {
// Cap at max
timeLeft = maxSeconds;
updateDisplay();
}
}
// Event Listeners
startBtn.addEventListener('click', startTimer);
resetBtn.addEventListener('click', resetTimer);
adjustButtons.forEach(btn => {
btn.addEventListener('click', () => {
const minutes = parseInt(btn.getAttribute('data-minutes') || '0', 10);
adjustTime(minutes);
});
});
// Initialize
updateDisplay();

169
work-break-timer/main.ts Normal file
View File

@@ -0,0 +1,169 @@
/**
* Work Break Timer Logic
*/
// Constants
const DEFAULT_TIME_MINUTES = 22;
const MAX_TIME_MINUTES = 180;
const SECONDS_PER_MINUTE = 60;
// State
let timeLeft = DEFAULT_TIME_MINUTES * SECONDS_PER_MINUTE; // in seconds
let isRunning = false;
let intervalId: number | null = null;
let audioContext: AudioContext | null = null;
// DOM Elements
const timerDisplay = document.getElementById('timerDisplay') as HTMLElement;
const startBtn = document.getElementById('startBtn') as HTMLButtonElement;
const resetBtn = document.getElementById('resetBtn') as HTMLButtonElement;
const adjustButtons = document.querySelectorAll('.adjust-btn');
/**
* Formats seconds into MM:SS string
*/
function 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')}`;
}
/**
* Updates the timer display
*/
function updateDisplay(): void {
timerDisplay.textContent = formatTime(timeLeft);
// Update document title
document.title = `${formatTime(timeLeft)} - Work Timer`;
}
/**
* Plays a simple beep sound using AudioContext
*/
function playAlarm(): void {
if (!audioContext) {
audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
}
// Create oscillator
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.type = 'sine';
oscillator.frequency.setValueAtTime(880, audioContext.currentTime); // A5
oscillator.frequency.exponentialRampToValueAtTime(440, audioContext.currentTime + 0.5); // Drop to A4
gainNode.gain.setValueAtTime(0.5, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.5);
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.start();
oscillator.stop(audioContext.currentTime + 0.5);
}
/**
* Handles the timer tick
*/
function onTick(): void {
if (timeLeft > 0) {
timeLeft--;
updateDisplay();
} else {
// Time is up
pauseTimer();
timerDisplay.classList.add('time-up');
playAlarm();
// Play alarm 3 times
setTimeout(playAlarm, 1000);
setTimeout(playAlarm, 2000);
}
}
/**
* Starts or resumes the timer
*/
function startTimer(): void {
if (timeLeft === 0) {
// If time is 0, reset to default before starting?
// Or just do nothing? Requirement says: "Start from currently set time".
// If currently set is 0, we can't really start.
// Let's assume if 0, we reset to default for convenience, or just return.
// Let's just return to be safe, or maybe the user wants to add time first.
if (timeLeft === 0) return;
}
if (!isRunning) {
isRunning = true;
startBtn.textContent = 'Pause';
timerDisplay.classList.remove('time-up');
// Use window.setInterval to avoid TypeScript confusion with NodeJS.Timeout
intervalId = window.setInterval(onTick, 1000);
} else {
// Pause
pauseTimer();
}
}
/**
* Pauses the timer
*/
function pauseTimer(): void {
isRunning = false;
startBtn.textContent = 'Resume';
if (intervalId !== null) {
clearInterval(intervalId);
intervalId = null;
}
}
/**
* Resets the timer
*/
function resetTimer(): void {
pauseTimer();
timeLeft = DEFAULT_TIME_MINUTES * SECONDS_PER_MINUTE;
startBtn.textContent = 'Start';
timerDisplay.classList.remove('time-up');
updateDisplay();
document.title = 'Work Break Timer';
}
/**
* Adjusts the time by adding minutes
*/
function adjustTime(minutes: number): void {
const newTime = timeLeft + (minutes * SECONDS_PER_MINUTE);
const maxSeconds = MAX_TIME_MINUTES * SECONDS_PER_MINUTE;
if (newTime <= maxSeconds) {
timeLeft = newTime;
updateDisplay();
// If timer finished and we add time, remove the alarm style
if (timeLeft > 0) {
timerDisplay.classList.remove('time-up');
}
} else {
// Cap at max
timeLeft = maxSeconds;
updateDisplay();
}
}
// Event Listeners
startBtn.addEventListener('click', startTimer);
resetBtn.addEventListener('click', resetTimer);
adjustButtons.forEach(btn => {
btn.addEventListener('click', () => {
const minutes = parseInt(btn.getAttribute('data-minutes') || '0', 10);
adjustTime(minutes);
});
});
// Initialize
updateDisplay();

144
work-break-timer/styles.css Normal file
View File

@@ -0,0 +1,144 @@
:root {
--bg-color: #f5f5f5;
--text-color: #333;
--primary-color: #2196F3;
--primary-hover: #1976D2;
--secondary-color: #757575;
--secondary-hover: #616161;
--danger-color: #f44336;
--timer-bg: #fff;
--shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: var(--bg-color);
color: var(--text-color);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.container {
background-color: var(--timer-bg);
padding: 2rem;
border-radius: 12px;
box-shadow: var(--shadow);
text-align: center;
width: 90%;
max-width: 400px;
}
.title {
font-size: 1.5rem;
margin-bottom: 1.5rem;
color: var(--text-color);
}
.timer-display {
font-size: 4rem;
font-weight: bold;
font-variant-numeric: tabular-nums;
margin-bottom: 2rem;
padding: 1rem;
border-radius: 8px;
background-color: #fafafa;
transition: background-color 0.3s ease, color 0.3s ease;
}
.timer-display.time-up {
background-color: var(--danger-color);
color: white;
animation: flash 1s infinite;
}
@keyframes flash {
0%, 100% { opacity: 1; }
50% { opacity: 0.8; }
}
.controls-section {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.adjustment-buttons {
display: flex;
justify-content: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.btn {
border: none;
border-radius: 6px;
padding: 0.75rem 1rem;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s, transform 0.1s;
font-weight: 500;
}
.btn:active {
transform: scale(0.98);
}
.adjust-btn {
background-color: #e0e0e0;
color: #333;
font-size: 0.9rem;
padding: 0.5rem 0.75rem;
}
.adjust-btn:hover {
background-color: #d5d5d5;
}
.main-controls {
display: flex;
justify-content: center;
gap: 1rem;
}
.control-btn {
min-width: 100px;
font-size: 1.1rem;
padding: 0.75rem 1.5rem;
}
.start-btn {
background-color: var(--primary-color);
color: white;
}
.start-btn:hover {
background-color: var(--primary-hover);
}
.reset-btn {
background-color: var(--secondary-color);
color: white;
}
.reset-btn:hover {
background-color: var(--secondary-hover);
}
@media (max-width: 480px) {
.timer-display {
font-size: 3rem;
}
.control-btn {
min-width: 80px;
padding: 0.6rem 1.2rem;
}
}