Compare commits
250 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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
.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": [
|
||||
|
||||
899
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.svg" 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/)
|
||||
|
||||
@@ -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: '/',
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
13403
package-lock.json
generated
37
package.json
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"name": "moodist",
|
||||
"type": "module",
|
||||
"version": "1.4.3",
|
||||
"version": "2.0.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,49 @@
|
||||
"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.0.7",
|
||||
"@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",
|
||||
"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",
|
||||
"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 +80,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 +88,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.6 KiB |
BIN
public/assets/pwa/192.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
public/assets/pwa/256.png
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
BIN
public/assets/pwa/512.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/assets/pwa/72.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
@@ -1,12 +1,4 @@
|
||||
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="128" height="128" rx="25" fill="#09090B"/>
|
||||
<path d="M64 19C76.4264 19 86.5 29.0736 86.5 41.5H75.25C75.25 35.2868 70.2132 30.25 64 30.25C57.7868 30.25 52.75 35.2868 52.75 41.5L41.5 41.5C41.5 29.0736 51.5736 19 64 19Z" fill="white"/>
|
||||
<path d="M41.5 86.5C29.0736 86.5 19 76.4264 19 64C19 51.5736 29.0736 41.5 41.5 41.5V52.75C35.2868 52.75 30.25 57.7868 30.25 64C30.25 70.2132 35.2868 75.25 41.5 75.25V86.5Z" fill="white"/>
|
||||
<path d="M86.5 86.5C86.5 98.9264 76.4264 109 64 109C51.5736 109 41.5 98.9264 41.5 86.5H52.75C52.75 92.7132 57.7868 97.75 64 97.75C70.2132 97.75 75.25 92.7132 75.25 86.5H86.5Z" fill="white"/>
|
||||
<path d="M86.5 86.5C98.9264 86.5 109 76.4264 109 64C109 51.5736 98.9264 41.5 86.5 41.5V52.75C92.7132 52.75 97.75 57.7868 97.75 64C97.75 70.2132 92.7132 75.25 86.5 75.25V86.5Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M64 86.5C76.4264 86.5 86.5 76.4264 86.5 64C86.5 51.5736 76.4264 41.5 64 41.5C51.5736 41.5 41.5 51.5736 41.5 64C41.5 76.4264 51.5736 86.5 64 86.5ZM64 75.25C70.2132 75.25 75.25 70.2132 75.25 64C75.25 57.7868 70.2132 52.75 64 52.75C57.7868 52.75 52.75 57.7868 52.75 64C52.75 70.2132 57.7868 75.25 64 75.25Z" fill="white"/>
|
||||
<path d="M30.25 41.5C30.25 35.2868 35.2868 30.25 41.5 30.25V19C29.0736 19 19 29.0736 19 41.5H30.25Z" fill="white"/>
|
||||
<path d="M97.75 41.5C97.75 35.2868 92.7132 30.25 86.5 30.25V19C98.9264 19 109 29.0736 109 41.5H97.75Z" fill="white"/>
|
||||
<path d="M97.75 86.5C97.75 92.7132 92.7132 97.75 86.5 97.75V109C98.9264 109 109 98.9264 109 86.5H97.75Z" fill="white"/>
|
||||
<path d="M30.25 86.5C30.25 92.7132 35.2868 97.75 41.5 97.75V109C29.0736 109 19 98.9264 19 86.5H30.25Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M51.2493 29.2516C58.2929 22.2495 69.7071 22.2495 76.7507 29.2516C77.1507 29.6492 77.5279 30.0608 77.8825 30.4848C78.433 30.4357 78.9908 30.4114 79.5547 30.4131C89.4866 30.4424 97.5576 38.5135 97.587 48.4453C97.5886 49.0092 97.5643 49.567 97.5152 50.1176C97.9392 50.4721 98.3508 50.8493 98.7484 51.2493C105.751 58.2929 105.751 69.7071 98.7484 76.7507C98.3508 77.1507 97.9392 77.5279 97.5152 77.8825C97.5643 78.433 97.5886 78.9908 97.587 79.5547C97.5576 89.4866 89.4865 97.5577 79.5547 97.587C78.9908 97.5886 78.433 97.5643 77.8824 97.5152C77.5279 97.9392 77.1506 98.3508 76.7507 98.7484C69.7071 105.751 58.2929 105.751 51.2493 98.7484C50.8493 98.3508 50.4721 97.9392 50.1175 97.5152C49.567 97.5643 49.0092 97.5886 48.4453 97.5869C38.5134 97.5576 30.4424 89.4865 30.413 79.5547C30.4114 78.9908 30.4357 78.433 30.4848 77.8824C30.0608 77.5279 29.6492 77.1506 29.2516 76.7507C22.2495 69.7071 22.2495 58.2929 29.2516 51.2493C29.6492 50.8493 30.0608 50.4721 30.4848 50.1175C30.4357 49.567 30.4114 49.0092 30.4131 48.4453C30.4424 38.5134 38.5135 30.4423 48.4453 30.413C49.0092 30.4114 49.567 30.4356 50.1176 30.4848C50.4721 30.0608 50.8494 29.6492 51.2493 29.2516ZM47.29 35.173C40.877 35.7508 35.7508 40.8769 35.1731 47.29C38.8003 45.8063 42.8126 45.5647 46.5652 46.5652C45.5647 42.8126 45.8063 38.8002 47.29 35.173ZM53.7297 50.3922C50.2143 46.1861 49.7248 40.3267 52.2613 35.6603C57.3546 37.1664 61.1517 41.6557 61.64 47.1156C61.6129 47.529 61.5997 47.9462 61.6009 48.3669L61.6302 58.2787L54.6421 51.2493C54.3456 50.951 54.0412 50.6653 53.7297 50.3922ZM50.3922 53.7297C46.1861 50.2143 40.3268 49.7248 35.6603 52.2613C37.1665 57.3546 41.6558 61.1517 47.1157 61.64C47.5291 61.6128 47.9462 61.5996 48.3668 61.6009L58.2787 61.6302L51.2493 54.6421C50.951 54.3456 50.6653 54.0412 50.3922 53.7297ZM39.3435 64C35.9825 62.0539 33.3162 59.046 31.8005 55.432C27.6743 60.3752 27.6743 67.6247 31.8005 72.5679C33.3162 68.954 35.9825 65.946 39.3435 64ZM35.6603 75.7387C37.1664 70.6454 41.6558 66.8483 47.1157 66.36C47.5291 66.3871 47.9463 66.4003 48.3669 66.3991L58.2787 66.3698L51.2493 73.3579C50.951 73.6544 50.6653 73.9588 50.3922 74.2703C46.1861 77.7857 40.3268 78.2752 35.6603 75.7387ZM35.1731 80.71C35.7508 87.123 40.8769 92.2492 47.29 92.8269C45.8063 89.1997 45.5647 85.1874 46.5652 81.4348C42.8126 82.4353 38.8003 82.1937 35.1731 80.71ZM53.7297 77.6078C50.2143 81.8138 49.7248 87.6732 52.2613 92.3397C57.3546 90.8336 61.1516 86.3443 61.64 80.8844C61.6128 80.471 61.5996 80.0538 61.6009 79.6332L61.6302 69.7213L54.6421 76.7507C54.3456 77.049 54.0412 77.3347 53.7297 77.6078ZM64 88.6565C62.0539 92.0175 59.046 94.6838 55.4321 96.1995C60.3753 100.326 67.6247 100.326 72.5679 96.1995C68.954 94.6838 65.946 92.0175 64 88.6565ZM75.7387 92.3397C70.6454 90.8336 66.8483 86.3443 66.36 80.8844C66.3871 80.471 66.4004 80.0538 66.3991 79.6331L66.3699 69.7213L73.3579 76.7507C73.6544 77.049 73.9588 77.3347 74.2703 77.6078C77.7857 81.8139 78.2752 87.6733 75.7387 92.3397ZM80.71 92.827C87.1231 92.2492 92.2492 87.1231 92.8269 80.71C89.1997 82.1937 85.1874 82.4353 81.4348 81.4348C82.4353 85.1874 82.1937 89.1997 80.71 92.827ZM77.6078 74.2703C81.8138 77.7857 87.6732 78.2752 92.3397 75.7387C90.8336 70.6454 86.3442 66.8483 80.8843 66.36C80.471 66.3872 80.0538 66.4004 79.6332 66.3991L69.7213 66.3698L76.7507 73.3579C77.049 73.6544 77.3347 73.9588 77.6078 74.2703ZM88.6565 64C92.0175 65.9461 94.6838 68.954 96.1995 72.568C100.326 67.6248 100.326 60.3753 96.1995 55.4321C94.6838 59.046 92.0175 62.054 88.6565 64ZM92.3397 52.2613C90.8336 57.3546 86.3442 61.1517 80.8843 61.64C80.471 61.6129 80.0538 61.5997 79.6331 61.6009L69.7213 61.6302L76.7507 54.6421C77.049 54.3456 77.3347 54.0412 77.6078 53.7297C81.8139 50.2143 87.6732 49.7248 92.3397 52.2613ZM92.8269 47.29C92.2492 40.877 87.1231 35.7508 80.71 35.1731C82.1937 38.8003 82.4353 42.8126 81.4348 46.5652C85.1874 45.5647 89.1997 45.8063 92.8269 47.29ZM74.2703 50.3922C77.7857 46.1861 78.2752 40.3268 75.7387 35.6603C70.6455 37.1664 66.8484 41.6557 66.36 47.1156C66.3872 47.529 66.4004 47.9462 66.3991 48.3668L66.3699 58.2787L73.3579 51.2493C73.6544 50.951 73.9588 50.6653 74.2703 50.3922ZM64 39.3435C62.054 35.9825 59.046 33.3162 55.4321 31.8005C60.3753 27.6743 67.6248 27.6743 72.568 31.8005C68.954 33.3162 65.9461 35.9825 64 39.3435Z" fill="#FAFAFA"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 4.4 KiB |
BIN
public/logo-dark.png
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
BIN
public/logo-light.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
@@ -1,11 +1,3 @@
|
||||
<svg width="200" height="200" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M100 150C127.614 150 150 127.614 150 100C150 72.3858 127.614 50 100 50C72.3858 50 50 72.3858 50 100C50 127.614 72.3858 150 100 150ZM100 125C113.807 125 125 113.807 125 100C125 86.1929 113.807 75 100 75C86.1929 75 75 86.1929 75 100C75 113.807 86.1929 125 100 125Z" fill="#FAFAFA"/>
|
||||
<path d="M50 50C22.3858 50 2.00172e-06 72.3857 7.94663e-07 100C-4.12393e-07 127.614 22.3858 150 50 150V125C36.1929 125 25 113.807 25 100C25 86.1929 36.1929 75 50 75V50Z" fill="#D4D4D8"/>
|
||||
<path d="M150 50C150 22.3858 127.614 2.41411e-06 100 0C72.3858 -2.41411e-06 50 22.3858 50 50L75 50C75 36.1929 86.1929 25 100 25C113.807 25 125 36.1929 125 50H150Z" fill="#D4D4D8"/>
|
||||
<path d="M150 150C177.614 150 200 127.614 200 100C200 72.3858 177.614 50 150 50V75C163.807 75 175 86.1929 175 100C175 113.807 163.807 125 150 125V150Z" fill="#D4D4D8"/>
|
||||
<path d="M50 150C50 177.614 72.3857 200 100 200C127.614 200 150 177.614 150 150H125C125 163.807 113.807 175 100 175C86.1929 175 75 163.807 75 150H50Z" fill="#D4D4D8"/>
|
||||
<path d="M25 50C25 36.1929 36.1929 25 50 25V1.39091e-06C22.3858 1.83851e-07 2.00172e-06 22.3857 7.94663e-07 50H25Z" fill="#A1A1AA"/>
|
||||
<path d="M150 25C163.807 25 175 36.1929 175 50H200C200 22.3858 177.614 3.24858e-06 150 8.34465e-07L150 25Z" fill="#A1A1AA"/>
|
||||
<path d="M175 150C175 163.807 163.807 175 150 175V200C177.614 200 200 177.614 200 150H175Z" fill="#A1A1AA"/>
|
||||
<path d="M50 175C36.1929 175 25 163.807 25 150H0C0 177.614 22.3857 200 50 200V175Z" fill="#A1A1AA"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M68.1232 13.129C85.7322 -4.37634 114.268 -4.37634 131.877 13.129C132.877 14.1229 133.82 15.1519 134.706 16.212C136.083 16.0892 137.477 16.0285 138.887 16.0326C163.716 16.1059 183.894 36.2836 183.967 61.1133C183.972 62.5231 183.911 63.9175 183.788 65.2939C184.848 66.1803 185.877 67.1234 186.871 68.1232C204.376 85.7322 204.376 114.268 186.871 131.877C185.877 132.877 184.848 133.82 183.788 134.706C183.911 136.083 183.972 137.477 183.967 138.887C183.894 163.716 163.716 183.894 138.887 183.967C137.477 183.972 136.082 183.911 134.706 183.788C133.82 184.848 132.877 185.877 131.877 186.871C114.268 204.376 85.7322 204.376 68.1232 186.871C67.1234 185.877 66.1803 184.848 65.2939 183.788C63.9175 183.911 62.523 183.972 61.1133 183.967C36.2836 183.894 16.1059 163.716 16.0326 138.887C16.0284 137.477 16.0892 136.082 16.212 134.706C15.1519 133.82 14.1229 132.877 13.129 131.877C-4.37634 114.268 -4.37634 85.7322 13.129 68.1232C14.1229 67.1234 15.1519 66.1803 16.212 65.2939C16.0892 63.9175 16.0285 62.523 16.0326 61.1132C16.1059 36.2836 36.2837 16.1059 61.1133 16.0325C62.5231 16.0284 63.9176 16.0891 65.294 16.2119C66.1803 15.1519 67.1234 14.1229 68.1232 13.129ZM58.225 27.9326C42.1924 29.3769 29.377 42.1923 27.9327 58.2249C37.0007 54.5157 47.0315 53.9118 56.413 56.413C53.9118 47.0315 54.5158 37.0006 58.225 27.9326ZM74.3243 65.9805C65.5357 55.4653 64.3121 40.8169 70.6533 29.1507C83.3865 32.916 92.8792 44.1393 94.1001 57.789C94.0322 58.8224 93.9991 59.8655 94.0023 60.9171L94.0754 85.6967L76.6053 68.1232C75.8639 67.3774 75.103 66.6632 74.3243 65.9805ZM65.9805 74.3243C55.4654 65.5357 40.8169 64.3121 29.1508 70.6533C32.9161 83.3864 44.1395 92.8792 57.7893 94.1C58.8226 94.0321 59.8656 93.9991 60.9171 94.0022L85.6967 94.0754L68.1232 76.6053C67.3774 75.8639 66.6632 75.103 65.9805 74.3243ZM38.3587 99.9999C29.9563 95.1348 23.2906 87.6149 19.5013 78.5801C9.18585 90.9381 9.18584 109.062 19.5013 121.42C23.2905 112.385 29.9563 104.865 38.3587 99.9999ZM29.1508 129.347C32.9161 116.613 44.1394 107.121 57.7893 105.9C58.8227 105.968 59.8656 106.001 60.9171 105.998L85.6968 105.925L68.1232 123.395C67.3774 124.136 66.6631 124.897 65.9805 125.676C55.4653 134.464 40.8169 135.688 29.1508 129.347ZM27.9327 141.775C29.377 157.808 42.1924 170.623 58.2249 172.067C54.5157 162.999 53.9118 152.969 56.413 143.587C47.0315 146.088 37.0007 145.484 27.9327 141.775ZM74.3243 134.019C65.5357 144.535 64.3121 159.183 70.6533 170.849C83.3864 167.084 92.8791 155.861 94.1001 142.211C94.0321 141.178 93.9991 140.135 94.0022 139.083L94.0754 114.303L76.6053 131.877C75.8639 132.623 75.103 133.337 74.3243 134.019ZM100 161.641C95.1349 170.044 87.6149 176.709 78.5801 180.499C90.9381 190.814 109.062 190.814 121.42 180.499C112.385 176.71 104.865 170.044 100 161.641ZM129.347 170.849C116.614 167.084 107.121 155.861 105.9 142.211C105.968 141.178 106.001 140.134 105.998 139.083L105.925 114.303L123.395 131.877C124.136 132.623 124.897 133.337 125.676 134.019C134.464 144.535 135.688 159.183 129.347 170.849ZM141.775 172.067C157.808 170.623 170.623 157.808 172.067 141.775C162.999 145.484 152.969 146.088 143.587 143.587C146.088 152.969 145.484 162.999 141.775 172.067ZM134.019 125.676C144.535 134.464 159.183 135.688 170.849 129.347C167.084 116.614 155.861 107.121 142.211 105.9C141.177 105.968 140.134 106.001 139.083 105.998L114.303 105.925L131.877 123.395C132.623 124.136 133.337 124.897 134.019 125.676ZM161.641 100C170.044 104.865 176.709 112.385 180.499 121.42C190.814 109.062 190.814 90.9382 180.499 78.5802C176.709 87.615 170.044 95.135 161.641 100ZM170.849 70.6533C167.084 83.3865 155.861 92.8793 142.211 94.1001C141.177 94.0322 140.134 93.9992 139.083 94.0023L114.303 94.0754L131.877 76.6053C132.623 75.8639 133.337 75.103 134.019 74.3243C144.535 65.5357 159.183 64.3121 170.849 70.6533ZM172.067 58.2249C170.623 42.1924 157.808 29.377 141.775 27.9327C145.484 37.0007 146.088 47.0315 143.587 56.413C152.969 53.9118 162.999 54.5157 172.067 58.2249ZM125.676 65.9805C134.464 55.4653 135.688 40.8169 129.347 29.1508C116.614 32.9161 107.121 44.1393 105.9 57.789C105.968 58.8224 106.001 59.8655 105.998 60.9171L105.925 85.6967L123.395 68.1232C124.136 67.3774 124.897 66.6631 125.676 65.9805ZM100 38.3587C95.135 29.9563 87.615 23.2905 78.5802 19.5012C90.9382 9.18585 109.062 9.18587 121.42 19.5013C112.385 23.2905 104.865 29.9563 100 38.3587Z" fill="#FAFAFA"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 4.4 KiB |
BIN
public/og.png
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 13 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/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 />
|
||||
|
||||
17
src/components/binary.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
import { generateRandomBinaryString } from '@/helpers/binary';
|
||||
|
||||
export function Binary() {
|
||||
const [binary, setBinary] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setBinary(generateRandomBinaryString(1000));
|
||||
|
||||
setInterval(() => {
|
||||
setBinary(generateRandomBinaryString(1000));
|
||||
}, 200);
|
||||
}, []);
|
||||
|
||||
return <span>{binary}</span>;
|
||||
}
|
||||
@@ -12,7 +12,6 @@
|
||||
background-color: var(--color-neutral-950);
|
||||
border: 1px solid var(--color-neutral-50);
|
||||
border-radius: 100px;
|
||||
outline: none;
|
||||
transition: 0.2s;
|
||||
|
||||
&:hover {
|
||||
@@ -27,4 +26,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,15 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
&: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 { 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">
|
||||
@@ -31,7 +42,6 @@ export function UnselectButton() {
|
||||
variants={variants}
|
||||
>
|
||||
<Tooltip
|
||||
hideDelay={0}
|
||||
showDelay={0}
|
||||
content={
|
||||
hasHistory
|
||||
@@ -50,10 +60,7 @@ export function UnselectButton() {
|
||||
styles.unselectButton,
|
||||
noSelected && !hasHistory && styles.disabled,
|
||||
)}
|
||||
onClick={() => {
|
||||
if (hasHistory) restoreHistory();
|
||||
else if (!noSelected) unselectAll(true);
|
||||
}}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
{hasHistory ? <BiUndo /> : <BiTrash />}
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
|
||||
import { Category } from '@/components/category';
|
||||
import { Category } from './category';
|
||||
import { Donate } from './donate';
|
||||
|
||||
import type { Categories } from '@/data/types';
|
||||
|
||||
@@ -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';
|
||||
59
src/components/cipher.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
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(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
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]);
|
||||
|
||||
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-200),
|
||||
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';
|
||||
173
src/components/hero.astro
Normal file
@@ -0,0 +1,173 @@
|
||||
---
|
||||
import { BsSoundwave } from 'react-icons/bs/index';
|
||||
|
||||
import { Container } from './container';
|
||||
import { CipherText } from './cipher';
|
||||
|
||||
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 <CipherText client:load text="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);
|
||||
}
|
||||
}
|
||||
|
||||
& .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;
|
||||
|
||||
/* & .gradient {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-foreground),
|
||||
var(--color-foreground-subtle)
|
||||
);
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
} */
|
||||
|
||||
& .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 />;
|
||||
}
|
||||
104
src/components/media-controls/media-session-track.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { getSilenceDataURL } from '@/helpers/sound';
|
||||
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 [isGenerated, setIsGenerated] = useState(false);
|
||||
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';
|
||||
|
||||
const generateSilence = useCallback(async () => {
|
||||
if (!masterAudioSoundRef.current) return;
|
||||
masterAudioSoundRef.current.src = await getSilenceDataURL();
|
||||
setIsGenerated(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isBrowser || !isPlaying || !isGenerated) return;
|
||||
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
...metadata,
|
||||
artwork: [
|
||||
{
|
||||
sizes: '200x200',
|
||||
src: artworkURL,
|
||||
type: 'image/png',
|
||||
},
|
||||
],
|
||||
});
|
||||
}, [artworkURL, isBrowser, isDarkTheme, isGenerated, isPlaying]);
|
||||
|
||||
useEffect(() => {
|
||||
generateSilence();
|
||||
}, [generateSilence]);
|
||||
|
||||
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 (!isGenerated) return;
|
||||
if (!masterAudioSoundRef.current) return;
|
||||
|
||||
if (isPlaying) {
|
||||
startMasterAudio();
|
||||
} else {
|
||||
stopMasterAudio();
|
||||
}
|
||||
}, [isGenerated, 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} />;
|
||||
}
|
||||
@@ -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 { 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
223
src/components/modals/binaural/binaural.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
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' },
|
||||
];
|
||||
|
||||
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;
|
||||
|
||||
leftOscillatorRef.current.frequency.value =
|
||||
baseFrequency - beatFrequency / 2;
|
||||
rightOscillatorRef.current.frequency.value =
|
||||
baseFrequency + beatFrequency / 2;
|
||||
|
||||
// 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(() => {
|
||||
// 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 'framer-motion';
|
||||
|
||||
import { padNumber } from '@/helpers/number';
|
||||
|
||||
import styles from './exercise.module.css';
|
||||
|
||||
type Exercise = 'Box Breathing' | 'Resonant Breathing' | '4-7-8 Breathing';
|
||||
type Phase = 'inhale' | 'exhale' | 'holdInhale' | 'holdExhale';
|
||||
|
||||
const EXERCISE_PHASES: Record<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>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,11 @@
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-neutral-400);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
& button {
|
||||
@@ -51,6 +56,11 @@
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-neutral-400);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&.primary {
|
||||
font-size: var(--font-xsm);
|
||||
color: var(--color-foreground);
|
||||
|
||||
@@ -2,7 +2,8 @@ import { FaPlay, FaRegTrashAlt } from 'react-icons/fa/index';
|
||||
|
||||
import styles from './list.module.css';
|
||||
|
||||
import { usePresetStore, useSoundStore } from '@/store';
|
||||
import { useSoundStore } from '@/stores/sound';
|
||||
import { usePresetStore } from '@/stores/preset';
|
||||
|
||||
interface ListProps {
|
||||
close: () => void;
|
||||
@@ -25,15 +26,15 @@ export function List({ close }: ListProps) {
|
||||
<p className={styles.empty}>You don't have any presets yet.</p>
|
||||
)}
|
||||
|
||||
{presets.map((preset, index) => (
|
||||
<div className={styles.preset} key={index}>
|
||||
{presets.map(preset => (
|
||||
<div className={styles.preset} key={preset.id}>
|
||||
<input
|
||||
placeholder="Untitled"
|
||||
type="text"
|
||||
value={preset.label}
|
||||
onChange={e => changeName(index, e.target.value)}
|
||||
onChange={e => changeName(preset.id, e.target.value)}
|
||||
/>
|
||||
<button onClick={() => deletePreset(index)}>
|
||||
<button onClick={() => deletePreset(preset.id)}>
|
||||
<FaRegTrashAlt />
|
||||
</button>
|
||||
<button
|
||||
|
||||
@@ -31,6 +31,11 @@
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-neutral-400);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
& button {
|
||||
@@ -48,6 +53,11 @@
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-neutral-400);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
|
||||
import { cn } from '@/helpers/styles';
|
||||
import { useSoundStore, usePresetStore } from '@/store';
|
||||
import { useSoundStore } from '@/stores/sound';
|
||||
import { usePresetStore } from '@/stores/preset';
|
||||
|
||||
import styles from './new.module.css';
|
||||
|
||||
|
||||
@@ -31,6 +31,11 @@
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-neutral-400);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
& button {
|
||||
@@ -47,7 +52,13 @@
|
||||
outline: none;
|
||||
transition: 0.2s;
|
||||
|
||||
&:hover {
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-neutral-400);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background-color: var(--color-neutral-200);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { IoCopyOutline, IoCheckmark } from 'react-icons/io5/index';
|
||||
import { Modal } from '@/components/modal';
|
||||
|
||||
import { useCopy } from '@/hooks/use-copy';
|
||||
import { useSoundStore } from '@/store';
|
||||
import { useSoundStore } from '@/stores/sound';
|
||||
|
||||
import styles from './share-link.module.css';
|
||||
|
||||
|
||||
@@ -51,7 +51,13 @@
|
||||
outline: none;
|
||||
transition: 0.2s;
|
||||
|
||||
&:hover {
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-neutral-400);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
color: var(--color-foreground);
|
||||
background-color: var(--color-neutral-300);
|
||||
}
|
||||
@@ -60,7 +66,8 @@
|
||||
color: var(--color-neutral-200);
|
||||
background-color: var(--color-neutral-950);
|
||||
|
||||
&:hover {
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background-color: var(--color-neutral-800);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@ import { useState, useEffect } from 'react';
|
||||
|
||||
import { Modal } from '@/components/modal';
|
||||
|
||||
import { useSoundStore } from '@/store';
|
||||
import { useSoundStore } from '@/stores/sound';
|
||||
import { useSnackbar } from '@/contexts/snackbar';
|
||||
import { useCloseListener } from '@/hooks/use-close-listener';
|
||||
import { cn } from '@/helpers/styles';
|
||||
import { sounds } from '@/data/sounds';
|
||||
|
||||
@@ -77,6 +78,8 @@ export function SharedModal() {
|
||||
showSnackbar('Done! You can now play the new selection.');
|
||||
};
|
||||
|
||||
useCloseListener(() => setIsOpen(false));
|
||||
|
||||
return (
|
||||
<Modal show={isOpen} onClose={() => setIsOpen(false)}>
|
||||
<h1 className={styles.heading}>New sound mix detected!</h1>
|
||||
|
||||