diff --git a/src/components/modals/breathing/breathing.module.css b/src/components/modals/breathing/breathing.module.css new file mode 100644 index 0000000..fdbd99d --- /dev/null +++ b/src/components/modals/breathing/breathing.module.css @@ -0,0 +1 @@ +/* WIP */ diff --git a/src/components/modals/breathing/breathing.tsx b/src/components/modals/breathing/breathing.tsx new file mode 100644 index 0000000..6acd11b --- /dev/null +++ b/src/components/modals/breathing/breathing.tsx @@ -0,0 +1,18 @@ +import { Modal } from '@/components/modal'; +import { Exercise } from './exercise'; + +import styles from './breathing.module.css'; + +interface TimerProps { + onClose: () => void; + show: boolean; +} + +export function BreathingExerciseModal({ onClose, show }: TimerProps) { + return ( + + Breathing Exercise + + + ); +} diff --git a/src/components/modals/breathing/exercise/exercise.module.css b/src/components/modals/breathing/exercise/exercise.module.css new file mode 100644 index 0000000..437810f --- /dev/null +++ b/src/components/modals/breathing/exercise/exercise.module.css @@ -0,0 +1,91 @@ +.exercise { + position: relative; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 75px 0; + margin-top: 12px; + background-color: var(--color-neutral-50); + border: 1px solid var(--color-neutral-200); + border-radius: 8px; + + & .timer { + position: absolute; + top: 4px; + left: 4px; + padding: 4px 12px; + font-size: var(--font-xsm); + color: var(--color-foreground-subtle); + background: linear-gradient( + var(--color-neutral-100), + var(--color-neutral-50) + ); + border: 1px solid var(--color-neutral-200); + border-radius: 4px; + } + + & .phase { + font-family: var(--font-display); + font-size: var(--font-lg); + font-weight: 600; + } + + & .circle { + position: absolute; + top: 50%; + left: 50%; + z-index: -1; + height: 55%; + aspect-ratio: 1 / 1; + background-image: radial-gradient( + var(--color-neutral-50), + var(--color-neutral-100) + ); + border: 1px solid var(--color-neutral-200); + border-radius: 50%; + transform: translate(-50%, -50%); + } +} + +.selectWrapper { + position: relative; + width: 100%; + height: 45px; + padding: 0 12px; + margin-top: 8px; + background-color: var(--color-neutral-50); + border: 1px solid var(--color-neutral-200); + border-radius: 8px; + + &::before { + position: absolute; + top: -1px; + left: 50%; + width: 80%; + height: 1px; + content: ''; + background: linear-gradient( + 90deg, + transparent, + var(--color-neutral-300), + transparent + ); + transform: translateX(-50%); + } + + & .selectBox { + width: 100%; + min-width: 0; + height: 100%; + font-size: var(--font-sm); + color: var(--color-foreground); + background-color: transparent; + border: none; + outline: none; + + & option { + color: var(--color-neutral-50); + } + } +} diff --git a/src/components/modals/breathing/exercise/exercise.tsx b/src/components/modals/breathing/exercise/exercise.tsx new file mode 100644 index 0000000..3920b27 --- /dev/null +++ b/src/components/modals/breathing/exercise/exercise.tsx @@ -0,0 +1,126 @@ +import { useState, useEffect, useMemo, useCallback } from 'react'; +import { motion } from 'framer-motion'; + +import { padNumber } from '@/helpers/number'; + +import styles from './exercise.module.css'; + +type Exercise = 'Box Breathing' | 'Resonant Breathing' | '4-7-8 Breathing'; +type Phase = 'inhale' | 'exhale' | 'holdInhale' | 'holdExhale'; + +const EXERCISE_PHASES: Record = { + '4-7-8 Breathing': ['inhale', 'holdInhale', 'exhale'], + 'Box Breathing': ['inhale', 'holdInhale', 'exhale', 'holdExhale'], + 'Resonant Breathing': ['inhale', 'exhale'], +}; + +const EXERCISE_DURATIONS: Record>> = { + '4-7-8 Breathing': { exhale: 8, holdInhale: 7, inhale: 4 }, + 'Box Breathing': { exhale: 4, holdExhale: 4, holdInhale: 4, inhale: 4 }, + 'Resonant Breathing': { exhale: 5, inhale: 5 }, // No holdExhale +}; + +const PHASE_LABELS: Record = { + exhale: 'Exhale', + holdExhale: 'Hold', + holdInhale: 'Hold', + inhale: 'Inhale', +}; + +export function Exercise() { + const [selectedExercise, setSelectedExercise] = + useState('4-7-8 Breathing'); + const [phaseIndex, setPhaseIndex] = useState(0); + + const phases = useMemo( + () => EXERCISE_PHASES[selectedExercise], + [selectedExercise], + ); + const durations = useMemo( + () => EXERCISE_DURATIONS[selectedExercise], + [selectedExercise], + ); + + const currentPhase = phases[phaseIndex]; + + const animationVariants = useMemo( + () => ({ + exhale: { + transform: 'translate(-50%, -50%) scale(1)', + transition: { duration: durations.exhale }, + }, + holdExhale: { + transform: 'translate(-50%, -50%) scale(1)', + transition: { duration: durations.holdExhale }, + }, + holdInhale: { + transform: 'translate(-50%, -50%) scale(1.5)', + transition: { duration: durations.holdInhale }, + }, + inhale: { + transform: 'translate(-50%, -50%) scale(1.5)', + transition: { duration: durations.inhale }, + }, + }), + [durations], + ); + + const resetExercise = useCallback(() => { + setPhaseIndex(0); + }, []); + + const updatePhase = useCallback(() => { + setPhaseIndex(prevIndex => (prevIndex + 1) % phases.length); + }, [phases.length]); + + useEffect(() => { + resetExercise(); + }, [selectedExercise, resetExercise]); + + useEffect(() => { + const intervalDuration = (durations[currentPhase] || 4) * 1000; + const interval = setInterval(updatePhase, intervalDuration); + + return () => clearInterval(interval); + }, [currentPhase, durations, updatePhase]); + + const [timer, setTimer] = useState(0); + + useEffect(() => { + const interval = setInterval(() => setTimer(prev => prev + 1), 1000); + + return () => clearInterval(interval); + }, []); + + return ( + <> + + + {padNumber(Math.floor(timer / 60))}:{padNumber(timer % 60)} + + + + {PHASE_LABELS[currentPhase]} + + + + setSelectedExercise(e.target.value as Exercise)} + > + {Object.keys(EXERCISE_PHASES).map(exercise => ( + + {exercise} + + ))} + + + > + ); +} diff --git a/src/components/modals/breathing/exercise/index.ts b/src/components/modals/breathing/exercise/index.ts new file mode 100644 index 0000000..881062d --- /dev/null +++ b/src/components/modals/breathing/exercise/index.ts @@ -0,0 +1 @@ +export { Exercise } from './exercise'; diff --git a/src/components/modals/breathing/index.ts b/src/components/modals/breathing/index.ts new file mode 100644 index 0000000..1433198 --- /dev/null +++ b/src/components/modals/breathing/index.ts @@ -0,0 +1 @@ +export { BreathingExerciseModal } from './breathing'; diff --git a/src/components/store-consumer/store-consumer.tsx b/src/components/store-consumer/store-consumer.tsx index f3f5f0e..a7c6edb 100644 --- a/src/components/store-consumer/store-consumer.tsx +++ b/src/components/store-consumer/store-consumer.tsx @@ -1,7 +1,9 @@ import { useEffect } from 'react'; import { useSoundStore } from '@/stores/sound'; +import { useNoteStore } from '@/stores/note'; import { usePresetStore } from '@/stores/preset'; +import { useTodoStore } from '@/stores/todo'; interface StoreConsumerProps { children: React.ReactNode; @@ -10,7 +12,9 @@ interface StoreConsumerProps { export function StoreConsumer({ children }: StoreConsumerProps) { useEffect(() => { useSoundStore.persist.rehydrate(); + useNoteStore.persist.rehydrate(); usePresetStore.persist.rehydrate(); + useTodoStore.persist.rehydrate(); }, []); return <>{children}>; diff --git a/src/components/toolbar/menu/items/breathing-exercise.tsx b/src/components/toolbar/menu/items/breathing-exercise.tsx new file mode 100644 index 0000000..5695afb --- /dev/null +++ b/src/components/toolbar/menu/items/breathing-exercise.tsx @@ -0,0 +1,18 @@ +import { IoMdFlower } from 'react-icons/io/index'; + +import { Item } from '../item'; + +interface BreathingExerciseProps { + open: () => void; +} + +export function BreathingExercise({ open }: BreathingExerciseProps) { + return ( + } + label="Breathing Exercise" + shortcut="Shift + B" + onClick={open} + /> + ); +} diff --git a/src/components/toolbar/menu/items/countdown.tsx b/src/components/toolbar/menu/items/countdown.tsx new file mode 100644 index 0000000..cd3b1a6 --- /dev/null +++ b/src/components/toolbar/menu/items/countdown.tsx @@ -0,0 +1,18 @@ +import { MdOutlineTimer } from 'react-icons/md/index'; + +import { Item } from '../item'; + +interface CountdownProps { + open: () => void; +} + +export function Countdown({ open }: CountdownProps) { + return ( + } + label="Countdown Timer" + shortcut="Shift + C" + onClick={open} + /> + ); +} diff --git a/src/components/toolbar/menu/items/index.ts b/src/components/toolbar/menu/items/index.ts index dda2e17..7c63287 100644 --- a/src/components/toolbar/menu/items/index.ts +++ b/src/components/toolbar/menu/items/index.ts @@ -5,3 +5,8 @@ export { Source as SourceItem } from './source'; export { Presets as PresetsItem } from './presets'; export { Shortcuts as ShortcutsItem } from './shortcuts'; export { SleepTimer as SleepTimerItem } from './sleep-timer'; +export { BreathingExercise as BreathingExerciseItem } from './breathing-exercise'; +export { Pomodoro as PomodoroItem } from './pomodoro'; +export { Notepad as NotepadItem } from './notepad'; +export { Todo as TodoItem } from './todo'; +export { Countdown as CountdownItem } from './countdown'; diff --git a/src/components/toolbar/menu/items/notepad.tsx b/src/components/toolbar/menu/items/notepad.tsx new file mode 100644 index 0000000..ef0ff39 --- /dev/null +++ b/src/components/toolbar/menu/items/notepad.tsx @@ -0,0 +1,23 @@ +import { MdNotes } from 'react-icons/md/index'; + +import { Item } from '../item'; + +import { useNoteStore } from '@/stores/note'; + +interface NotepadProps { + open: () => void; +} + +export function Notepad({ open }: NotepadProps) { + const note = useNoteStore(state => state.note); + + return ( + } + label="Notepad" + shortcut="Shift + N" + onClick={open} + /> + ); +} diff --git a/src/components/toolbar/menu/items/pomodoro.tsx b/src/components/toolbar/menu/items/pomodoro.tsx new file mode 100644 index 0000000..43473b5 --- /dev/null +++ b/src/components/toolbar/menu/items/pomodoro.tsx @@ -0,0 +1,23 @@ +import { MdOutlineAvTimer } from 'react-icons/md/index'; + +import { Item } from '../item'; + +import { usePomodoroStore } from '@/stores/pomodoro'; + +interface PomodoroProps { + open: () => void; +} + +export function Pomodoro({ open }: PomodoroProps) { + const running = usePomodoroStore(state => state.running); + + return ( + } + label="Pomodoro" + shortcut="Shift + P" + onClick={open} + /> + ); +} diff --git a/src/components/toolbar/menu/items/todo.tsx b/src/components/toolbar/menu/items/todo.tsx new file mode 100644 index 0000000..f2dbc1e --- /dev/null +++ b/src/components/toolbar/menu/items/todo.tsx @@ -0,0 +1,18 @@ +import { MdTaskAlt } from 'react-icons/md/index'; + +import { Item } from '../item'; + +interface TodoProps { + open: () => void; +} + +export function Todo({ open }: TodoProps) { + return ( + } + label="Todo Checklist" + shortcut="Shift + T" + onClick={open} + /> + ); +} diff --git a/src/components/toolbar/menu/menu.tsx b/src/components/toolbar/menu/menu.tsx index 65ca77d..f2c36b2 100644 --- a/src/components/toolbar/menu/menu.tsx +++ b/src/components/toolbar/menu/menu.tsx @@ -12,12 +12,19 @@ import { PresetsItem, ShortcutsItem, SleepTimerItem, + BreathingExerciseItem, + PomodoroItem, + NotepadItem, + TodoItem, + CountdownItem, } from './items'; import { Divider } from './divider'; import { ShareLinkModal } from '@/components/modals/share-link'; import { PresetsModal } from '@/components/modals/presets'; import { ShortcutsModal } from '@/components/modals/shortcuts'; import { SleepTimerModal } from '@/components/modals/sleep-timer'; +import { BreathingExerciseModal } from '@/components/modals/breathing'; +import { Pomodoro, Notepad, Todo, Countdown } from '@/components/toolbox'; import { fade, mix, slideY } from '@/lib/motion'; import { useSoundStore } from '@/stores/sound'; @@ -64,10 +71,15 @@ export function Menu() { ); useHotkeys('shift+m', () => setIsOpen(prev => !prev)); - useHotkeys('shift+p', () => open('presets')); + useHotkeys('shift+alt+p', () => open('presets')); useHotkeys('shift+h', () => open('shortcuts')); + useHotkeys('shift+b', () => open('breathing')); + useHotkeys('shift+n', () => open('notepad')); + useHotkeys('shift+p', () => open('pomodoro')); + useHotkeys('shift+t', () => open('todo')); + useHotkeys('shift+c', () => open('countdown')); useHotkeys('shift+s', () => open('shareLink'), { enabled: !noSelected }); - useHotkeys('shift+t', () => open('sleepTimer')); + useHotkeys('shift+alt+t', () => open('sleepTimer')); useCloseListener(closeAll); @@ -105,6 +117,13 @@ export function Menu() { open('sleepTimer')} /> + + open('countdown')} /> + open('pomodoro')} /> + open('notepad')} /> + open('todo')} /> + open('breathing')} /> + open('shortcuts')} /> @@ -123,10 +142,22 @@ export function Menu() { show={modals.shareLink} onClose={() => close('shareLink')} /> + close('breathing')} + /> close('shortcuts')} /> + open('pomodoro')} + show={modals.pomodoro} + onClose={() => close('pomodoro')} + /> + close('notepad')} /> + close('todo')} /> + close('countdown')} /> close('presets')} /> void; + show: boolean; +} + +export function Countdown({ onClose, show }: CountdownProps) { + const [hours, setHours] = useState(0); + const [minutes, setMinutes] = useState(0); + const [seconds, setSeconds] = useState(0); + const [timeLeft, setTimeLeft] = useState(0); + const [initialTime, setInitialTime] = useState(0); + const [isActive, setIsActive] = useState(false); + const [isFormVisible, setIsFormVisible] = useState(true); + + const alarm = useSoundEffect('/sounds/alarm.mp3'); + + useEffect(() => { + let timer: NodeJS.Timeout; + + if (isActive && timeLeft > 0) { + timer = setTimeout(() => setTimeLeft(timeLeft - 1), 1000); + } else if (timeLeft === 0 && isActive) { + alarm.play(); + setIsActive(false); + setIsFormVisible(true); + } + + return () => clearTimeout(timer); + }, [isActive, timeLeft, alarm]); + + const handleStart = useCallback(() => { + if (hours > 0 || minutes > 0 || seconds > 0) { + const totalTime = + (hours || 0) * 3600 + (minutes || 0) * 60 + (seconds || 0); + + setTimeLeft(totalTime); + setInitialTime(totalTime); + setIsActive(true); + setIsFormVisible(false); + } + }, [hours, minutes, seconds]); + + const handleBack = useCallback(() => { + setIsActive(false); + setIsFormVisible(true); + setTimeLeft(0); + }, []); + + const toggleTimer = useCallback(() => { + setIsActive(prev => !prev); + }, []); + + const formatTime = useCallback((time: number) => { + const hrs = Math.floor(time / 3600); + const mins = Math.floor((time % 3600) / 60); + const secs = time % 60; + + return `${padNumber(hrs)}:${padNumber(mins)}:${padNumber(secs)}`; + }, []); + + const elapsedTime = initialTime - timeLeft; + + return ( + + + Countdown Timer + Super simple countdown timer. + + + {isFormVisible ? ( + + + setHours(Math.max(0, parseInt(e.target.value)))} + /> + + : + + + setMinutes(Math.max(0, Math.min(59, parseInt(e.target.value)))) + } + /> + + : + + + setSeconds(Math.max(0, Math.min(59, parseInt(e.target.value)))) + } + /> + + + + + Start + + + + ) : ( + + + - {formatTime(elapsedTime)} + {formatTime(timeLeft)} + + + + + Back + + + + {isActive ? 'Pause' : 'Start'} + + + + )} + + ); +} diff --git a/src/components/toolbox/countdown/index.ts b/src/components/toolbox/countdown/index.ts new file mode 100644 index 0000000..a7731c7 --- /dev/null +++ b/src/components/toolbox/countdown/index.ts @@ -0,0 +1 @@ +export { Countdown } from './countdown'; diff --git a/src/components/toolbox/generics/button/button.module.css b/src/components/toolbox/generics/button/button.module.css new file mode 100644 index 0000000..a96e112 --- /dev/null +++ b/src/components/toolbox/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/toolbox/generics/button/button.tsx b/src/components/toolbox/generics/button/button.tsx new file mode 100644 index 0000000..5828ad6 --- /dev/null +++ b/src/components/toolbox/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 ( + + + {icon} + + + ); +} diff --git a/src/components/toolbox/generics/button/index.ts b/src/components/toolbox/generics/button/index.ts new file mode 100644 index 0000000..a039b75 --- /dev/null +++ b/src/components/toolbox/generics/button/index.ts @@ -0,0 +1 @@ +export { Button } from './button'; diff --git a/src/components/toolbox/index.ts b/src/components/toolbox/index.ts new file mode 100644 index 0000000..0b100ca --- /dev/null +++ b/src/components/toolbox/index.ts @@ -0,0 +1,4 @@ +export { Notepad } from './notepad'; +export { Pomodoro } from './pomodoro'; +export { Todo } from './todo'; +export { Countdown } from './countdown'; diff --git a/src/components/toolbox/notepad/button/button.module.css b/src/components/toolbox/notepad/button/button.module.css new file mode 100644 index 0000000..4a4e756 --- /dev/null +++ b/src/components/toolbox/notepad/button/button.module.css @@ -0,0 +1,45 @@ +.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; + transition-property: border-color, color, background-color; + + &:focus-visible { + outline: 2px solid var(--color-neutral-400); + outline-offset: 2px; + } + + &.critical { + color: #f43f5e; + border-color: #f43f5e; + + &:hover { + background-color: rgb(244 63 94 / 10%); + } + } + + &.recommended { + font-size: var(--font-xsm); + color: #22c55e; + border-color: #22c55e; + + &:hover { + background-color: rgb(34 197 94 / 10%); + } + } + + &:hover, + &:focus-visible { + background-color: var(--color-neutral-200); + } +} diff --git a/src/components/toolbox/notepad/button/button.tsx b/src/components/toolbox/notepad/button/button.tsx new file mode 100644 index 0000000..d3da910 --- /dev/null +++ b/src/components/toolbox/notepad/button/button.tsx @@ -0,0 +1,36 @@ +import { Tooltip } from '@/components/tooltip'; + +import { cn } from '@/helpers/styles'; + +import styles from './button.module.css'; + +interface ButtonProps { + critical?: boolean; + icon: React.ReactElement; + onClick: () => void; + recommended?: boolean; + tooltip: string; +} + +export function Button({ + critical, + icon, + onClick, + recommended, + tooltip, +}: ButtonProps) { + return ( + + + {icon} + + + ); +} diff --git a/src/components/toolbox/notepad/button/index.ts b/src/components/toolbox/notepad/button/index.ts new file mode 100644 index 0000000..a039b75 --- /dev/null +++ b/src/components/toolbox/notepad/button/index.ts @@ -0,0 +1 @@ +export { Button } from './button'; diff --git a/src/components/toolbox/notepad/index.ts b/src/components/toolbox/notepad/index.ts new file mode 100644 index 0000000..8a3fad5 --- /dev/null +++ b/src/components/toolbox/notepad/index.ts @@ -0,0 +1 @@ +export { Notepad } from './notepad'; diff --git a/src/components/toolbox/notepad/notepad.module.css b/src/components/toolbox/notepad/notepad.module.css new file mode 100644 index 0000000..2dadc40 --- /dev/null +++ b/src/components/toolbox/notepad/notepad.module.css @@ -0,0 +1,44 @@ +.header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + + & .label { + font-size: var(--font-sm); + font-weight: 500; + color: var(--color-foreground-subtle); + } + + & .buttons { + display: flex; + column-gap: 4px; + align-items: center; + } +} + +.textarea { + width: 100%; + height: 350px; + padding: 12px; + line-height: 1.6; + color: var(--color-foreground-subtle); + resize: none; + background-color: var(--color-neutral-50); + border: 1px solid var(--color-neutral-200); + border-radius: 4px; + outline: none; + scroll-padding-bottom: 12px; + + &:focus-visible { + outline: 2px solid var(--color-neutral-400); + outline-offset: 2px; + } +} + +.counter { + margin-top: 8px; + font-size: var(--font-xsm); + color: var(--color-foreground-subtle); + text-align: center; +} diff --git a/src/components/toolbox/notepad/notepad.tsx b/src/components/toolbox/notepad/notepad.tsx new file mode 100644 index 0000000..921556f --- /dev/null +++ b/src/components/toolbox/notepad/notepad.tsx @@ -0,0 +1,90 @@ +import { useRef, useEffect } from 'react'; +import { BiTrash } from 'react-icons/bi/index'; +import { LuCopy, LuDownload } from 'react-icons/lu/index'; +import { FaCheck } from 'react-icons/fa6/index'; +import { FaUndo } from 'react-icons/fa/index'; + +import { Modal } from '@/components/modal'; +import { Button } from './button'; + +import { useNoteStore } from '@/stores/note'; +import { useCopy } from '@/hooks/use-copy'; +import { download } from '@/helpers/download'; + +import styles from './notepad.module.css'; + +interface NotepadProps { + onClose: () => void; + show: boolean; +} + +export function Notepad({ onClose, show }: NotepadProps) { + const textareaRef = useRef(null); + + const note = useNoteStore(state => state.note); + const history = useNoteStore(state => state.history); + const write = useNoteStore(state => state.write); + const words = useNoteStore(state => state.words()); + const characters = useNoteStore(state => state.characters()); + const clear = useNoteStore(state => state.clear); + const restore = useNoteStore(state => state.restore); + + const { copy, copying } = useCopy(); + + useEffect(() => { + if (show && textareaRef.current) { + setTimeout(() => { + textareaRef.current?.focus(); + }, 10); + } + }, [show]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + e.stopPropagation(); + + if (e.key === 'Escape') onClose(); + }; + + return ( + + + Your Note + + : } + tooltip="Copy Note" + onClick={() => copy(note)} + /> + } + tooltip="Download Note" + onClick={() => download('Moodit Note.txt', note)} + /> + : } + recommended={!!history} + tooltip={history ? 'Restore Note' : 'Clear Note'} + onClick={() => (history ? restore() : clear())} + /> + + + + write(e.target.value)} + onKeyDown={handleKeyDown} + /> + + + {characters} character{characters !== 1 && 's'} • {words} word + {words !== 1 && 's'} + + + ); +} diff --git a/src/components/toolbox/pomodoro/index.ts b/src/components/toolbox/pomodoro/index.ts new file mode 100644 index 0000000..9b721ae --- /dev/null +++ b/src/components/toolbox/pomodoro/index.ts @@ -0,0 +1 @@ +export { Pomodoro } from './pomodoro'; diff --git a/src/components/toolbox/pomodoro/pomodoro.module.css b/src/components/toolbox/pomodoro/pomodoro.module.css new file mode 100644 index 0000000..33109cf --- /dev/null +++ b/src/components/toolbox/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/toolbox/pomodoro/pomodoro.tsx b/src/components/toolbox/pomodoro/pomodoro.tsx new file mode 100644 index 0000000..7d6ea7b --- /dev/null +++ b/src/components/toolbox/pomodoro/pomodoro.tsx @@ -0,0 +1,179 @@ +import { useState, useEffect, useRef, useMemo } from 'react'; +import { FaUndo, FaPlay, FaPause } from 'react-icons/fa/index'; +import { IoMdSettings } from 'react-icons/io/index'; + +import { Modal } from '@/components/modal'; +import { Button } from '../generics/button'; +import { Timer } from './timer'; +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'; + +interface PomodoroProps { + onClose: () => void; + open: () => void; + show: boolean; +} + +export function Pomodoro({ onClose, open, show }: PomodoroProps) { + 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 + + + } + tooltip="Change Times" + onClick={() => { + onClose(); + setShowSetting(true); + }} + /> + + + + + + + + + {completions[selectedTab] || 0} completed + + + } + smallIcon + tooltip="Restart" + onClick={restart} + /> + : } + smallIcon + tooltip={running ? 'Pause' : 'Start'} + onClick={toggleRunning} + /> + + + + + { + setShowSetting(false); + setTimes(times); + open(); + }} + onClose={() => { + setShowSetting(false); + open(); + }} + /> + > + ); +} diff --git a/src/components/toolbox/pomodoro/setting/index.ts b/src/components/toolbox/pomodoro/setting/index.ts new file mode 100644 index 0000000..0394f8b --- /dev/null +++ b/src/components/toolbox/pomodoro/setting/index.ts @@ -0,0 +1 @@ +export { Setting } from './setting'; diff --git a/src/components/toolbox/pomodoro/setting/setting.module.css b/src/components/toolbox/pomodoro/setting/setting.module.css new file mode 100644 index 0000000..89583ef --- /dev/null +++ b/src/components/toolbox/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/toolbox/pomodoro/setting/setting.tsx b/src/components/toolbox/pomodoro/setting/setting.tsx new file mode 100644 index 0000000..8db1a93 --- /dev/null +++ b/src/components/toolbox/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 + + + + + + + + + Cancel + + + Save + + + + + ); +} + +interface FieldProps { + id: string; + label: string; + onChange: (value: number | string) => void; + value: number | string; +} + +function Field({ id, label, onChange, value }: FieldProps) { + return ( + + + {label} (minutes) + + { + onChange(e.target.value === '' ? '' : Number(e.target.value)); + }} + /> + + ); +} diff --git a/src/components/toolbox/pomodoro/tabs/index.ts b/src/components/toolbox/pomodoro/tabs/index.ts new file mode 100644 index 0000000..81aabb7 --- /dev/null +++ b/src/components/toolbox/pomodoro/tabs/index.ts @@ -0,0 +1 @@ +export { Tabs } from './tabs'; diff --git a/src/components/toolbox/pomodoro/tabs/tabs.module.css b/src/components/toolbox/pomodoro/tabs/tabs.module.css new file mode 100644 index 0000000..222164c --- /dev/null +++ b/src/components/toolbox/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/toolbox/pomodoro/tabs/tabs.tsx b/src/components/toolbox/pomodoro/tabs/tabs.tsx new file mode 100644 index 0000000..728bc5e --- /dev/null +++ b/src/components/toolbox/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 => ( + onSelect(tab.id)} + > + {tab.label} + + ))} + + ); +} diff --git a/src/components/toolbox/pomodoro/timer/index.ts b/src/components/toolbox/pomodoro/timer/index.ts new file mode 100644 index 0000000..91b3f08 --- /dev/null +++ b/src/components/toolbox/pomodoro/timer/index.ts @@ -0,0 +1 @@ +export { Timer } from './timer'; diff --git a/src/components/toolbox/pomodoro/timer/timer.module.css b/src/components/toolbox/pomodoro/timer/timer.module.css new file mode 100644 index 0000000..e007b0c --- /dev/null +++ b/src/components/toolbox/pomodoro/timer/timer.module.css @@ -0,0 +1,29 @@ +.timer { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + padding: 48px 0; + font-size: var(--font-xlg); + font-weight: 500; + background-color: var(--color-neutral-50); + border: 1px solid var(--color-neutral-200); + border-radius: 12px; + + &::after { + position: absolute; + bottom: -1px; + left: 50%; + width: 75%; + height: 1px; + content: ''; + background: linear-gradient( + 90deg, + transparent, + var(--color-neutral-400), + transparent + ); + transform: translateX(-50%); + } +} diff --git a/src/components/toolbox/pomodoro/timer/timer.tsx b/src/components/toolbox/pomodoro/timer/timer.tsx new file mode 100644 index 0000000..e99157f --- /dev/null +++ b/src/components/toolbox/pomodoro/timer/timer.tsx @@ -0,0 +1,27 @@ +import { padNumber } from '@/helpers/number'; + +import styles from './timer.module.css'; + +interface TimerProps { + timer: number; +} + +export function Timer({ timer }: TimerProps) { + let hours = Math.floor(timer / 3600); + let minutes = Math.floor((timer % 3600) / 60); + let seconds = timer % 60; + + hours = isNaN(hours) ? 0 : hours; + minutes = isNaN(minutes) ? 0 : minutes; + seconds = isNaN(seconds) ? 0 : seconds; + + const formattedHours = padNumber(hours); + const formattedMinutes = padNumber(minutes); + const formattedSeconds = padNumber(seconds); + + return ( + + {formattedHours}:{formattedMinutes}:{formattedSeconds} + + ); +} diff --git a/src/components/toolbox/todo/form/form.module.css b/src/components/toolbox/todo/form/form.module.css new file mode 100644 index 0000000..e48d56f --- /dev/null +++ b/src/components/toolbox/todo/form/form.module.css @@ -0,0 +1,35 @@ +.wrapper { + display: flex; + align-items: center; + height: 45px; + padding: 4px; + margin-top: 12px; + background-color: var(--color-neutral-50); + border: 1px solid var(--color-neutral-200); + border-radius: 8px; + + & input { + flex-grow: 1; + min-width: 0; + height: 100%; + padding: 0 8px; + color: var(--color-foreground); + background-color: transparent; + border: none; + outline: none; + } + + & button { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + padding: 0 16px; + 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; + } +} diff --git a/src/components/toolbox/todo/form/form.tsx b/src/components/toolbox/todo/form/form.tsx new file mode 100644 index 0000000..853f04c --- /dev/null +++ b/src/components/toolbox/todo/form/form.tsx @@ -0,0 +1,33 @@ +import { useState } from 'react'; + +import { useTodoStore } from '@/stores/todo'; + +import styles from './form.module.css'; + +export function Form() { + const [value, setValue] = useState(''); + + const addTodo = useTodoStore(state => state.addTodo); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (!value.trim().length) return; + + addTodo(value); + setValue(''); + }; + + return ( + + + setValue(e.target.value)} + /> + Add + + + ); +} diff --git a/src/components/toolbox/todo/form/index.ts b/src/components/toolbox/todo/form/index.ts new file mode 100644 index 0000000..9398c9e --- /dev/null +++ b/src/components/toolbox/todo/form/index.ts @@ -0,0 +1 @@ +export { Form } from './form'; diff --git a/src/components/toolbox/todo/index.ts b/src/components/toolbox/todo/index.ts new file mode 100644 index 0000000..b54a84e --- /dev/null +++ b/src/components/toolbox/todo/index.ts @@ -0,0 +1 @@ +export { Todo } from './todo'; diff --git a/src/components/toolbox/todo/todo.module.css b/src/components/toolbox/todo/todo.module.css new file mode 100644 index 0000000..fdbd99d --- /dev/null +++ b/src/components/toolbox/todo/todo.module.css @@ -0,0 +1 @@ +/* WIP */ diff --git a/src/components/toolbox/todo/todo.tsx b/src/components/toolbox/todo/todo.tsx new file mode 100644 index 0000000..05663cd --- /dev/null +++ b/src/components/toolbox/todo/todo.tsx @@ -0,0 +1,20 @@ +import { Modal } from '@/components/modal'; +import { Form } from './form'; +import { Todos } from './todos'; + +import styles from './todo.module.css'; + +interface TodoProps { + onClose: () => void; + show: boolean; +} + +export function Todo({ onClose, show }: TodoProps) { + return ( + + Todos + + + + ); +} diff --git a/src/components/toolbox/todo/todos/index.ts b/src/components/toolbox/todo/todos/index.ts new file mode 100644 index 0000000..55b9f3d --- /dev/null +++ b/src/components/toolbox/todo/todos/index.ts @@ -0,0 +1 @@ +export { Todos } from './todos'; diff --git a/src/components/toolbox/todo/todos/todo/index.ts b/src/components/toolbox/todo/todos/todo/index.ts new file mode 100644 index 0000000..b54a84e --- /dev/null +++ b/src/components/toolbox/todo/todos/todo/index.ts @@ -0,0 +1 @@ +export { Todo } from './todo'; diff --git a/src/components/toolbox/todo/todos/todo/todo.module.css b/src/components/toolbox/todo/todos/todo/todo.module.css new file mode 100644 index 0000000..b239bcd --- /dev/null +++ b/src/components/toolbox/todo/todos/todo/todo.module.css @@ -0,0 +1,45 @@ +.wrapper { + display: flex; + column-gap: 4px; + align-items: center; + height: 45px; + padding: 4px; + margin-top: 12px; + background-color: var(--color-neutral-50); + border: 1px solid var(--color-neutral-200); + border-radius: 8px; + + & .checkbox { + display: block; + margin: 0 8px 0 4px; + } + + & .textbox { + flex-grow: 1; + min-width: 0; + height: 100%; + font-size: var(--font-sm); + color: var(--color-foreground); + background-color: transparent; + border: none; + outline: none; + + &.done { + color: var(--color-foreground-subtle); + text-decoration: line-through; + } + } + + & button { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + aspect-ratio: 1 / 1; + color: #f43f5e; + cursor: pointer; + background-color: rgb(244 63 94 / 15%); + border: none; + border-radius: 4px; + } +} diff --git a/src/components/toolbox/todo/todos/todo/todo.tsx b/src/components/toolbox/todo/todos/todo/todo.tsx new file mode 100644 index 0000000..3861d7f --- /dev/null +++ b/src/components/toolbox/todo/todos/todo/todo.tsx @@ -0,0 +1,41 @@ +import { FaRegTrashAlt } from 'react-icons/fa/index'; + +import { useTodoStore } from '@/stores/todo'; +import { cn } from '@/helpers/styles'; + +import styles from './todo.module.css'; + +interface TodoProps { + done: boolean; + id: string; + todo: string; +} + +export function Todo({ done, id, todo }: TodoProps) { + const deleteTodo = useTodoStore(state => state.deleteTodo); + const toggleTodo = useTodoStore(state => state.toggleTodo); + const editTodo = useTodoStore(state => state.editTodo); + + const handleCheck = () => toggleTodo(id); + const handleDelete = () => deleteTodo(id); + + return ( + + + editTodo(id, e.target.value)} + /> + + + + + ); +} diff --git a/src/components/toolbox/todo/todos/todos.module.css b/src/components/toolbox/todo/todos/todos.module.css new file mode 100644 index 0000000..fdbd99d --- /dev/null +++ b/src/components/toolbox/todo/todos/todos.module.css @@ -0,0 +1 @@ +/* WIP */ diff --git a/src/components/toolbox/todo/todos/todos.tsx b/src/components/toolbox/todo/todos/todos.tsx new file mode 100644 index 0000000..0b81812 --- /dev/null +++ b/src/components/toolbox/todo/todos/todos.tsx @@ -0,0 +1,17 @@ +import { Todo } from './todo'; + +import { useTodoStore } from '@/stores/todo'; + +import styles from './todos.module.css'; + +export function Todos() { + const todos = useTodoStore(state => state.todos); + + return ( + + {todos.map(todo => ( + + ))} + + ); +} diff --git a/src/hooks/use-sound-effect.ts b/src/hooks/use-sound-effect.ts new file mode 100644 index 0000000..a461d62 --- /dev/null +++ b/src/hooks/use-sound-effect.ts @@ -0,0 +1,55 @@ +import { useMemo, useEffect, useCallback } from 'react'; +import { Howl } from 'howler'; + +import { useSSR } from './use-ssr'; + +/** + * A custom React hook to manage sound effects using Howler.js. + * + * @param {string} src - The source URL of the sound file. + * @param {number} [volume=1] - The initial volume of the sound, ranging from 0.0 to 1.0. + * @returns {{ play: () => void, stop: () => void, pause: () => void }} An object containing control functions for the sound: + * - play: Function to play the sound. + * - stop: Function to stop the sound. + * - pause: Function to pause the sound. + */ +export function useSoundEffect(src: string, volume: number = 1) { + const { isBrowser } = useSSR(); + + const sound = useMemo(() => { + let sound: Howl | null = null; + + if (isBrowser) { + sound = new Howl({ + html5: true, + src: src, + }); + } + + return sound; + }, [src, isBrowser]); + + useEffect(() => { + if (sound) sound.volume(volume ?? 1); + }, [sound, volume]); + + const play = useCallback(() => { + if (sound) { + if (!sound.playing()) { + sound.play(); + } + } + }, [sound]); + + const stop = useCallback(() => { + if (sound) sound.stop(); + }, [sound]); + + const pause = useCallback(() => { + if (sound) sound.pause(); + }, [sound]); + + const control = useMemo(() => ({ pause, play, stop }), [play, stop, pause]); + + return control; +} diff --git a/src/stores/note/index.ts b/src/stores/note/index.ts new file mode 100644 index 0000000..9ec59e5 --- /dev/null +++ b/src/stores/note/index.ts @@ -0,0 +1,28 @@ +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; +import merge from 'deepmerge'; + +import { type NoteState, createState } from './note.state'; +import { type NoteActions, createActions } from './note.actions'; + +export const useNoteStore = create()( + persist( + (...a) => ({ + ...createState(...a), + ...createActions(...a), + }), + { + merge: (persisted, current) => + merge( + current, + // @ts-ignore + persisted, + ), + name: 'moodist-note', + partialize: state => ({ note: state.note }), + skipHydration: true, + storage: createJSONStorage(() => localStorage), + version: 0, + }, + ), +); diff --git a/src/stores/note/note.actions.ts b/src/stores/note/note.actions.ts new file mode 100644 index 0000000..d7bab4c --- /dev/null +++ b/src/stores/note/note.actions.ts @@ -0,0 +1,34 @@ +import type { StateCreator } from 'zustand'; + +import type { NoteState } from './note.state'; + +export interface NoteActions { + clear: () => void; + restore: () => void; + write: (note: string) => void; +} + +export const createActions: StateCreator< + NoteActions & NoteState, + [], + [], + NoteActions +> = (set, get) => { + return { + clear() { + if (!get().note) return; + + set({ history: get().note, note: '' }); + }, + + restore() { + if (!get().history) return; + + set({ history: null, note: get().history! }); + }, + + write(note) { + set({ history: null, note }); + }, + }; +}; diff --git a/src/stores/note/note.state.ts b/src/stores/note/note.state.ts new file mode 100644 index 0000000..2e00df0 --- /dev/null +++ b/src/stores/note/note.state.ts @@ -0,0 +1,27 @@ +import type { StateCreator } from 'zustand'; + +import type { NoteActions } from './note.actions'; +import { count } from '@/helpers/counter'; + +export interface NoteState { + characters: () => number; + history: string | null; + note: string; + words: () => number; +} + +export const createState: StateCreator< + NoteState & NoteActions, + [], + [], + NoteState +> = (set, get) => ({ + characters() { + return count(get().note).characters; + }, + history: null, + note: '', + words() { + return count(get().note).words; + }, +}); diff --git a/src/stores/pomodoro/index.ts b/src/stores/pomodoro/index.ts new file mode 100644 index 0000000..d717db5 --- /dev/null +++ b/src/stores/pomodoro/index.ts @@ -0,0 +1,13 @@ +import { create } from 'zustand'; + +interface PomodoroStore { + running: boolean; + setRunning: (value: boolean) => void; +} + +export const usePomodoroStore = create()(set => ({ + running: false, + setRunning(value: boolean) { + set({ running: value }); + }, +})); diff --git a/src/stores/todo.ts b/src/stores/todo.ts new file mode 100644 index 0000000..eb2101e --- /dev/null +++ b/src/stores/todo.ts @@ -0,0 +1,81 @@ +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; +import merge from 'deepmerge'; +import { v4 as uuid } from 'uuid'; + +interface TodoStore { + addTodo: (todo: string) => void; + deleteTodo: (id: string) => void; + editTodo: (id: string, newTodo: string) => void; + todos: Array<{ + createdAt: number; + done: boolean; + id: string; + todo: string; + }>; + toggleTodo: (id: string) => void; +} + +export const useTodoStore = create()( + persist( + (set, get) => ({ + addTodo(todo) { + set({ + todos: [ + { + createdAt: Date.now(), + done: false, + id: uuid(), + todo, + }, + ...get().todos, + ], + }); + }, + + deleteTodo(id) { + set({ + todos: get().todos.filter(todo => todo.id !== id), + }); + }, + + editTodo(id, newTodo) { + set({ + todos: get().todos.map(todo => { + if (todo.id !== id) return todo; + + return { + ...todo, + todo: newTodo, + }; + }), + }); + }, + + todos: [], + + toggleTodo(id) { + set({ + todos: get().todos.map(todo => { + if (todo.id !== id) return todo; + + return { + ...todo, + done: !todo.done, + }; + }), + }); + }, + }), + { + merge: (persisted, current) => + merge(current, persisted as Partial), + + name: 'moodist-todos', + partialize: state => ({ todos: state.todos }), + skipHydration: true, + storage: createJSONStorage(() => localStorage), + version: 0, + }, + ), +);
{PHASE_LABELS[currentPhase]}
Super simple countdown timer.
- {formatTime(elapsedTime)}
+ {characters} character{characters !== 1 && 's'} • {words} word + {words !== 1 && 's'} +
+ {completions[selectedTab] || 0} completed +