diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 0000000..aeb1e60 --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,12 @@ +module.exports = { + semi: true, + tabWidth: 4, + useTabs: false, + singleQuote: true, + trailingComma: 'es5', + bracketSpacing: true, + arrowParens: 'avoid', + printWidth: 120, + endOfLine: 'lf', + quoteProps: 'as-needed', +}; diff --git a/images/cancel.svg b/asset/images/cancel.svg similarity index 100% rename from images/cancel.svg rename to asset/images/cancel.svg diff --git a/images/edit.svg b/asset/images/edit.svg similarity index 100% rename from images/edit.svg rename to asset/images/edit.svg diff --git a/asset/images/exit-full-screen.svg b/asset/images/exit-full-screen.svg new file mode 100644 index 0000000..8c1fb8f --- /dev/null +++ b/asset/images/exit-full-screen.svg @@ -0,0 +1,3 @@ + diff --git a/asset/images/favicon.ico b/asset/images/favicon.ico new file mode 100644 index 0000000..37eaa29 Binary files /dev/null and b/asset/images/favicon.ico differ diff --git a/images/full-screen.svg b/asset/images/full-screen.svg similarity index 100% rename from images/full-screen.svg rename to asset/images/full-screen.svg diff --git a/images/ok.svg b/asset/images/ok.svg similarity index 100% rename from images/ok.svg rename to asset/images/ok.svg diff --git a/images/pause.svg b/asset/images/pause.svg similarity index 100% rename from images/pause.svg rename to asset/images/pause.svg diff --git a/images/play.svg b/asset/images/play.svg similarity index 100% rename from images/play.svg rename to asset/images/play.svg diff --git a/images/reset.svg b/asset/images/reset.svg similarity index 100% rename from images/reset.svg rename to asset/images/reset.svg diff --git a/asset/javascript/TimeController.js b/asset/javascript/TimeController.js new file mode 100644 index 0000000..e3213bc --- /dev/null +++ b/asset/javascript/TimeController.js @@ -0,0 +1,337 @@ +import './helper.js'; +import { TimerStatus } from './TimerStatus.js'; +import { + formatTimeUnit, + hourToSeconds, + minuteToSeconds, + remainingSeconds, + secondsToHour, + secondsToMinute, +} from './TimeUtils.js'; + +function TimerController(reference) { + const hourInput = reference.querySelector('.js-hour-input'); + const minuteInput = reference.querySelector('.js-minute-input'); + const secondInput = reference.querySelector('.js-seconds-input'); + + const actionButtonsContainer = reference.querySelector('.js-stopwatch-action-buttons'); + const enterFullscreenButton = actionButtonsContainer.querySelector('.js-enter-fullscreen-button'); + const exitFullscreenButton = actionButtonsContainer.querySelector('.js-exit-fullscreen-button'); + const startButton = actionButtonsContainer.querySelector('.js-start-button'); + const stopButton = actionButtonsContainer.querySelector('.js-stop-button'); + const pauseButton = actionButtonsContainer.querySelector('.js-pause-button'); + const editButton = actionButtonsContainer.querySelector('.js-edit-button'); + + const editActionButtonsContainer = reference.querySelector('.js-edit-container-stopwatch'); + const cancelEditButton = editActionButtonsContainer.querySelector('.js-cancel-edit-button'); + const finishEditButton = editActionButtonsContainer.querySelector('.js-finish-edit-button'); + + const countdownContainerReference = reference.querySelector('.js-countdown-container'); + const countdownNumber = countdownContainerReference.querySelector('.js-countdown-number'); + const closeCountdownButton = countdownContainerReference.querySelector('.js-close-countdown-button'); + + const DEFAULT_INTERVAL = 1000; + const DEFAULT_SECONDS = 30; + + const tickTackSound = new Audio('./asset/sound/tick-tack.wav'); + const stopSound = new Audio('./asset/sound/stop.mp3'); + + let lastTimerStatus = TimerStatus.STOPPED; + let previousTimerValue = DEFAULT_SECONDS; + let timerIntervalId = null; + let preventOpenCountdown = false; + + function init() { + bindInputs(); + bindButtons(); + setInputValues(DEFAULT_SECONDS); + } + + var bindInputs = function () { + hourInput.addEventListener('input', function () { + let maxHours = 99; + validateInput(hourInput, maxHours); + }); + + hourInput.addEventListener('keydown', function (event) { + if (event.key === 'Enter') { + event.preventDefault(); + event.stopPropagation(); + minuteInput.focus(); + } + }); + + minuteInput.addEventListener('input', function () { + let maxMinutes = 59; + validateInput(minuteInput, maxMinutes); + }); + + minuteInput.addEventListener('keydown', function (event) { + if (event.key === 'Enter') { + event.preventDefault(); + event.stopPropagation(); + secondInput.focus(); + } + }); + + secondInput.addEventListener('input', function () { + let maxSeconds = 59; + validateInput(secondInput, maxSeconds); + }); + + secondInput.addEventListener('keydown', function (event) { + if (event.key === 'Enter') { + event.preventDefault(); + event.stopPropagation(); + finishEditInput(); + startButton.focus(); + } + }); + }; + + function validateInput(input, maxValue) { + let value = parseInt(input.value) || 0; + + if (value < 0) value = 0; + if (value > maxValue) value = maxValue; + + input.value = formatTimeUnit(value); + } + + function bindButtons() { + editButton.addEventListener('click', openEditInput); + cancelEditButton.addEventListener('click', cancelEditInput); + finishEditButton.addEventListener('click', finishEditInput); + + startButton.addEventListener('click', start); + stopButton.addEventListener('click', stop); + pauseButton.addEventListener('click', pause); + + enterFullscreenButton.addEventListener('click', handleFullscreen); + exitFullscreenButton.addEventListener('click', handleFullscreen); + closeCountdownButton.addEventListener('click', closeCountdownContainer); + } + + function openEditInput() { + lastTimerStatus = TimerStatus.EDITING; + previousTimerValue = getInputsValueAsSeconds(); + + toggleDisableInputs(false); + toggleButtonsContainer(true); + + setTimeout(() => { + hourInput.focus(); + }, 10); + } + + function cancelEditInput() { + setInputValues(previousTimerValue); + finishEditInput(); + } + + function finishEditInput() { + lastTimerStatus = TimerStatus.PAUSED; + toggleDisableInputs(true); + toggleButtonsContainer(false); + window.getSelection().removeAllRanges(); + + let seconds = getInputsValueAsSeconds(); + + if (seconds <= 0) { + setInputValues(DEFAULT_SECONDS); + } + } + + function start() { + const canStart = TimerStatus.isStopped(lastTimerStatus) || TimerStatus.isPaused(lastTimerStatus); + + if (!canStart) return; + + startButton.hideElement(); + pauseButton.showElement(); + stopButton.showElement(); + editButton.hideElement(); + + lastTimerStatus = TimerStatus.RUNNING; + initTimer(); + } + + function initTimer() { + timerIntervalId = setInterval(() => { + var canStart = + TimerStatus.isRunning(lastTimerStatus) || + TimerStatus.isCountdown(lastTimerStatus) || + TimerStatus.isPaused(lastTimerStatus); + + if (canStart) { + let seconds = getInputsValueAsSeconds(); + seconds--; + + if (seconds <= 10) { + lastTimerStatus = TimerStatus.COUNTDOWN; + playCountdownSound(); + + if (!preventOpenCountdown) executeCountdown(seconds); + } + + setInputValues(seconds); + + if (seconds == 0) { + preventOpenCountdown = false; + lastTimerStatus = TimerStatus.STOPPED; + + showDefaultButtons(); + playStopSound(); + clearInterval(timerIntervalId); + } + } else { + clearInterval(timerIntervalId); + } + }, DEFAULT_INTERVAL); + } + + function playCountdownSound() { + tickTackSound.volume = 0.5; + tickTackSound.loop = false; + tickTackSound.currentTime = 0; + tickTackSound.play(); + } + + function playStopSound() { + stopSound.volume = 0.5; + stopSound.loop = false; + stopSound.currentTime = 0; + stopSound.play(); + + setTimeout(() => { + const fade = setInterval(() => { + if (stopSound.volume > 0.05) { + stopSound.volume -= 0.05; + } else { + stopSound.volume = 0; + stopSound.pause(); + stopSound.currentTime = 0; + clearInterval(fade); + setInputValues(DEFAULT_SECONDS); + preventOpenCountdown = false; + } + }, 200); + }, 3000); + } + + function executeCountdown(seconds) { + countdownContainerReference.showElement(); + countdownNumber.textContent = seconds; + + if (seconds % 2 === 0) { + countdownContainerReference.classList.add('even'); + countdownContainerReference.classList.remove('odd'); + return; + } + + countdownContainerReference.classList.add('odd'); + countdownContainerReference.classList.remove('even'); + } + + function stop() { + if (TimerStatus.isStopped(lastTimerStatus)) return; + + preventOpenCountdown = false; + lastTimerStatus = TimerStatus.STOPPED; + showDefaultButtons(); + setInputValues(DEFAULT_SECONDS); + clearInterval(timerIntervalId); + } + + function pause() { + if (lastTimerStatus === TimerStatus.RUNNING || lastTimerStatus === TimerStatus.COUNTDOWN) { + lastTimerStatus = TimerStatus.PAUSED; + startButton.showElement(); + pauseButton.hideElement(); + clearInterval(timerIntervalId); + } + } + + function handleFullscreen() { + if (!document.fullscreenElement) { + exitFullscreenButton.showElement(); + enterFullscreenButton.hideElement(); + + document.documentElement.requestFullscreen(); + return; + } + + enterFullscreenButton.showElement(); + exitFullscreenButton.hideElement(); + + document.exitFullscreen(); + } + + function closeCountdownContainer() { + preventOpenCountdown = true; + countdownContainerReference.hideElement(); + countdownContainerReference.classList.remove('even', 'odd'); + countdownNumber.textContent = ''; + stopSound.pause(); + stopSound.currentTime = 0; + stopSound.volume = 0; + } + + function toggleButtonsContainer(isEditing) { + if (isEditing) { + actionButtonsContainer.hideElement(); + editActionButtonsContainer.showElement(); + } else { + actionButtonsContainer.showElement(); + editActionButtonsContainer.hideElement(); + } + } + + function toggleDisableInputs(disable) { + hourInput.disabled = disable; + minuteInput.disabled = disable; + secondInput.disabled = disable; + } + + function showDefaultButtons() { + startButton.showElement(); + pauseButton.hideElement(); + stopButton.hideElement(); + editButton.showElement(); + } + + function getInputsValueAsSeconds() { + const { seconds, minutes, hours } = getInputValues(); + + const minutesAsSeconds = minuteToSeconds(minutes); + const hourAsSeconds = hourToSeconds(hours); + + return seconds + minutesAsSeconds + hourAsSeconds; + } + + function getInputValues() { + const hours = parseInt(hourInput.value) || 0; + const minutes = parseInt(minuteInput.value) || 0; + const seconds = parseInt(secondInput.value) || 0; + + return { seconds, minutes, hours }; + } + + function setInputValues(totalSeconds = 0) { + const hours = secondsToHour(totalSeconds); + const minutes = secondsToMinute(totalSeconds); + const seconds = remainingSeconds(totalSeconds); + + hourInput.value = formatTimeUnit(hours); + minuteInput.value = formatTimeUnit(minutes); + secondInput.value = formatTimeUnit(seconds); + } + + init(); +} + +document.addEventListener('DOMContentLoaded', function () { + let reference = document.querySelector('.js-body'); + const timerController = new TimerController(reference); + window.timerController = timerController; +}); diff --git a/asset/javascript/TimeUtils.js b/asset/javascript/TimeUtils.js new file mode 100644 index 0000000..ceb7492 --- /dev/null +++ b/asset/javascript/TimeUtils.js @@ -0,0 +1,32 @@ +const hourToSeconds = hour => { + return hour * 3600; +}; + +const minuteToSeconds = minute => { + return minute * 60; +}; + +const secondsToHour = seconds => { + return Math.floor(seconds / 3600); +}; + +const secondsToMinute = seconds => { + return Math.floor((seconds % 3600) / 60); +}; + +const remainingSeconds = seconds => { + return seconds % 60; +}; + +const formatTimeUnit = value => { + return value.toString().padStart(2, '0').slice(-2); +}; + +export { + hourToSeconds, + minuteToSeconds, + secondsToHour, + secondsToMinute, + remainingSeconds, + formatTimeUnit, +}; diff --git a/asset/javascript/TimerStatus.js b/asset/javascript/TimerStatus.js new file mode 100644 index 0000000..6f92340 --- /dev/null +++ b/asset/javascript/TimerStatus.js @@ -0,0 +1,11 @@ +export const TimerStatus = { + STOPPED: 'STOPPED', + PAUSED: 'PAUSED', + COUNTDOWN: 'COUNTDOWN', + RUNNING: 'RUNNING', + EDITING: 'EDITING', + isRunning: status => status === TimerStatus.RUNNING, + isCountdown: status => status === TimerStatus.COUNTDOWN, + isPaused: status => status === TimerStatus.PAUSED, + isStopped: status => status === TimerStatus.STOPPED, +}; diff --git a/asset/javascript/helper.js b/asset/javascript/helper.js new file mode 100644 index 0000000..e5ab8b2 --- /dev/null +++ b/asset/javascript/helper.js @@ -0,0 +1,11 @@ +HTMLElement.prototype.showElement = function showElement() { + if (this.classList.contains('hide')) { + this.classList.remove('hide'); + } +}; + +HTMLElement.prototype.hideElement = function hideElement() { + if (!this.classList.contains('hide')) { + this.classList.add('hide'); + } +}; diff --git a/asset/sound/stop.mp3 b/asset/sound/stop.mp3 new file mode 100644 index 0000000..3fcd229 Binary files /dev/null and b/asset/sound/stop.mp3 differ diff --git a/asset/sound/tick-tack.wav b/asset/sound/tick-tack.wav new file mode 100644 index 0000000..41817d8 Binary files /dev/null and b/asset/sound/tick-tack.wav differ diff --git a/asset/style/timer.css b/asset/style/timer.css new file mode 100644 index 0000000..17a8128 --- /dev/null +++ b/asset/style/timer.css @@ -0,0 +1,137 @@ +@import url("https://fonts.googleapis.com/css2?family=Orbitron:wght@400..900&display=swap"); +:root { + --primary-bg-color: #242424; + --primary-text-color: #46ffbe; + --secondary-bg-color: #46ffbe; + --secondary-text-color: #444444; +} + +* { + font-family: "Orbitron", sans-serif; + box-sizing: border-box; +} + +body, +html { + background-color: var(--primary-bg-color); + color: var(--primary-text-color); + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 48px; + margin: 0; + height: 100dvh; + width: 100dvw; +} +body::-moz-selection, html::-moz-selection { + background: var(--secondary-bg-color); + color: var(--secondary-text-color); +} +body::selection, +html::selection { + background: var(--secondary-bg-color); + color: var(--secondary-text-color); +} +body ::-moz-selection, +html ::-moz-selection { + background: var(--secondary-bg-color); + color: var(--secondary-text-color); +} + +.countdown-container { + width: 100%; + height: 100%; + position: absolute; + background: var(--primary-bg-color); + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + transition: all 0.5s ease; +} +.countdown-container .countdown-number { + font-size: 202px; + transition: all 0.5s ease; +} +.countdown-container.odd { + background: var(--secondary-bg-color); + color: var(--secondary-text-color); +} +.countdown-container.even { + background: var(--primary-bg-color); + color: var(--primary-text-color); +} + +.input-stopwatch-container { + width: 100%; + display: flex; + justify-content: center; + transition: all 0.5s ease; + font-size: 112px; +} +@media screen and (max-width: 768px) { + .input-stopwatch-container { + font-size: 56px; + } +} +.input-stopwatch-container input { + width: auto; + min-width: 1px; + max-width: 2ch; + margin: 0; + padding: 0; + color: var(--primary-text-color); + border: none; + background: none; + outline: none; + text-align: center; + font-size: inherit; + border-radius: 5px; +} +.input-stopwatch-container input::-webkit-outer-spin-button, .input-stopwatch-container input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} +.input-stopwatch-container input[type=number] { + appearance: none; + -webkit-appearance: none; + -moz-appearance: textfield; +} + +.action-button-container { + display: flex; + flex-direction: row; + gap: 32px; +} +.action-button-container button { + display: flex; + align-items: center; + justify-content: center; + padding: 0; + height: 32px; + width: 32px; + background-color: transparent; + border: 1px solid white; + border-radius: 99px; + color: inherit; + cursor: pointer; + transition: all 0.5s ease; +} +.action-button-container button img { + max-width: 100%; + max-height: 100%; + display: block; +} +.action-button-container button:hover { + background-color: rgba(255, 255, 255, 0.15); +} +.action-button-container button:active, .action-button-container button:focus, .action-button-container button:focus-visible, .action-button-container button:focus-within { + box-shadow: 0 0 10px var(--secondary-bg-color); + background-color: rgba(255, 255, 255, 0.15); + outline: none; +} + +.hide { + display: none !important; +}/*# sourceMappingURL=timer.css.map */ \ No newline at end of file diff --git a/asset/style/timer.css.map b/asset/style/timer.css.map new file mode 100644 index 0000000..0c5bc96 --- /dev/null +++ b/asset/style/timer.css.map @@ -0,0 +1 @@ +{"version":3,"sources":["timer.scss","timer.css"],"names":[],"mappings":"AAAQ,2FAAA;AAER;EACI,2BAAA;EACA,6BAAA;EACA,6BAAA;EACA,+BAAA;ACAJ;;ADGA;EACI,mCAAA;EACA,sBAAA;ACAJ;;ADGA;;EAEI,yCAAA;EACA,gCAAA;EACA,aAAA;EACA,sBAAA;EACA,uBAAA;EACA,mBAAA;EACA,SAAA;EACA,SAAA;EACA,cAAA;EACA,aAAA;ACAJ;ADEI;EACI,qCAAA;EACA,kCAAA;ACCR;ADHI;;EACI,qCAAA;EACA,kCAAA;ACCR;ADEI;;EACI,qCAAA;EACA,kCAAA;ACCR;;ADGA;EACI,WAAA;EACA,YAAA;EACA,kBAAA;EACA,mCAAA;EACA,aAAA;EACA,mBAAA;EACA,uBAAA;EACA,sBAAA;EACA,yBAAA;ACAJ;ADEI;EACI,gBAAA;EACA,yBAAA;ACAR;ADGI;EACI,qCAAA;EACA,kCAAA;ACDR;ADII;EACI,mCAAA;EACA,gCAAA;ACFR;;ADMA;EACI,WAAA;EACA,aAAA;EACA,uBAAA;EACA,yBAAA;EACA,gBAAA;ACHJ;ADKI;EAPJ;IAQQ,eAAA;ECFN;AACF;ADII;EACI,WAAA;EACA,cAAA;EACA,cAAA;EACA,SAAA;EACA,UAAA;EACA,gCAAA;EACA,YAAA;EACA,gBAAA;EACA,aAAA;EACA,kBAAA;EACA,kBAAA;EACA,kBAAA;ACFR;ADIQ;EAEI,wBAAA;EACA,SAAA;ACHZ;ADMQ;EACI,gBAAA;EACA,wBAAA;EACA,0BAAA;ACJZ;;ADSA;EACI,aAAA;EACA,mBAAA;EACA,SAAA;ACNJ;ADQI;EACI,aAAA;EACA,mBAAA;EACA,uBAAA;EACA,UAAA;EACA,YAAA;EACA,WAAA;EACA,6BAAA;EACA,uBAAA;EACA,mBAAA;EACA,cAAA;EACA,eAAA;EACA,yBAAA;ACNR;ADQQ;EACI,eAAA;EACA,gBAAA;EACA,cAAA;ACNZ;ADSQ;EACI,2CAAA;ACPZ;ADUQ;EAII,8CAAA;EACA,2CAAA;EACA,aAAA;ACXZ;;ADgBA;EACI,wBAAA;ACbJ","file":"timer.css"} \ No newline at end of file diff --git a/asset/style/timer.scss b/asset/style/timer.scss new file mode 100644 index 0000000..f773877 --- /dev/null +++ b/asset/style/timer.scss @@ -0,0 +1,147 @@ +@import url("https://fonts.googleapis.com/css2?family=Orbitron:wght@400..900&display=swap"); + +:root { + --primary-bg-color: #242424; + --primary-text-color: #46ffbe; + --secondary-bg-color: #46ffbe; + --secondary-text-color: #444444; +} + +* { + font-family: "Orbitron", sans-serif; + box-sizing: border-box; +} + +body, +html { + background-color: var(--primary-bg-color); + color: var(--primary-text-color); + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 48px; + margin: 0; + height: 100dvh; + width: 100dvw; + + &::selection { + background: var(--secondary-bg-color); + color: var(--secondary-text-color); + } + + ::-moz-selection { + background: var(--secondary-bg-color); + color: var(--secondary-text-color); + } +} + +.countdown-container { + width: 100%; + height: 100%; + position: absolute; + background: var(--primary-bg-color); + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + transition: all 0.5s ease; + + .countdown-number { + font-size: 202px; + transition: all 0.5s ease; + } + + &.odd { + background: var(--secondary-bg-color); + color: var(--secondary-text-color); + } + + &.even { + background: var(--primary-bg-color); + color: var(--primary-text-color); + } +} + +.input-stopwatch-container { + width: 100%; + display: flex; + justify-content: center; + transition: all 0.5s ease; + font-size: 112px; + + @media screen and (max-width: 768px) { + font-size: 56px; + } + + input { + width: auto; + min-width: 1px; + max-width: 2ch; + margin: 0; + padding: 0; + color: var(--primary-text-color); + border: none; + background: none; + outline: none; + text-align: center; + font-size: inherit; + border-radius: 5px; + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + + &[type="number"] { + appearance: none; + -webkit-appearance: none; + -moz-appearance: textfield; + } + } +} + +.action-button-container { + display: flex; + flex-direction: row; + gap: 32px; + + button { + display: flex; + align-items: center; + justify-content: center; + padding: 0; + height: 32px; + width: 32px; + background-color: transparent; + border: 1px solid white; + border-radius: 99px; + color: inherit; + cursor: pointer; + transition: all 0.5s ease; + + img { + max-width: 100%; + max-height: 100%; + display: block; + } + + &:hover { + background-color: rgba(255, 255, 255, 0.15); + } + + &:active, + &:focus, + &:focus-visible, + &:focus-within { + box-shadow: 0 0 10px var(--secondary-bg-color); + background-color: rgba(255, 255, 255, 0.15); + outline: none; + } + } +} + +.hide { + display: none !important; +} diff --git a/index.html b/index.html index 4f20ad2..38e315b 100644 --- a/index.html +++ b/index.html @@ -1,50 +1,62 @@ -
+ + - -