diff --git a/src/components/tools/generics/button/button.module.css b/src/components/tools/generics/button/button.module.css new file mode 100644 index 0000000..a96e112 --- /dev/null +++ b/src/components/tools/generics/button/button.module.css @@ -0,0 +1,34 @@ +.button { + display: flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + font-size: var(--font-sm); + color: var(--color-foreground); + cursor: pointer; + background-color: var(--color-neutral-100); + border: 1px solid var(--color-neutral-200); + border-radius: 4px; + outline: none; + transition: 0.2s; + + &:focus-visible { + outline: 2px solid var(--color-neutral-400); + outline-offset: 2px; + } + + &:hover, + &:focus-visible { + background-color: var(--color-neutral-200); + } + + &.smallIcon { + font-size: var(--font-xsm); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.4; + } +} diff --git a/src/components/tools/generics/button/button.tsx b/src/components/tools/generics/button/button.tsx new file mode 100644 index 0000000..5828ad6 --- /dev/null +++ b/src/components/tools/generics/button/button.tsx @@ -0,0 +1,33 @@ +import { Tooltip } from '@/components/tooltip'; + +import { cn } from '@/helpers/styles'; + +import styles from './button.module.css'; + +interface ButtonProps { + disabled?: boolean; + icon: React.ReactElement; + onClick: () => void; + smallIcon?: boolean; + tooltip: string; +} + +export function Button({ + disabled = false, + icon, + onClick, + smallIcon, + tooltip, +}: ButtonProps) { + return ( + + + + ); +} diff --git a/src/components/tools/generics/button/index.ts b/src/components/tools/generics/button/index.ts new file mode 100644 index 0000000..a039b75 --- /dev/null +++ b/src/components/tools/generics/button/index.ts @@ -0,0 +1 @@ +export { Button } from './button'; diff --git a/src/components/tools/pomodoro/index.ts b/src/components/tools/pomodoro/index.ts new file mode 100644 index 0000000..9b721ae --- /dev/null +++ b/src/components/tools/pomodoro/index.ts @@ -0,0 +1 @@ +export { Pomodoro } from './pomodoro'; diff --git a/src/components/tools/pomodoro/pomodoro.module.css b/src/components/tools/pomodoro/pomodoro.module.css new file mode 100644 index 0000000..33109cf --- /dev/null +++ b/src/components/tools/pomodoro/pomodoro.module.css @@ -0,0 +1,36 @@ +.header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + + & .title { + font-size: var(--font-sm); + font-weight: 500; + color: var(--color-foreground-subtle); + } + + & .buttons { + display: flex; + column-gap: 4px; + align-items: center; + } +} + +.control { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 8px; + + & .completed { + font-size: var(--font-xsm); + color: var(--color-foreground-subtle); + } + + & .buttons { + display: flex; + column-gap: 4px; + align-items: center; + } +} diff --git a/src/components/tools/pomodoro/pomodoro.tsx b/src/components/tools/pomodoro/pomodoro.tsx new file mode 100644 index 0000000..b26535f --- /dev/null +++ b/src/components/tools/pomodoro/pomodoro.tsx @@ -0,0 +1,168 @@ +import { useState, useEffect, useRef, useMemo } from 'react'; +import { FaUndo, FaPlay, FaPause } from 'react-icons/fa/index'; +import { IoMdSettings } from 'react-icons/io/index'; + +import { Button } from '../generics/button'; +import { Timer } from '@/components/timer'; +import { Container } from '@/components/container'; +import { Tabs } from './tabs'; +import { Setting } from './setting'; + +import { useLocalStorage } from '@/hooks/use-local-storage'; +import { useSoundEffect } from '@/hooks/use-sound-effect'; +import { usePomodoroStore } from '@/stores/pomodoro'; +import { useCloseListener } from '@/hooks/use-close-listener'; + +import styles from './pomodoro.module.css'; + +export function Pomodoro() { + const [showSetting, setShowSetting] = useState(false); + + const [selectedTab, setSelectedTab] = useState('pomodoro'); + + const running = usePomodoroStore(state => state.running); + const setRunning = usePomodoroStore(state => state.setRunning); + + const [timer, setTimer] = useState(0); + const interval = useRef | null>(null); + + const alarm = useSoundEffect('/sounds/alarm.mp3'); + + const defaultTimes = useMemo( + () => ({ + long: 15 * 60, + pomodoro: 25 * 60, + short: 5 * 60, + }), + [], + ); + + const [times, setTimes] = useLocalStorage>( + 'moodist-pomodoro-setting', + defaultTimes, + ); + + const [completions, setCompletions] = useState>({ + long: 0, + pomodoro: 0, + short: 0, + }); + + const tabs = useMemo( + () => [ + { id: 'pomodoro', label: 'Pomodoro' }, + { id: 'short', label: 'Break' }, + { id: 'long', label: 'Long Break' }, + ], + [], + ); + + useCloseListener(() => setShowSetting(false)); + + useEffect(() => { + if (running) { + if (interval.current) clearInterval(interval.current); + + interval.current = setInterval(() => { + setTimer(prev => prev - 1); + }, 1000); + } else { + if (interval.current) clearInterval(interval.current); + } + }, [running]); + + useEffect(() => { + if (timer <= 0 && running) { + if (interval.current) clearInterval(interval.current); + + alarm.play(); + + setRunning(false); + setCompletions(prev => ({ + ...prev, + [selectedTab]: prev[selectedTab] + 1, + })); + } + }, [timer, selectedTab, running, setRunning, alarm]); + + useEffect(() => { + const time = times[selectedTab] || 10; + + if (interval.current) clearInterval(interval.current); + + setRunning(false); + setTimer(time); + }, [selectedTab, times, setRunning]); + + const toggleRunning = () => { + if (running) setRunning(false); + else if (timer <= 0) { + const time = times[selectedTab] || 10; + + setTimer(time); + setRunning(true); + } else setRunning(true); + }; + + const restart = () => { + if (interval.current) clearInterval(interval.current); + + const time = times[selectedTab] || 10; + + setRunning(false); + setTimer(time); + }; + + return ( + +
+

Pomodoro Timer

+ +
+
+
+ + + + +
+

+ {completions[selectedTab] || 0} completed +

+
+
+
+ + { + setShowSetting(false); + setTimes(times); + }} + onClose={() => { + setShowSetting(false); + }} + /> +
+ ); +} diff --git a/src/components/tools/pomodoro/setting/index.ts b/src/components/tools/pomodoro/setting/index.ts new file mode 100644 index 0000000..0394f8b --- /dev/null +++ b/src/components/tools/pomodoro/setting/index.ts @@ -0,0 +1 @@ +export { Setting } from './setting'; diff --git a/src/components/tools/pomodoro/setting/setting.module.css b/src/components/tools/pomodoro/setting/setting.module.css new file mode 100644 index 0000000..89583ef --- /dev/null +++ b/src/components/tools/pomodoro/setting/setting.module.css @@ -0,0 +1,76 @@ +.title { + margin-bottom: 16px; + font-family: var(--font-heading); + font-size: var(--font-md); + font-weight: 600; +} + +& .form { + display: flex; + flex-direction: column; + + & .field { + display: flex; + flex-direction: column; + row-gap: 8px; + margin-bottom: 16px; + + & .label { + font-size: var(--font-sm); + color: var(--color-foreground); + + & span { + color: var(--color-foreground-subtle); + } + } + + & .input { + display: block; + height: 40px; + padding: 0 8px; + color: var(--color-foreground); + background-color: var(--color-neutral-50); + border: 1px solid var(--color-neutral-200); + border-radius: 4px; + outline: none; + + &:focus-visible { + outline: 2px solid var(--color-neutral-400); + outline-offset: 2px; + } + } + } + + & .buttons { + display: flex; + column-gap: 8px; + align-items: center; + justify-content: flex-end; + + & button { + display: flex; + align-items: center; + justify-content: center; + height: 40px; + padding: 0 16px; + font-size: var(--font-sm); + font-weight: 500; + color: var(--color-foreground); + cursor: pointer; + background-color: var(--color-neutral-200); + border: none; + border-radius: 4px; + outline: none; + + &:focus-visible { + outline: 2px solid var(--color-neutral-400); + outline-offset: 2px; + } + + &.primary { + color: var(--color-neutral-100); + background-color: var(--color-neutral-950); + } + } + } +} diff --git a/src/components/tools/pomodoro/setting/setting.tsx b/src/components/tools/pomodoro/setting/setting.tsx new file mode 100644 index 0000000..8db1a93 --- /dev/null +++ b/src/components/tools/pomodoro/setting/setting.tsx @@ -0,0 +1,110 @@ +import { useEffect, useState } from 'react'; + +import { Modal } from '@/components/modal'; + +import styles from './setting.module.css'; + +interface SettingProps { + onChange: (newTimes: Record) => void; + onClose: () => void; + show: boolean; + times: Record; +} + +export function Setting({ onChange, onClose, show, times }: SettingProps) { + const [values, setValues] = useState>(times); + + useEffect(() => { + if (show) setValues(times); + }, [times, show]); + + const handleChange = (id: string) => (value: number | string) => { + setValues(prev => ({ + ...prev, + [id]: typeof value === 'number' ? value * 60 : '', + })); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + const newValues: Record = {}; + + Object.keys(values).forEach(name => { + newValues[name] = + typeof values[name] === 'number' ? values[name] : times[name]; + }); + + onChange(newValues); + }; + + const handleCancel = (e: React.MouseEvent) => { + e.preventDefault(); + + onClose(); + }; + + return ( + +

Change Times

+ +
+ + + + +
+ + +
+ +
+ ); +} + +interface FieldProps { + id: string; + label: string; + onChange: (value: number | string) => void; + value: number | string; +} + +function Field({ id, label, onChange, value }: FieldProps) { + return ( +
+ + { + onChange(e.target.value === '' ? '' : Number(e.target.value)); + }} + /> +
+ ); +} diff --git a/src/components/tools/pomodoro/tabs/index.ts b/src/components/tools/pomodoro/tabs/index.ts new file mode 100644 index 0000000..81aabb7 --- /dev/null +++ b/src/components/tools/pomodoro/tabs/index.ts @@ -0,0 +1 @@ +export { Tabs } from './tabs'; diff --git a/src/components/tools/pomodoro/tabs/tabs.module.css b/src/components/tools/pomodoro/tabs/tabs.module.css new file mode 100644 index 0000000..222164c --- /dev/null +++ b/src/components/tools/pomodoro/tabs/tabs.module.css @@ -0,0 +1,43 @@ +.tabs { + display: flex; + column-gap: 4px; + align-items: center; + padding: 4px; + margin: 8px 0; + background-color: var(--color-neutral-50); + border: 1px solid var(--color-neutral-200); + border-radius: 8px; + + & .tab { + display: flex; + flex-grow: 1; + align-items: center; + justify-content: center; + height: 45px; + font-size: var(--font-sm); + color: var(--color-foreground-subtle); + cursor: pointer; + background-color: transparent; + border: 1px solid transparent; + border-radius: 4px; + outline: none; + transition: 0.2s; + + &:focus-visible { + outline: 2px solid var(--color-neutral-400); + outline-offset: 2px; + } + + &.selected { + color: var(--color-foreground); + background-color: var(--color-neutral-200); + border-color: var(--color-neutral-300); + } + + &:not(.selected):hover, + &:not(.selected):focus-visible { + color: var(--color-foreground); + background-color: var(--color-neutral-100); + } + } +} diff --git a/src/components/tools/pomodoro/tabs/tabs.tsx b/src/components/tools/pomodoro/tabs/tabs.tsx new file mode 100644 index 0000000..728bc5e --- /dev/null +++ b/src/components/tools/pomodoro/tabs/tabs.tsx @@ -0,0 +1,25 @@ +import { cn } from '@/helpers/styles'; + +import styles from './tabs.module.css'; + +interface TabsProps { + onSelect: (id: string) => void; + selectedTab: string; + tabs: Array<{ id: string; label: string }>; +} + +export function Tabs({ onSelect, selectedTab, tabs }: TabsProps) { + return ( +
+ {tabs.map(tab => ( + + ))} +
+ ); +} diff --git a/src/pages/tools/pomodoro.astro b/src/pages/tools/pomodoro.astro new file mode 100644 index 0000000..6ca8d26 --- /dev/null +++ b/src/pages/tools/pomodoro.astro @@ -0,0 +1,19 @@ +--- +import Layout from '@/layouts/layout.astro'; + +import Donate from '@/components/donate.astro'; +import Hero from '@/components/tools/hero.astro'; +import Footer from '@/components/footer.astro'; +import About from '@/components/tools/about.astro'; +import { Pomodoro as PomodoroTimer } from '@/components/tools/pomodoro'; +--- + + + + + + +