47 Commits

Author SHA1 Message Date
MAZE
60cb453847 feat: add shortcut for breathing exercise 2024-07-01 18:54:09 +03:30
MAZE
fc4f52146e feat: add simple breathing exercise tool 2024-07-01 18:50:12 +03:30
MAZE
1a1359c989 fix: icons path 2024-06-25 20:02:19 +04:30
MAZE
a6c7ac41ad feat: replace reverse timer 2024-06-25 20:01:10 +04:30
MAZE
3e11fb6123 feat: add move up and down functionality 2024-06-25 19:56:04 +04:30
MAZE
d356d77aa9 test: write tests for motion lib 2024-06-19 14:26:23 +04:30
MAZE
9cc0ccd325 test: write more tests 2024-06-19 14:23:27 +04:30
MAZE
cad85c7667 test: write tests for random helper 2024-06-19 14:18:47 +04:30
MAZE
def9a57e0c test: add Vitest and some tests 2024-06-19 14:12:06 +04:30
MAZE
74f6b5851d feat: scroll into view after marking favorite 2024-06-17 21:01:53 +04:30
MAZE
f4c66e3092 feat: scroll the new timer into view 2024-06-16 22:22:32 +03:30
MAZE
28abc16b9c style: remove animations 2024-06-16 22:14:47 +03:30
MAZE
787a9b60b5 style: add animation to presets 2024-06-16 22:14:44 +04:30
MAZE
73a5c21be9 chore: add animation to countdown timer 2024-06-16 22:12:12 +04:30
MAZE
cfd2744e92 chore: add toolbox copy 2024-06-16 21:06:26 +04:30
MAZE
4c0f417469 feat: add persist mode to the modal 2024-06-16 19:32:40 +03:30
MAZE
9d1d8f8035 style: change notice 2024-06-16 18:51:23 +03:30
MAZE
8a79ccf018 style: change button style 2024-06-16 18:50:06 +03:30
MAZE
a3c384d105 style: add title to timer 2024-06-16 18:49:15 +03:30
MAZE
96ca376885 style: increase menu width 2024-06-16 18:44:35 +03:30
MAZE
18987cc339 style: add min width 2024-06-16 19:41:09 +04:30
MAZE
919831538f style: change item order 2024-06-16 19:28:29 +04:30
MAZE
edd15f4b9a fix: change shortcuts 2024-06-16 19:27:33 +04:30
MAZE
09c0a6ce93 fix: change icon path 2024-06-16 19:23:54 +04:30
MAZE
2bfb9b181c feat: implement countdown timer functionality 2024-06-16 19:19:22 +04:30
MAZE
c272914416 feat: add basic form 2024-06-16 19:00:38 +04:30
MAZE
d73b2bc1ff refactor: rename components 2024-06-16 18:47:57 +04:30
MAZE
c5657d0642 feat: add countdown timer structure 2024-06-16 18:40:13 +04:30
MAZE
c35409ce0a refactor: separate the migration 2024-06-16 18:09:51 +04:30
MAZE
7658842324 refactor: use the ID instead of index 2024-06-16 17:42:44 +04:30
MAZE
78222be011 feat: add ID to presets 2024-06-16 17:39:44 +04:30
MAZE
2c8135db43 refactor: add description for events 2024-06-15 13:36:10 +04:30
MAZE
fddf75cdca refactor: write JSDoc for libs 2024-06-15 13:32:00 +04:30
MAZE
0f50e6ae8b refactor: add JSDoc for custom hooks 2024-06-15 13:19:00 +04:30
MAZE
4ae0504937 refactor: add JSDoc for helper functions 2024-06-15 13:06:48 +04:30
MAZE
af075b32e6 style: add focus state 2024-06-15 12:58:24 +04:30
MAZE
82d8240b97 feat: add active indicator for sleep timer 2024-06-15 12:55:45 +04:30
MAZE
096251ec0a refactor: change stores structure 2024-06-15 12:44:46 +04:30
MAZE
2a86a88ed6 refactor: rename stores folder 2024-06-15 12:36:47 +04:30
MAZE
c60dcc74ed chore(release): 1.5.1 2024-06-14 20:53:41 +03:30
MAZE
aca746148e Merge branch 'develop' 2024-06-14 20:53:14 +03:30
MAZE
095e3c795e chore: add more sounds 2024-06-14 20:51:35 +03:30
MAZE
7e65bb75f9 chore: add washing machine sound 2024-06-14 20:36:41 +03:30
MAZE
0533460667 refactor: rename some functions 2024-06-14 19:08:00 +04:30
MAZE
9d633a9637 refactor: use nullish operator 2024-06-14 18:57:13 +04:30
MAZE
a9fe7f7b4f chore: update README file 2024-06-12 23:42:28 +03:30
MAZE
ffe260f4a0 refactor: migrate to Astro components 2024-05-20 13:14:42 +04:30
129 changed files with 4556 additions and 1803 deletions

