mirror of
https://github.com/remvze/moodist.git
synced 2026-03-05 19:43:13 +08:00
Compare commits
47 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 | ||
|
|
c60dcc74ed | ||
|
|
aca746148e | ||
|
|
095e3c795e | ||
|
|
7e65bb75f9 | ||
|
|
0533460667 | ||
|
|
9d633a9637 | ||
|
|
a9fe7f7b4f | ||
|
|
ffe260f4a0 |
@@ -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",
|
||||
{
|
||||
|
||||
16
CHANGELOG.md
16
CHANGELOG.md
@@ -2,6 +2,22 @@
|
||||
|
||||
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
||||
|
||||
### [1.5.1](https://github.com/remvze/moodist/compare/v1.5.0...v1.5.1) (2024-06-14)
|
||||
|
||||
|
||||
### ♻️ Code Refactoring
|
||||
|
||||
* migrate to Astro components ([ffe260f](https://github.com/remvze/moodist/commit/ffe260f4a02238cb83cf92ed06c4f9c75ba189a4))
|
||||
* rename some functions ([0533460](https://github.com/remvze/moodist/commit/05334606673a6268ca35083ea31e28cdb11f1b86))
|
||||
* use nullish operator ([9d633a9](https://github.com/remvze/moodist/commit/9d633a963772c3444b6e9effc7920fe190b0614d))
|
||||
|
||||
|
||||
### 🚚 Chores
|
||||
|
||||
* add more sounds ([095e3c7](https://github.com/remvze/moodist/commit/095e3c795ef699e9e99c5eb364badaadce8a884b))
|
||||
* add washing machine sound ([7e65bb7](https://github.com/remvze/moodist/commit/7e65bb75f9871603c30ecfc578ad109a969a2a58))
|
||||
* update README file ([a9fe7f7](https://github.com/remvze/moodist/commit/a9fe7f7b4f9ca91704d76a314e3c3368fbc4f1cf))
|
||||
|
||||
## [1.5.0](https://github.com/remvze/moodist/compare/v1.4.3...v1.5.0) (2024-05-19)
|
||||
|
||||
|
||||
|
||||
22
README.md
22
README.md
@@ -16,17 +16,17 @@
|
||||
|
||||
## Features
|
||||
|
||||
1. 🎵 Over 75 ambient sounds
|
||||
1. 📝 Persistent sound selection
|
||||
1. ✈️ Sharing sound selections with others
|
||||
1. 🧰 Custom sound presets
|
||||
1. 🌙 Sleep timer for sounds
|
||||
1. 📓 Notepad for quick notes
|
||||
1. 🍅 Pomodoro timer
|
||||
1. ✅ Simple to-do list (soon)
|
||||
1. ⌨️ Keyboard shortcuts for everything
|
||||
1. 🥷 Privacy focused: no data collection
|
||||
1. 💰 Completely free, open-source, and self-hostable
|
||||
1. 🎵 Over 75 ambient sounds.
|
||||
1. 📝 Persistent sound selection.
|
||||
1. ✈️ Sharing sound selections with others.
|
||||
1. 🧰 Custom sound presets.
|
||||
1. 🌙 Sleep timer for sounds.
|
||||
1. 📓 Notepad for quick notes.
|
||||
1. 🍅 Pomodoro timer.
|
||||
1. ✅ Simple to-do list (soon).
|
||||
1. ⌨️ Keyboard shortcuts for everything.
|
||||
1. 🥷 Privacy focused: no data collection.
|
||||
1. 💰 Completely free, open-source, and self-hostable.
|
||||
|
||||
## Tools
|
||||
|
||||
|
||||
2983
package-lock.json
generated
2983
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"name": "moodist",
|
||||
"type": "module",
|
||||
"version": "1.5.0",
|
||||
"version": "1.5.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
public/sounds/nature/jungle.mp3
Normal file
BIN
public/sounds/nature/jungle.mp3
Normal file
Binary file not shown.
BIN
public/sounds/places/laundry-room.mp3
Normal file
BIN
public/sounds/places/laundry-room.mp3
Normal file
Binary file not shown.
BIN
public/sounds/things/washing-machine.mp3
Normal file
BIN
public/sounds/things/washing-machine.mp3
Normal file
Binary file not shown.
160
src/components/about.astro
Normal file
160
src/components/about.astro
Normal file
@@ -0,0 +1,160 @@
|
||||
---
|
||||
import { Container } from '@/components/container';
|
||||
|
||||
import { count as soundCount } from '@/lib/sounds';
|
||||
|
||||
const count = soundCount();
|
||||
|
||||
const paragraphs = [
|
||||
{
|
||||
body: 'Craving a calming escape from the daily grind? Do you need the perfect soundscape to boost your focus or lull you into peaceful sleep? Look no further than Moodist, your free and open-source ambient sound generator! Ditch the subscriptions and registrations – with Moodist, you unlock a world of soothing and immersive audio experiences, entirely for free.',
|
||||
title: 'Free Ambient Sounds',
|
||||
},
|
||||
{
|
||||
body: `Dive into an expansive library of ${count} carefully curated sounds. Nature lovers will find solace in the gentle murmur of streams, the rhythmic crash of waves, or the crackling warmth of a campfire. Cityscapes come alive with the soft hum of cafes, the rhythmic clatter of trains, or the calming white noise of traffic. And for those seeking deeper focus or relaxation, Moodist offers binaural beats and color noise designed to enhance your state of mind.`,
|
||||
title: 'Carefully Curated Sounds',
|
||||
},
|
||||
{
|
||||
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 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',
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<section class="about">
|
||||
<div class="effect"></div>
|
||||
|
||||
<Container tight>
|
||||
{
|
||||
paragraphs.map((paragraph, index) => (
|
||||
<div class="paragraph">
|
||||
<div class="counter">
|
||||
<span>0{index + 1}</span> / 0{paragraphs.length}
|
||||
</div>
|
||||
|
||||
<h2 class="title">{paragraph.title}</h2>
|
||||
<p class="body">{paragraph.body}</p>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
<button class="button" id="use-moodist"> Use Moodist</button>
|
||||
</Container>
|
||||
</section>
|
||||
|
||||
<script lang="ts">
|
||||
const button = document.getElementById('use-moodist');
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
const app = document.getElementById('app');
|
||||
|
||||
app?.scrollIntoView();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.about {
|
||||
padding-top: 10px;
|
||||
|
||||
& .effect {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 80px;
|
||||
background: linear-gradient(var(--color-neutral-50), transparent);
|
||||
}
|
||||
|
||||
& .paragraph {
|
||||
padding: 30px 0;
|
||||
background: linear-gradient(
|
||||
transparent,
|
||||
var(--color-neutral-50) 10%,
|
||||
var(--color-neutral-50) 90%,
|
||||
transparent
|
||||
);
|
||||
|
||||
&:last-of-type {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
& .counter {
|
||||
width: max-content;
|
||||
padding: 6px 16px;
|
||||
margin-bottom: 16px;
|
||||
font-size: var(--font-xsm);
|
||||
color: var(--color-foreground-subtle);
|
||||
background: linear-gradient(var(--color-neutral-100), transparent);
|
||||
border: 1px solid var(--color-neutral-300);
|
||||
border-radius: 20px 20px 20px 8px;
|
||||
|
||||
& span {
|
||||
font-weight: 500;
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
& .title {
|
||||
margin-bottom: 8px;
|
||||
font-family: var(--font-heading);
|
||||
font-size: var(--font-md);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
& .body {
|
||||
line-height: 1.6;
|
||||
color: var(--color-foreground-subtle);
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px 16px;
|
||||
margin-top: 20px;
|
||||
font-size: var(--font-xsm);
|
||||
font-weight: 500;
|
||||
color: var(--color-foreground);
|
||||
cursor: pointer;
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--color-neutral-200);
|
||||
border-radius: 50px;
|
||||
outline: none;
|
||||
transition: 0.2s;
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: 50%;
|
||||
width: 70%;
|
||||
height: 1px;
|
||||
content: '';
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
var(--color-neutral-300),
|
||||
transparent
|
||||
);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background-color: var(--color-neutral-100);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-neutral-400);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,96 +0,0 @@
|
||||
.about {
|
||||
padding-top: 10px;
|
||||
|
||||
& .effect {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 80px;
|
||||
background: linear-gradient(var(--color-neutral-50), transparent);
|
||||
}
|
||||
|
||||
& .paragraph {
|
||||
padding: 30px 0;
|
||||
background: linear-gradient(
|
||||
transparent,
|
||||
var(--color-neutral-50) 10%,
|
||||
var(--color-neutral-50) 90%,
|
||||
transparent
|
||||
);
|
||||
|
||||
&:last-of-type {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
& .counter {
|
||||
width: max-content;
|
||||
padding: 6px 16px;
|
||||
margin-bottom: 16px;
|
||||
font-size: var(--font-xsm);
|
||||
color: var(--color-foreground-subtle);
|
||||
background: linear-gradient(var(--color-neutral-100), transparent);
|
||||
border: 1px solid var(--color-neutral-300);
|
||||
border-radius: 20px 20px 20px 8px;
|
||||
|
||||
& span {
|
||||
font-weight: 500;
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
& .title {
|
||||
margin-bottom: 8px;
|
||||
font-family: var(--font-heading);
|
||||
font-size: var(--font-md);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
& .body {
|
||||
line-height: 1.6;
|
||||
color: var(--color-foreground-subtle);
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px 16px;
|
||||
margin-top: 20px;
|
||||
font-size: var(--font-xsm);
|
||||
font-weight: 500;
|
||||
color: var(--color-foreground);
|
||||
cursor: pointer;
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--color-neutral-200);
|
||||
border-radius: 50px;
|
||||
outline: none;
|
||||
transition: 0.2s;
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: 50%;
|
||||
width: 70%;
|
||||
height: 1px;
|
||||
content: '';
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
var(--color-neutral-300),
|
||||
transparent
|
||||
);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background-color: var(--color-neutral-100);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-neutral-400);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { Container } from '@/components/container';
|
||||
import { count as soundCount } from '@/lib/sounds';
|
||||
|
||||
import styles from './about.module.css';
|
||||
|
||||
export function About() {
|
||||
const count = soundCount();
|
||||
|
||||
const paragraphs = [
|
||||
{
|
||||
body: 'Craving a calming escape from the daily grind? Do you need the perfect soundscape to boost your focus or lull you into peaceful sleep? Look no further than Moodist, your free and open-source ambient sound generator! Ditch the subscriptions and registrations – with Moodist, you unlock a world of soothing and immersive audio experiences, entirely for free.',
|
||||
title: 'Free Ambient Sounds',
|
||||
},
|
||||
{
|
||||
body: `Dive into an expansive library of ${count} carefully curated sounds. Nature lovers will find solace in the gentle murmur of streams, the rhythmic crash of waves, or the crackling warmth of a campfire. Cityscapes come alive with the soft hum of cafes, the rhythmic clatter of trains, or the calming white noise of traffic. And for those seeking deeper focus or relaxation, Moodist offers binaural beats and color noise designed to enhance your state of mind.`,
|
||||
title: 'Carefully Curated Sounds',
|
||||
},
|
||||
{
|
||||
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: "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',
|
||||
},
|
||||
];
|
||||
|
||||
const handleClick = () => {
|
||||
const app = document.getElementById('app');
|
||||
|
||||
app?.scrollIntoView();
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={styles.about}>
|
||||
<div className={styles.effect} />
|
||||
|
||||
<Container tight>
|
||||
{paragraphs.map((paragraph, index) => (
|
||||
<div className={styles.paragraph} key={index}>
|
||||
<div className={styles.counter}>
|
||||
<span>0{index + 1}</span> / 0{paragraphs.length}
|
||||
</div>
|
||||
|
||||
<h2 className={styles.title}>{paragraph.title}</h2>
|
||||
<p className={styles.body}>{paragraph.body}</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button className={styles.button} onClick={handleClick}>
|
||||
Use Moodist
|
||||
</button>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { About } from './about';
|
||||
@@ -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>
|
||||
|
||||
57
src/components/donate.astro
Normal file
57
src/components/donate.astro
Normal file
@@ -0,0 +1,57 @@
|
||||
---
|
||||
import { Container } from './container';
|
||||
---
|
||||
|
||||
<Container>
|
||||
<section class="wrapper">
|
||||
<p class="text">
|
||||
Enjoy Moodist?{' '}
|
||||
<a
|
||||
href="https://buymeacoffee.com/remvze"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Support with a donation!
|
||||
</a>
|
||||
</p>
|
||||
</section>
|
||||
</Container>
|
||||
|
||||
<style>
|
||||
.wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
font-size: var(--font-xsm);
|
||||
color: var(--color-foreground-subtle);
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 80%;
|
||||
height: 1px;
|
||||
content: '';
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
var(--color-neutral-200),
|
||||
transparent
|
||||
);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
& .text {
|
||||
text-align: center;
|
||||
|
||||
& a {
|
||||
font-weight: 500;
|
||||
color: var(--color-foreground);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,36 +0,0 @@
|
||||
.wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
font-size: var(--font-xsm);
|
||||
color: var(--color-foreground-subtle);
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 80%;
|
||||
height: 1px;
|
||||
content: '';
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
var(--color-neutral-200),
|
||||
transparent
|
||||
);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
& .text {
|
||||
text-align: center;
|
||||
|
||||
& a {
|
||||
font-weight: 500;
|
||||
color: var(--color-foreground);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Container } from '@/components/container';
|
||||
|
||||
import styles from './donate.module.css';
|
||||
|
||||
export function Donate() {
|
||||
return (
|
||||
<Container>
|
||||
<section className={styles.wrapper}>
|
||||
<p className={styles.text}>
|
||||
Enjoy Moodist?{' '}
|
||||
<a
|
||||
href="https://buymeacoffee.com/remvze"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Support with a donation!
|
||||
</a>
|
||||
</p>
|
||||
</section>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { Donate } from './donate';
|
||||
31
src/components/footer.astro
Normal file
31
src/components/footer.astro
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
import { Container } from './container';
|
||||
---
|
||||
|
||||
<footer class="footer">
|
||||
<Container>
|
||||
<p>
|
||||
Created by <a href="https://twitter.com/remvze">Maze ✦</a>
|
||||
</p>
|
||||
</Container>
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100px;
|
||||
|
||||
& p {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--color-foreground-subtle);
|
||||
text-align: center;
|
||||
|
||||
& a {
|
||||
font-weight: 500;
|
||||
color: var(--color-foreground);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,17 +0,0 @@
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100px;
|
||||
|
||||
& p {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--color-foreground-subtle);
|
||||
text-align: center;
|
||||
|
||||
& a {
|
||||
font-weight: 500;
|
||||
color: var(--color-foreground);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Container } from '@/components/container';
|
||||
|
||||
import styles from './footer.module.css';
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className={styles.footer}>
|
||||
<Container>
|
||||
<p>
|
||||
Created by <a href="https://twitter.com/remvze">Maze ✦</a>
|
||||
</p>
|
||||
</Container>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { Footer } from './footer';
|
||||
142
src/components/hero.astro
Normal file
142
src/components/hero.astro
Normal file
@@ -0,0 +1,142 @@
|
||||
---
|
||||
import { BsSoundwave } from 'react-icons/bs/index';
|
||||
|
||||
import { Container } from './container';
|
||||
|
||||
import { count as soundCount } from '@/lib/sounds';
|
||||
|
||||
const count = soundCount();
|
||||
---
|
||||
|
||||
<div class="hero">
|
||||
<Container>
|
||||
<div class="wrapper">
|
||||
<img
|
||||
alt="Faded Moodist Logo"
|
||||
class="logo"
|
||||
height={45}
|
||||
src="/logo.svg"
|
||||
width={45}
|
||||
/>
|
||||
|
||||
<div class="title">
|
||||
<div class="left"></div>
|
||||
<h2>Moodist</h2>
|
||||
<div class="right"></div>
|
||||
</div>
|
||||
|
||||
<h1 class="desc">Ambient sounds for focus and calm.</h1>
|
||||
|
||||
<p class="sounds">
|
||||
<span class="icon">
|
||||
<BsSoundwave />
|
||||
</span>
|
||||
<span>{count} Sounds</span>
|
||||
</p>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
text-align: center;
|
||||
|
||||
.wrapper {
|
||||
position: relative;
|
||||
padding: 100px 0 80px;
|
||||
}
|
||||
|
||||
& .logo {
|
||||
display: block;
|
||||
width: 45px;
|
||||
margin: 0 auto 12px;
|
||||
}
|
||||
|
||||
& .title {
|
||||
display: flex;
|
||||
column-gap: 15px;
|
||||
align-items: center;
|
||||
|
||||
& div {
|
||||
flex-grow: 1;
|
||||
height: 1px;
|
||||
|
||||
&.left {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
transparent,
|
||||
var(--color-neutral-200),
|
||||
var(--color-neutral-300)
|
||||
);
|
||||
}
|
||||
|
||||
&.right {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-neutral-300),
|
||||
var(--color-neutral-200),
|
||||
transparent,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
& h2 {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--font-2xlg);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
& .desc {
|
||||
margin-top: 5px;
|
||||
line-height: 1.6;
|
||||
color: var(--color-foreground-subtle);
|
||||
}
|
||||
|
||||
& .sounds {
|
||||
position: relative;
|
||||
display: flex;
|
||||
column-gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: max-content;
|
||||
height: 28px;
|
||||
padding-right: 12px;
|
||||
margin: 20px auto 0;
|
||||
font-size: var(--font-xsm);
|
||||
color: var(--color-foreground-subtle);
|
||||
background: linear-gradient(var(--color-neutral-100), transparent);
|
||||
border: 1px solid var(--color-neutral-200);
|
||||
border-radius: 100px;
|
||||
|
||||
& .icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 0 10px;
|
||||
color: var(--color-foreground);
|
||||
border-right: 1px solid var(--color-neutral-200);
|
||||
border-radius: 0 100px 100px 0;
|
||||
}
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: 50%;
|
||||
width: 70%;
|
||||
height: 1px;
|
||||
content: '';
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
var(--color-neutral-400),
|
||||
transparent
|
||||
);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,120 +0,0 @@
|
||||
.hero {
|
||||
text-align: center;
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
padding: 100px 0 80px;
|
||||
|
||||
/* padding: 120px 0 60px; */
|
||||
|
||||
& .pattern {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: -1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image: radial-gradient(
|
||||
var(--color-neutral-300) 5%,
|
||||
transparent 5%
|
||||
);
|
||||
background-position: top center;
|
||||
background-size: 31px 31px;
|
||||
opacity: 0.9;
|
||||
mask-image: linear-gradient(#fff, transparent, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
& .logo {
|
||||
display: block;
|
||||
width: 45px;
|
||||
margin: 0 auto 12px;
|
||||
}
|
||||
|
||||
& .title {
|
||||
display: flex;
|
||||
column-gap: 15px;
|
||||
align-items: center;
|
||||
|
||||
& div {
|
||||
flex-grow: 1;
|
||||
height: 1px;
|
||||
|
||||
&.left {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
transparent,
|
||||
var(--color-neutral-200),
|
||||
var(--color-neutral-300)
|
||||
);
|
||||
}
|
||||
|
||||
&.right {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-neutral-300),
|
||||
var(--color-neutral-200),
|
||||
transparent,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
& h2 {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--font-2xlg);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
& .desc {
|
||||
margin-top: 5px;
|
||||
line-height: 1.6;
|
||||
color: var(--color-foreground-subtle);
|
||||
}
|
||||
|
||||
& .sounds {
|
||||
position: relative;
|
||||
display: flex;
|
||||
column-gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: max-content;
|
||||
height: 28px;
|
||||
padding-right: 12px;
|
||||
margin: 20px auto 0;
|
||||
font-size: var(--font-xsm);
|
||||
color: var(--color-foreground-subtle);
|
||||
background: linear-gradient(var(--color-neutral-100), transparent);
|
||||
border: 1px solid var(--color-neutral-200);
|
||||
border-radius: 100px;
|
||||
|
||||
& .icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 0 10px;
|
||||
color: var(--color-foreground);
|
||||
border-right: 1px solid var(--color-neutral-200);
|
||||
border-radius: 0 100px 100px 0;
|
||||
}
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: 50%;
|
||||
width: 70%;
|
||||
height: 1px;
|
||||
content: '';
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
var(--color-neutral-400),
|
||||
transparent
|
||||
);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { BsSoundwave } from 'react-icons/bs/index';
|
||||
|
||||
import { Container } from '@/components/container';
|
||||
import { count as soundCount } from '@/lib/sounds';
|
||||
|
||||
import styles from './hero.module.css';
|
||||
|
||||
export function Hero() {
|
||||
const count = useMemo(soundCount, []);
|
||||
|
||||
return (
|
||||
<div className={styles.hero}>
|
||||
<Container className={styles.container}>
|
||||
{/* <div className={styles.pattern} /> */}
|
||||
|
||||
<img
|
||||
alt="Faded Moodist Logo"
|
||||
className={styles.logo}
|
||||
height={45}
|
||||
src="/logo.svg"
|
||||
width={45}
|
||||
/>
|
||||
|
||||
<div className={styles.title}>
|
||||
<div className={styles.left}></div>
|
||||
<h2>Moodist</h2>
|
||||
<div className={styles.right}></div>
|
||||
</div>
|
||||
|
||||
<h1 className={styles.desc}>Ambient sounds for focus and calm.</h1>
|
||||
|
||||
<p className={styles.sounds}>
|
||||
<span className={styles.icon}>
|
||||
<BsSoundwave />
|
||||
</span>
|
||||
<span>{count} Sounds</span>
|
||||
</p>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { Hero } from './hero';
|
||||
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';
|
||||
@@ -27,8 +28,8 @@ export const Sound = forwardRef<HTMLDivElement, SoundProps>(function Sound(
|
||||
) {
|
||||
const isPlaying = useSoundStore(state => state.isPlaying);
|
||||
const play = useSoundStore(state => state.play);
|
||||
const select = useSoundStore(state => state.select);
|
||||
const unselect = useSoundStore(state => state.unselect);
|
||||
const selectSound = useSoundStore(state => state.select);
|
||||
const unselectSound = useSoundStore(state => state.unselect);
|
||||
const setVolume = useSoundStore(state => state.setVolume);
|
||||
const volume = useSoundStore(state => state.sounds[id].volume);
|
||||
const isSelected = useSoundStore(state => state.sounds[id].isSelected);
|
||||
@@ -53,23 +54,23 @@ export const Sound = forwardRef<HTMLDivElement, SoundProps>(function Sound(
|
||||
else if (hidden && !isSelected) unselectHidden(label);
|
||||
}, [label, isSelected, hidden, selectHidden, unselectHidden]);
|
||||
|
||||
const _select = useCallback(() => {
|
||||
const select = useCallback(() => {
|
||||
if (locked) return;
|
||||
select(id);
|
||||
selectSound(id);
|
||||
play();
|
||||
}, [select, play, id, locked]);
|
||||
}, [selectSound, play, id, locked]);
|
||||
|
||||
const _unselect = useCallback(() => {
|
||||
const unselect = useCallback(() => {
|
||||
if (locked) return;
|
||||
unselect(id);
|
||||
unselectSound(id);
|
||||
setVolume(id, 0.5);
|
||||
}, [unselect, setVolume, id, locked]);
|
||||
}, [unselectSound, setVolume, id, locked]);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (locked) return;
|
||||
if (isSelected) _unselect();
|
||||
else _select();
|
||||
}, [isSelected, _select, _unselect, locked]);
|
||||
if (isSelected) unselect();
|
||||
else select();
|
||||
}, [isSelected, select, unselect, locked]);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
toggle();
|
||||
|
||||
101
src/components/source.astro
Normal file
101
src/components/source.astro
Normal file
@@ -0,0 +1,101 @@
|
||||
---
|
||||
import { FaGithub } from 'react-icons/fa/index';
|
||||
|
||||
import { SpecialButton } from './special-button';
|
||||
import { Container } from './container';
|
||||
---
|
||||
|
||||
<div class="source">
|
||||
<Container>
|
||||
<div class="wrapper">
|
||||
<div class="iconContainer">
|
||||
<div class="tail"></div>
|
||||
<div class="icon">
|
||||
<FaGithub />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="title">Open Source</h2>
|
||||
<p class="desc">Moodist is free and open-source!</p>
|
||||
|
||||
<div class="button">
|
||||
<SpecialButton href="https://github.com/remvze/moodist">
|
||||
Source Code
|
||||
</SpecialButton>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.source {
|
||||
margin-top: 40px;
|
||||
|
||||
& .wrapper {
|
||||
position: relative;
|
||||
padding: 0 20px 40px;
|
||||
background: linear-gradient(transparent, rgb(24 24 27 / 70%));
|
||||
border-radius: 0 0 20px 20px;
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 70%;
|
||||
height: 1px;
|
||||
content: '';
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
var(--color-neutral-400),
|
||||
transparent
|
||||
);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
& .iconContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
|
||||
& .tail {
|
||||
width: 1px;
|
||||
height: 75px;
|
||||
background: linear-gradient(transparent, var(--color-neutral-300));
|
||||
}
|
||||
|
||||
& .icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
font-size: var(--font-md);
|
||||
background-color: var(--color-neutral-100);
|
||||
border: 1px solid var(--color-neutral-300);
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
& .title {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--font-lg);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
& .desc {
|
||||
margin-top: 8px;
|
||||
color: var(--color-foreground-subtle);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 16px auto 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1 +0,0 @@
|
||||
export { Source } from './source';
|
||||
@@ -1,70 +0,0 @@
|
||||
.source {
|
||||
/* margin-top: 80px; */
|
||||
|
||||
margin-top: 40px;
|
||||
|
||||
& .wrapper {
|
||||
position: relative;
|
||||
padding: 0 20px 40px;
|
||||
background: linear-gradient(transparent, rgb(24 24 27 / 70%));
|
||||
border-radius: 0 0 20px 20px;
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 70%;
|
||||
height: 1px;
|
||||
content: '';
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
var(--color-neutral-400),
|
||||
transparent
|
||||
);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
& .iconContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
|
||||
& .tail {
|
||||
width: 1px;
|
||||
height: 75px;
|
||||
background: linear-gradient(transparent, var(--color-neutral-300));
|
||||
}
|
||||
|
||||
& .icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
font-size: var(--font-md);
|
||||
background-color: var(--color-neutral-100);
|
||||
border: 1px solid var(--color-neutral-300);
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
& .title {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--font-lg);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
& .desc {
|
||||
margin-top: 8px;
|
||||
color: var(--color-foreground-subtle);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.button {
|
||||
margin: 16px auto 0;
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { FaGithub } from 'react-icons/fa/index';
|
||||
|
||||
import { Container } from '@/components/container';
|
||||
import { SpecialButton } from '@/components/special-button';
|
||||
|
||||
import styles from './source.module.css';
|
||||
|
||||
export function Source() {
|
||||
return (
|
||||
<div className={styles.source}>
|
||||
<Container>
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.iconContainer}>
|
||||
<div className={styles.tail} />
|
||||
<div className={styles.icon}>
|
||||
<FaGithub />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className={styles.title}>Open Source</h2>
|
||||
<p className={styles.desc}>Moodist is free and open-source!</p>
|
||||
<SpecialButton
|
||||
className={styles.button}
|
||||
href="https://github.com/remvze/moodist"
|
||||
>
|
||||
Source Code
|
||||
</SpecialButton>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,7 +1,13 @@
|
||||
import { GiWaterfall } from 'react-icons/gi/index';
|
||||
import { BsFire, BsFillDropletFill } from 'react-icons/bs/index';
|
||||
import { BiSolidTree, BiWater } from 'react-icons/bi/index';
|
||||
import { FaWater, FaWind, FaLeaf, FaRegSnowflake } from 'react-icons/fa/index';
|
||||
import {
|
||||
FaWater,
|
||||
FaWind,
|
||||
FaLeaf,
|
||||
FaRegSnowflake,
|
||||
FaTree,
|
||||
} from 'react-icons/fa/index';
|
||||
|
||||
import type { Category } from '../types';
|
||||
|
||||
@@ -69,6 +75,12 @@ export const nature: Category = {
|
||||
label: 'Droplets',
|
||||
src: '/sounds/nature/droplets.mp3',
|
||||
},
|
||||
{
|
||||
icon: <FaTree />,
|
||||
id: 'jungle',
|
||||
label: 'Jungle',
|
||||
src: '/sounds/nature/jungle.mp3',
|
||||
},
|
||||
],
|
||||
title: 'Nature',
|
||||
};
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { BiSolidCoffeeAlt, BiSolidPlaneAlt } from 'react-icons/bi/index';
|
||||
import {
|
||||
BiSolidCoffeeAlt,
|
||||
BiSolidPlaneAlt,
|
||||
BiSolidDryer,
|
||||
} from 'react-icons/bi/index';
|
||||
import { FaChurch, FaSubway, FaShoppingBasket } from 'react-icons/fa/index';
|
||||
import { TbScubaMask, TbBeerFilled } from 'react-icons/tb/index';
|
||||
import { GiVillage, GiCarousel } from 'react-icons/gi/index';
|
||||
@@ -94,6 +98,12 @@ export const places: Category = {
|
||||
label: 'Laboratory',
|
||||
src: '/sounds/places/laboratory.mp3',
|
||||
},
|
||||
{
|
||||
icon: <BiSolidDryer />,
|
||||
id: 'laundry-room',
|
||||
label: 'Laundry Room',
|
||||
src: '/sounds/places/laundry-room.mp3',
|
||||
},
|
||||
],
|
||||
title: 'Places',
|
||||
};
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { GiWindchimes, GiFilmProjector } from 'react-icons/gi/index';
|
||||
import {
|
||||
GiWindchimes,
|
||||
GiFilmProjector,
|
||||
GiWashingMachine,
|
||||
} from 'react-icons/gi/index';
|
||||
import { BsFillKeyboardFill } from 'react-icons/bs/index';
|
||||
import { FaKeyboard, FaClock, FaFan } from 'react-icons/fa/index';
|
||||
import { MdSmartToy, MdWaterDrop, MdRadio } from 'react-icons/md/index';
|
||||
@@ -91,6 +95,12 @@ export const things: Category = {
|
||||
label: 'Morse Code',
|
||||
src: '/sounds/things/morse-code.mp3',
|
||||
},
|
||||
{
|
||||
icon: <GiWashingMachine />,
|
||||
id: 'washing-machine',
|
||||
label: 'Washing Machine',
|
||||
src: '/sounds/things/washing-machine.mp3',
|
||||
},
|
||||
],
|
||||
title: 'Things',
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user