Compare commits
293 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa2b47ace4 | ||
|
|
3a96d38a77 | ||
|
|
7e8f23f5fa | ||
|
|
d0160763ee | ||
|
|
b921629ee3 | ||
|
|
ee139150f5 | ||
|
|
04c52962c3 | ||
|
|
97ca030534 | ||
|
|
e160d26677 | ||
|
|
642a551226 | ||
|
|
6ac65c1948 | ||
|
|
50687c97ca | ||
|
|
95b641a88f | ||
|
|
d11a6ab062 | ||
|
|
a071ba04c7 | ||
|
|
a179c09d0c | ||
|
|
066af9e2f3 | ||
|
|
1e5bda707c | ||
|
|
e2bb4dd55f | ||
|
|
d9df0d4b2c | ||
|
|
3feb9c1a09 | ||
|
|
b191e6067d | ||
|
|
81d9d7ca03 | ||
|
|
1e24cbc6eb | ||
|
|
78fb8cd76f | ||
|
|
4c8d577527 | ||
|
|
fcbe50c78c | ||
|
|
af096077ae | ||
|
|
4996cc893c | ||
|
|
d6484103a7 | ||
|
|
374de8b0d2 | ||
|
|
b171793040 | ||
|
|
dcc91e038d | ||
|
|
348fc1e8c4 | ||
|
|
a0a7f94c33 | ||
|
|
2f994c6094 | ||
|
|
fb82117742 | ||
|
|
7951e9829a | ||
|
|
755c442263 | ||
|
|
df210a1246 | ||
|
|
4895a7266d | ||
|
|
87f64e6574 | ||
|
|
496c831552 | ||
|
|
c5adffb4d7 | ||
|
|
536db4cd15 | ||
|
|
761c730129 | ||
|
|
11e0ba2f93 | ||
|
|
4a92d2f1c1 | ||
|
|
99e694161f | ||
|
|
3d1d45cd49 | ||
|
|
309dd89a8c | ||
|
|
699f49bfa3 | ||
|
|
29bebb3ec7 | ||
|
|
7a47282165 | ||
|
|
2b85b276eb | ||
|
|
0a1bf16d18 | ||
|
|
10259d013f | ||
|
|
e61307a302 | ||
|
|
cb340c53a3 | ||
|
|
3b77c12114 | ||
|
|
b8ed79f48a | ||
|
|
d3a9f1ddba | ||
|
|
18ed2e6f05 | ||
|
|
3b829fce07 | ||
|
|
e77c67bc24 | ||
|
|
14c331ab6e | ||
|
|
5c536786ea | ||
|
|
2e1fce4669 | ||
|
|
d759064373 | ||
|
|
f40e8206f8 | ||
|
|
d2e289e5d5 | ||
|
|
a59db41dc5 | ||
|
|
554309ebd8 | ||
|
|
be38b92647 | ||
|
|
b497d16fd8 | ||
|
|
ace0d6eecc | ||
|
|
aa8161aac5 | ||
|
|
c6cc61a17f | ||
|
|
7f3ac26b98 | ||
|
|
6a4dc1ed95 | ||
|
|
4cc85975e5 | ||
|
|
d42eb25f7b | ||
|
|
4f45279938 | ||
|
|
105f53ea02 | ||
|
|
f3cea66847 | ||
|
|
a4a31dd43e | ||
|
|
973e0df6fb | ||
|
|
13d26b3337 | ||
|
|
e1de5c48b2 | ||
|
|
07f37ef17f | ||
|
|
bb39b4ba98 | ||
|
|
76fdc74710 | ||
|
|
41845ffe5e | ||
|
|
48a85b2601 | ||
|
|
5865fc867d | ||
|
|
b27f24d374 | ||
|
|
5c9a2aa23a | ||
|
|
12d3255d57 | ||
|
|
c12ef12b79 | ||
|
|
ba3cd5ca5b | ||
|
|
a3b794d974 | ||
|
|
3ef4a076a2 | ||
|
|
1f2b6b952c | ||
|
|
2bbdc7e09e | ||
|
|
47a63a774e | ||
|
|
edd53d8102 | ||
|
|
302a71cdc6 | ||
|
|
b73fd0b16e | ||
|
|
5b3972b347 | ||
|
|
bee391acfe | ||
|
|
1fd02f927c | ||
|
|
d56f8be448 | ||
|
|
eee755378a | ||
|
|
4b015016e7 | ||
|
|
251f30930c | ||
|
|
a29e2c20e4 | ||
|
|
1cf9a85e13 | ||
|
|
69eb8832da | ||
|
|
c1ece582f4 | ||
|
|
b32d8b2803 | ||
|
|
1768ba1548 | ||
|
|
a80289db57 | ||
|
|
9208663050 | ||
|
|
d2edeb48be | ||
|
|
27f25785e1 | ||
|
|
f526f97908 | ||
|
|
e399673462 | ||
|
|
3d83a1427f | ||
|
|
ddf929f4c0 | ||
|
|
5ffb06be03 | ||
|
|
d6ed3fd251 | ||
|
|
0052b917a8 | ||
|
|
9e38a8fd7d | ||
|
|
60cb453847 | ||
|
|
fc4f52146e | ||
|
|
1a1359c989 | ||
|
|
a6c7ac41ad | ||
|
|
3e11fb6123 | ||
|
|
ee0a28b296 | ||
|
|
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 | ||
|
|
78656bb61f | ||
|
|
629f0a514e | ||
|
|
9338b1d30a | ||
|
|
34d3f07581 | ||
|
|
cf4870b0d6 | ||
|
|
9f0a28d930 | ||
|
|
56b0e9bf1a | ||
|
|
4f752bb6d0 | ||
|
|
1547b0a436 | ||
|
|
9ad16306cf | ||
|
|
4b73e45dd4 | ||
|
|
05b298f51e | ||
|
|
8d01d74bd3 | ||
|
|
f311ec114e | ||
|
|
df1b05f7ce | ||
|
|
ea0dfff9c1 | ||
|
|
fc1bd07b7d | ||
|
|
f79e941527 | ||
|
|
11a4514a0f | ||
|
|
e41f901041 | ||
|
|
de49d37f08 | ||
|
|
5f066a4eff | ||
|
|
b925a2e04f | ||
|
|
c66cddc4c9 | ||
|
|
7cb0f1c752 | ||
|
|
d09e598297 | ||
|
|
5899d1bbbb | ||
|
|
81678ea384 | ||
|
|
d9246b692b | ||
|
|
c893e2a6ad | ||
|
|
f025213ef2 | ||
|
|
6ce766af47 | ||
|
|
7c57fb686b | ||
|
|
dc139e41e6 | ||
|
|
b990778142 | ||
|
|
672988c36e | ||
|
|
06d0dfbe7e | ||
|
|
954a1b1ce2 | ||
|
|
383f898125 | ||
|
|
8d90344b26 | ||
|
|
c614e3d4f5 | ||
|
|
781adcf17e | ||
|
|
3e44516509 | ||
|
|
aeccf2dabd | ||
|
|
8e6e690006 | ||
|
|
75e7c48b21 | ||
|
|
dcef777295 | ||
|
|
cc77f9e9c0 | ||
|
|
8fe90daf1e | ||
|
|
34d3c72f35 | ||
|
|
9d458fb60e | ||
|
|
e674738ce7 | ||
|
|
77e2ec5e79 | ||
|
|
4adfb3ddc9 | ||
|
|
ae0cbf1aa3 | ||
|
|
dbbd68b73d | ||
|
|
2e375ad40a | ||
|
|
58bf28bb24 | ||
|
|
0517c31fc1 | ||
|
|
71b62ed3dd | ||
|
|
0300df3852 | ||
|
|
3f3bcdda21 | ||
|
|
f19d151f4a | ||
|
|
43f6245227 | ||
|
|
9b7d3c645b | ||
|
|
f8fb1ed61e | ||
|
|
6fe9ce8915 | ||
|
|
603d318e68 | ||
|
|
65ca7e1c94 | ||
|
|
583578b315 | ||
|
|
60f167c4d7 | ||
|
|
99f3a41598 | ||
|
|
d3a2a12e1f | ||
|
|
ebb35deaf9 | ||
|
|
9ad49d021a | ||
|
|
8307657628 | ||
|
|
3b0c22968e | ||
|
|
e490a1da84 | ||
|
|
7c6f068d15 | ||
|
|
a46a4cdc96 | ||
|
|
b955fc93f4 | ||
|
|
54c777276d | ||
|
|
136a009379 | ||
|
|
601ba6def7 | ||
|
|
89a83089c5 | ||
|
|
2192335238 | ||
|
|
af92b1ed90 | ||
|
|
f81ea9e7bd | ||
|
|
98e5021f56 | ||
|
|
9774532308 | ||
|
|
837826fbc1 | ||
|
|
2f84268017 | ||
|
|
24a53c81df | ||
|
|
ab9d47befb | ||
|
|
60cc2e9369 | ||
|
|
42f82ab95d | ||
|
|
669df1f082 | ||
|
|
a3cfbb98db | ||
|
|
3c8d75b018 | ||
|
|
e7d7a37a12 | ||
|
|
6f9c941a87 | ||
|
|
8596a0014c | ||
|
|
908fe01c5e | ||
|
|
8009e1519f | ||
|
|
0252fa96ab | ||
|
|
48291a6457 | ||
|
|
ddae0b660f | ||
|
|
8669489747 | ||
|
|
4f4ffe3e3a | ||
|
|
42d3bd9e8c | ||
|
|
4b5456a51d | ||
|
|
fa9711a1e0 |
|
|
@ -1,15 +1,12 @@
|
|||
{
|
||||
"root": true,
|
||||
|
||||
"env": {
|
||||
"browser": true,
|
||||
"amd": true,
|
||||
"node": true,
|
||||
"es2022": true
|
||||
},
|
||||
|
||||
"parser": "@typescript-eslint/parser",
|
||||
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module",
|
||||
|
|
@ -17,7 +14,6 @@
|
|||
"jsx": true
|
||||
}
|
||||
},
|
||||
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
|
|
@ -28,9 +24,9 @@
|
|||
"plugin:jsx-a11y/recommended",
|
||||
"plugin:react-hooks/recommended",
|
||||
"plugin:astro/recommended",
|
||||
"prettier"
|
||||
"prettier",
|
||||
"plugin:storybook/recommended"
|
||||
],
|
||||
|
||||
"plugins": [
|
||||
"@typescript-eslint",
|
||||
"typescript-sort-keys",
|
||||
|
|
@ -38,7 +34,6 @@
|
|||
"sort-destructure-keys",
|
||||
"prettier"
|
||||
],
|
||||
|
||||
"rules": {
|
||||
"@typescript-eslint/ban-ts-comment": "off",
|
||||
"prettier/prettier": "error",
|
||||
|
|
@ -46,6 +41,8 @@
|
|||
"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",
|
||||
"jsx-a11y/label-has-associated-control": "off",
|
||||
"react/jsx-sort-props": [
|
||||
"warn",
|
||||
{
|
||||
|
|
@ -54,48 +51,40 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
|
||||
"settings": {
|
||||
"react": {
|
||||
"version": "detect"
|
||||
},
|
||||
|
||||
"import/parsers": {
|
||||
"@typescript-eslint/parser": [".ts", ".tsx", ".js", ".jsx"]
|
||||
},
|
||||
|
||||
"import/resolver": {
|
||||
"typescript": true,
|
||||
"node": true,
|
||||
|
||||
"alias": {
|
||||
"extensions": [".js", ".jsx", ".ts", ".tsx", ".d.ts"],
|
||||
"map": [["@", "./src"]]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["**/*.astro"],
|
||||
"parser": "astro-eslint-parser",
|
||||
|
||||
"parserOptions": {
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"extraFileExtensions": [".astro"]
|
||||
},
|
||||
|
||||
"rules": {
|
||||
"prettier/prettier": "error",
|
||||
"react/no-unknown-property": "off",
|
||||
"react/jsx-key": "off"
|
||||
"react/jsx-key": "off",
|
||||
"react/jsx-no-undef": "off"
|
||||
},
|
||||
|
||||
"globals": {
|
||||
"Astro": "readonly"
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
"files": ["**/*.astro/*.js"],
|
||||
"rules": {
|
||||
|
|
|
|||
2
.github/workflows/build_docker.yml
vendored
|
|
@ -34,7 +34,7 @@ jobs:
|
|||
GIT_TAG=${GIT_TAG#refs/tags/}
|
||||
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64,linux/arm/v7 \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
-t $IMAGE_NAME:latest \
|
||||
-t $IMAGE_NAME:$GIT_TAG \
|
||||
--push .
|
||||
|
|
|
|||
2
.gitignore
vendored
|
|
@ -19,3 +19,5 @@ pnpm-debug.log*
|
|||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
|
||||
*storybook.log
|
||||
46
.storybook/main.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import path from 'node:path';
|
||||
|
||||
import type { StorybookConfig } from '@storybook/react-vite';
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
|
||||
|
||||
addons: [
|
||||
'@storybook/addon-onboarding',
|
||||
'@storybook/addon-links',
|
||||
'@storybook/addon-essentials',
|
||||
'@chromatic-com/storybook',
|
||||
'@storybook/addon-interactions',
|
||||
'@storybook/addon-a11y',
|
||||
],
|
||||
|
||||
framework: {
|
||||
name: '@storybook/react-vite',
|
||||
options: {},
|
||||
},
|
||||
|
||||
docs: {
|
||||
autodocs: 'tag',
|
||||
},
|
||||
|
||||
viteFinal(config) {
|
||||
return {
|
||||
...config,
|
||||
|
||||
define: {
|
||||
'process.env.NODE_DEBUG': false, // https://github.com/storybookjs/storybook/issues/18920
|
||||
},
|
||||
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
find: '@',
|
||||
replacement: path.resolve(__dirname, '../src'),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
16
.storybook/preview.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import '../src/styles/global.css';
|
||||
|
||||
import type { Preview } from '@storybook/react';
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
||||
|
|
@ -8,7 +8,8 @@
|
|||
|
||||
"rules": {
|
||||
"import-notation": "string",
|
||||
"selector-class-pattern": null
|
||||
"selector-class-pattern": null,
|
||||
"no-descending-specificity": null
|
||||
},
|
||||
|
||||
"overrides": [
|
||||
|
|
|
|||
977
CHANGELOG.md
25
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# Contributing Guidelines
|
||||
|
||||
Thank you for considering contributing to our project! We welcome your contributions.
|
||||
|
||||
## How to Contribute
|
||||
|
||||
1. Fork the repository.
|
||||
2. Create a new branch: `git checkout -b feature/your-feature-name`.
|
||||
3. Make your changes and commit them: `git commit -m 'feat: add some feature'`.
|
||||
4. Push to the branch: `git push origin feature/your-feature-name`.
|
||||
5. Submit a pull request. ⚡
|
||||
|
||||
⚠️ **Notice**: Commit messages should follow [Conventional Commits Specification](https://www.conventionalcommits.org/en/v1.0.0/).
|
||||
|
||||
## Report Bugs
|
||||
|
||||
To report a bug, please open an issue on GitHub and provide detailed information about the bug, including steps to reproduce it.
|
||||
|
||||
## Request Features
|
||||
|
||||
To request a new feature, open an issue on GitHub and describe the feature you would like to see added.
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the project's [LICENSE](LICENSE).
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
FROM node:20-alpine3.18 AS build
|
||||
FROM docker.io/node:20-alpine3.18 AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine AS runtime
|
||||
FROM docker.io/nginx:alpine AS runtime
|
||||
COPY ./docker/nginx/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
|
|
|
|||
87
README.md
|
|
@ -1,6 +1,89 @@
|
|||
<div align="center">
|
||||
<img src="/assets/banner.svg" alt="Moodist Logo Banner" />
|
||||
<img src="/assets/banner.png" alt="Moodist Logo Banner" />
|
||||
<h2>Moodist 🌲</h2>
|
||||
<p>Ambient sounds for focus and calm.</p>
|
||||
<a href="https://moodist.app">Visit <strong>Moodist</strong></a> | <a href="https://buymeacoffee.com/remvze">Buy Me a Coffee</a>
|
||||
<a href="https://moodist.mvze.net">Visit <strong>Moodist</strong></a> | <a href="https://buymeacoffee.com/remvze">Buy Me a Coffee</a>
|
||||
</div>
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- ⚡ [Features](#features)
|
||||
- 🧰 [Tools](#tools)
|
||||
- 🔮 [Commands](#commands)
|
||||
- 🚧 [Contributing](#contributing)
|
||||
- ⭐ [Support](#support-moodist)
|
||||
- 📜 [License](#license)
|
||||
|
||||
## 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. ⏯️ Media controls.
|
||||
1. ⌨️ Keyboard shortcuts for everything.
|
||||
1. 🥷 Privacy focused: no data collection.
|
||||
1. 💰 Completely free, open-source, and self-hostable.
|
||||
|
||||
## Tools
|
||||
|
||||
- ⚡ **TypeScript**: Programming Language
|
||||
- 🔨 **React**: UI Library
|
||||
- 🧑🚀 **Astro**: Meta Framework
|
||||
- 🎨 **CSS Modules**: Styling
|
||||
- 🐻 **Zustand**: State Management
|
||||
- 🎭 **Framer Motion**: Animation Library
|
||||
- ⚙️ **Radix**: Accessible Components
|
||||
- 📕 **Storybook**: Component Documentation
|
||||
- 🧪 **Vitest**: Unit Testing (soon)
|
||||
- 🔭 **Playwright**: End-To-End Testing (soon)
|
||||
- 🔍 **ESLint**: Code Linting
|
||||
- 🧹 **Prettier**: Code Formatting
|
||||
- 🧼 **Stylelint**: CSS Linting
|
||||
- 🐶 **Husky**: Git Hooks
|
||||
- 📝 **Lint Staged**: Running Linters on Staged Files
|
||||
- 🧽 **Commitlint**: Git Commit Linting
|
||||
- 🧭 **Commitizen**: Git Commit Message Helper
|
||||
- 📓 **Standard Version**: Versioning and CHANGLOG Generation
|
||||
- 🧰 **PostCSS**: CSS Transformations
|
||||
|
||||
## Commands
|
||||
|
||||
- `npm run dev`: run development server
|
||||
- `npm run build`: build for production
|
||||
- `npm run preview`: preview the built app
|
||||
- `npm run lint`: lint files using ESLint
|
||||
- `npm run lint:fix`: lint and fix using ESLint
|
||||
- `npm run lint:style`: lint styles using Stylelint
|
||||
- `npm run lint:style:fix`: lint and fix styles using Stylelint
|
||||
- `npm run format`: format files using Prettier
|
||||
- `npm run commit`: commit message using Commitizen
|
||||
- `npm run release:major`: release major version
|
||||
- `npm run release:minor`: release minor version
|
||||
- `npm run release:patch`: release patch version
|
||||
- `npm run storybook`: run Storybook
|
||||
|
||||
## Contributing
|
||||
|
||||
🚧 Please check [CONTRIBUTING.md](CONTRIBUTING.md) file.
|
||||
|
||||
## Support Moodist
|
||||
|
||||
⭐ Give a star if you liked this project.
|
||||
|
||||
☕ [Buy Me a Coffee](https://buymeacoffee.com/remvze) to help me maintain Moodist.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
### ⚠️ Third-Party Assets
|
||||
|
||||
Some sounds used in this project are sourced from third-party providers and **are subject to different licenses**:
|
||||
|
||||
- Sounds licensed under the **Pixabay Content License**: [Pixabay Content License](https://pixabay.com/service/license-summary/)
|
||||
- Sounds licensed under **CC0**: [Creative Commons Zero License](https://creativecommons.org/publicdomain/zero/1.0/)
|
||||
|
|
|
|||
BIN
assets/banner.png
Normal file
|
After Width: | Height: | Size: 193 KiB |
|
|
@ -1,40 +0,0 @@
|
|||
<svg width="1200" height="400" viewBox="0 0 1200 400" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="1200" height="400" rx="25" fill="#09090B"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M600 237.5C620.711 237.5 637.5 220.711 637.5 200C637.5 179.289 620.711 162.5 600 162.5C579.289 162.5 562.5 179.289 562.5 200C562.5 220.711 579.289 237.5 600 237.5ZM600 218.75C610.355 218.75 618.75 210.355 618.75 200C618.75 189.645 610.355 181.25 600 181.25C589.645 181.25 581.25 189.645 581.25 200C581.25 210.355 589.645 218.75 600 218.75Z" fill="#FAFAFA"/>
|
||||
<path d="M562.5 162.5C541.789 162.5 525 179.289 525 200C525 220.711 541.789 237.5 562.5 237.5L562.5 218.75C552.145 218.75 543.75 210.355 543.75 200C543.75 189.645 552.145 181.25 562.5 181.25L562.5 162.5Z" fill="#D4D4D8"/>
|
||||
<path d="M637.5 162.5C637.5 141.789 620.711 125 600 125C579.289 125 562.5 141.789 562.5 162.5L581.25 162.5C581.25 152.145 589.645 143.75 600 143.75C610.355 143.75 618.75 152.145 618.75 162.5L637.5 162.5Z" fill="#D4D4D8"/>
|
||||
<path d="M637.5 237.5C658.211 237.5 675 220.711 675 200C675 179.289 658.211 162.5 637.5 162.5L637.5 181.25C647.855 181.25 656.25 189.645 656.25 200C656.25 210.355 647.855 218.75 637.5 218.75L637.5 237.5Z" fill="#D4D4D8"/>
|
||||
<path d="M562.5 237.5C562.5 258.211 579.289 275 600 275C620.711 275 637.5 258.211 637.5 237.5H618.75C618.75 247.855 610.355 256.25 600 256.25C589.645 256.25 581.25 247.855 581.25 237.5H562.5Z" fill="#D4D4D8"/>
|
||||
<path d="M543.75 162.5C543.75 152.145 552.145 143.75 562.5 143.75L562.5 125C541.789 125 525 141.789 525 162.5L543.75 162.5Z" fill="#A1A1AA"/>
|
||||
<path d="M637.5 143.75C647.855 143.75 656.25 152.145 656.25 162.5L675 162.5C675 141.789 658.211 125 637.5 125L637.5 143.75Z" fill="#A1A1AA"/>
|
||||
<path d="M656.25 237.5C656.25 247.855 647.855 256.25 637.5 256.25L637.5 275C658.211 275 675 258.211 675 237.5L656.25 237.5Z" fill="#A1A1AA"/>
|
||||
<path d="M562.5 256.25C552.145 256.25 543.75 247.855 543.75 237.5H525C525 258.211 541.789 275 562.5 275V256.25Z" fill="#A1A1AA"/>
|
||||
<path d="M693.75 237.5C693.75 247.855 685.355 256.25 675 256.25L675 275C695.711 275 712.5 258.211 712.5 237.5L693.75 237.5Z" fill="#18181B"/>
|
||||
<path d="M656.25 275C656.25 285.355 647.855 293.75 637.5 293.75L637.5 312.5C658.211 312.5 675 295.711 675 275L656.25 275Z" fill="#18181B"/>
|
||||
<path d="M525 256.25C514.645 256.25 506.25 247.855 506.25 237.5H487.5C487.5 258.211 504.289 275 525 275V256.25Z" fill="#18181B"/>
|
||||
<path d="M562.5 293.75C552.145 293.75 543.75 285.355 543.75 275H525C525 295.711 541.789 312.5 562.5 312.5V293.75Z" fill="#18181B"/>
|
||||
<path d="M562.5 331.25C552.145 331.25 543.75 322.855 543.75 312.5H525C525 333.211 541.789 350 562.5 350V331.25Z" fill="#18181B"/>
|
||||
<path d="M525 293.75C514.645 293.75 506.25 285.355 506.25 275H487.5C487.5 295.711 504.289 312.5 525 312.5V293.75Z" fill="#18181B"/>
|
||||
<path d="M487.5 256.25C477.145 256.25 468.75 247.855 468.75 237.5H450C450 258.211 466.789 275 487.5 275V256.25Z" fill="#18181B"/>
|
||||
<path d="M543.75 125C543.75 114.645 552.145 106.25 562.5 106.25L562.5 87.5C541.789 87.5 525 104.289 525 125L543.75 125Z" fill="#18181B"/>
|
||||
<path d="M506.25 162.5C506.25 152.145 514.645 143.75 525 143.75L525 125C504.289 125 487.5 141.789 487.5 162.5L506.25 162.5Z" fill="#18181B"/>
|
||||
<path d="M468.75 162.5C468.75 152.145 477.145 143.75 487.5 143.75L487.5 125C466.789 125 450 141.789 450 162.5L468.75 162.5Z" fill="#18181B"/>
|
||||
<path d="M506.25 125C506.25 114.645 514.645 106.25 525 106.25L525 87.5C504.289 87.5 487.5 104.289 487.5 125L506.25 125Z" fill="#18181B"/>
|
||||
<path d="M543.75 87.5C543.75 77.1447 552.145 68.75 562.5 68.75L562.5 50C541.789 50 525 66.7893 525 87.5L543.75 87.5Z" fill="#18181B"/>
|
||||
<path d="M675 143.75C685.355 143.75 693.75 152.145 693.75 162.5L712.5 162.5C712.5 141.789 695.711 125 675 125L675 143.75Z" fill="#18181B"/>
|
||||
<path d="M637.5 106.25C647.855 106.25 656.25 114.645 656.25 125L675 125C675 104.289 658.211 87.5 637.5 87.5L637.5 106.25Z" fill="#18181B"/>
|
||||
<path d="M637.5 68.75C647.855 68.75 656.25 77.1447 656.25 87.5L675 87.5C675 66.7893 658.211 50 637.5 50L637.5 68.75Z" fill="#18181B"/>
|
||||
<path d="M675 106.25C685.355 106.25 693.75 114.645 693.75 125L712.5 125C712.5 104.289 695.711 87.5 675 87.5L675 106.25Z" fill="#18181B"/>
|
||||
<path d="M712.5 143.75C722.855 143.75 731.25 152.145 731.25 162.5L750 162.5C750 141.789 733.211 125 712.5 125L712.5 143.75Z" fill="#18181B"/>
|
||||
<path d="M693.75 275C693.75 285.355 685.355 293.75 675 293.75L675 312.5C695.711 312.5 712.5 295.711 712.5 275L693.75 275Z" fill="#18181B"/>
|
||||
<path d="M731.25 237.5C731.25 247.855 722.855 256.25 712.5 256.25L712.5 275C733.211 275 750 258.211 750 237.5L731.25 237.5Z" fill="#18181B"/>
|
||||
<path d="M656.25 312.5C656.25 322.855 647.855 331.25 637.5 331.25L637.5 350C658.211 350 675 333.211 675 312.5L656.25 312.5Z" fill="#18181B"/>
|
||||
<path d="M525 162.5C504.289 162.5 487.5 179.289 487.5 200C487.5 220.711 504.289 237.5 525 237.5L525 218.75C514.645 218.75 506.25 210.355 506.25 200C506.25 189.645 514.645 181.25 525 181.25L525 162.5Z" fill="#18181B"/>
|
||||
<path d="M487.5 162.5C466.789 162.5 450 179.289 450 200C450 220.711 466.789 237.5 487.5 237.5L487.5 218.75C477.145 218.75 468.75 210.355 468.75 200C468.75 189.645 477.145 181.25 487.5 181.25L487.5 162.5Z" fill="#18181B"/>
|
||||
<path d="M637.5 125C637.5 104.289 620.711 87.5 600 87.5C579.289 87.5 562.5 104.289 562.5 125L581.25 125C581.25 114.645 589.645 106.25 600 106.25C610.355 106.25 618.75 114.645 618.75 125L637.5 125Z" fill="#18181B"/>
|
||||
<path d="M637.5 87.5C637.5 66.7893 620.711 50 600 50C579.289 50 562.5 66.7893 562.5 87.5L581.25 87.5C581.25 77.1447 589.645 68.75 600 68.75C610.355 68.75 618.75 77.1447 618.75 87.5L637.5 87.5Z" fill="#18181B"/>
|
||||
<path d="M675 237.5C695.711 237.5 712.5 220.711 712.5 200C712.5 179.289 695.711 162.5 675 162.5L675 181.25C685.355 181.25 693.75 189.645 693.75 200C693.75 210.355 685.355 218.75 675 218.75L675 237.5Z" fill="#18181B"/>
|
||||
<path d="M712.5 237.5C733.211 237.5 750 220.711 750 200C750 179.289 733.211 162.5 712.5 162.5L712.5 181.25C722.855 181.25 731.25 189.645 731.25 200C731.25 210.355 722.855 218.75 712.5 218.75L712.5 237.5Z" fill="#18181B"/>
|
||||
<path d="M562.5 275C562.5 295.711 579.289 312.5 600 312.5C620.711 312.5 637.5 295.711 637.5 275H618.75C618.75 285.355 610.355 293.75 600 293.75C589.645 293.75 581.25 285.355 581.25 275H562.5Z" fill="#18181B"/>
|
||||
<path d="M562.5 312.5C562.5 333.211 579.289 350 600 350C620.711 350 637.5 333.211 637.5 312.5H618.75C618.75 322.855 610.355 331.25 600 331.25C589.645 331.25 581.25 322.855 581.25 312.5H562.5Z" fill="#18181B"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 6.3 KiB |
|
|
@ -1,8 +1,36 @@
|
|||
import { defineConfig } from 'astro/config';
|
||||
|
||||
import react from "@astrojs/react";
|
||||
import react from '@astrojs/react';
|
||||
import AstroPWA from '@vite-pwa/astro';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
integrations: [react()]
|
||||
});
|
||||
integrations: [
|
||||
react(),
|
||||
AstroPWA({
|
||||
manifest: {
|
||||
background_color: '#09090b',
|
||||
description: 'Ambient sounds for focus and calm.',
|
||||
display: 'standalone',
|
||||
icons: [
|
||||
...[72, 128, 144, 152, 192, 256, 512].map(size => ({
|
||||
sizes: `${size}x${size}`,
|
||||
src: `/assets/pwa/${size}.png`,
|
||||
type: 'image/png',
|
||||
})),
|
||||
],
|
||||
name: 'Moodist',
|
||||
orientation: 'any',
|
||||
scope: '/',
|
||||
short_name: 'Moodist',
|
||||
start_url: '/',
|
||||
theme_color: '#09090b',
|
||||
},
|
||||
registerType: 'prompt',
|
||||
workbox: {
|
||||
globPatterns: ['**/*'],
|
||||
maximumFileSizeToCacheInBytes: Number.MAX_SAFE_INTEGER,
|
||||
navigateFallback: '/',
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
|
|
|||
13951
package-lock.json
generated
39
package.json
|
|
@ -1,13 +1,14 @@
|
|||
{
|
||||
"name": "moodist",
|
||||
"type": "module",
|
||||
"version": "1.4.2",
|
||||
"version": "2.4.0",
|
||||
"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}",
|
||||
|
|
@ -18,27 +19,51 @@
|
|||
"release": "standard-version --no-verify",
|
||||
"release:major": "npm run release -- --release-as major",
|
||||
"release:minor": "npm run release -- --release-as minor",
|
||||
"release:patch": "npm run release -- --release-as patch"
|
||||
"release:patch": "npm run release -- --release-as patch",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"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-checkbox": "1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "2.0.6",
|
||||
"@radix-ui/react-slider": "1.2.3",
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
"@types/howler": "2.2.10",
|
||||
"@types/react": "^18.2.25",
|
||||
"@types/react-dom": "^18.2.10",
|
||||
"astro": "4.0.3",
|
||||
"@vite-pwa/astro": "0.5.0",
|
||||
"astro": "4.10.3",
|
||||
"deepmerge": "4.3.1",
|
||||
"focus-trap-react": "10.2.3",
|
||||
"framer-motion": "10.16.4",
|
||||
"howler": "2.2.4",
|
||||
"js-confetti": "0.12.0",
|
||||
"motion": "12.23.24",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hotkeys-hook": "3.2.1",
|
||||
"react-icons": "4.11.0",
|
||||
"react-wrap-balancer": "1.1.0",
|
||||
"react-youtube": "10.1.0",
|
||||
"uuid": "10.0.0",
|
||||
"zustand": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "1.3.3",
|
||||
"@commitlint/cli": "17.7.2",
|
||||
"@commitlint/config-conventional": "17.7.0",
|
||||
"@storybook/addon-a11y": "8.0.9",
|
||||
"@storybook/addon-essentials": "8.0.9",
|
||||
"@storybook/addon-interactions": "8.0.9",
|
||||
"@storybook/addon-links": "8.0.9",
|
||||
"@storybook/addon-onboarding": "8.0.9",
|
||||
"@storybook/blocks": "8.0.9",
|
||||
"@storybook/react": "8.0.9",
|
||||
"@storybook/react-vite": "8.0.9",
|
||||
"@storybook/test": "8.0.9",
|
||||
"@typescript-eslint/eslint-plugin": "6.7.4",
|
||||
"@typescript-eslint/parser": "6.7.4",
|
||||
"astro-eslint-parser": "0.16.0",
|
||||
|
|
@ -57,6 +82,7 @@
|
|||
"eslint-plugin-react-hooks": "4.6.0",
|
||||
"eslint-plugin-sort-destructure-keys": "1.5.0",
|
||||
"eslint-plugin-sort-keys-fix": "1.1.2",
|
||||
"eslint-plugin-storybook": "0.8.0",
|
||||
"eslint-plugin-typescript-sort-keys": "3.1.0",
|
||||
"husky": "8.0.3",
|
||||
"lint-staged": "14.0.1",
|
||||
|
|
@ -64,11 +90,14 @@
|
|||
"postcss-nesting": "12.0.1",
|
||||
"prettier": "3.0.3",
|
||||
"prettier-plugin-astro": "0.12.0",
|
||||
"prop-types": "15.8.1",
|
||||
"standard-version": "9.5.0",
|
||||
"storybook": "8.0.9",
|
||||
"stylelint": "15.10.3",
|
||||
"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/assets/pwa/128.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
public/assets/pwa/144.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
public/assets/pwa/152.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
public/assets/pwa/192.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
public/assets/pwa/256.png
Normal file
|
After Width: | Height: | Size: 8 KiB |
BIN
public/assets/pwa/512.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
public/assets/pwa/72.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 10 KiB |
BIN
public/logo-dark.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
public/logo-light.png
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 10 KiB |
BIN
public/og.png
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 14 KiB |
BIN
public/sounds/animals/beehive.mp3
Normal file
BIN
public/sounds/animals/chickens.mp3
Normal file
BIN
public/sounds/animals/cows.mp3
Normal file
BIN
public/sounds/animals/sheep.mp3
Normal file
BIN
public/sounds/animals/woodpecker.mp3
Normal file
BIN
public/sounds/nature/jungle.mp3
Normal file
BIN
public/sounds/nature/walk-on-gravel.mp3
Normal file
BIN
public/sounds/places/laundry-room.mp3
Normal file
BIN
public/sounds/places/library.mp3
Normal file
BIN
public/sounds/places/restaurant.mp3
Normal file
BIN
public/sounds/rain/rain-on-car-roof.mp3
Normal file
BIN
public/sounds/silence.wav
Normal file
BIN
public/sounds/things/vinyl-effect.mp3
Normal file
BIN
public/sounds/things/washing-machine.mp3
Normal file
BIN
public/sounds/things/windshield-wipers.mp3
Normal file
156
src/components/about.astro
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
---
|
||||
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: "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,90 +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 {
|
||||
background-color: var(--color-neutral-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -12,15 +12,21 @@ import { Categories } from '@/components/categories';
|
|||
import { SharedModal } from '@/components/modals/shared';
|
||||
import { Toolbar } from '@/components/toolbar';
|
||||
import { SnackbarProvider } from '@/contexts/snackbar';
|
||||
import { MediaControls } from '@/components/media-controls';
|
||||
|
||||
import { sounds } from '@/data/sounds';
|
||||
import { FADE_OUT } from '@/constants/events';
|
||||
|
||||
import type { Sound } from '@/data/types';
|
||||
import { subscribe } from '@/lib/event';
|
||||
|
||||
export function App() {
|
||||
const categories = useMemo(() => sounds.categories, []);
|
||||
|
||||
const favorites = useSoundStore(useShallow(state => state.getFavorites()));
|
||||
const pause = useSoundStore(state => state.pause);
|
||||
const lock = useSoundStore(state => state.lock);
|
||||
const unlock = useSoundStore(state => state.unlock);
|
||||
|
||||
const favoriteSounds = useMemo(() => {
|
||||
const favoriteSounds = categories
|
||||
|
|
@ -52,6 +58,19 @@ export function App() {
|
|||
return () => document.removeEventListener('visibilitychange', onChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = subscribe(FADE_OUT, (e: { duration: number }) => {
|
||||
lock();
|
||||
|
||||
setTimeout(() => {
|
||||
pause();
|
||||
unlock();
|
||||
}, e.duration);
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [pause, lock, unlock]);
|
||||
|
||||
const allCategories = useMemo(() => {
|
||||
const favorites = [];
|
||||
|
||||
|
|
@ -70,6 +89,7 @@ export function App() {
|
|||
return (
|
||||
<SnackbarProvider>
|
||||
<StoreConsumer>
|
||||
<MediaControls />
|
||||
<Container>
|
||||
<div id="app" />
|
||||
<Buttons />
|
||||
|
|
|
|||
7
src/components/binary.astro
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
import { generateRandomBinaryString } from '@/helpers/binary';
|
||||
|
||||
const binary = generateRandomBinaryString(1000);
|
||||
---
|
||||
|
||||
<span>{binary}</span>
|
||||
|
|
@ -12,13 +12,16 @@
|
|||
background-color: var(--color-neutral-950);
|
||||
border: 1px solid var(--color-neutral-50);
|
||||
border-radius: 100px;
|
||||
outline: none;
|
||||
transition: 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-neutral-800);
|
||||
}
|
||||
|
||||
&:not(.disabled):active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
&:disabled,
|
||||
&.disabled {
|
||||
cursor: not-allowed;
|
||||
|
|
@ -27,4 +30,9 @@
|
|||
& span {
|
||||
font-size: var(--font-lg);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-neutral-400);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { BiPause, BiPlay } from 'react-icons/bi/index';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
|
||||
import { useSoundStore } from '@/store';
|
||||
import { useSoundStore } from '@/stores/sound';
|
||||
import { useSnackbar } from '@/contexts/snackbar';
|
||||
import { cn } from '@/helpers/styles';
|
||||
|
||||
|
|
@ -12,35 +13,40 @@ export function PlayButton() {
|
|||
const pause = useSoundStore(state => state.pause);
|
||||
const toggle = useSoundStore(state => state.togglePlay);
|
||||
const noSelected = useSoundStore(state => state.noSelected());
|
||||
const locked = useSoundStore(state => state.locked);
|
||||
|
||||
const showSnackbar = useSnackbar();
|
||||
|
||||
const handleClick = () => {
|
||||
const handleToggle = useCallback(() => {
|
||||
if (locked) return;
|
||||
|
||||
if (noSelected) return showSnackbar('Please first select a sound to play.');
|
||||
|
||||
toggle();
|
||||
};
|
||||
}, [showSnackbar, toggle, noSelected, locked]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPlaying && noSelected) pause();
|
||||
}, [isPlaying, pause, noSelected]);
|
||||
|
||||
useHotkeys('shift+space', handleToggle, {}, [handleToggle]);
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-disabled={noSelected}
|
||||
className={cn(styles.playButton, noSelected && styles.disabled)}
|
||||
onClick={handleClick}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<>
|
||||
<span>
|
||||
<span aria-hidden="true">
|
||||
<BiPause />
|
||||
</span>{' '}
|
||||
Pause
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>
|
||||
<span aria-hidden="true">
|
||||
<BiPlay />
|
||||
</span>{' '}
|
||||
Play
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@
|
|||
background-color: var(--color-neutral-100);
|
||||
border: 1px solid var(--color-neutral-300);
|
||||
border-radius: 100px;
|
||||
outline: none;
|
||||
transition: 0.2s;
|
||||
|
||||
&:disabled,
|
||||
|
|
@ -20,9 +19,19 @@
|
|||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
&:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background-color: var(--color-neutral-200);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-neutral-400);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { useCallback } from 'react';
|
||||
import { BiUndo, BiTrash } from 'react-icons/bi/index';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
|
||||
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';
|
||||
|
||||
|
|
@ -14,12 +16,21 @@ export function UnselectButton() {
|
|||
const restoreHistory = useSoundStore(state => state.restoreHistory);
|
||||
const hasHistory = useSoundStore(state => !!state.history);
|
||||
const unselectAll = useSoundStore(state => state.unselectAll);
|
||||
const locked = useSoundStore(state => state.locked);
|
||||
|
||||
const variants = {
|
||||
...mix(fade(), slideX(15)),
|
||||
exit: { opacity: 0 },
|
||||
};
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
if (locked) return;
|
||||
if (hasHistory) restoreHistory();
|
||||
else if (!noSelected) unselectAll(true);
|
||||
}, [hasHistory, noSelected, unselectAll, restoreHistory, locked]);
|
||||
|
||||
useHotkeys('shift+r', handleToggle, {}, [handleToggle]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence mode="wait">
|
||||
|
|
@ -30,34 +41,31 @@ export function UnselectButton() {
|
|||
initial="hidden"
|
||||
variants={variants}
|
||||
>
|
||||
<Tooltip
|
||||
hideDelay={0}
|
||||
showDelay={0}
|
||||
content={
|
||||
hasHistory
|
||||
? 'Restore unselected sounds.'
|
||||
: 'Unselect all sounds.'
|
||||
}
|
||||
>
|
||||
<button
|
||||
disabled={noSelected && !hasHistory}
|
||||
aria-label={
|
||||
<Tooltip.Provider delayDuration={0}>
|
||||
<Tooltip
|
||||
content={
|
||||
hasHistory
|
||||
? 'Restore Unselected Sounds'
|
||||
: 'Unselect All Sounds'
|
||||
? 'Restore unselected sounds.'
|
||||
: 'Unselect all sounds.'
|
||||
}
|
||||
className={cn(
|
||||
styles.unselectButton,
|
||||
noSelected && !hasHistory && styles.disabled,
|
||||
)}
|
||||
onClick={() => {
|
||||
if (hasHistory) restoreHistory();
|
||||
else if (!noSelected) unselectAll(true);
|
||||
}}
|
||||
>
|
||||
{hasHistory ? <BiUndo /> : <BiTrash />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<button
|
||||
disabled={noSelected && !hasHistory}
|
||||
aria-label={
|
||||
hasHistory
|
||||
? 'Restore Unselected Sounds'
|
||||
: 'Unselect All Sounds'
|
||||
}
|
||||
className={cn(
|
||||
styles.unselectButton,
|
||||
noSelected && !hasHistory && styles.disabled,
|
||||
)}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
{hasHistory ? <BiUndo /> : <BiTrash />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</Tooltip.Provider>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { AnimatePresence } from 'framer-motion';
|
||||
import { AnimatePresence } from 'motion/react';
|
||||
|
||||
import { Category } from '@/components/category';
|
||||
import { Category } from './category';
|
||||
import { Donate } from './donate';
|
||||
|
||||
import type { Categories } from '@/data/types';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding-bottom: 80px;
|
||||
|
||||
& .title {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--font-lg);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
& .categoryIconsWrapper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
& .icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
font-size: var(--font-md);
|
||||
color: var(--color-foreground);
|
||||
cursor: pointer;
|
||||
background: linear-gradient(
|
||||
var(--color-neutral-50),
|
||||
var(--color-neutral-100)
|
||||
);
|
||||
border: 1px solid var(--color-neutral-300);
|
||||
border-radius: 50%;
|
||||
transition: 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(
|
||||
var(--color-neutral-100),
|
||||
var(--color-neutral-200)
|
||||
);
|
||||
transform: scale(1.15);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
42
src/components/categories/category-icons/category-icons.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { sounds } from '@/data/sounds';
|
||||
import { useMemo } from 'react';
|
||||
import styles from './category-icons.module.css';
|
||||
import { Container } from '@/components/container';
|
||||
import { Tooltip } from '@/components/tooltip';
|
||||
|
||||
export default function CategoryIcons() {
|
||||
const categories = useMemo(() => sounds.categories, []);
|
||||
|
||||
const goto = (id: string) => {
|
||||
const category = document.getElementById(`category-${id}`);
|
||||
category?.scrollIntoView();
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className={styles.wrapper}>
|
||||
<h3 className={styles.title}>Categories</h3>
|
||||
<div className={styles.categoryIconsWrapper}>
|
||||
<Tooltip.Provider delayDuration={0}>
|
||||
{categories.map(category => {
|
||||
return (
|
||||
<Tooltip
|
||||
content={category.title}
|
||||
key={category.id}
|
||||
placement="bottom"
|
||||
>
|
||||
<button
|
||||
className={styles.icon}
|
||||
onClick={() => goto(category.id)}
|
||||
>
|
||||
{category.icon}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</Tooltip.Provider>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
|
@ -22,7 +22,10 @@
|
|||
width: 45px;
|
||||
height: 45px;
|
||||
font-size: var(--font-md);
|
||||
background-color: var(--color-neutral-100);
|
||||
background: linear-gradient(
|
||||
var(--color-neutral-50),
|
||||
var(--color-neutral-100)
|
||||
);
|
||||
border: 1px solid var(--color-neutral-300);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
|
@ -16,10 +16,12 @@ 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>
|
||||
<div aria-hidden="true" className={styles.icon}>
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.title}>{title}</div>
|
||||
|
|
@ -20,7 +20,10 @@
|
|||
width: 45px;
|
||||
height: 45px;
|
||||
font-size: var(--font-md);
|
||||
background-color: var(--color-neutral-100);
|
||||
background: linear-gradient(
|
||||
var(--color-neutral-50),
|
||||
var(--color-neutral-100)
|
||||
);
|
||||
border: 1px solid var(--color-neutral-300);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
|
@ -31,6 +34,16 @@
|
|||
font-size: var(--font-lg);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
|
||||
& span {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-foreground),
|
||||
var(--color-foreground-subtle)
|
||||
);
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
& .desc {
|
||||
|
|
|
|||
|
|
@ -9,12 +9,14 @@ export function Donate() {
|
|||
<div className={styles.donate}>
|
||||
<div className={styles.iconContainer}>
|
||||
<div className={styles.tail} />
|
||||
<div className={styles.icon}>
|
||||
<div aria-hidden="true" className={styles.icon}>
|
||||
<FaCoffee />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.title}>Support Me</div>
|
||||
<div className={styles.title}>
|
||||
<span>Support Me</span>
|
||||
</div>
|
||||
<p className={styles.desc}>Help me keep Moodist ad-free.</p>
|
||||
<SpecialButton
|
||||
className={styles.button}
|
||||
|
|
|
|||
23
src/components/checkbox/checkbox.module.css
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
.checkboxRoot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
background: var(--color-neutral-100);
|
||||
border: 2px solid var(--color-neutral-300);
|
||||
border-radius: 4px;
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
.checkboxRoot[data-state='checked'] {
|
||||
background: var(--color-neutral-950);
|
||||
border: 2px solid var(--color-neutral-950);
|
||||
}
|
||||
|
||||
.checkboxIndicator {
|
||||
font-size: var(--font-2xsm);
|
||||
color: var(--color-neutral-50);
|
||||
transform: translateY(2px);
|
||||
}
|
||||
38
src/components/checkbox/checkbox.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import * as RadixCheckbox from '@radix-ui/react-checkbox';
|
||||
import { FaCheck } from 'react-icons/fa6/index';
|
||||
|
||||
import styles from './checkbox.module.css';
|
||||
|
||||
type CheckboxInputProps = {
|
||||
checked?: boolean;
|
||||
className?: string;
|
||||
defaultChecked?: boolean;
|
||||
disabled?: boolean;
|
||||
onChange?: (checked: boolean) => void;
|
||||
};
|
||||
|
||||
export function Checkbox({
|
||||
checked,
|
||||
className,
|
||||
defaultChecked = false,
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: CheckboxInputProps) {
|
||||
const handleCheckedChange = (checked: boolean) => {
|
||||
if (onChange) onChange(checked);
|
||||
};
|
||||
|
||||
return (
|
||||
<RadixCheckbox.Root
|
||||
checked={checked}
|
||||
className={`${styles.checkboxRoot} ${className}`}
|
||||
defaultChecked={defaultChecked}
|
||||
disabled={disabled}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
>
|
||||
<RadixCheckbox.Indicator className={styles.checkboxIndicator}>
|
||||
<FaCheck />
|
||||
</RadixCheckbox.Indicator>
|
||||
</RadixCheckbox.Root>
|
||||
);
|
||||
}
|
||||
1
src/components/checkbox/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { Checkbox } from './checkbox';
|
||||
61
src/components/cipher.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface CipherTextProps {
|
||||
interval?: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const chars = '-_~`!@#$%^&*()+=[]{}|;:,.<>?';
|
||||
|
||||
export function CipherText({ interval = 50, text }: CipherTextProps) {
|
||||
const [outputText, setOutputText] = useState('');
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => setIsMounted(true), 2000);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMounted) return;
|
||||
|
||||
let timer: NodeJS.Timeout;
|
||||
|
||||
if (outputText !== text) {
|
||||
timer = setInterval(() => {
|
||||
if (outputText.length < text.length) {
|
||||
setOutputText(prev => prev + text[prev.length]);
|
||||
} else {
|
||||
clearInterval(timer);
|
||||
}
|
||||
}, interval);
|
||||
}
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [text, interval, outputText, isMounted]);
|
||||
|
||||
useEffect(() => {
|
||||
if (outputText === text) {
|
||||
setTimeout(() => setOutputText(''), 6000);
|
||||
}
|
||||
}, [outputText, text]);
|
||||
|
||||
const remainder =
|
||||
outputText.length < text.length
|
||||
? text
|
||||
.slice(outputText.length)
|
||||
.split('')
|
||||
.map(() => chars[Math.floor(Math.random() * chars.length)])
|
||||
.join('')
|
||||
: '';
|
||||
|
||||
if (!isMounted) {
|
||||
return <span>{text}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="text-white">
|
||||
{outputText}
|
||||
{remainder}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
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-400),
|
||||
transparent
|
||||
);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
& .text {
|
||||
text-align: center;
|
||||
|
||||
& a {
|
||||
font-weight: 500;
|
||||
color: var(--color-foreground);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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
|
|
@ -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';
|
||||
174
src/components/hero.astro
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
---
|
||||
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">
|
||||
<div class="pattern"></div>
|
||||
<div class="logo-wrapper">
|
||||
<img
|
||||
alt="Faded Moodist Logo"
|
||||
aria-hidden="true"
|
||||
class="logo"
|
||||
height={48}
|
||||
src="/logo.svg"
|
||||
width={48}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h1 class="title">
|
||||
Ambient Sounds<span class="line">For Focus and Calm</span>
|
||||
</h1>
|
||||
<h2 class="desc">Free and Open-Source.</h2>
|
||||
|
||||
<p class="sounds">
|
||||
<span aria-hidden="true" class="icon">
|
||||
<BsSoundwave />
|
||||
</span>
|
||||
<span>{count} Sounds</span>
|
||||
</p>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
text-align: center;
|
||||
|
||||
.wrapper {
|
||||
position: relative;
|
||||
padding: 120px 0 80px;
|
||||
|
||||
& .pattern {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: -1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image: radial-gradient(
|
||||
var(--color-neutral-500) 5%,
|
||||
transparent 5%
|
||||
);
|
||||
background-position: top center;
|
||||
background-size: 21px 21px;
|
||||
opacity: 0.8;
|
||||
mask-image: linear-gradient(#fff, transparent, transparent);
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
width: 300px;
|
||||
height: 100px;
|
||||
content: '';
|
||||
background: var(--color-neutral-200);
|
||||
filter: blur(50px);
|
||||
border-radius: 100%;
|
||||
opacity: 0.8;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& .logo-wrapper {
|
||||
mask-image: linear-gradient(#000, rgb(0 0 0 / 40%), rgb(0 0 0 / 5%));
|
||||
|
||||
& .logo {
|
||||
display: block;
|
||||
width: 48px;
|
||||
margin: 0 auto 20px;
|
||||
opacity: 1;
|
||||
animation-name: logo;
|
||||
animation-duration: 60s;
|
||||
animation-timing-function: linear;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
}
|
||||
|
||||
& .title {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--font-xlg);
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
|
||||
& .line {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
background: linear-gradient(
|
||||
var(--color-foreground-subtler),
|
||||
var(--color-foreground-subtle)
|
||||
);
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
& .desc {
|
||||
margin-top: 12px;
|
||||
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%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes logo {
|
||||
0% {
|
||||
transform: rotate(0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</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';
|
||||
1
src/components/media-controls/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { MediaControls } from './media-controls';
|
||||
20
src/components/media-controls/media-controls.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { MediaSessionTrack } from './media-session-track';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSSR } from '@/hooks/use-ssr';
|
||||
|
||||
export function MediaControls() {
|
||||
const [mediaControlsEnabled, setMediaControlsEnabled] = useState(false);
|
||||
const { isBrowser } = useSSR();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isBrowser) return;
|
||||
|
||||
setMediaControlsEnabled('mediaSession' in navigator);
|
||||
}, [isBrowser]);
|
||||
|
||||
if (!mediaControlsEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <MediaSessionTrack />;
|
||||
}
|
||||
98
src/components/media-controls/media-session-track.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { BrowserDetect } from '@/helpers/browser-detect';
|
||||
|
||||
import { useSoundStore } from '@/stores/sound';
|
||||
|
||||
import { useSSR } from '@/hooks/use-ssr';
|
||||
import { useDarkTheme } from '@/hooks/use-dark-theme';
|
||||
|
||||
const metadata: MediaMetadataInit = {
|
||||
artist: 'Moodist',
|
||||
title: 'Ambient Sounds for Focus and Calm',
|
||||
};
|
||||
|
||||
export function MediaSessionTrack() {
|
||||
const { isBrowser } = useSSR();
|
||||
const isDarkTheme = useDarkTheme();
|
||||
const isPlaying = useSoundStore(state => state.isPlaying);
|
||||
const play = useSoundStore(state => state.play);
|
||||
const pause = useSoundStore(state => state.pause);
|
||||
const masterAudioSoundRef = useRef<HTMLAudioElement>(null);
|
||||
const artworkURL = isDarkTheme ? '/logo-dark.png' : '/logo-light.png';
|
||||
|
||||
useEffect(() => {
|
||||
if (!isBrowser || !isPlaying) return;
|
||||
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
...metadata,
|
||||
artwork: [
|
||||
{
|
||||
sizes: '200x200',
|
||||
src: artworkURL,
|
||||
type: 'image/png',
|
||||
},
|
||||
],
|
||||
});
|
||||
}, [artworkURL, isBrowser, isDarkTheme, isPlaying]);
|
||||
|
||||
const startMasterAudio = useCallback(async () => {
|
||||
if (!masterAudioSoundRef.current) return;
|
||||
if (!masterAudioSoundRef.current.paused) return;
|
||||
|
||||
try {
|
||||
await masterAudioSoundRef.current.play();
|
||||
|
||||
navigator.mediaSession.playbackState = 'playing';
|
||||
navigator.mediaSession.setActionHandler('play', play);
|
||||
navigator.mediaSession.setActionHandler('pause', pause);
|
||||
} catch {
|
||||
// Do nothing
|
||||
}
|
||||
}, [pause, play]);
|
||||
|
||||
const stopMasterAudio = useCallback(() => {
|
||||
if (!masterAudioSoundRef.current) return;
|
||||
/**
|
||||
* Otherwise in Safari we cannot play the audio again
|
||||
* through the media session controls
|
||||
*/
|
||||
if (BrowserDetect.isSafari()) {
|
||||
masterAudioSoundRef.current.load();
|
||||
} else {
|
||||
masterAudioSoundRef.current.pause();
|
||||
}
|
||||
navigator.mediaSession.playbackState = 'paused';
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!masterAudioSoundRef.current) return;
|
||||
|
||||
if (isPlaying) {
|
||||
startMasterAudio();
|
||||
} else {
|
||||
stopMasterAudio();
|
||||
}
|
||||
}, [isPlaying, startMasterAudio, stopMasterAudio]);
|
||||
|
||||
useEffect(() => {
|
||||
const masterAudioSound = masterAudioSoundRef.current;
|
||||
|
||||
return () => {
|
||||
masterAudioSound?.pause();
|
||||
|
||||
navigator.mediaSession.setActionHandler('play', null);
|
||||
navigator.mediaSession.setActionHandler('pause', null);
|
||||
navigator.mediaSession.playbackState = 'none';
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<audio
|
||||
id="media-session-track"
|
||||
loop
|
||||
ref={masterAudioSoundRef}
|
||||
src="/sounds/silence.wav"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
import styles from './item.module.css';
|
||||
|
||||
interface ItemProps {
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
href?: string;
|
||||
icon: React.ReactElement;
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function Item({
|
||||
active,
|
||||
disabled = false,
|
||||
href,
|
||||
icon,
|
||||
label,
|
||||
onClick = () => {},
|
||||
}: ItemProps) {
|
||||
const Comp = href ? 'a' : 'button';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={styles.item}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
{...(href ? { href, target: '_blank' } : {})}
|
||||
>
|
||||
<span className={styles.icon}>{icon}</span> {label}
|
||||
{active && <div className={styles.active} />}
|
||||
</Comp>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
export { Shuffle as ShuffleItem } from './shuffle';
|
||||
export { Share as ShareItem } from './share';
|
||||
export { Donate as DonateItem } from './donate';
|
||||
export { Notepad as NotepadItem } from './notepad';
|
||||
export { Source as SourceItem } from './source';
|
||||
export { Pomodoro as PomodoroItem } from './pomodoro';
|
||||
export { Presets as PresetsItem } from './presets';
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import { BiShuffle } from 'react-icons/bi/index';
|
||||
|
||||
import { useSoundStore } from '@/store';
|
||||
|
||||
import { Item } from '../item';
|
||||
|
||||
export function Shuffle() {
|
||||
const shuffle = useSoundStore(state => state.shuffle);
|
||||
|
||||
return <Item icon={<BiShuffle />} label="Shuffle Sounds" onClick={shuffle} />;
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
.wrapper {
|
||||
& .menuButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
font-size: var(--font-md);
|
||||
color: var(--color-foreground);
|
||||
cursor: pointer;
|
||||
background-color: var(--color-neutral-100);
|
||||
border: 1px solid var(--color-neutral-300);
|
||||
border-radius: 50%;
|
||||
transition: 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-neutral-200);
|
||||
}
|
||||
}
|
||||
|
||||
& .menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 4px;
|
||||
width: 240px;
|
||||
height: max-content;
|
||||
padding: 4px;
|
||||
overflow: auto;
|
||||
background-color: var(--color-neutral-100);
|
||||
border: 1px solid var(--color-neutral-300);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { IoMenu, IoClose } from 'react-icons/io5/index';
|
||||
import {
|
||||
useFloating,
|
||||
autoUpdate,
|
||||
offset,
|
||||
flip,
|
||||
shift,
|
||||
size,
|
||||
useClick,
|
||||
useDismiss,
|
||||
useRole,
|
||||
useInteractions,
|
||||
FloatingFocusManager,
|
||||
} from '@floating-ui/react';
|
||||
|
||||
import {
|
||||
ShuffleItem,
|
||||
ShareItem,
|
||||
DonateItem,
|
||||
NotepadItem,
|
||||
SourceItem,
|
||||
PomodoroItem,
|
||||
PresetsItem,
|
||||
} from './items';
|
||||
import { Divider } from './divider';
|
||||
import { ShareLinkModal } from '@/components/modals/share-link';
|
||||
import { PresetsModal } from '@/components/modals/presets';
|
||||
import { Notepad, Pomodoro } from '@/components/toolbox';
|
||||
|
||||
import styles from './menu.module.css';
|
||||
|
||||
export function Menu() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const [showPresets, setShowPresets] = useState(false);
|
||||
const [showShareLink, setShowShareLink] = useState(false);
|
||||
const [showNotepad, setShowNotepad] = useState(false);
|
||||
const [showPomodoro, setShowPomodoro] = useState(false);
|
||||
|
||||
const { context, floatingStyles, refs } = useFloating({
|
||||
middleware: [
|
||||
offset(12),
|
||||
flip(),
|
||||
shift(),
|
||||
size({
|
||||
apply({ availableHeight, elements }) {
|
||||
Object.assign(elements.floating.style, {
|
||||
maxHeight: `${availableHeight}px`,
|
||||
});
|
||||
},
|
||||
padding: 10,
|
||||
}),
|
||||
],
|
||||
onOpenChange: setIsOpen,
|
||||
open: isOpen,
|
||||
placement: 'top-end',
|
||||
whileElementsMounted: autoUpdate,
|
||||
});
|
||||
|
||||
const click = useClick(context);
|
||||
const dismiss = useDismiss(context);
|
||||
const role = useRole(context);
|
||||
|
||||
const { getFloatingProps, getReferenceProps } = useInteractions([
|
||||
click,
|
||||
dismiss,
|
||||
role,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.wrapper}>
|
||||
<button
|
||||
aria-label="Menu"
|
||||
className={styles.menuButton}
|
||||
ref={refs.setReference}
|
||||
onClick={() => setIsOpen(prev => !prev)}
|
||||
{...getReferenceProps()}
|
||||
>
|
||||
{isOpen ? <IoClose /> : <IoMenu />}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<FloatingFocusManager context={context} modal={false}>
|
||||
<div
|
||||
ref={refs.setFloating}
|
||||
style={floatingStyles}
|
||||
{...getFloatingProps()}
|
||||
className={styles.menu}
|
||||
>
|
||||
<PresetsItem open={() => setShowPresets(true)} />
|
||||
<ShareItem open={() => setShowShareLink(true)} />
|
||||
<ShuffleItem />
|
||||
<Divider />
|
||||
<NotepadItem open={() => setShowNotepad(true)} />
|
||||
<PomodoroItem open={() => setShowPomodoro(true)} />
|
||||
<Divider />
|
||||
<DonateItem />
|
||||
<SourceItem />
|
||||
</div>
|
||||
</FloatingFocusManager>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ShareLinkModal
|
||||
show={showShareLink}
|
||||
onClose={() => setShowShareLink(false)}
|
||||
/>
|
||||
|
||||
<PresetsModal show={showPresets} onClose={() => setShowPresets(false)} />
|
||||
|
||||
<Notepad show={showNotepad} onClose={() => setShowNotepad(false)} />
|
||||
<Pomodoro show={showPomodoro} onClose={() => setShowPomodoro(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -50,7 +50,13 @@
|
|||
cursor: pointer;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-neutral-400);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
26
src/components/modal/modal.stories.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { Modal } from './modal';
|
||||
|
||||
const meta: Meta<typeof Modal> = {
|
||||
component: Modal,
|
||||
title: 'Modal',
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: 'Hello World',
|
||||
show: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Wide: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
wide: true,
|
||||
},
|
||||
};
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { useEffect } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { IoClose } from 'react-icons/io5/index';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
|
||||
import { Portal } from '@/components/portal';
|
||||
|
||||
|
|
@ -13,14 +14,18 @@ interface ModalProps {
|
|||
children: React.ReactNode;
|
||||
lockBody?: boolean;
|
||||
onClose: () => void;
|
||||
persist?: boolean;
|
||||
show: boolean;
|
||||
wide?: boolean;
|
||||
}
|
||||
|
||||
const TRANSITION_DURATION = 300;
|
||||
|
||||
export function Modal({
|
||||
children,
|
||||
lockBody = true,
|
||||
onClose,
|
||||
persist = false,
|
||||
show,
|
||||
wide,
|
||||
}: ModalProps) {
|
||||
|
|
@ -31,44 +36,72 @@ export function Modal({
|
|||
|
||||
useEffect(() => {
|
||||
if (show && lockBody) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.body.style.overflowY = 'hidden';
|
||||
} else if (lockBody) {
|
||||
document.body.style.overflow = 'auto';
|
||||
// Wait for transition to finish before allowing scrollbar to return
|
||||
setTimeout(() => {
|
||||
document.body.style.overflowY = 'auto';
|
||||
}, TRANSITION_DURATION);
|
||||
}
|
||||
}, [show, lockBody]);
|
||||
|
||||
useEffect(() => {
|
||||
function keyListener(e: KeyboardEvent) {
|
||||
if (show && e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', keyListener);
|
||||
|
||||
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}
|
||||
transition={{ duration: TRANSITION_DURATION / 1000 }}
|
||||
variants={variants.overlay}
|
||||
onClick={onClose}
|
||||
onKeyDown={onClose}
|
||||
/>
|
||||
<div className={styles.modal}>
|
||||
<motion.div
|
||||
{...animationProps}
|
||||
className={cn(styles.content, wide && styles.wide)}
|
||||
transition={{ duration: TRANSITION_DURATION / 1000 }}
|
||||
variants={variants.modal}
|
||||
>
|
||||
<button className={styles.close} onClick={onClose}>
|
||||
<IoClose />
|
||||
</button>
|
||||
{children}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</FocusTrap>
|
||||
);
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<AnimatePresence>
|
||||
{show && (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{persist ? (
|
||||
<div style={{ display: show ? 'block' : 'none' }}>{content}</div>
|
||||
) : (
|
||||
<AnimatePresence>{show && content}</AnimatePresence>
|
||||
)}
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
76
src/components/modals/binaural/binaural.module.css
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
.header {
|
||||
margin-bottom: 16px;
|
||||
|
||||
& .title {
|
||||
margin-bottom: 4px;
|
||||
font-family: var(--font-heading);
|
||||
font-size: var(--font-md);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
& .desc {
|
||||
color: var(--color-foreground-subtle);
|
||||
}
|
||||
}
|
||||
|
||||
.fieldWrapper {
|
||||
margin-bottom: 12px;
|
||||
|
||||
& label {
|
||||
display: block;
|
||||
font-size: var(--font-sm);
|
||||
font-weight: 500;
|
||||
color: var(--color-foreground);
|
||||
|
||||
& input,
|
||||
select {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 45px;
|
||||
padding: 0 8px;
|
||||
margin-top: 4px;
|
||||
color: var(--color-foreground);
|
||||
background-color: var(--color-neutral-50);
|
||||
border: 1px solid var(--color-neutral-200);
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
& .volume {
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
column-gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
|
||||
& button {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 45px;
|
||||
font-weight: 500;
|
||||
color: var(--color-foreground);
|
||||
cursor: pointer;
|
||||
background-color: var(--color-neutral-200);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
&.primary {
|
||||
color: var(--color-neutral-50);
|
||||
background-color: var(--color-neutral-950);
|
||||
}
|
||||
}
|
||||
}
|
||||
243
src/components/modals/binaural/binaural.tsx
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
|
||||
import { Modal } from '@/components/modal';
|
||||
import { Slider } from '@/components/slider';
|
||||
|
||||
import styles from './binaural.module.css';
|
||||
|
||||
interface BinauralProps {
|
||||
onClose: () => void;
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
interface Preset {
|
||||
baseFrequency: number;
|
||||
beatFrequency: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const presets: Preset[] = [
|
||||
{ baseFrequency: 100, beatFrequency: 2, name: 'Delta (Deep Sleep) 2 Hz' },
|
||||
{ baseFrequency: 100, beatFrequency: 5, name: 'Theta (Meditation) 5 Hz' },
|
||||
{ baseFrequency: 100, beatFrequency: 10, name: 'Alpha (Relaxation) 10 Hz' },
|
||||
{ baseFrequency: 100, beatFrequency: 20, name: 'Beta (Focus) 20 Hz' },
|
||||
{ baseFrequency: 100, beatFrequency: 40, name: 'Gamma (Cognition) 40 Hz' },
|
||||
{ baseFrequency: 440, beatFrequency: 10, name: 'Custom' },
|
||||
];
|
||||
|
||||
function computeBinauralBeatOscillatorFrequencies(
|
||||
baseFrequency: number,
|
||||
beatFrequency: number,
|
||||
) {
|
||||
return {
|
||||
leftFrequency: baseFrequency - beatFrequency / 2,
|
||||
rightFrequency: baseFrequency + beatFrequency / 2,
|
||||
};
|
||||
}
|
||||
|
||||
export function BinauralModal({ onClose, show }: BinauralProps) {
|
||||
const [baseFrequency, setBaseFrequency] = useState<number>(440); // Default to A4 note
|
||||
const [beatFrequency, setBeatFrequency] = useState<number>(10); // Default to 10 Hz difference
|
||||
const [volume, setVolume] = useState<number>(0.5); // Default volume at 50%
|
||||
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||
const [selectedPreset, setSelectedPreset] = useState<string>('Custom');
|
||||
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const leftOscillatorRef = useRef<OscillatorNode | null>(null);
|
||||
const rightOscillatorRef = useRef<OscillatorNode | null>(null);
|
||||
const gainNodeRef = useRef<GainNode | null>(null);
|
||||
|
||||
const startSound = () => {
|
||||
if (isPlaying) return;
|
||||
|
||||
// Initialize the AudioContext
|
||||
audioContextRef.current = new window.AudioContext();
|
||||
const audioContext = audioContextRef.current;
|
||||
|
||||
if (!audioContext) return;
|
||||
|
||||
// Create a gain node for volume control
|
||||
gainNodeRef.current = audioContext.createGain();
|
||||
gainNodeRef.current.gain.value = volume; // Set volume based on state
|
||||
|
||||
// Create oscillators for left and right channels
|
||||
leftOscillatorRef.current = audioContext.createOscillator();
|
||||
rightOscillatorRef.current = audioContext.createOscillator();
|
||||
|
||||
if (
|
||||
!leftOscillatorRef.current ||
|
||||
!rightOscillatorRef.current ||
|
||||
!gainNodeRef.current
|
||||
)
|
||||
return;
|
||||
|
||||
const { leftFrequency, rightFrequency } =
|
||||
computeBinauralBeatOscillatorFrequencies(baseFrequency, beatFrequency);
|
||||
leftOscillatorRef.current.frequency.value = leftFrequency;
|
||||
rightOscillatorRef.current.frequency.value = rightFrequency;
|
||||
|
||||
// Pan oscillators to left and right
|
||||
const leftPanner = audioContext.createStereoPanner();
|
||||
leftPanner.pan.value = -1;
|
||||
|
||||
const rightPanner = audioContext.createStereoPanner();
|
||||
rightPanner.pan.value = 1;
|
||||
|
||||
// Connect nodes
|
||||
leftOscillatorRef.current.connect(leftPanner).connect(gainNodeRef.current);
|
||||
rightOscillatorRef.current
|
||||
.connect(rightPanner)
|
||||
.connect(gainNodeRef.current);
|
||||
gainNodeRef.current.connect(audioContext.destination);
|
||||
|
||||
// Start oscillators
|
||||
leftOscillatorRef.current.start();
|
||||
rightOscillatorRef.current.start();
|
||||
|
||||
setIsPlaying(true);
|
||||
};
|
||||
|
||||
const stopSound = useCallback(() => {
|
||||
if (!isPlaying) return;
|
||||
|
||||
leftOscillatorRef.current?.stop();
|
||||
rightOscillatorRef.current?.stop();
|
||||
audioContextRef.current?.close();
|
||||
|
||||
setIsPlaying(false);
|
||||
}, [isPlaying]);
|
||||
|
||||
useEffect(() => {
|
||||
// Update gain node when volume changes
|
||||
if (gainNodeRef.current) {
|
||||
gainNodeRef.current.gain.value = volume;
|
||||
}
|
||||
}, [volume]);
|
||||
|
||||
useEffect(() => {
|
||||
// Update base frequency for both left and right oscillators when it changes
|
||||
if (leftOscillatorRef.current && rightOscillatorRef.current) {
|
||||
const { leftFrequency, rightFrequency } =
|
||||
computeBinauralBeatOscillatorFrequencies(baseFrequency, beatFrequency);
|
||||
leftOscillatorRef.current.frequency.value = leftFrequency;
|
||||
rightOscillatorRef.current.frequency.value = rightFrequency;
|
||||
}
|
||||
}, [baseFrequency, beatFrequency]);
|
||||
|
||||
useEffect(() => {
|
||||
// Cleanup when component unmounts
|
||||
return () => {
|
||||
if (isPlaying) {
|
||||
stopSound();
|
||||
}
|
||||
};
|
||||
}, [isPlaying, stopSound]);
|
||||
|
||||
useEffect(() => {
|
||||
// Update frequencies when a preset is selected
|
||||
if (selectedPreset !== 'Custom') {
|
||||
const preset = presets.find(p => p.name === selectedPreset);
|
||||
if (preset) {
|
||||
setBaseFrequency(preset.baseFrequency);
|
||||
setBeatFrequency(preset.beatFrequency);
|
||||
}
|
||||
}
|
||||
}, [selectedPreset]);
|
||||
|
||||
const handlePresetChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const selected = e.target.value;
|
||||
setSelectedPreset(selected);
|
||||
|
||||
if (selected === 'Custom') {
|
||||
// Allow user to input custom frequencies
|
||||
return;
|
||||
}
|
||||
|
||||
const preset = presets.find(p => p.name === selected);
|
||||
if (preset) {
|
||||
setBaseFrequency(preset.baseFrequency);
|
||||
setBeatFrequency(preset.beatFrequency);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal show={show} onClose={onClose}>
|
||||
<header className={styles.header}>
|
||||
<h2 className={styles.title}>Binaural Beat</h2>
|
||||
<p className={styles.desc}>Binaural beat generator.</p>
|
||||
</header>
|
||||
|
||||
<div className={styles.fieldWrapper}>
|
||||
<label>
|
||||
Presets:
|
||||
<select value={selectedPreset} onChange={handlePresetChange}>
|
||||
{presets.map(preset => (
|
||||
<option key={preset.name} value={preset.name}>
|
||||
{preset.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{selectedPreset === 'Custom' && (
|
||||
<>
|
||||
<div className={styles.fieldWrapper}>
|
||||
<label>
|
||||
Base Frequency (Hz):
|
||||
<input
|
||||
max="1500"
|
||||
min="20"
|
||||
step="0.1"
|
||||
type="number"
|
||||
value={baseFrequency}
|
||||
onChange={e =>
|
||||
setBaseFrequency(parseFloat(e.target.value || '0'))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className={styles.fieldWrapper}>
|
||||
<label>
|
||||
Beat Frequency (Hz):
|
||||
<input
|
||||
max="40"
|
||||
min="0.1"
|
||||
step="0.1"
|
||||
type="number"
|
||||
value={beatFrequency}
|
||||
onChange={e =>
|
||||
setBeatFrequency(parseFloat(e.target.value || '0'))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className={styles.fieldWrapper}>
|
||||
<label>
|
||||
Volume:
|
||||
<Slider
|
||||
className={styles.volume}
|
||||
max={1}
|
||||
min={0}
|
||||
step={0.01}
|
||||
value={volume}
|
||||
onChange={value => setVolume(value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className={styles.buttons}>
|
||||
<button
|
||||
className={styles.primary}
|
||||
disabled={isPlaying}
|
||||
onClick={startSound}
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
<button disabled={!isPlaying} onClick={stopSound}>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
1
src/components/modals/binaural/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { BinauralModal } from './binaural';
|
||||
1
src/components/modals/breathing/breathing.module.css
Normal file
|
|
@ -0,0 +1 @@
|
|||
/* WIP */
|
||||
18
src/components/modals/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 BreathingExerciseModal({ onClose, show }: TimerProps) {
|
||||
return (
|
||||
<Modal show={show} onClose={onClose}>
|
||||
<h2 className={styles.title}>Breathing Exercise</h2>
|
||||
<Exercise />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
91
src/components/modals/breathing/exercise/exercise.module.css
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
.exercise {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 75px 0;
|
||||
margin-top: 12px;
|
||||
background-color: var(--color-neutral-50);
|
||||
border: 1px solid var(--color-neutral-200);
|
||||
border-radius: 8px;
|
||||
|
||||
& .timer {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
padding: 4px 12px;
|
||||
font-size: var(--font-xsm);
|
||||
color: var(--color-foreground-subtle);
|
||||
background: linear-gradient(
|
||||
var(--color-neutral-100),
|
||||
var(--color-neutral-50)
|
||||
);
|
||||
border: 1px solid var(--color-neutral-200);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
& .phase {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--font-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
& .circle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
z-index: -1;
|
||||
height: 55%;
|
||||
aspect-ratio: 1 / 1;
|
||||
background-image: radial-gradient(
|
||||
var(--color-neutral-50),
|
||||
var(--color-neutral-100)
|
||||
);
|
||||
border: 1px solid var(--color-neutral-200);
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
.selectWrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 45px;
|
||||
padding: 0 12px;
|
||||
margin-top: 8px;
|
||||
background-color: var(--color-neutral-50);
|
||||
border: 1px solid var(--color-neutral-200);
|
||||
border-radius: 8px;
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: 50%;
|
||||
width: 80%;
|
||||
height: 1px;
|
||||
content: '';
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
var(--color-neutral-300),
|
||||
transparent
|
||||
);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
& .selectBox {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
font-size: var(--font-sm);
|
||||
color: var(--color-foreground);
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
|
||||
& option {
|
||||
color: var(--color-neutral-50);
|
||||
}
|
||||
}
|
||||
}
|
||||
126
src/components/modals/breathing/exercise/exercise.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
import { padNumber } from '@/helpers/number';
|
||||
|
||||
import styles from './exercise.module.css';
|
||||
|
||||
type Exercise = 'Box Breathing' | 'Resonant Breathing' | '4-7-8 Breathing';
|
||||
type Phase = 'inhale' | 'exhale' | 'holdInhale' | 'holdExhale';
|
||||
|
||||
const EXERCISE_PHASES: Record<Exercise, Phase[]> = {
|
||||
'4-7-8 Breathing': ['inhale', 'holdInhale', 'exhale'],
|
||||
'Box Breathing': ['inhale', 'holdInhale', 'exhale', 'holdExhale'],
|
||||
'Resonant Breathing': ['inhale', 'exhale'],
|
||||
};
|
||||
|
||||
const EXERCISE_DURATIONS: Record<Exercise, Partial<Record<Phase, number>>> = {
|
||||
'4-7-8 Breathing': { exhale: 8, holdInhale: 7, inhale: 4 },
|
||||
'Box Breathing': { exhale: 4, holdExhale: 4, holdInhale: 4, inhale: 4 },
|
||||
'Resonant Breathing': { exhale: 5, inhale: 5 }, // No holdExhale
|
||||
};
|
||||
|
||||
const PHASE_LABELS: Record<Phase, string> = {
|
||||
exhale: 'Exhale',
|
||||
holdExhale: 'Hold',
|
||||
holdInhale: 'Hold',
|
||||
inhale: 'Inhale',
|
||||
};
|
||||
|
||||
export function Exercise() {
|
||||
const [selectedExercise, setSelectedExercise] =
|
||||
useState<Exercise>('4-7-8 Breathing');
|
||||
const [phaseIndex, setPhaseIndex] = useState(0);
|
||||
|
||||
const phases = useMemo(
|
||||
() => EXERCISE_PHASES[selectedExercise],
|
||||
[selectedExercise],
|
||||
);
|
||||
const durations = useMemo(
|
||||
() => EXERCISE_DURATIONS[selectedExercise],
|
||||
[selectedExercise],
|
||||
);
|
||||
|
||||
const currentPhase = phases[phaseIndex];
|
||||
|
||||
const animationVariants = useMemo(
|
||||
() => ({
|
||||
exhale: {
|
||||
transform: 'translate(-50%, -50%) scale(1)',
|
||||
transition: { duration: durations.exhale },
|
||||
},
|
||||
holdExhale: {
|
||||
transform: 'translate(-50%, -50%) scale(1)',
|
||||
transition: { duration: durations.holdExhale },
|
||||
},
|
||||
holdInhale: {
|
||||
transform: 'translate(-50%, -50%) scale(1.5)',
|
||||
transition: { duration: durations.holdInhale },
|
||||
},
|
||||
inhale: {
|
||||
transform: 'translate(-50%, -50%) scale(1.5)',
|
||||
transition: { duration: durations.inhale },
|
||||
},
|
||||
}),
|
||||
[durations],
|
||||
);
|
||||
|
||||
const resetExercise = useCallback(() => {
|
||||
setPhaseIndex(0);
|
||||
}, []);
|
||||
|
||||
const updatePhase = useCallback(() => {
|
||||
setPhaseIndex(prevIndex => (prevIndex + 1) % phases.length);
|
||||
}, [phases.length]);
|
||||
|
||||
useEffect(() => {
|
||||
resetExercise();
|
||||
}, [selectedExercise, resetExercise]);
|
||||
|
||||
useEffect(() => {
|
||||
const intervalDuration = (durations[currentPhase] || 4) * 1000;
|
||||
const interval = setInterval(updatePhase, intervalDuration);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [currentPhase, durations, updatePhase]);
|
||||
|
||||
const [timer, setTimer] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setTimer(prev => prev + 1), 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.exercise}>
|
||||
<div className={styles.timer}>
|
||||
{padNumber(Math.floor(timer / 60))}:{padNumber(timer % 60)}
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
animate={currentPhase}
|
||||
className={styles.circle}
|
||||
key={selectedExercise}
|
||||
variants={animationVariants}
|
||||
/>
|
||||
<p className={styles.phase}>{PHASE_LABELS[currentPhase]}</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.selectWrapper}>
|
||||
<select
|
||||
className={styles.selectBox}
|
||||
value={selectedExercise}
|
||||
onChange={e => setSelectedExercise(e.target.value as Exercise)}
|
||||
>
|
||||
{Object.keys(EXERCISE_PHASES).map(exercise => (
|
||||
<option key={exercise} value={exercise}>
|
||||
{exercise}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
src/components/modals/breathing/exercise/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { Exercise } from './exercise';
|
||||
1
src/components/modals/breathing/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { BreathingExerciseModal } from './breathing';
|
||||
1
src/components/modals/isochronic/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { IsochronicModal } from './isochronic';
|
||||
76
src/components/modals/isochronic/isochornic.module.css
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
.header {
|
||||
margin-bottom: 16px;
|
||||
|
||||
& .title {
|
||||
margin-bottom: 4px;
|
||||
font-family: var(--font-heading);
|
||||
font-size: var(--font-md);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
& .desc {
|
||||
color: var(--color-foreground-subtle);
|
||||
}
|
||||
}
|
||||
|
||||
.fieldWrapper {
|
||||
margin-bottom: 12px;
|
||||
|
||||
& label {
|
||||
display: block;
|
||||
font-size: var(--font-sm);
|
||||
font-weight: 500;
|
||||
color: var(--color-foreground);
|
||||
|
||||
& input,
|
||||
select {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 45px;
|
||||
padding: 0 8px;
|
||||
margin-top: 4px;
|
||||
color: var(--color-foreground);
|
||||
background-color: var(--color-neutral-50);
|
||||
border: 1px solid var(--color-neutral-200);
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
& .volume {
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
column-gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
|
||||
& button {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 45px;
|
||||
font-weight: 500;
|
||||
color: var(--color-foreground);
|
||||
cursor: pointer;
|
||||
background-color: var(--color-neutral-200);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
&.primary {
|
||||
color: var(--color-neutral-50);
|
||||
background-color: var(--color-neutral-950);
|
||||
}
|
||||
}
|
||||
}
|
||||
258
src/components/modals/isochronic/isochronic.tsx
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
|
||||
import { Modal } from '@/components/modal';
|
||||
import { Slider } from '@/components/slider';
|
||||
|
||||
import styles from './isochornic.module.css';
|
||||
|
||||
interface IsochronicProps {
|
||||
onClose: () => void;
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
interface Preset {
|
||||
baseFrequency: number;
|
||||
beatFrequency: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const presets: Preset[] = [
|
||||
{ baseFrequency: 100, beatFrequency: 2, name: 'Delta (Deep Sleep) 2 Hz' },
|
||||
{ baseFrequency: 100, beatFrequency: 5, name: 'Theta (Meditation) 5 Hz' },
|
||||
{ baseFrequency: 100, beatFrequency: 10, name: 'Alpha (Relaxation) 10 Hz' },
|
||||
{ baseFrequency: 100, beatFrequency: 20, name: 'Beta (Focus) 20 Hz' },
|
||||
{ baseFrequency: 100, beatFrequency: 40, name: 'Gamma (Cognition) 40 Hz' },
|
||||
{ baseFrequency: 440, beatFrequency: 10, name: 'Custom' },
|
||||
];
|
||||
|
||||
export function IsochronicModal({ onClose, show }: IsochronicProps) {
|
||||
const [baseFrequency, setBaseFrequency] = useState<number>(440); // Default A4 note
|
||||
const [beatFrequency, setBeatFrequency] = useState<number>(10); // Default 10 Hz beat
|
||||
const [volume, setVolume] = useState<number>(0.5); // Default volume at 50%
|
||||
const [waveform] = useState<OscillatorType>('sine'); // Default waveform
|
||||
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||
const [selectedPreset, setSelectedPreset] = useState<string>('Custom');
|
||||
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const oscillatorRef = useRef<OscillatorNode | null>(null);
|
||||
const gainNodeRef = useRef<GainNode | null>(null);
|
||||
const beatGainRef = useRef<GainNode | null>(null);
|
||||
const modulatorRef = useRef<OscillatorNode | null>(null);
|
||||
|
||||
const startSound = () => {
|
||||
if (isPlaying) return;
|
||||
|
||||
audioContextRef.current = new window.AudioContext();
|
||||
const audioContext = audioContextRef.current;
|
||||
|
||||
if (!audioContext) return;
|
||||
|
||||
// Main gain node for volume control
|
||||
gainNodeRef.current = audioContext.createGain();
|
||||
gainNodeRef.current.gain.value = volume;
|
||||
|
||||
// Oscillator for the base tone
|
||||
oscillatorRef.current = audioContext.createOscillator();
|
||||
oscillatorRef.current.frequency.value = baseFrequency;
|
||||
oscillatorRef.current.type = waveform;
|
||||
|
||||
// Gain node to create isochronic beats
|
||||
beatGainRef.current = audioContext.createGain();
|
||||
beatGainRef.current.gain.value = 0; // Start with silence
|
||||
|
||||
// Oscillator for modulation
|
||||
modulatorRef.current = audioContext.createOscillator();
|
||||
modulatorRef.current.frequency.value = beatFrequency;
|
||||
modulatorRef.current.type = 'square'; // Square wave for on/off effect
|
||||
|
||||
// Modulator gain to adjust modulation depth
|
||||
const modulatorGain = audioContext.createGain();
|
||||
modulatorGain.gain.value = 0.5; // Modulation depth
|
||||
|
||||
// Connect modulator to the beat gain node
|
||||
modulatorRef.current
|
||||
.connect(modulatorGain)
|
||||
.connect(beatGainRef.current.gain);
|
||||
|
||||
// Connect oscillator through beat gain and main gain to destination
|
||||
oscillatorRef.current
|
||||
.connect(beatGainRef.current)
|
||||
.connect(gainNodeRef.current)
|
||||
.connect(audioContext.destination);
|
||||
|
||||
// Start oscillators
|
||||
oscillatorRef.current.start();
|
||||
modulatorRef.current.start();
|
||||
|
||||
setIsPlaying(true);
|
||||
};
|
||||
|
||||
const stopSound = useCallback(() => {
|
||||
if (!isPlaying) return;
|
||||
|
||||
oscillatorRef.current?.stop();
|
||||
modulatorRef.current?.stop();
|
||||
audioContextRef.current?.close();
|
||||
|
||||
setIsPlaying(false);
|
||||
}, [isPlaying]);
|
||||
|
||||
useEffect(() => {
|
||||
// Update gain when volume changes
|
||||
if (gainNodeRef.current) {
|
||||
gainNodeRef.current.gain.value = volume;
|
||||
}
|
||||
}, [volume]);
|
||||
|
||||
useEffect(() => {
|
||||
// Update base frequency when it changes
|
||||
if (oscillatorRef.current) {
|
||||
oscillatorRef.current.frequency.value = baseFrequency;
|
||||
}
|
||||
}, [baseFrequency]);
|
||||
|
||||
useEffect(() => {
|
||||
// Update beat frequency when it changes
|
||||
if (modulatorRef.current) {
|
||||
modulatorRef.current.frequency.value = beatFrequency;
|
||||
}
|
||||
}, [beatFrequency]);
|
||||
|
||||
useEffect(() => {
|
||||
// Update waveform when it changes
|
||||
if (oscillatorRef.current) {
|
||||
oscillatorRef.current.type = waveform;
|
||||
}
|
||||
}, [waveform]);
|
||||
|
||||
useEffect(() => {
|
||||
// Cleanup when component unmounts
|
||||
return () => {
|
||||
if (isPlaying) {
|
||||
stopSound();
|
||||
}
|
||||
};
|
||||
}, [isPlaying, stopSound]);
|
||||
|
||||
useEffect(() => {
|
||||
// Update frequencies when a preset is selected
|
||||
if (selectedPreset !== 'Custom') {
|
||||
const preset = presets.find(p => p.name === selectedPreset);
|
||||
if (preset) {
|
||||
setBaseFrequency(preset.baseFrequency);
|
||||
setBeatFrequency(preset.beatFrequency);
|
||||
}
|
||||
}
|
||||
}, [selectedPreset]);
|
||||
|
||||
const handlePresetChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const selected = e.target.value;
|
||||
setSelectedPreset(selected);
|
||||
|
||||
if (selected === 'Custom') {
|
||||
// Allow user to input custom frequencies
|
||||
return;
|
||||
}
|
||||
|
||||
const preset = presets.find(p => p.name === selected);
|
||||
if (preset) {
|
||||
setBaseFrequency(preset.baseFrequency);
|
||||
setBeatFrequency(preset.beatFrequency);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal show={show} onClose={onClose}>
|
||||
<header className={styles.header}>
|
||||
<h2 className={styles.title}>Isochronic Tone</h2>
|
||||
<p className={styles.desc}>Isochronic tone generator.</p>
|
||||
</header>
|
||||
|
||||
<div className={styles.fieldWrapper}>
|
||||
<label>
|
||||
Presets:
|
||||
<select value={selectedPreset} onChange={handlePresetChange}>
|
||||
{presets.map(preset => (
|
||||
<option key={preset.name} value={preset.name}>
|
||||
{preset.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{selectedPreset === 'Custom' && (
|
||||
<>
|
||||
<div className={styles.fieldWrapper}>
|
||||
<label>
|
||||
Base Frequency (Hz):
|
||||
<input
|
||||
max="2000"
|
||||
min="20"
|
||||
step="0.1"
|
||||
type="number"
|
||||
value={baseFrequency}
|
||||
onChange={e =>
|
||||
setBaseFrequency(parseFloat(e.target.value || '0'))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className={styles.fieldWrapper}>
|
||||
<label>
|
||||
Tone Frequency (Hz):
|
||||
<input
|
||||
max="40"
|
||||
min="0.1"
|
||||
step="0.1"
|
||||
type="number"
|
||||
value={beatFrequency}
|
||||
onChange={e =>
|
||||
setBeatFrequency(parseFloat(e.target.value || '0'))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{/* <div className={styles.fieldWrapper}>
|
||||
<label>
|
||||
Waveform:
|
||||
<select
|
||||
value={waveform}
|
||||
onChange={e => setWaveform(e.target.value as OscillatorType)}
|
||||
>
|
||||
<option value="sine">Sine</option>
|
||||
<option value="square">Square</option>
|
||||
<option value="sawtooth">Sawtooth</option>
|
||||
<option value="triangle">Triangle</option>
|
||||
</select>
|
||||
</label>
|
||||
</div> */}
|
||||
</>
|
||||
)}
|
||||
<div className={styles.fieldWrapper}>
|
||||
<label>
|
||||
Volume:
|
||||
<Slider
|
||||
className={styles.volume}
|
||||
max={1}
|
||||
min={0}
|
||||
step={0.01}
|
||||
value={volume}
|
||||
onChange={value => setVolume(value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className={styles.buttons}>
|
||||
<button
|
||||
className={styles.primary}
|
||||
disabled={isPlaying}
|
||||
onClick={startSound}
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
<button disabled={!isPlaying} onClick={stopSound}>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
1
src/components/modals/lofi/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { LofiModal } from './lofi';
|
||||