View File

@@ -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",
{

View File

@@ -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)

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

160
src/components/about.astro Normal file
View 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>

View File

@@ -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;
}
}
}

View File

@@ -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>
);
}

View File

@@ -1 +0,0 @@
export { About } from './about';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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>

View 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>

View File

@@ -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;
}
}
}

View File

@@ -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>
);
}

View File

@@ -1 +0,0 @@
export { Donate } from './donate';

View 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>

View File

@@ -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;
}
}
}

View File

@@ -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>
);
}

View File

@@ -1 +0,0 @@
export { Footer } from './footer';

142
src/components/hero.astro Normal file
View 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>

View File

@@ -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%);
}
}
}

View File

@@ -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>
);
}

View File

@@ -1 +0,0 @@
export { Hero } from './hero';

View 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}
/>
);
}

View File

@@ -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}
/>
);
}

View File

@@ -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';

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -1,6 +1,6 @@
import { BiShuffle } from 'react-icons/bi/index';
import { useSoundStore } from '@/store';
import { useSoundStore } from '@/stores/sound';
import { Item } from '../item';

View File

@@ -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"

View File

@@ -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;

View File

@@ -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')}

View File

@@ -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>
);
}

View File

@@ -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&apos;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

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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',

View File

@@ -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;
}
}
}
}

View File

@@ -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');

View File

@@ -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';

View File

@@ -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

View File

@@ -1,4 +1,4 @@
import { useSoundStore } from '@/store';
import { useSoundStore } from '@/stores/sound';
import styles from './range.module.css';

View File

@@ -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
View 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>

View File

@@ -1 +0,0 @@
export { Source } from './source';

View File

@@ -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;
}
}

View File

@@ -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>
);
}

View File

@@ -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}</>;

View File

@@ -0,0 +1 @@
/* WIP */

View 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>
);
}

View File

@@ -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;
}

View 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>
</>
);
}

View File

@@ -0,0 +1 @@
export { Exercise } from './exercise';

View File

@@ -0,0 +1 @@
export { BreathingExercise } from './breathing';

View File

@@ -0,0 +1,6 @@
.title {
margin-bottom: 16px;
font-family: var(--font-heading);
font-size: var(--font-md);
font-weight: 600;
}

View 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>
);
}

View File

@@ -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;
}
}

View 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>
);
}

View File

@@ -0,0 +1 @@
export { Field } from './field';

View 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;
}

View 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>
);
}

View File

@@ -0,0 +1 @@
export { Form } from './form';

View File

@@ -0,0 +1 @@
export { CountdownTimer } from './countdown-timer';

View File

@@ -0,0 +1 @@
export { Timers } from './timers';

View File

@@ -0,0 +1 @@
export { Notice } from './notice';

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1 @@
export { Timer } from './timer';

View File

@@ -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);
}
}
}

View 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>
);
}

View File

@@ -0,0 +1 @@
export { Toolbar } from './toolbar';

View File

@@ -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);
}
}
}

View File

@@ -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>
);
}

View File

@@ -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);
}
}
}

View 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>
);
});

View File

@@ -1,2 +1,4 @@
export { Notepad } from './notepad';
export { Pomodoro } from './pomodoro';
export { CountdownTimer } from './countdown-timer';
export { BreathingExercise } from './breathing';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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

View File

@@ -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',
};

View File

@@ -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',
};

View File

@@ -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',
};

View File

@@ -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();

View File

@@ -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(

View File

@@ -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;
}

View File

@@ -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 }))

View File

@@ -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(' ');

View 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);
});
});

View 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');
});
});

View 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);
});
});

View 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');
});
});

View 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
View 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