mirror of
https://github.com/remvze/moodist.git
synced 2026-03-05 19:43:13 +08:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60cb453847 | ||
|
|
fc4f52146e | ||
|
|
1a1359c989 | ||
|
|
a6c7ac41ad | ||
|
|
3e11fb6123 | ||
|
|
d356d77aa9 | ||
|
|
9cc0ccd325 | ||
|
|
cad85c7667 | ||
|
|
def9a57e0c | ||
|
|
74f6b5851d | ||
|
|
f4c66e3092 | ||
|
|
28abc16b9c | ||
|
|
787a9b60b5 | ||
|
|
73a5c21be9 | ||
|
|
cfd2744e92 | ||
|
|
4c0f417469 | ||
|
|
9d1d8f8035 | ||
|
|
8a79ccf018 | ||
|
|
a3c384d105 | ||
|
|
96ca376885 | ||
|
|
18987cc339 | ||
|
|
919831538f | ||
|
|
edd15f4b9a | ||
|
|
09c0a6ce93 | ||
|
|
2bfb9b181c | ||
|
|
c272914416 | ||
|
|
d73b2bc1ff | ||
|
|
c5657d0642 | ||
|
|
c35409ce0a | ||
|
|
7658842324 | ||
|
|
78222be011 | ||
|
|
2c8135db43 | ||
|
|
fddf75cdca | ||
|
|
0f50e6ae8b | ||
|
|
4ae0504937 | ||
|
|
af075b32e6 | ||
|
|
82d8240b97 | ||
|
|
096251ec0a | ||
|
|
2a86a88ed6 |
@@ -41,6 +41,7 @@
|
||||
"sort-destructure-keys/sort-destructure-keys": "warn",
|
||||
"jsx-a11y/no-static-element-interactions": "off",
|
||||
"jsx-a11y/media-has-caption": "off",
|
||||
"jsx-a11y/no-noninteractive-tabindex": "off",
|
||||
"react/jsx-sort-props": [
|
||||
"warn",
|
||||
{
|
||||
|
||||
2979
package-lock.json
generated
2979
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -8,6 +8,7 @@
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"test": "vitest",
|
||||
"lint": "eslint . --ext .js,.jsx,.ts,.tsx,.astro",
|
||||
"lint:fix": "npm run lint -- --fix",
|
||||
"lint:style": "stylelint ./**/*.{css,astro,html}",
|
||||
@@ -23,14 +24,15 @@
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/react": "^3.0.3",
|
||||
"@astrojs/react": "3.6.0",
|
||||
"@floating-ui/react": "0.26.0",
|
||||
"@formkit/auto-animate": "0.8.2",
|
||||
"@radix-ui/react-dropdown-menu": "2.0.6",
|
||||
"@radix-ui/react-tooltip": "1.0.7",
|
||||
"@types/howler": "2.2.10",
|
||||
"@types/react": "^18.2.25",
|
||||
"@types/react-dom": "^18.2.10",
|
||||
"astro": "4.0.3",
|
||||
"astro": "4.10.3",
|
||||
"deepmerge": "4.3.1",
|
||||
"focus-trap-react": "10.2.3",
|
||||
"framer-motion": "10.16.4",
|
||||
@@ -40,6 +42,7 @@
|
||||
"react-hotkeys-hook": "3.2.1",
|
||||
"react-icons": "4.11.0",
|
||||
"react-wrap-balancer": "1.1.0",
|
||||
"uuid": "10.0.0",
|
||||
"zustand": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -88,6 +91,7 @@
|
||||
"stylelint-config-html": "1.1.0",
|
||||
"stylelint-config-recess-order": "4.4.0",
|
||||
"stylelint-config-standard": "34.0.0",
|
||||
"stylelint-prettier": "4.0.2"
|
||||
"stylelint-prettier": "4.0.2",
|
||||
"vitest": "1.6.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,10 +18,10 @@ const paragraphs = [
|
||||
body: 'The beauty of Moodist lies in its simplicity and customization. No complex menus or confusing options – just choose your desired sounds, adjust the volume balance, and hit play. Want to blend the gentle chirping of birds with the soothing sound of rain? No problem! Layer as many sounds as you like to create your personalized soundscape oasis.',
|
||||
title: 'Create Your Soundscape',
|
||||
},
|
||||
// {
|
||||
// body: 'Moodist goes beyond just ambient sounds by offering a suite of productivity tools to help you stay organized and focused. Utilize the built-in pomodoro timer to structure your workday in focused intervals, jot down thoughts and ideas in the simple notepad, and keep track of your tasks with the handy to-do list. These tools seamlessly integrate with the ambient soundscapes, allowing you to create a personalized environment that fosters both focus and relaxation.',
|
||||
// title: 'A Productivity Toolbox',
|
||||
// },
|
||||
{
|
||||
body: 'Moodist offers more than ambient sounds with its suite of productivity tools to keep you organized and focused. Use the built-in pomodoro timer for structured work intervals, jot down ideas in the notepad, track tasks with the to-do list (coming soon), and set multiple timers with the distraction-free countdown timer. These tools integrate seamlessly with the ambient soundscapes, creating a personalized environment that fosters focus and relaxation.',
|
||||
title: 'A Productivity Toolbox',
|
||||
},
|
||||
{
|
||||
body: "Whether you're looking to unwind after a long day, enhance your focus during work, or lull yourself into a peaceful sleep, Moodist has the perfect soundscape waiting for you. The best part? It's completely free and open-source, so you can enjoy its benefits without any strings attached. Start using Moodist today and discover your new haven of tranquility and focus!",
|
||||
title: 'Sounds for Every Moment',
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useShallow } from 'zustand/react/shallow';
|
||||
import { BiSolidHeart } from 'react-icons/bi/index';
|
||||
import { Howler } from 'howler';
|
||||
|
||||
import { useSoundStore } from '@/store';
|
||||
import { useSoundStore } from '@/stores/sound';
|
||||
|
||||
import { Container } from '@/components/container';
|
||||
import { StoreConsumer } from '@/components/store-consumer';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { BiPause, BiPlay } from 'react-icons/bi/index';
|
||||
|
||||
import { useSoundStore } from '@/store';
|
||||
import { useSoundStore } from '@/stores/sound';
|
||||
import { useSnackbar } from '@/contexts/snackbar';
|
||||
import { cn } from '@/helpers/styles';
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { AnimatePresence, motion } from 'framer-motion';
|
||||
|
||||
import { Tooltip } from '@/components/tooltip';
|
||||
|
||||
import { useSoundStore } from '@/store';
|
||||
import { useSoundStore } from '@/stores/sound';
|
||||
import { cn } from '@/helpers/styles';
|
||||
import { fade, mix, slideX } from '@/lib/motion';
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ export function Category({
|
||||
title,
|
||||
}: CategoryProps) {
|
||||
return (
|
||||
<div className={styles.category}>
|
||||
<div className={styles.category} id={`category-${id}`}>
|
||||
<div className={styles.iconContainer}>
|
||||
<div className={styles.tail} />
|
||||
<div className={styles.icon}>{icon}</div>
|
||||
|
||||
18
src/components/menu/items/breathing-exercise.tsx
Normal file
18
src/components/menu/items/breathing-exercise.tsx
Normal file
@@ -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 (
|
||||
<Item
|
||||
icon={<IoMdFlower />}
|
||||
label="Breathing Exercise"
|
||||
shortcut="Shift + B"
|
||||
onClick={open}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2,12 +2,17 @@ import { MdOutlineTimer } from 'react-icons/md/index';
|
||||
|
||||
import { Item } from '../item';
|
||||
|
||||
export function CountdownTimer() {
|
||||
interface SleepTimerProps {
|
||||
open: () => void;
|
||||
}
|
||||
|
||||
export function CountdownTimer({ open }: SleepTimerProps) {
|
||||
return (
|
||||
<Item
|
||||
href="https://timesy.app"
|
||||
icon={<MdOutlineTimer />}
|
||||
label="Countdown Timer"
|
||||
shortcut="Shift + C"
|
||||
onClick={open}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,3 +8,4 @@ export { Presets as PresetsItem } from './presets';
|
||||
export { Shortcuts as ShortcutsItem } from './shortcuts';
|
||||
export { SleepTimer as SleepTimerItem } from './sleep-timer';
|
||||
export { CountdownTimer as CountdownTimerItem } from './countdown-timer';
|
||||
export { BreathingExercise as BreathingExerciseItem } from './breathing-exercise';
|
||||
|
||||
@@ -2,7 +2,7 @@ import { MdNotes } from 'react-icons/md/index';
|
||||
|
||||
import { Item } from '../item';
|
||||
|
||||
import { useNoteStore } from '@/store';
|
||||
import { useNoteStore } from '@/stores/note';
|
||||
|
||||
interface NotepadProps {
|
||||
open: () => void;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { MdOutlineAvTimer } from 'react-icons/md/index';
|
||||
|
||||
import { Item } from '../item';
|
||||
|
||||
import { usePomodoroStore } from '@/store';
|
||||
import { usePomodoroStore } from '@/stores/pomodoro';
|
||||
|
||||
interface PomodoroProps {
|
||||
open: () => void;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { IoShareSocialSharp } from 'react-icons/io5/index';
|
||||
|
||||
import { Item } from '../item';
|
||||
|
||||
import { useSoundStore } from '@/store';
|
||||
import { useSoundStore } from '@/stores/sound';
|
||||
|
||||
interface ShareProps {
|
||||
open: () => void;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BiShuffle } from 'react-icons/bi/index';
|
||||
|
||||
import { useSoundStore } from '@/store';
|
||||
import { useSoundStore } from '@/stores/sound';
|
||||
|
||||
import { Item } from '../item';
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { IoMoonSharp } from 'react-icons/io5/index';
|
||||
|
||||
import { useSleepTimerStore } from '@/stores/sleep-timer';
|
||||
import { Item } from '../item';
|
||||
|
||||
interface SleepTimerProps {
|
||||
@@ -7,8 +8,11 @@ interface SleepTimerProps {
|
||||
}
|
||||
|
||||
export function SleepTimer({ open }: SleepTimerProps) {
|
||||
const active = useSleepTimerStore(state => state.active);
|
||||
|
||||
return (
|
||||
<Item
|
||||
active={active}
|
||||
icon={<IoMoonSharp />}
|
||||
label="Sleep Timer"
|
||||
shortcut="Shift + T"
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 4px;
|
||||
width: 250px;
|
||||
width: 270px;
|
||||
height: max-content;
|
||||
max-height: var(--radix-dropdown-menu-content-available-height);
|
||||
padding: 4px;
|
||||
|
||||
@@ -11,19 +11,25 @@ import {
|
||||
NotepadItem,
|
||||
SourceItem,
|
||||
PomodoroItem,
|
||||
CountdownTimerItem,
|
||||
PresetsItem,
|
||||
ShortcutsItem,
|
||||
SleepTimerItem,
|
||||
CountdownTimerItem,
|
||||
BreathingExerciseItem,
|
||||
} 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 { Notepad, Pomodoro } from '@/components/toolbox';
|
||||
import {
|
||||
Notepad,
|
||||
Pomodoro,
|
||||
CountdownTimer,
|
||||
BreathingExercise,
|
||||
} from '@/components/toolbox';
|
||||
import { fade, mix, slideY } from '@/lib/motion';
|
||||
import { useSoundStore } from '@/store';
|
||||
import { useSoundStore } from '@/stores/sound';
|
||||
|
||||
import styles from './menu.module.css';
|
||||
import { useCloseListener } from '@/hooks/use-close-listener';
|
||||
@@ -36,6 +42,8 @@ export function Menu() {
|
||||
|
||||
const initial = useMemo(
|
||||
() => ({
|
||||
breathingExercise: false,
|
||||
countdownTimer: false,
|
||||
notepad: false,
|
||||
pomodoro: false,
|
||||
presets: false,
|
||||
@@ -67,6 +75,8 @@ export function Menu() {
|
||||
useHotkeys('shift+m', () => setIsOpen(prev => !prev));
|
||||
useHotkeys('shift+n', () => open('notepad'));
|
||||
useHotkeys('shift+p', () => open('pomodoro'));
|
||||
useHotkeys('shift+c', () => open('countdownTimer'));
|
||||
useHotkeys('shift+b', () => open('breathingExercise'));
|
||||
useHotkeys('shift+alt+p', () => open('presets'));
|
||||
useHotkeys('shift+h', () => open('shortcuts'));
|
||||
useHotkeys('shift+s', () => open('shareLink'), { enabled: !noSelected });
|
||||
@@ -109,9 +119,12 @@ export function Menu() {
|
||||
<SleepTimerItem open={() => open('sleepTimer')} />
|
||||
|
||||
<Divider />
|
||||
<NotepadItem open={() => open('notepad')} />
|
||||
<PomodoroItem open={() => open('pomodoro')} />
|
||||
<CountdownTimerItem />
|
||||
<NotepadItem open={() => open('notepad')} />
|
||||
<BreathingExerciseItem
|
||||
open={() => open('breathingExercise')}
|
||||
/>
|
||||
<CountdownTimerItem open={() => open('countdownTimer')} />
|
||||
|
||||
<Divider />
|
||||
<ShortcutsItem open={() => open('shortcuts')} />
|
||||
@@ -142,6 +155,14 @@ export function Menu() {
|
||||
show={modals.pomodoro}
|
||||
onClose={() => close('pomodoro')}
|
||||
/>
|
||||
<BreathingExercise
|
||||
show={modals.breathingExercise}
|
||||
onClose={() => close('breathingExercise')}
|
||||
/>
|
||||
<CountdownTimer
|
||||
show={modals.countdownTimer}
|
||||
onClose={() => close('countdownTimer')}
|
||||
/>
|
||||
<SleepTimerModal
|
||||
show={modals.sleepTimer}
|
||||
onClose={() => close('sleepTimer')}
|
||||
|
||||
@@ -14,6 +14,7 @@ interface ModalProps {
|
||||
children: React.ReactNode;
|
||||
lockBody?: boolean;
|
||||
onClose: () => void;
|
||||
persist?: boolean;
|
||||
show: boolean;
|
||||
wide?: boolean;
|
||||
}
|
||||
@@ -22,6 +23,7 @@ export function Modal({
|
||||
children,
|
||||
lockBody = true,
|
||||
onClose,
|
||||
persist = false,
|
||||
show,
|
||||
wide,
|
||||
}: ModalProps) {
|
||||
@@ -50,40 +52,49 @@ export function Modal({
|
||||
return () => document.removeEventListener('keydown', keyListener);
|
||||
}, [onClose, show]);
|
||||
|
||||
const animationProps = persist
|
||||
? {
|
||||
animate: show ? 'show' : 'hidden',
|
||||
}
|
||||
: {
|
||||
animate: 'show',
|
||||
exit: 'hidden',
|
||||
initial: 'hidden',
|
||||
};
|
||||
|
||||
const content = (
|
||||
<FocusTrap active={show}>
|
||||
<div>
|
||||
<motion.div
|
||||
{...animationProps}
|
||||
className={styles.overlay}
|
||||
variants={variants.overlay}
|
||||
onClick={onClose}
|
||||
onKeyDown={onClose}
|
||||
/>
|
||||
<div className={styles.modal}>
|
||||
<motion.div
|
||||
{...animationProps}
|
||||
className={cn(styles.content, wide && styles.wide)}
|
||||
variants={variants.modal}
|
||||
>
|
||||
<button className={styles.close} onClick={onClose}>
|
||||
<IoClose />
|
||||
</button>
|
||||
{children}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</FocusTrap>
|
||||
);
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<AnimatePresence>
|
||||
{show && (
|
||||
<FocusTrap>
|
||||
<div>
|
||||
<motion.div
|
||||
animate="show"
|
||||
className={styles.overlay}
|
||||
exit="hidden"
|
||||
initial="hidden"
|
||||
variants={variants.overlay}
|
||||
onClick={onClose}
|
||||
onKeyDown={onClose}
|
||||
/>
|
||||
<div className={styles.modal}>
|
||||
<motion.div
|
||||
animate="show"
|
||||
className={cn(styles.content, wide && styles.wide)}
|
||||
exit="hidden"
|
||||
initial="hidden"
|
||||
variants={variants.modal}
|
||||
>
|
||||
<button className={styles.close} onClick={onClose}>
|
||||
<IoClose />
|
||||
</button>
|
||||
|
||||
{children}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</FocusTrap>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{persist ? (
|
||||
<div style={{ display: show ? 'block' : 'none' }}>{content}</div>
|
||||
) : (
|
||||
<AnimatePresence>{show && content}</AnimatePresence>
|
||||
)}
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { FaPlay, FaRegTrashAlt } from 'react-icons/fa/index';
|
||||
|
||||
import styles from './list.module.css';
|
||||
|
||||
import { usePresetStore, useSoundStore } from '@/store';
|
||||
import { useSoundStore } from '@/stores/sound';
|
||||
import { usePresetStore } from '@/stores/preset';
|
||||
|
||||
interface ListProps {
|
||||
close: () => void;
|
||||
@@ -25,15 +26,15 @@ export function List({ close }: ListProps) {
|
||||
<p className={styles.empty}>You don't have any presets yet.</p>
|
||||
)}
|
||||
|
||||
{presets.map((preset, index) => (
|
||||
<div className={styles.preset} key={index}>
|
||||
{presets.map(preset => (
|
||||
<div className={styles.preset} key={preset.id}>
|
||||
<input
|
||||
placeholder="Untitled"
|
||||
type="text"
|
||||
value={preset.label}
|
||||
onChange={e => changeName(index, e.target.value)}
|
||||
onChange={e => changeName(preset.id, e.target.value)}
|
||||
/>
|
||||
<button onClick={() => deletePreset(index)}>
|
||||
<button onClick={() => deletePreset(preset.id)}>
|
||||
<FaRegTrashAlt />
|
||||
</button>
|
||||
<button
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
|
||||
import { cn } from '@/helpers/styles';
|
||||
import { useSoundStore, usePresetStore } from '@/store';
|
||||
import { useSoundStore } from '@/stores/sound';
|
||||
import { usePresetStore } from '@/stores/preset';
|
||||
|
||||
import styles from './new.module.css';
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { IoCopyOutline, IoCheckmark } from 'react-icons/io5/index';
|
||||
import { Modal } from '@/components/modal';
|
||||
|
||||
import { useCopy } from '@/hooks/use-copy';
|
||||
import { useSoundStore } from '@/store';
|
||||
import { useSoundStore } from '@/stores/sound';
|
||||
|
||||
import styles from './share-link.module.css';
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
|
||||
import { Modal } from '@/components/modal';
|
||||
|
||||
import { useSoundStore } from '@/store';
|
||||
import { useSoundStore } from '@/stores/sound';
|
||||
import { useSnackbar } from '@/contexts/snackbar';
|
||||
import { useCloseListener } from '@/hooks/use-close-listener';
|
||||
import { cn } from '@/helpers/styles';
|
||||
|
||||
@@ -29,6 +29,14 @@ export function ShortcutsModal({ onClose, show }: ShortcutsModalProps) {
|
||||
keys: ['Shift', 'P'],
|
||||
label: 'Pomodoro Timer',
|
||||
},
|
||||
{
|
||||
keys: ['Shift', 'B'],
|
||||
label: 'Breathing Exercise',
|
||||
},
|
||||
{
|
||||
keys: ['Shift', 'C'],
|
||||
label: 'Countdown Timer',
|
||||
},
|
||||
{
|
||||
keys: ['Shift', 'T'],
|
||||
label: 'Sleep Timer',
|
||||
|
||||
@@ -42,6 +42,11 @@
|
||||
border: 1px solid var(--color-neutral-200);
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-neutral-400);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@ import { useEffect, useState, useRef, useMemo } from 'react';
|
||||
import { Modal } from '@/components/modal';
|
||||
import { Timer } from '@/components/timer';
|
||||
import { dispatch } from '@/lib/event';
|
||||
import { useSoundStore } from '@/store';
|
||||
import { useSoundStore } from '@/stores/sound';
|
||||
import { cn } from '@/helpers/styles';
|
||||
import { FADE_OUT } from '@/constants/events';
|
||||
import { useSleepTimerStore } from '@/stores/sleep-timer';
|
||||
|
||||
import styles from './sleep-timer.module.css';
|
||||
|
||||
@@ -15,8 +16,12 @@ interface SleepTimerModalProps {
|
||||
}
|
||||
|
||||
export function SleepTimerModal({ onClose, show }: SleepTimerModalProps) {
|
||||
const setActive = useSleepTimerStore(state => state.set);
|
||||
|
||||
const [running, setRunning] = useState(false);
|
||||
|
||||
useEffect(() => setActive(running), [running, setActive]);
|
||||
|
||||
const [hours, setHours] = useState<string>('0');
|
||||
const [minutes, setMinutes] = useState<string>('10');
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BiShuffle } from 'react-icons/bi/index';
|
||||
|
||||
import { Tooltip } from '@/components/tooltip';
|
||||
import { useSoundStore } from '@/store';
|
||||
import { useSoundStore } from '@/stores/sound';
|
||||
|
||||
import styles from './shuffle.module.css';
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { BiHeart, BiSolidHeart } from 'react-icons/bi/index';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
|
||||
import { useSoundStore } from '@/store';
|
||||
import { useSoundStore } from '@/stores/sound';
|
||||
import { cn } from '@/helpers/styles';
|
||||
import { fade } from '@/lib/motion';
|
||||
|
||||
import styles from './favorite.module.css';
|
||||
|
||||
import { useKeyboardButton } from '@/hooks/use-keyboard-button';
|
||||
import { waitUntil } from '@/helpers/wait';
|
||||
|
||||
interface FavoriteProps {
|
||||
id: string;
|
||||
@@ -18,11 +19,25 @@ export function Favorite({ id, label }: FavoriteProps) {
|
||||
const isFavorite = useSoundStore(state => state.sounds[id].isFavorite);
|
||||
const toggleFavorite = useSoundStore(state => state.toggleFavorite);
|
||||
|
||||
const handleToggle = async () => {
|
||||
toggleFavorite(id);
|
||||
|
||||
// Check if false -> true
|
||||
if (!isFavorite) {
|
||||
await waitUntil(
|
||||
() => !!document.getElementById('category-favorites'),
|
||||
50,
|
||||
);
|
||||
|
||||
document
|
||||
.getElementById('category-favorites')
|
||||
?.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
const variants = fade();
|
||||
|
||||
const handleKeyDown = useKeyboardButton(() => {
|
||||
toggleFavorite(id);
|
||||
});
|
||||
const handleKeyDown = useKeyboardButton(handleToggle);
|
||||
|
||||
return (
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
@@ -36,7 +51,7 @@ export function Favorite({ id, label }: FavoriteProps) {
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
toggleFavorite(id);
|
||||
handleToggle();
|
||||
}}
|
||||
>
|
||||
<motion.span
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useSoundStore } from '@/store';
|
||||
import { useSoundStore } from '@/stores/sound';
|
||||
|
||||
import styles from './range.module.css';
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ import { Range } from './range';
|
||||
import { Favorite } from './favorite';
|
||||
|
||||
import { useSound } from '@/hooks/use-sound';
|
||||
import { useSoundStore, useLoadingStore } from '@/store';
|
||||
import { useSoundStore } from '@/stores/sound';
|
||||
import { useLoadingStore } from '@/stores/loading';
|
||||
import { cn } from '@/helpers/styles';
|
||||
|
||||
import styles from './sound.module.css';
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useSoundStore, useNoteStore, usePresetStore } from '@/store';
|
||||
import { useSoundStore } from '@/stores/sound';
|
||||
import { useNoteStore } from '@/stores/note';
|
||||
import { usePresetStore } from '@/stores/preset';
|
||||
import { useCountdownTimers } from '@/stores/countdown-timers';
|
||||
|
||||
interface StoreConsumerProps {
|
||||
children: React.ReactNode;
|
||||
@@ -11,6 +14,7 @@ export function StoreConsumer({ children }: StoreConsumerProps) {
|
||||
useSoundStore.persist.rehydrate();
|
||||
useNoteStore.persist.rehydrate();
|
||||
usePresetStore.persist.rehydrate();
|
||||
useCountdownTimers.persist.rehydrate();
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
|
||||
1
src/components/toolbox/breathing/breathing.module.css
Normal file
1
src/components/toolbox/breathing/breathing.module.css
Normal file
@@ -0,0 +1 @@
|
||||
/* WIP */
|
||||
18
src/components/toolbox/breathing/breathing.tsx
Normal file
18
src/components/toolbox/breathing/breathing.tsx
Normal file
@@ -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 BreathingExercise({ onClose, show }: TimerProps) {
|
||||
return (
|
||||
<Modal show={show} onClose={onClose}>
|
||||
<h2 className={styles.title}>Breathing Exercise</h2>
|
||||
<Exercise />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
.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;
|
||||
|
||||
& .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(transparent, var(--color-neutral-100));
|
||||
border: 1px solid var(--color-neutral-200);
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
.selectBox {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 45px;
|
||||
padding: 0 12px;
|
||||
margin-top: 8px;
|
||||
font-size: var(--font-sm);
|
||||
color: var(--color-foreground);
|
||||
background-color: var(--color-neutral-100);
|
||||
border: 1px solid var(--color-neutral-200);
|
||||
border-radius: 8px;
|
||||
}
|
||||
120
src/components/toolbox/breathing/exercise/exercise.tsx
Normal file
120
src/components/toolbox/breathing/exercise/exercise.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
import styles from './exercise.module.css';
|
||||
|
||||
type Exercise = 'Box Breathing' | 'Resonant Breathing' | '4-7-8 Breathing';
|
||||
type Phase = 'inhale' | 'exhale' | 'holdInhale' | 'holdExhale';
|
||||
|
||||
export function Exercise() {
|
||||
const [selectedExercise, setSelectedExercise] =
|
||||
useState<Exercise>('4-7-8 Breathing');
|
||||
|
||||
const getAnimationPhases = (
|
||||
exercise: Exercise,
|
||||
): Array<'inhale' | 'holdInhale' | 'exhale' | 'holdExhale'> => {
|
||||
switch (exercise) {
|
||||
case 'Box Breathing':
|
||||
return ['inhale', 'holdInhale', 'exhale', 'holdExhale'];
|
||||
case 'Resonant Breathing':
|
||||
return ['inhale', 'exhale'];
|
||||
case '4-7-8 Breathing':
|
||||
return ['inhale', 'holdInhale', 'exhale'];
|
||||
default:
|
||||
return ['inhale', 'holdInhale', 'exhale', 'holdExhale'];
|
||||
}
|
||||
};
|
||||
|
||||
const getAnimationDurations = (exercise: Exercise) => {
|
||||
switch (exercise) {
|
||||
case 'Box Breathing':
|
||||
return { exhale: 4, holdExhale: 4, holdInhale: 4, inhale: 4 };
|
||||
case 'Resonant Breathing':
|
||||
return { exhale: 5, inhale: 5 };
|
||||
case '4-7-8 Breathing':
|
||||
return { exhale: 8, holdInhale: 7, inhale: 4 };
|
||||
default:
|
||||
return { exhale: 4, holdExhale: 4, holdInhale: 4, inhale: 4 };
|
||||
}
|
||||
};
|
||||
|
||||
const getLabel = (phase: Phase) => {
|
||||
switch (phase) {
|
||||
case 'inhale':
|
||||
return 'Inhale';
|
||||
case 'exhale':
|
||||
return 'Exhale';
|
||||
default:
|
||||
return 'Hold';
|
||||
}
|
||||
};
|
||||
|
||||
const [phase, setPhase] = useState<Phase>('inhale');
|
||||
const [durations, setDurations] = useState(
|
||||
getAnimationDurations(selectedExercise),
|
||||
);
|
||||
|
||||
const animationVariants = {
|
||||
exhale: { scale: 1, transition: { duration: durations.exhale } },
|
||||
holdExhale: {
|
||||
scale: 1,
|
||||
transition: { duration: durations.holdExhale || 4 },
|
||||
},
|
||||
holdInhale: {
|
||||
scale: 1.5,
|
||||
transition: { duration: durations.holdInhale || 4 },
|
||||
},
|
||||
inhale: { scale: 1.5, transition: { duration: durations.inhale } },
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setDurations(getAnimationDurations(selectedExercise));
|
||||
}, [selectedExercise]);
|
||||
|
||||
useEffect(() => {
|
||||
const phases = getAnimationPhases(selectedExercise);
|
||||
|
||||
let phaseIndex = 0;
|
||||
|
||||
setPhase(phases[phaseIndex]);
|
||||
|
||||
const interval = setInterval(
|
||||
() => {
|
||||
phaseIndex = (phaseIndex + 1) % phases.length;
|
||||
|
||||
setPhase(phases[phaseIndex]);
|
||||
},
|
||||
(durations[phases[phaseIndex]] || 4) * 1000,
|
||||
);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [selectedExercise, durations]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.exercise}>
|
||||
<motion.div
|
||||
animate={phase}
|
||||
className={styles.circle}
|
||||
key={selectedExercise}
|
||||
transition={{ ease: 'linear' }}
|
||||
variants={animationVariants}
|
||||
transformTemplate={(_, generatedString) =>
|
||||
`translate(-50%, -50%) ${generatedString}`
|
||||
}
|
||||
/>
|
||||
<p className={styles.phase}>{getLabel(phase)}</p>
|
||||
</div>
|
||||
|
||||
<select
|
||||
className={styles.selectBox}
|
||||
value={selectedExercise}
|
||||
onChange={e => setSelectedExercise(e.target.value as Exercise)}
|
||||
>
|
||||
<option value="Box Breathing">Box Breathing</option>
|
||||
<option value="Resonant Breathing">Resonant Breathing</option>
|
||||
<option value="4-7-8 Breathing">4-7-8 Breathing</option>
|
||||
</select>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
src/components/toolbox/breathing/exercise/index.ts
Normal file
1
src/components/toolbox/breathing/exercise/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Exercise } from './exercise';
|
||||
1
src/components/toolbox/breathing/index.ts
Normal file
1
src/components/toolbox/breathing/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { BreathingExercise } from './breathing';
|
||||
@@ -0,0 +1,6 @@
|
||||
.title {
|
||||
margin-bottom: 16px;
|
||||
font-family: var(--font-heading);
|
||||
font-size: var(--font-md);
|
||||
font-weight: 600;
|
||||
}
|
||||
25
src/components/toolbox/countdown-timer/countdown-timer.tsx
Normal file
25
src/components/toolbox/countdown-timer/countdown-timer.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useAutoAnimate } from '@formkit/auto-animate/react';
|
||||
|
||||
import { Modal } from '@/components/modal';
|
||||
|
||||
import { Form } from './form';
|
||||
import { Timers } from './timers';
|
||||
|
||||
import styles from './countdown-timer.module.css';
|
||||
|
||||
interface TimerProps {
|
||||
onClose: () => void;
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
export function CountdownTimer({ onClose, show }: TimerProps) {
|
||||
const [containerRef, enableAnimations] = useAutoAnimate<HTMLDivElement>();
|
||||
|
||||
return (
|
||||
<Modal persist show={show} onClose={onClose}>
|
||||
<h2 className={styles.title}>Countdown Timer</h2>
|
||||
<Form enableAnimations={enableAnimations} />
|
||||
<Timers enableAnimations={enableAnimations} ref={containerRef} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
.field {
|
||||
flex-grow: 1;
|
||||
|
||||
& .label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: var(--font-sm);
|
||||
font-weight: 500;
|
||||
|
||||
& .optional {
|
||||
font-weight: 400;
|
||||
color: var(--color-foreground-subtle);
|
||||
}
|
||||
}
|
||||
|
||||
& .input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 40px;
|
||||
padding: 0 16px;
|
||||
color: var(--color-foreground);
|
||||
background-color: var(--color-neutral-50);
|
||||
border: 1px solid var(--color-neutral-200);
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
51
src/components/toolbox/countdown-timer/form/field/field.tsx
Normal file
51
src/components/toolbox/countdown-timer/form/field/field.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import styles from './field.module.css';
|
||||
|
||||
interface FieldProps {
|
||||
children?: React.ReactNode;
|
||||
label: string;
|
||||
onChange: (value: string | number) => void;
|
||||
optional?: boolean;
|
||||
type: 'text' | 'select';
|
||||
value: string | number;
|
||||
}
|
||||
|
||||
export function Field({
|
||||
children,
|
||||
label,
|
||||
onChange,
|
||||
optional,
|
||||
type,
|
||||
value,
|
||||
}: FieldProps) {
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label} htmlFor={label.toLowerCase()}>
|
||||
{label}{' '}
|
||||
{optional && <span className={styles.optional}>(optional)</span>}
|
||||
</label>
|
||||
|
||||
{type === 'text' && (
|
||||
<input
|
||||
autoComplete="off"
|
||||
className={styles.input}
|
||||
id={label.toLowerCase()}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{type === 'select' && (
|
||||
<select
|
||||
autoComplete="off"
|
||||
className={styles.input}
|
||||
id={label.toLowerCase()}
|
||||
value={value}
|
||||
onChange={e => onChange(parseInt(e.target.value))}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { Field } from './field';
|
||||
27
src/components/toolbox/countdown-timer/form/form.module.css
Normal file
27
src/components/toolbox/countdown-timer/form/form.module.css
Normal file
@@ -0,0 +1,27 @@
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 28px;
|
||||
|
||||
& .button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 45px;
|
||||
font-weight: 500;
|
||||
color: var(--color-neutral-50);
|
||||
cursor: pointer;
|
||||
background-color: var(--color-neutral-950);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.timeFields {
|
||||
display: flex;
|
||||
column-gap: 12px;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
}
|
||||
112
src/components/toolbox/countdown-timer/form/form.tsx
Normal file
112
src/components/toolbox/countdown-timer/form/form.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
|
||||
import { Field } from './field';
|
||||
|
||||
import { useCountdownTimers } from '@/stores/countdown-timers';
|
||||
import { waitUntil } from '@/helpers/wait';
|
||||
|
||||
import styles from './form.module.css';
|
||||
|
||||
interface FormProps {
|
||||
enableAnimations: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export function Form({ enableAnimations }: FormProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [hours, setHours] = useState(0);
|
||||
const [minutes, setMinutes] = useState(10);
|
||||
const [seconds, setSeconds] = useState(0);
|
||||
|
||||
const totalSeconds = useMemo(
|
||||
() => hours * 60 * 60 + minutes * 60 + seconds,
|
||||
[hours, minutes, seconds],
|
||||
);
|
||||
|
||||
const add = useCountdownTimers(state => state.add);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (totalSeconds === 0) return;
|
||||
|
||||
enableAnimations(false);
|
||||
|
||||
const id = add({
|
||||
name,
|
||||
total: totalSeconds,
|
||||
});
|
||||
|
||||
setName('');
|
||||
|
||||
await waitUntil(() => !!document.getElementById(`timer-${id}`), 50);
|
||||
|
||||
document
|
||||
.getElementById(`timer-${id}`)
|
||||
?.scrollIntoView({ behavior: 'smooth' });
|
||||
|
||||
enableAnimations(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<form className={styles.form} onSubmit={handleSubmit}>
|
||||
<Field
|
||||
label="Timer Name"
|
||||
optional
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={value => setName(value as string)}
|
||||
/>
|
||||
|
||||
<div className={styles.timeFields}>
|
||||
<Field
|
||||
label="Hours"
|
||||
type="select"
|
||||
value={hours}
|
||||
onChange={value => setHours(value as number)}
|
||||
>
|
||||
{Array(13)
|
||||
.fill(null)
|
||||
.map((_, index) => (
|
||||
<option key={`hour-${index}`} value={index}>
|
||||
{index}
|
||||
</option>
|
||||
))}
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Minutes"
|
||||
type="select"
|
||||
value={minutes}
|
||||
onChange={value => setMinutes(value as number)}
|
||||
>
|
||||
{Array(60)
|
||||
.fill(null)
|
||||
.map((_, index) => (
|
||||
<option key={`minutes-${index}`} value={index}>
|
||||
{index}
|
||||
</option>
|
||||
))}
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Seconds"
|
||||
type="select"
|
||||
value={seconds}
|
||||
onChange={value => setSeconds(value as number)}
|
||||
>
|
||||
{Array(60)
|
||||
.fill(null)
|
||||
.map((_, index) => (
|
||||
<option key={`seconds-${index}`} value={index}>
|
||||
{index}
|
||||
</option>
|
||||
))}
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<button className={styles.button} type="submit">
|
||||
Add Timer
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
1
src/components/toolbox/countdown-timer/form/index.ts
Normal file
1
src/components/toolbox/countdown-timer/form/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Form } from './form';
|
||||
1
src/components/toolbox/countdown-timer/index.ts
Normal file
1
src/components/toolbox/countdown-timer/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { CountdownTimer } from './countdown-timer';
|
||||
1
src/components/toolbox/countdown-timer/timers/index.ts
Normal file
1
src/components/toolbox/countdown-timer/timers/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Timers } from './timers';
|
||||
@@ -0,0 +1 @@
|
||||
export { Notice } from './notice';
|
||||
@@ -0,0 +1,11 @@
|
||||
.notice {
|
||||
padding: 16px;
|
||||
margin-top: 16px;
|
||||
font-size: var(--font-sm);
|
||||
line-height: 1.65;
|
||||
color: var(--color-foreground-subtle);
|
||||
text-align: center;
|
||||
background-color: var(--color-neutral-50);
|
||||
border: 1px dashed var(--color-neutral-300);
|
||||
border-radius: 8px;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import styles from './notice.module.css';
|
||||
|
||||
export function Notice() {
|
||||
return (
|
||||
<p className={styles.notice}>
|
||||
Please do not close your browser tab while timers are running, otherwise
|
||||
all timers will be stopped.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { Timer } from './timer';
|
||||
@@ -0,0 +1,127 @@
|
||||
.timer {
|
||||
position: relative;
|
||||
padding: 8px;
|
||||
overflow: hidden;
|
||||
background-color: var(--color-neutral-100);
|
||||
border: 1px solid var(--color-neutral-200);
|
||||
border-radius: 8px;
|
||||
|
||||
&:not(:last-of-type) {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
& .header {
|
||||
position: relative;
|
||||
top: -8px;
|
||||
width: 100%;
|
||||
|
||||
& .bar {
|
||||
height: 2px;
|
||||
margin: 0 -8px;
|
||||
background-color: var(--color-neutral-200);
|
||||
|
||||
& .completed {
|
||||
height: 100%;
|
||||
background-color: var(--color-neutral-500);
|
||||
transition: 0.2s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& .footer {
|
||||
display: flex;
|
||||
column-gap: 4px;
|
||||
align-items: center;
|
||||
|
||||
& .control {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
column-gap: 4px;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
padding: 4px;
|
||||
background-color: var(--color-neutral-50);
|
||||
border: 1px solid var(--color-neutral-200);
|
||||
border-radius: 4px;
|
||||
|
||||
& .input {
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
padding: 0 8px;
|
||||
color: var(--color-foreground-subtle);
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
|
||||
&.finished {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
|
||||
& .button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
color: var(--color-foreground);
|
||||
cursor: pointer;
|
||||
background-color: var(--color-neutral-200);
|
||||
border: 1px solid var(--color-neutral-300);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
transition: 0.2s;
|
||||
|
||||
&.reset {
|
||||
background-color: var(--color-neutral-100);
|
||||
border: none;
|
||||
}
|
||||
|
||||
&:disabled,
|
||||
&.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& .delete {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
min-width: 38px;
|
||||
height: 38px;
|
||||
color: #f43f5e;
|
||||
cursor: pointer;
|
||||
background-color: rgb(244 63 94 / 10%);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
transition: 0.2s;
|
||||
|
||||
&.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& .left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 120px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-2xlg);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
|
||||
& span {
|
||||
color: var(--color-foreground-subtle);
|
||||
}
|
||||
}
|
||||
}
|
||||
222
src/components/toolbox/countdown-timer/timers/timer/timer.tsx
Normal file
222
src/components/toolbox/countdown-timer/timers/timer/timer.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import { useRef, useMemo, useState, useEffect } from 'react';
|
||||
import {
|
||||
IoPlay,
|
||||
IoPause,
|
||||
IoRefresh,
|
||||
IoTrashOutline,
|
||||
} from 'react-icons/io5/index';
|
||||
|
||||
import { Toolbar } from './toolbar';
|
||||
|
||||
import { useCountdownTimers } from '@/stores/countdown-timers';
|
||||
import { useAlarm } from '@/hooks/use-alarm';
|
||||
import { useSnackbar } from '@/contexts/snackbar';
|
||||
import { padNumber } from '@/helpers/number';
|
||||
import { cn } from '@/helpers/styles';
|
||||
|
||||
import styles from './timer.module.css';
|
||||
|
||||
interface TimerProps {
|
||||
enableAnimations: (enabled: boolean) => void;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export function Timer({ enableAnimations, id }: TimerProps) {
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const lastActiveTimeRef = useRef<number | null>(null);
|
||||
const lastStateRef = useRef<{ spent: number; total: number } | null>(null);
|
||||
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
|
||||
const { first, last, name, spent, total } = useCountdownTimers(state =>
|
||||
state.getTimer(id),
|
||||
);
|
||||
const tick = useCountdownTimers(state => state.tick);
|
||||
const rename = useCountdownTimers(state => state.rename);
|
||||
const reset = useCountdownTimers(state => state.reset);
|
||||
const deleteTimer = useCountdownTimers(state => state.delete);
|
||||
|
||||
const left = useMemo(() => total - spent, [total, spent]);
|
||||
|
||||
const hours = useMemo(() => Math.floor(left / 3600), [left]);
|
||||
const minutes = useMemo(() => Math.floor((left % 3600) / 60), [left]);
|
||||
const seconds = useMemo(() => left % 60, [left]);
|
||||
|
||||
const [isReversed, setIsReversed] = useState(false);
|
||||
|
||||
const spentHours = useMemo(() => Math.floor(spent / 3600), [spent]);
|
||||
const spentMinutes = useMemo(() => Math.floor((spent % 3600) / 60), [spent]);
|
||||
const spentSeconds = useMemo(() => spent % 60, [spent]);
|
||||
|
||||
const playAlarm = useAlarm();
|
||||
|
||||
const showSnackbar = useSnackbar();
|
||||
|
||||
const handleStart = () => {
|
||||
if (left > 0) setIsRunning(true);
|
||||
};
|
||||
|
||||
const handlePause = () => setIsRunning(false);
|
||||
|
||||
const handleToggle = () => {
|
||||
if (isRunning) handlePause();
|
||||
else handleStart();
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (spent === 0) return;
|
||||
|
||||
if (isRunning) return showSnackbar('Please first stop the timer.');
|
||||
|
||||
setIsRunning(false);
|
||||
reset(id);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (isRunning) return showSnackbar('Please first stop the timer.');
|
||||
|
||||
enableAnimations(false);
|
||||
|
||||
deleteTimer(id);
|
||||
|
||||
setTimeout(() => enableAnimations(true), 100);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isRunning) {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
|
||||
intervalRef.current = setInterval(() => tick(id), 1000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [isRunning, tick, id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (left === 0 && isRunning) {
|
||||
setIsRunning(false);
|
||||
playAlarm();
|
||||
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
}
|
||||
}, [left, isRunning, playAlarm]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleBlur = () => {
|
||||
if (isRunning) {
|
||||
lastActiveTimeRef.current = Date.now();
|
||||
lastStateRef.current = { spent, total };
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
if (isRunning && lastActiveTimeRef.current && lastStateRef.current) {
|
||||
const elapsed = Math.floor(
|
||||
(Date.now() - lastActiveTimeRef.current) / 1000,
|
||||
);
|
||||
const previousLeft =
|
||||
lastStateRef.current.total - lastStateRef.current.spent;
|
||||
const currentLeft = left;
|
||||
const correctedLeft = previousLeft - elapsed;
|
||||
|
||||
if (correctedLeft < currentLeft) {
|
||||
tick(id, currentLeft - correctedLeft);
|
||||
}
|
||||
|
||||
lastActiveTimeRef.current = null;
|
||||
lastStateRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('blur', handleBlur);
|
||||
window.addEventListener('focus', handleFocus);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('blur', handleBlur);
|
||||
window.removeEventListener('focus', handleFocus);
|
||||
};
|
||||
}, [isRunning, tick, id, spent, total, left]);
|
||||
|
||||
return (
|
||||
<div className={styles.timer} id={`timer-${id}`}>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.bar}>
|
||||
<div
|
||||
className={styles.completed}
|
||||
style={{ width: `${(left / total) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Toolbar first={first} id={id} last={last} />
|
||||
|
||||
<div
|
||||
className={styles.left}
|
||||
tabIndex={0}
|
||||
onClick={() => setIsReversed(prev => !prev)}
|
||||
onKeyDown={() => setIsReversed(prev => !prev)}
|
||||
>
|
||||
{!isReversed ? (
|
||||
<>
|
||||
{padNumber(hours)}
|
||||
<span>:</span>
|
||||
{padNumber(minutes)}
|
||||
<span>:</span>
|
||||
{padNumber(seconds)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>-</span>
|
||||
{padNumber(spentHours)}
|
||||
<span>:</span>
|
||||
{padNumber(spentMinutes)}
|
||||
<span>:</span>
|
||||
{padNumber(spentSeconds)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className={styles.footer}>
|
||||
<div className={styles.control}>
|
||||
<input
|
||||
className={cn(styles.input, left === 0 && styles.finished)}
|
||||
placeholder="Untitled"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => rename(id, e.target.value)}
|
||||
/>
|
||||
|
||||
<button
|
||||
aria-disabled={isRunning || spent === 0}
|
||||
className={cn(
|
||||
styles.button,
|
||||
styles.reset,
|
||||
(isRunning || spent === 0) && styles.disabled,
|
||||
)}
|
||||
onClick={handleReset}
|
||||
>
|
||||
<IoRefresh />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={styles.button}
|
||||
disabled={!isRunning && left === 0}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
{isRunning ? <IoPause /> : <IoPlay />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
aria-disabled={isRunning}
|
||||
className={cn(styles.delete, isRunning && styles.disabled)}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<IoTrashOutline />
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { Toolbar } from './toolbar';
|
||||
@@ -0,0 +1,37 @@
|
||||
.toolbar {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
display: flex;
|
||||
column-gap: 4px;
|
||||
align-items: center;
|
||||
height: 30px;
|
||||
padding: 4px;
|
||||
background-color: var(--color-neutral-50);
|
||||
border: 1px solid var(--color-neutral-200);
|
||||
border-radius: 4px;
|
||||
|
||||
& button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
font-size: var(--font-xsm);
|
||||
color: var(--color-foreground-subtle);
|
||||
cursor: pointer;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
transition: 0.2s;
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
&:not(:disabled):hover {
|
||||
color: var(--color-foreground);
|
||||
background-color: var(--color-neutral-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { IoIosArrowDown, IoIosArrowUp } from 'react-icons/io/index';
|
||||
|
||||
import { useCountdownTimers } from '@/stores/countdown-timers';
|
||||
|
||||
import styles from './toolbar.module.css';
|
||||
|
||||
interface ToolbarProps {
|
||||
first: boolean;
|
||||
id: string;
|
||||
last: boolean;
|
||||
}
|
||||
|
||||
export function Toolbar({ first, id, last }: ToolbarProps) {
|
||||
const moveUp = useCountdownTimers(state => state.moveUp);
|
||||
const moveDown = useCountdownTimers(state => state.moveDown);
|
||||
|
||||
return (
|
||||
<div className={styles.toolbar}>
|
||||
<button
|
||||
disabled={first}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
moveUp(id);
|
||||
}}
|
||||
>
|
||||
<IoIosArrowUp />
|
||||
</button>
|
||||
<button
|
||||
disabled={last}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
moveDown(id);
|
||||
}}
|
||||
>
|
||||
<IoIosArrowDown />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
.timers {
|
||||
margin-top: 48px;
|
||||
|
||||
& > header {
|
||||
display: flex;
|
||||
column-gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
& .title {
|
||||
font-family: var(--font-heading);
|
||||
font-size: var(--font-md);
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
& .line {
|
||||
flex-grow: 1;
|
||||
height: 0;
|
||||
border-top: 1px dashed var(--color-neutral-200);
|
||||
}
|
||||
|
||||
& .spent {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--color-foreground-subtle);
|
||||
}
|
||||
}
|
||||
}
|
||||
55
src/components/toolbox/countdown-timer/timers/timers.tsx
Normal file
55
src/components/toolbox/countdown-timer/timers/timers.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useMemo, forwardRef } from 'react';
|
||||
|
||||
import { Timer } from './timer';
|
||||
import { Notice } from './notice';
|
||||
|
||||
import { useCountdownTimers } from '@/stores/countdown-timers';
|
||||
|
||||
import styles from './timers.module.css';
|
||||
|
||||
interface TimersProps {
|
||||
enableAnimations: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export const Timers = forwardRef(function Timers(
|
||||
{ enableAnimations }: TimersProps,
|
||||
ref: React.ForwardedRef<HTMLDivElement>,
|
||||
) {
|
||||
const timers = useCountdownTimers(state => state.timers);
|
||||
const spent = useCountdownTimers(state => state.spent());
|
||||
const total = useCountdownTimers(state => state.total());
|
||||
|
||||
const spentMinutes = useMemo(() => Math.floor(spent / 60), [spent]);
|
||||
const totalMinutes = useMemo(() => Math.floor(total / 60), [total]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{timers.length > 0 ? (
|
||||
<div className={styles.timers}>
|
||||
<header>
|
||||
<h2 className={styles.title}>Timers</h2>
|
||||
<div className={styles.line} />
|
||||
{totalMinutes > 0 && (
|
||||
<p className={styles.spent}>
|
||||
{spentMinutes} / {totalMinutes} Minute
|
||||
{totalMinutes !== 1 && 's'}
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div ref={ref}>
|
||||
{timers.map(timer => (
|
||||
<Timer
|
||||
enableAnimations={enableAnimations}
|
||||
id={timer.id}
|
||||
key={timer.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Notice />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,2 +1,4 @@
|
||||
export { Notepad } from './notepad';
|
||||
export { Pomodoro } from './pomodoro';
|
||||
export { CountdownTimer } from './countdown-timer';
|
||||
export { BreathingExercise } from './breathing';
|
||||
|
||||
@@ -7,7 +7,7 @@ import { FaUndo } from 'react-icons/fa/index';
|
||||
import { Modal } from '@/components/modal';
|
||||
import { Button } from './button';
|
||||
|
||||
import { useNoteStore } from '@/store';
|
||||
import { useNoteStore } from '@/stores/note';
|
||||
import { useCopy } from '@/hooks/use-copy';
|
||||
import { download } from '@/helpers/download';
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Setting } from './setting';
|
||||
|
||||
import { useLocalStorage } from '@/hooks/use-local-storage';
|
||||
import { useSoundEffect } from '@/hooks/use-sound-effect';
|
||||
import { usePomodoroStore } from '@/store';
|
||||
import { usePomodoroStore } from '@/stores/pomodoro';
|
||||
import { useCloseListener } from '@/hooks/use-close-listener';
|
||||
|
||||
import styles from './pomodoro.module.css';
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export const FADE_OUT = 'FADE_OUT';
|
||||
export const CLOSE_MODALS = 'CLOSE_MODALS';
|
||||
export const FADE_OUT = 'FADE_OUT'; // Fade out sounds
|
||||
export const CLOSE_MODALS = 'CLOSE_MODALS'; // Close all modals
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
/**
|
||||
* Counts the number of characters and words in a given string.
|
||||
*
|
||||
* @param {string} _string - The input string to be analyzed.
|
||||
* @returns {{characters: number, words: number}} An object containing the counts:
|
||||
* - characters: The number of non-whitespace characters in the input string.
|
||||
* - words: The number of words in the input string.
|
||||
*/
|
||||
export function count(_string: string) {
|
||||
const string = _string.trim();
|
||||
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* Triggers a download of a file with the specified filename and content.
|
||||
*
|
||||
* @param {string} filename - The name of the file to be downloaded.
|
||||
* @param {string} content - The content to be included in the downloaded file.
|
||||
*/
|
||||
export function download(filename: string, content: string) {
|
||||
const element = document.createElement('a');
|
||||
element.setAttribute(
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
/**
|
||||
* Pads a given number with leading zeros to ensure it reaches a specified length.
|
||||
*
|
||||
* @param {number} number - The number to be padded.
|
||||
* @param {number} [maxLength=2] - The desired length of the resulting string. Defaults to 2 if not provided.
|
||||
* @returns {string} The padded number as a string.
|
||||
*/
|
||||
export function padNumber(number: number, maxLength: number = 2): string {
|
||||
return number.toString().padStart(maxLength, '0');
|
||||
const isNegative = number < 0;
|
||||
const absoluteNumber = Math.abs(number).toString();
|
||||
const paddedNumber = absoluteNumber.padStart(maxLength, '0');
|
||||
|
||||
return isNegative ? `-${paddedNumber}` : paddedNumber;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,63 @@
|
||||
/**
|
||||
* Generates a random number between the specified minimum and maximum values.
|
||||
*
|
||||
* @param {number} min - The minimum value (inclusive).
|
||||
* @param {number} max - The maximum value (exclusive).
|
||||
* @returns {number} A random number between min (inclusive) and max (exclusive).
|
||||
*/
|
||||
export function random(min: number, max: number): number {
|
||||
return Math.random() * (max - min) + min;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a random integer between the specified minimum and maximum values.
|
||||
*
|
||||
* @param {number} min - The minimum value (inclusive).
|
||||
* @param {number} max - The maximum value (exclusive).
|
||||
* @returns {number} A random integer between min (inclusive) and max (exclusive).
|
||||
*/
|
||||
export function randomInt(min: number, max: number): number {
|
||||
return Math.floor(random(min, max));
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks a random element from the given array.
|
||||
*
|
||||
* @template T
|
||||
* @param {Array<T>} array - The array to pick an element from.
|
||||
* @returns {T} A random element from the array.
|
||||
*/
|
||||
export function pick<T>(array: Array<T>): T {
|
||||
const randomIndex = random(0, array.length);
|
||||
if (array.length === 0) {
|
||||
throw new Error("The array shouldn't be empty");
|
||||
}
|
||||
|
||||
const randomIndex = randomInt(0, array.length);
|
||||
|
||||
return array[randomIndex];
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks a specified number of random elements from the given array.
|
||||
*
|
||||
* @template T
|
||||
* @param {Array<T>} array - The array to pick elements from.
|
||||
* @param {number} count - The number of elements to pick.
|
||||
* @returns {Array<T>} An array containing the picked elements.
|
||||
*/
|
||||
export function pickMany<T>(array: Array<T>, count: number): Array<T> {
|
||||
const shuffled = shuffle(array);
|
||||
|
||||
return shuffled.slice(0, count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuffles the elements of the given array in random order.
|
||||
*
|
||||
* @template T
|
||||
* @param {Array<T>} array - The array to shuffle.
|
||||
* @returns {Array<T>} The shuffled array.
|
||||
*/
|
||||
export function shuffle<T>(array: Array<T>): Array<T> {
|
||||
return array
|
||||
.map(value => ({ sort: Math.random(), value }))
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
type className = undefined | null | false | string;
|
||||
|
||||
/**
|
||||
* Combines multiple class names into a single string, filtering out invalid values.
|
||||
*
|
||||
* @param {...(undefined|null|false|string)} classNames - The class names to be combined.
|
||||
* @returns {string} A single string containing all valid class names separated by spaces.
|
||||
*/
|
||||
export function cn(...classNames: Array<className>): string {
|
||||
const className = classNames.filter(className => !!className).join(' ');
|
||||
|
||||
|
||||
53
src/helpers/tests/counter.test.ts
Normal file
53
src/helpers/tests/counter.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { count } from '../counter';
|
||||
|
||||
describe('count function', () => {
|
||||
it('should count characters and words in an empty string', () => {
|
||||
const result = count('');
|
||||
expect(result.characters).toBe(0);
|
||||
expect(result.words).toBe(0);
|
||||
});
|
||||
|
||||
it('should count characters and words in a string with multiple words', () => {
|
||||
const result = count('Hello world');
|
||||
expect(result.characters).toBe(10);
|
||||
expect(result.words).toBe(2);
|
||||
});
|
||||
|
||||
it('should count characters and words in a string with multiple spaces', () => {
|
||||
const result = count(' Hello world ');
|
||||
expect(result.characters).toBe(10);
|
||||
expect(result.words).toBe(2);
|
||||
});
|
||||
|
||||
it('should count characters and words in a string with newlines', () => {
|
||||
const result = count('Hello\nworld');
|
||||
expect(result.characters).toBe(10);
|
||||
expect(result.words).toBe(2);
|
||||
});
|
||||
|
||||
it('should count characters and words in a string with special characters', () => {
|
||||
const result = count('Hello, world!');
|
||||
expect(result.characters).toBe(12);
|
||||
expect(result.words).toBe(2);
|
||||
});
|
||||
|
||||
it('should count characters and words in a string with only spaces', () => {
|
||||
const result = count(' ');
|
||||
expect(result.characters).toBe(0);
|
||||
expect(result.words).toBe(0);
|
||||
});
|
||||
|
||||
it('should count characters and words in a string with a single word', () => {
|
||||
const result = count('Vitest');
|
||||
expect(result.characters).toBe(6);
|
||||
expect(result.words).toBe(1);
|
||||
});
|
||||
|
||||
it('should count characters and words in a string with multiple lines and spaces', () => {
|
||||
const result = count(' Hello \n world ');
|
||||
expect(result.characters).toBe(10);
|
||||
expect(result.words).toBe(2);
|
||||
});
|
||||
});
|
||||
49
src/helpers/tests/number.test.ts
Normal file
49
src/helpers/tests/number.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { padNumber } from '../number';
|
||||
|
||||
describe('padNumber function', () => {
|
||||
it('should pad a single digit number to two digits by default', () => {
|
||||
const result = padNumber(5);
|
||||
expect(result).toBe('05');
|
||||
});
|
||||
|
||||
it('should not pad a number that already has two digits by default', () => {
|
||||
const result = padNumber(12);
|
||||
expect(result).toBe('12');
|
||||
});
|
||||
|
||||
it('should pad a number to the specified length', () => {
|
||||
const result = padNumber(7, 4);
|
||||
expect(result).toBe('0007');
|
||||
});
|
||||
|
||||
it('should not pad a number that already meets the specified length', () => {
|
||||
const result = padNumber(1234, 4);
|
||||
expect(result).toBe('1234');
|
||||
});
|
||||
|
||||
it('should pad a number that has more digits than the specified length', () => {
|
||||
const result = padNumber(123, 5);
|
||||
expect(result).toBe('00123');
|
||||
});
|
||||
|
||||
it('should handle zero correctly', () => {
|
||||
const result = padNumber(0, 3);
|
||||
expect(result).toBe('000');
|
||||
});
|
||||
|
||||
it('should pad negative numbers correctly', () => {
|
||||
const result = padNumber(-5, 3);
|
||||
expect(result).toBe('-005');
|
||||
});
|
||||
|
||||
it('should handle very large padding lengths', () => {
|
||||
const result = padNumber(42, 10);
|
||||
expect(result).toBe('0000000042');
|
||||
});
|
||||
|
||||
it('should handle the maximum length being less than the number length', () => {
|
||||
const result = padNumber(12345, 3);
|
||||
expect(result).toBe('12345');
|
||||
});
|
||||
});
|
||||
89
src/helpers/tests/random.test.ts
Normal file
89
src/helpers/tests/random.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { random, randomInt, pick, pickMany, shuffle } from '../random';
|
||||
|
||||
describe('random function', () => {
|
||||
it('should generate a number between min (inclusive) and max (exclusive)', () => {
|
||||
const min = 1;
|
||||
const max = 10;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const result = random(min, max);
|
||||
expect(result).toBeGreaterThanOrEqual(min);
|
||||
expect(result).toBeLessThan(max);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('randomInt function', () => {
|
||||
it('should generate an integer between min (inclusive) and max (exclusive)', () => {
|
||||
const min = 1;
|
||||
const max = 10;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const result = randomInt(min, max);
|
||||
expect(result).toBeGreaterThanOrEqual(min);
|
||||
expect(result).toBeLessThan(max);
|
||||
expect(Number.isInteger(result)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('pick function', () => {
|
||||
it('should pick a random element from the array', () => {
|
||||
const array = [1, 2, 3, 4, 5];
|
||||
const result = pick(array);
|
||||
expect(array).toContain(result);
|
||||
});
|
||||
|
||||
it('should handle an array with one element', () => {
|
||||
const array = [1];
|
||||
const result = pick(array);
|
||||
expect(result).toBe(1);
|
||||
});
|
||||
|
||||
it('should throw an error when picking from an empty array', () => {
|
||||
const array: unknown[] = [];
|
||||
expect(() => pick(array)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickMany function', () => {
|
||||
it('should pick the specified number of random elements from the array', () => {
|
||||
const array = [1, 2, 3, 4, 5];
|
||||
const count = 3;
|
||||
const result = pickMany(array, count);
|
||||
expect(result).toHaveLength(count);
|
||||
result.forEach(element => {
|
||||
expect(array).toContain(element);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle picking more elements than in the array', () => {
|
||||
const array = [1, 2, 3];
|
||||
const count = 5;
|
||||
const result = pickMany(array, count);
|
||||
expect(result).toHaveLength(array.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shuffle function', () => {
|
||||
it('should shuffle the elements of the array', () => {
|
||||
const array = [1, 2, 3, 4, 5];
|
||||
const result = shuffle(array);
|
||||
expect(result).toHaveLength(array.length);
|
||||
expect(result).not.toEqual(array); // It's possible for the arrays to be equal, but this is highly unlikely
|
||||
array.forEach(element => {
|
||||
expect(result).toContain(element);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle an empty array', () => {
|
||||
const array: unknown[] = [];
|
||||
const result = shuffle(array);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle an array with one element', () => {
|
||||
const array = [1];
|
||||
const result = shuffle(array);
|
||||
expect(result).toEqual(array);
|
||||
});
|
||||
});
|
||||
49
src/helpers/tests/styles.test.ts
Normal file
49
src/helpers/tests/styles.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { cn } from '../styles';
|
||||
|
||||
describe('cn function', () => {
|
||||
it('should return an empty string when no arguments are provided', () => {
|
||||
const result = cn();
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should return an empty string when all arguments are invalid values', () => {
|
||||
const result = cn(undefined, null, false, '');
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should return a single class name when one valid string is provided', () => {
|
||||
const result = cn('class1');
|
||||
expect(result).toBe('class1');
|
||||
});
|
||||
|
||||
it('should combine multiple class names into a single string separated by spaces', () => {
|
||||
const result = cn('class1', 'class2', 'class3');
|
||||
expect(result).toBe('class1 class2 class3');
|
||||
});
|
||||
|
||||
it('should filter out invalid values and combine valid class names', () => {
|
||||
const result = cn('class1', undefined, 'class2', null, false, 'class3', '');
|
||||
expect(result).toBe('class1 class2 class3');
|
||||
});
|
||||
|
||||
it('should handle a mix of valid and invalid class names', () => {
|
||||
const result = cn('class1', '', false, null, 'class2');
|
||||
expect(result).toBe('class1 class2');
|
||||
});
|
||||
|
||||
it('should return an empty string when all class names are empty strings', () => {
|
||||
const result = cn('', '', '');
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should handle single class name with leading and trailing spaces', () => {
|
||||
const result = cn(' class1 ');
|
||||
expect(result).toBe(' class1 ');
|
||||
});
|
||||
|
||||
it('should handle class names with spaces in between', () => {
|
||||
const result = cn('class1 class2', 'class3 class4');
|
||||
expect(result).toBe('class1 class2 class3 class4');
|
||||
});
|
||||
});
|
||||
67
src/helpers/tests/wait.test.ts
Normal file
67
src/helpers/tests/wait.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { waitUntil } from '../wait';
|
||||
|
||||
describe('waitUntil function', () => {
|
||||
it('should resolve when the function returns true', async () => {
|
||||
const mockFunc = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(false)
|
||||
.mockReturnValueOnce(false)
|
||||
.mockReturnValueOnce(true);
|
||||
|
||||
await waitUntil(mockFunc, 50);
|
||||
expect(mockFunc).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should reject if the function throws an error', async () => {
|
||||
const mockFunc = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(false)
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('Test error');
|
||||
});
|
||||
|
||||
await expect(waitUntil(mockFunc, 50)).rejects.toThrow('Test error');
|
||||
expect(mockFunc).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should repeatedly call the function at the specified interval', async () => {
|
||||
const mockFunc = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(false)
|
||||
.mockReturnValueOnce(false)
|
||||
.mockReturnValueOnce(true);
|
||||
|
||||
const interval = 100;
|
||||
const startTime = Date.now();
|
||||
|
||||
await waitUntil(mockFunc, interval);
|
||||
|
||||
const endTime = Date.now();
|
||||
const elapsedTime = endTime - startTime;
|
||||
|
||||
expect(elapsedTime).toBeGreaterThanOrEqual(2 * interval);
|
||||
expect(mockFunc).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should handle the function returning true on the first call', async () => {
|
||||
const mockFunc = vi.fn().mockReturnValueOnce(true);
|
||||
|
||||
await waitUntil(mockFunc, 50);
|
||||
expect(mockFunc).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle the function never returning true (timeout simulation)', async () => {
|
||||
const mockFunc = vi.fn().mockReturnValue(false);
|
||||
|
||||
// Using a very short timeout to simulate test timeout
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Test timeout')), 300),
|
||||
);
|
||||
|
||||
await expect(
|
||||
Promise.race([waitUntil(mockFunc, 50), timeoutPromise]),
|
||||
).rejects.toThrow('Test timeout');
|
||||
expect(mockFunc).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
27
src/helpers/wait.ts
Normal file
27
src/helpers/wait.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Repeatedly calls a function at a specified interval until it returns `true`.
|
||||
*
|
||||
* @param {() => boolean} func - A function that returns a boolean. The interval will continue until this function returns `true`.
|
||||
* @param {number} interval - The time, in milliseconds, between each call to `func`.
|
||||
* @returns {Promise<void>} A promise that resolves when `func` returns `true`, or rejects if an error is thrown during execution of `func`.
|
||||
*/
|
||||
export function waitUntil(
|
||||
func: () => boolean,
|
||||
interval: number,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const intervalId = setInterval(() => {
|
||||
try {
|
||||
const result = func();
|
||||
|
||||
if (result) {
|
||||
clearInterval(intervalId);
|
||||
resolve();
|
||||
}
|
||||
} catch (error) {
|
||||
clearInterval(intervalId);
|
||||
reject(error);
|
||||
}
|
||||
}, interval);
|
||||
});
|
||||
}
|
||||
24
src/hooks/use-alarm.ts
Normal file
24
src/hooks/use-alarm.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useSound } from './use-sound';
|
||||
import { useAlarmStore } from '@/stores/alarm';
|
||||
|
||||
export function useAlarm() {
|
||||
const { play: playSound } = useSound(
|
||||
'/sounds/alarm.mp3',
|
||||
{ volume: 1 },
|
||||
true,
|
||||
);
|
||||
const isPlaying = useAlarmStore(state => state.isPlaying);
|
||||
const play = useAlarmStore(state => state.play);
|
||||
const stop = useAlarmStore(state => state.stop);
|
||||
|
||||
const playAlarm = useCallback(() => {
|
||||
if (!isPlaying) {
|
||||
playSound(stop);
|
||||
play();
|
||||
}
|
||||
}, [isPlaying, playSound, play, stop]);
|
||||
|
||||
return playAlarm;
|
||||
}
|
||||
@@ -2,6 +2,11 @@ import { useEffect } from 'react';
|
||||
|
||||
import { onCloseModals } from '@/lib/modal';
|
||||
|
||||
/**
|
||||
* A custom React hook that registers a listener function to be called when modals are to be closed.
|
||||
*
|
||||
* @param {Function} listener - The function to be called when modals are to be closed.
|
||||
*/
|
||||
export function useCloseListener(listener: () => void) {
|
||||
useEffect(() => {
|
||||
const unsubscribe = onCloseModals(listener);
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* A custom React hook to copy text to the clipboard with a temporary state indication.
|
||||
*
|
||||
* @param {number} [timeout=1500] - The duration in milliseconds for which the `copying` state remains true. Defaults to 1500 milliseconds.
|
||||
* @returns {{ copy: (content: string) => void, copying: boolean }} An object containing:
|
||||
* - copy: The function to copy content to the clipboard.
|
||||
* - copying: A boolean indicating if a copy operation is in progress.
|
||||
*/
|
||||
export function useCopy(timeout = 1500) {
|
||||
const [copying, setCopying] = useState(false);
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { useCallback } from 'react';
|
||||
import type { KeyboardEvent } from 'react';
|
||||
|
||||
/**
|
||||
* A custom React hook that creates a keyboard event handler for 'Enter' and 'Space' keys.
|
||||
*
|
||||
* @param {Function} actionCallback - The function to be called when 'Enter' or 'Space' is pressed.
|
||||
* @returns {Function} A keyboard event handler function that triggers the action callback.
|
||||
*/
|
||||
export const useKeyboardButton = (
|
||||
actionCallback: () => void,
|
||||
): ((event: KeyboardEvent<HTMLElement>) => void) => {
|
||||
|
||||
@@ -2,6 +2,14 @@ import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
|
||||
|
||||
type SetValue<T> = Dispatch<SetStateAction<T>>;
|
||||
|
||||
/**
|
||||
* A custom React hook to manage state with localStorage persistence.
|
||||
*
|
||||
* @template T
|
||||
* @param {string} key - The key under which the value is stored in localStorage.
|
||||
* @param {T} fallback - The fallback value to use if there is no value in localStorage.
|
||||
* @returns {[T, SetValue<T>]} An array containing the stateful value and a function to update it.
|
||||
*/
|
||||
export function useLocalStorage<T>(key: string, fallback: T): [T, SetValue<T>] {
|
||||
const [value, setValue] = useState(fallback);
|
||||
|
||||
|
||||
@@ -3,6 +3,16 @@ 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();
|
||||
|
||||
|
||||
@@ -1,14 +1,33 @@
|
||||
import { useMemo, useEffect, useCallback, useState } from 'react';
|
||||
import { Howl } from 'howler';
|
||||
|
||||
import { useLoadingStore } from '@/store';
|
||||
import { useLoadingStore } from '@/stores/loading';
|
||||
import { subscribe } from '@/lib/event';
|
||||
import { useSSR } from './use-ssr';
|
||||
import { FADE_OUT } from '@/constants/events';
|
||||
|
||||
/**
|
||||
* A custom React hook to manage sound playback using Howler.js with additional features.
|
||||
*
|
||||
* This hook initializes a Howl instance for playing sound effects in the browser,
|
||||
* and provides control functions to play, stop, pause, and fade out the sound.
|
||||
* It also handles loading state management and supports event subscription for fade-out effects.
|
||||
*
|
||||
* @param {string} src - The source URL of the sound file.
|
||||
* @param {Object} [options] - Options for sound playback.
|
||||
* @param {boolean} [options.loop=false] - Whether the sound should loop.
|
||||
* @param {number} [options.volume=0.5] - The initial volume of the sound, ranging from 0.0 to 1.0.
|
||||
* @returns {{ play: () => void, stop: () => void, pause: () => void, fadeOut: (duration: number) => void, isLoading: boolean }} 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.
|
||||
* - fadeOut: Function to fade out the sound over a given duration.
|
||||
* - isLoading: A boolean indicating if the sound is currently loading.
|
||||
*/
|
||||
export function useSound(
|
||||
src: string,
|
||||
options: { loop?: boolean; volume?: number } = {},
|
||||
options: { loop?: boolean; preload?: boolean; volume?: number } = {},
|
||||
html5: boolean = false,
|
||||
) {
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
const isLoading = useLoadingStore(state => state.loaders[src]);
|
||||
@@ -20,17 +39,18 @@ export function useSound(
|
||||
|
||||
if (isBrowser) {
|
||||
sound = new Howl({
|
||||
html5,
|
||||
onload: () => {
|
||||
setIsLoading(src, false);
|
||||
setHasLoaded(true);
|
||||
},
|
||||
preload: false,
|
||||
preload: options.preload ?? false,
|
||||
src: src,
|
||||
});
|
||||
}
|
||||
|
||||
return sound;
|
||||
}, [src, isBrowser, setIsLoading]);
|
||||
}, [src, isBrowser, setIsLoading, html5, options.preload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sound) {
|
||||
@@ -42,18 +62,23 @@ export function useSound(
|
||||
if (sound) sound.volume(options.volume ?? 0.5);
|
||||
}, [sound, options.volume]);
|
||||
|
||||
const play = useCallback(() => {
|
||||
if (sound) {
|
||||
if (!hasLoaded && !isLoading) {
|
||||
setIsLoading(src, true);
|
||||
sound.load();
|
||||
}
|
||||
const play = useCallback(
|
||||
(cb?: () => void) => {
|
||||
if (sound) {
|
||||
if (!hasLoaded && !isLoading) {
|
||||
setIsLoading(src, true);
|
||||
sound.load();
|
||||
}
|
||||
|
||||
if (!sound.playing()) {
|
||||
sound.play();
|
||||
if (!sound.playing()) {
|
||||
sound.play();
|
||||
}
|
||||
|
||||
if (typeof cb === 'function') sound.once('end', cb);
|
||||
}
|
||||
}
|
||||
}, [src, setIsLoading, sound, hasLoaded, isLoading]);
|
||||
},
|
||||
[src, setIsLoading, sound, hasLoaded, isLoading],
|
||||
);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
if (sound) sound.stop();
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/**
|
||||
* A custom React hook to determine if the code is running in a browser or server environment.
|
||||
*
|
||||
* @returns {{ isBrowser: boolean, isServer: boolean }} An object containing:
|
||||
* - isBrowser: A boolean indicating if the code is running in a browser environment.
|
||||
* - isServer: A boolean indicating if the code is running in a server environment.
|
||||
*/
|
||||
export function useSSR() {
|
||||
const isDOM =
|
||||
typeof window !== 'undefined' &&
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
/**
|
||||
* Dispatches a custom event with an optional detail payload.
|
||||
*
|
||||
* @template T
|
||||
* @param {string} eventName - The name of the event to be dispatched.
|
||||
* @param {T} [detail] - Optional data to be passed with the event.
|
||||
*/
|
||||
export function dispatch<T>(eventName: string, detail?: T) {
|
||||
const event = new CustomEvent(eventName, { detail });
|
||||
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes a listener function to a custom event.
|
||||
*
|
||||
* @template T
|
||||
* @param {string} eventName - The name of the event to listen for.
|
||||
* @param {(e: T) => void} listener - The function to be called when the event is dispatched.
|
||||
* @returns {Function} A function to unsubscribe the listener from the event.
|
||||
*/
|
||||
export function subscribe<T>(eventName: string, listener: (e: T) => void) {
|
||||
const handler = (event: Event) => {
|
||||
if ('detail' in event) {
|
||||
@@ -18,6 +33,12 @@ export function subscribe<T>(eventName: string, listener: (e: T) => void) {
|
||||
return () => unsubscribe(eventName, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribes a listener function from a custom event.
|
||||
*
|
||||
* @param {string} eventName - The name of the event to unsubscribe from.
|
||||
* @param {(e: Event) => void} listener - The function to be removed from the event listeners.
|
||||
*/
|
||||
export function unsubscribe(eventName: string, listener: (e: Event) => void) {
|
||||
document.removeEventListener(eventName, listener);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { dispatch, subscribe } from './event';
|
||||
import { CLOSE_MODALS } from '@/constants/events';
|
||||
|
||||
/**
|
||||
* Dispatches the CLOSE_MODALS event to signal that modals should be closed.
|
||||
*/
|
||||
export function closeModals() {
|
||||
dispatch(CLOSE_MODALS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes a listener function to the CLOSE_MODALS event.
|
||||
*
|
||||
* @param {() => void} listener - The function to be called when the CLOSE_MODALS event is dispatched.
|
||||
* @returns {Function} A function to unsubscribe the listener from the CLOSE_MODALS event.
|
||||
*/
|
||||
export function onCloseModals(listener: () => void) {
|
||||
const unsubscribe = subscribe(CLOSE_MODALS, listener);
|
||||
|
||||
|
||||
@@ -7,6 +7,11 @@ type Motion = {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a fade motion object with opacity transition.
|
||||
*
|
||||
* @returns {Motion} An object containing the hidden and show states for a fade transition.
|
||||
*/
|
||||
export function fade(): Motion {
|
||||
return {
|
||||
hidden: { opacity: 0 },
|
||||
@@ -14,6 +19,13 @@ export function fade(): Motion {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a scale motion object with scaling transition.
|
||||
*
|
||||
* @param {number} [from=0.85] - The initial scale value for the hidden state.
|
||||
* @param {number} [to=1] - The final scale value for the show state.
|
||||
* @returns {Motion} An object containing the hidden and show states for a scale transition.
|
||||
*/
|
||||
export function scale(from = 0.85, to = 1): Motion {
|
||||
return {
|
||||
hidden: { scale: from },
|
||||
@@ -21,6 +33,13 @@ export function scale(from = 0.85, to = 1): Motion {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a slide motion object with horizontal sliding transition.
|
||||
*
|
||||
* @param {number} [from=-10] - The initial x position for the hidden state.
|
||||
* @param {number} [to=0] - The final x position for the show state.
|
||||
* @returns {Motion} An object containing the hidden and show states for a horizontal slide transition.
|
||||
*/
|
||||
export function slideX(from = -10, to = 0): Motion {
|
||||
return {
|
||||
hidden: { x: from },
|
||||
@@ -28,6 +47,13 @@ export function slideX(from = -10, to = 0): Motion {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a slide motion object with vertical sliding transition.
|
||||
*
|
||||
* @param {number} [from=-10] - The initial y position for the hidden state.
|
||||
* @param {number} [to=0] - The final y position for the show state.
|
||||
* @returns {Motion} An object containing the hidden and show states for a vertical slide transition.
|
||||
*/
|
||||
export function slideY(from = -10, to = 0): Motion {
|
||||
return {
|
||||
hidden: { y: from },
|
||||
@@ -35,6 +61,15 @@ export function slideY(from = -10, to = 0): Motion {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines multiple motion objects into a single motion object.
|
||||
*
|
||||
* This function merges the hidden and show states of the provided motion objects
|
||||
* into a single motion object.
|
||||
*
|
||||
* @param {...Motion} motions - The motion objects to be combined.
|
||||
* @returns {Motion} An object containing the combined hidden and show states.
|
||||
*/
|
||||
export function mix(...motions: Array<Motion>): Motion {
|
||||
let hidden = {};
|
||||
let show = {};
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { sounds } from '@/data/sounds';
|
||||
|
||||
/**
|
||||
* Counts the total number of sounds across all categories.
|
||||
*
|
||||
* @param {boolean} [round=false] - Whether to round the count down to the nearest multiple of 5.
|
||||
* @returns {number} The total count of sounds, optionally rounded down.
|
||||
*/
|
||||
export function count(round: boolean = false) {
|
||||
let count = 0;
|
||||
|
||||
|
||||
99
src/lib/tests/motion.test.ts
Normal file
99
src/lib/tests/motion.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { fade, scale, slideX, slideY, mix } from '../motion';
|
||||
|
||||
describe('fade function', () => {
|
||||
it('should create a fade motion object with default opacity values', () => {
|
||||
const result = fade();
|
||||
expect(result).toEqual({
|
||||
hidden: { opacity: 0 },
|
||||
show: { opacity: 1 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('scale function', () => {
|
||||
it('should create a scale motion object with default values', () => {
|
||||
const result = scale();
|
||||
expect(result).toEqual({
|
||||
hidden: { scale: 0.85 },
|
||||
show: { scale: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('should create a scale motion object with custom values', () => {
|
||||
const result = scale(0.5, 1.5);
|
||||
expect(result).toEqual({
|
||||
hidden: { scale: 0.5 },
|
||||
show: { scale: 1.5 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('slideX function', () => {
|
||||
it('should create a slide motion object with default x values', () => {
|
||||
const result = slideX();
|
||||
expect(result).toEqual({
|
||||
hidden: { x: -10 },
|
||||
show: { x: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it('should create a slide motion object with custom x values', () => {
|
||||
const result = slideX(-20, 10);
|
||||
expect(result).toEqual({
|
||||
hidden: { x: -20 },
|
||||
show: { x: 10 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('slideY function', () => {
|
||||
it('should create a slide motion object with default y values', () => {
|
||||
const result = slideY();
|
||||
expect(result).toEqual({
|
||||
hidden: { y: -10 },
|
||||
show: { y: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it('should create a slide motion object with custom y values', () => {
|
||||
const result = slideY(-20, 10);
|
||||
expect(result).toEqual({
|
||||
hidden: { y: -20 },
|
||||
show: { y: 10 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('mix function', () => {
|
||||
it('should combine multiple motion objects into a single motion object', () => {
|
||||
const fadeMotion = fade();
|
||||
const scaleMotion = scale();
|
||||
const result = mix(fadeMotion, scaleMotion);
|
||||
expect(result).toEqual({
|
||||
hidden: { opacity: 0, scale: 0.85 },
|
||||
show: { opacity: 1, scale: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle overlapping properties in motion objects', () => {
|
||||
const slideXMotion = slideX();
|
||||
const slideYMotion = slideY();
|
||||
const result = mix(slideXMotion, slideYMotion);
|
||||
expect(result).toEqual({
|
||||
hidden: { x: -10, y: -10 },
|
||||
show: { x: 0, y: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle single motion object', () => {
|
||||
const fadeMotion = fade();
|
||||
const result = mix(fadeMotion);
|
||||
expect(result).toEqual(fadeMotion);
|
||||
});
|
||||
|
||||
it('should handle empty motion objects', () => {
|
||||
const result = mix();
|
||||
expect(result).toEqual({ hidden: {}, show: {} });
|
||||
});
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
export { useSoundStore } from './sound';
|
||||
export { useLoadingStore } from './loading';
|
||||
export { useNoteStore } from './note';
|
||||
export { usePomodoroStore } from './pomodoro';
|
||||
export { usePresetStore } from './preset';
|
||||
@@ -1,52 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
import { createJSONStorage, persist } from 'zustand/middleware';
|
||||
import merge from 'deepmerge';
|
||||
|
||||
interface PresetStore {
|
||||
addPreset: (label: string, sounds: Record<string, number>) => void;
|
||||
changeName: (index: number, newName: string) => void;
|
||||
deletePreset: (index: number) => void;
|
||||
presets: Array<{
|
||||
label: string;
|
||||
sounds: Record<string, number>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const usePresetStore = create<PresetStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
addPreset(label: string, sounds: Record<string, number>) {
|
||||
set({ presets: [{ label, sounds }, ...get().presets] });
|
||||
},
|
||||
|
||||
changeName(index: number, newName: string) {
|
||||
const presets = get().presets.map((preset, i) => {
|
||||
if (i === index) return { ...preset, label: newName };
|
||||
|
||||
return preset;
|
||||
});
|
||||
|
||||
set({ presets });
|
||||
},
|
||||
|
||||
deletePreset(index: number) {
|
||||
set({ presets: get().presets.filter((_, i) => index !== i) });
|
||||
},
|
||||
|
||||
presets: [],
|
||||
}),
|
||||
{
|
||||
merge: (persisted, current) =>
|
||||
merge(
|
||||
current,
|
||||
// @ts-ignore
|
||||
persisted,
|
||||
),
|
||||
name: 'moodist-presets',
|
||||
partialize: state => ({ presets: state.presets }),
|
||||
skipHydration: true,
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
version: 0,
|
||||
},
|
||||
),
|
||||
);
|
||||
19
src/stores/alarm/index.ts
Normal file
19
src/stores/alarm/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface AlarmStore {
|
||||
isPlaying: boolean;
|
||||
play: () => void;
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
export const useAlarmStore = create<AlarmStore>()(set => ({
|
||||
isPlaying: false,
|
||||
|
||||
play() {
|
||||
set({ isPlaying: true });
|
||||
},
|
||||
|
||||
stop() {
|
||||
set({ isPlaying: false });
|
||||
},
|
||||
}));
|
||||
159
src/stores/countdown-timers/index.ts
Normal file
159
src/stores/countdown-timers/index.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { create } from 'zustand';
|
||||
import { createJSONStorage, persist } from 'zustand/middleware';
|
||||
|
||||
interface Timer {
|
||||
id: string;
|
||||
name: string;
|
||||
spent: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface State {
|
||||
spent: () => number;
|
||||
timers: Array<Timer>;
|
||||
total: () => number;
|
||||
}
|
||||
|
||||
interface Actions {
|
||||
add: (timer: { name: string; total: number }) => string;
|
||||
delete: (id: string) => void;
|
||||
getTimer: (id: string) => Timer & { first: boolean; last: boolean };
|
||||
moveDown: (id: string) => void;
|
||||
moveUp: (id: string) => void;
|
||||
rename: (id: string, newName: string) => void;
|
||||
reset: (id: string) => void;
|
||||
tick: (id: string, amount?: number) => void;
|
||||
}
|
||||
|
||||
export const useCountdownTimers = create<State & Actions>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
add({ name, total }) {
|
||||
const id = uuid();
|
||||
|
||||
set(state => ({
|
||||
timers: [
|
||||
{
|
||||
id,
|
||||
name,
|
||||
spent: 0,
|
||||
total,
|
||||
},
|
||||
...state.timers,
|
||||
],
|
||||
}));
|
||||
|
||||
return id;
|
||||
},
|
||||
|
||||
delete(id) {
|
||||
set(state => ({
|
||||
timers: state.timers.filter(timer => timer.id !== id),
|
||||
}));
|
||||
},
|
||||
|
||||
getTimer(id) {
|
||||
const timers = get().timers;
|
||||
const timer = timers.filter(timer => timer.id === id)[0];
|
||||
const index = timers.indexOf(timer);
|
||||
|
||||
return {
|
||||
...timer,
|
||||
first: index === 0,
|
||||
last: index === timers.length - 1,
|
||||
};
|
||||
},
|
||||
|
||||
moveDown(id) {
|
||||
set(state => {
|
||||
const index = state.timers.findIndex(timer => timer.id === id);
|
||||
|
||||
if (index < state.timers.length - 1) {
|
||||
const newTimers = [...state.timers];
|
||||
|
||||
[newTimers[index + 1], newTimers[index]] = [
|
||||
newTimers[index],
|
||||
newTimers[index + 1],
|
||||
];
|
||||
|
||||
return { timers: newTimers };
|
||||
}
|
||||
|
||||
return state;
|
||||
});
|
||||
},
|
||||
|
||||
moveUp(id) {
|
||||
set(state => {
|
||||
const index = state.timers.findIndex(timer => timer.id === id);
|
||||
|
||||
if (index > 0) {
|
||||
const newTimers = [...state.timers];
|
||||
|
||||
[newTimers[index - 1], newTimers[index]] = [
|
||||
newTimers[index],
|
||||
newTimers[index - 1],
|
||||
];
|
||||
|
||||
return { timers: newTimers };
|
||||
}
|
||||
|
||||
return state;
|
||||
});
|
||||
},
|
||||
|
||||
rename(id, newName) {
|
||||
set(state => ({
|
||||
timers: state.timers.map(timer => {
|
||||
if (timer.id !== id) return timer;
|
||||
|
||||
return { ...timer, name: newName };
|
||||
}),
|
||||
}));
|
||||
},
|
||||
|
||||
reset(id) {
|
||||
set(state => ({
|
||||
timers: state.timers.map(timer => {
|
||||
if (timer.id !== id) return timer;
|
||||
|
||||
return { ...timer, spent: 0 };
|
||||
}),
|
||||
}));
|
||||
},
|
||||
|
||||
spent() {
|
||||
return get().timers.reduce((prev, curr) => prev + curr.spent, 0);
|
||||
},
|
||||
|
||||
tick(id, amount = 1) {
|
||||
set(state => ({
|
||||
timers: state.timers.map(timer => {
|
||||
if (timer.id !== id) return timer;
|
||||
|
||||
const updatedSpent =
|
||||
timer.spent + amount > timer.total
|
||||
? timer.total
|
||||
: timer.spent + amount;
|
||||
|
||||
return { ...timer, spent: updatedSpent };
|
||||
}),
|
||||
}));
|
||||
},
|
||||
|
||||
timers: [],
|
||||
|
||||
total() {
|
||||
return get().timers.reduce((prev, curr) => prev + curr.total, 0);
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'moodist-countdown-timers',
|
||||
partialize: state => ({ timers: state.timers }),
|
||||
skipHydration: true,
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
version: 0,
|
||||
},
|
||||
),
|
||||
);
|
||||
71
src/stores/preset/index.ts
Normal file
71
src/stores/preset/index.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { create } from 'zustand';
|
||||
import { createJSONStorage, persist } from 'zustand/middleware';
|
||||
import merge from 'deepmerge';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
interface PresetStore {
|
||||
addPreset: (label: string, sounds: Record<string, number>) => void;
|
||||
changeName: (id: string, newName: string) => void;
|
||||
deletePreset: (id: string) => void;
|
||||
presets: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
sounds: Record<string, number>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const usePresetStore = create<PresetStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
addPreset(label: string, sounds: Record<string, number>) {
|
||||
set({ presets: [{ id: uuid(), label, sounds }, ...get().presets] });
|
||||
},
|
||||
|
||||
changeName(id: string, newName: string) {
|
||||
const presets = get().presets.map(preset => {
|
||||
if (preset.id === id) return { ...preset, label: newName };
|
||||
|
||||
return preset;
|
||||
});
|
||||
|
||||
set({ presets });
|
||||
},
|
||||
|
||||
deletePreset(id: string) {
|
||||
set({ presets: get().presets.filter(preset => preset.id !== id) });
|
||||
},
|
||||
|
||||
presets: [],
|
||||
}),
|
||||
{
|
||||
merge: (persisted, current) =>
|
||||
merge(current, persisted as Partial<PresetStore>),
|
||||
|
||||
migrate,
|
||||
name: 'moodist-presets',
|
||||
partialize: state => ({ presets: state.presets }),
|
||||
skipHydration: true,
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
version: 1,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
function migrate(persistedState: unknown, version: number) {
|
||||
let persisted = persistedState as Partial<PresetStore>;
|
||||
|
||||
/**
|
||||
* In version 0, presets didn't have an ID
|
||||
*/
|
||||
if (version < 1) {
|
||||
persisted = {
|
||||
...persisted,
|
||||
presets: (persisted.presets || []).map(preset => {
|
||||
if (preset.id) return preset;
|
||||
return { ...preset, id: uuid() };
|
||||
}),
|
||||
} as PresetStore;
|
||||
}
|
||||
|
||||
return persisted as PresetStore;
|
||||
}
|
||||
13
src/stores/sleep-timer/index.ts
Normal file
13
src/stores/sleep-timer/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface SleepTimerStore {
|
||||
active: boolean;
|
||||
set: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export const useSleepTimerStore = create<SleepTimerStore>()(set => ({
|
||||
active: false,
|
||||
set(value: boolean) {
|
||||
set({ active: value });
|
||||
},
|
||||
}));
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user