├── .npmrc ├── .eslintignore ├── .prettierignore ├── .stylelintignore ├── src ├── components │ ├── app │ │ ├── index.ts │ │ ├── app.module.css │ │ └── app.tsx │ ├── container │ │ ├── index.ts │ │ ├── container.module.css │ │ └── container.tsx │ ├── footer.astro │ ├── hero.astro │ └── note.astro ├── styles │ ├── variables │ │ ├── index.css │ │ ├── color.css │ │ └── typography.css │ ├── global.css │ ├── base │ │ └── base.css │ └── fonts.css ├── env.d.ts ├── helpers │ ├── number.ts │ ├── string.ts │ ├── styles.ts │ └── random.ts ├── pages │ └── index.astro ├── hooks │ ├── use-sound.ts │ └── use-local-storage.ts └── layouts │ └── layout.astro ├── .czrc ├── public ├── og.png ├── chime.mp3 ├── images │ └── noise.png ├── robots.txt ├── fonts │ ├── inter-v13-latin-500.woff2 │ ├── lora-v35-latin-regular.woff2 │ ├── inter-v13-latin-regular.woff2 │ ├── space-mono-v13-latin-700.woff2 │ ├── space-mono-v13-latin-regular.woff2 │ └── instrument-serif-v4-latin-regular.woff2 ├── logo.svg └── favicon.svg ├── .commitlintrc.json ├── .editorconfig ├── .husky ├── pre-commit └── commit-msg ├── postcss.config.cjs ├── Caddyfile ├── .prettierrc.json ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── docker-compose.yml ├── README.md ├── .lintstagedrc.json ├── tsconfig.json ├── Dockerfile ├── astro.config.mjs ├── .gitignore ├── .stylelintrc.json ├── .versionrc.json ├── .github └── workflows │ └── build_docker.yml ├── LICENSE ├── package.json ├── .eslintrc.json └── CHANGELOG.md /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .output/ 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .output/ 4 | -------------------------------------------------------------------------------- /.stylelintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .output/ 4 | -------------------------------------------------------------------------------- /src/components/app/index.ts: -------------------------------------------------------------------------------- 1 | export { App } from './app'; 2 | -------------------------------------------------------------------------------- /.czrc: -------------------------------------------------------------------------------- 1 | { 2 | "path": "./node_modules/cz-conventional-changelog" 3 | } 4 | -------------------------------------------------------------------------------- /public/og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remvze/calmness/HEAD/public/og.png -------------------------------------------------------------------------------- /src/components/container/index.ts: -------------------------------------------------------------------------------- 1 | export { Container } from './container'; 2 | -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"] 3 | } 4 | -------------------------------------------------------------------------------- /public/chime.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remvze/calmness/HEAD/public/chime.mp3 -------------------------------------------------------------------------------- /src/styles/variables/index.css: -------------------------------------------------------------------------------- 1 | @import 'color.css'; 2 | @import 'typography.css'; 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | -------------------------------------------------------------------------------- /public/images/noise.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remvze/calmness/HEAD/public/images/noise.png -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx commitlint --edit $1 5 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | 4 | Sitemap: https://calmness.mvze.net/sitemap-index.xml 5 | -------------------------------------------------------------------------------- /src/styles/global.css: -------------------------------------------------------------------------------- 1 | @import 'variables/index.css'; 2 | @import 'base/base.css'; 3 | @import 'fonts.css'; 4 | -------------------------------------------------------------------------------- /public/fonts/inter-v13-latin-500.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remvze/calmness/HEAD/public/fonts/inter-v13-latin-500.woff2 -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {}, 4 | 'postcss-nesting': {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /public/fonts/lora-v35-latin-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remvze/calmness/HEAD/public/fonts/lora-v35-latin-regular.woff2 -------------------------------------------------------------------------------- /public/fonts/inter-v13-latin-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remvze/calmness/HEAD/public/fonts/inter-v13-latin-regular.woff2 -------------------------------------------------------------------------------- /public/fonts/space-mono-v13-latin-700.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remvze/calmness/HEAD/public/fonts/space-mono-v13-latin-700.woff2 -------------------------------------------------------------------------------- /public/fonts/space-mono-v13-latin-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remvze/calmness/HEAD/public/fonts/space-mono-v13-latin-regular.woff2 -------------------------------------------------------------------------------- /public/fonts/instrument-serif-v4-latin-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remvze/calmness/HEAD/public/fonts/instrument-serif-v4-latin-regular.woff2 -------------------------------------------------------------------------------- /src/helpers/number.ts: -------------------------------------------------------------------------------- 1 | export function padNumber(number: number, maxLength: number = 2): string { 2 | return number.toString().padStart(maxLength, '0'); 3 | } 4 | -------------------------------------------------------------------------------- /Caddyfile: -------------------------------------------------------------------------------- 1 | :8080 { 2 | file_server 3 | root * /var/www/html 4 | 5 | handle_errors { 6 | rewrite * /index.html 7 | file_server 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/pages/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Layout from '@/layouts/layout.astro'; 3 | import { App } from '@/components/app'; 4 | --- 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["prettier-plugin-astro"], 3 | "singleQuote": true, 4 | "arrowParens": "avoid", 5 | "endOfLine": "lf", 6 | "tabWidth": 2, 7 | "semi": true 8 | } 9 | -------------------------------------------------------------------------------- /src/components/container/container.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | width: 90%; 3 | max-width: 450px; 4 | margin: 0 auto; 5 | 6 | &.wide { 7 | max-width: 850px; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/helpers/string.ts: -------------------------------------------------------------------------------- 1 | export function truncateString(str: string, num: number) { 2 | if (str.length > num) { 3 | return str.slice(0, num) + '...'; 4 | } else { 5 | return str; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "astro-build.astro-vscode", 4 | "esbenp.prettier-vscode", 5 | "dbaeumer.vscode-eslint", 6 | "stylelint.vscode-stylelint" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | timesy: 3 | image: ghcr.io/remvze/calmness 4 | logging: 5 | options: 6 | max-size: 1g 7 | restart: always 8 | ports: 9 | - '8080:8080' 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

Calmness

3 |

A breathing exercises tool.

4 | Visit Calmness | Buy Me a Coffee 5 |
6 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "*.{ts,tsx,js,jsx}": "eslint --fix", 3 | "*.{json,md}": "prettier --write", 4 | "*.css": "stylelint --fix", 5 | "*.astro": ["eslint --fix", "stylelint --fix"], 6 | "*.html": ["prettier --write", "stylelint --fix"] 7 | } 8 | -------------------------------------------------------------------------------- /src/helpers/styles.ts: -------------------------------------------------------------------------------- 1 | type className = undefined | null | false | string; 2 | 3 | export function cn(...classNames: Array): string { 4 | const className = classNames.filter(className => !!className).join(' '); 5 | 6 | return className; 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strict", 3 | "compilerOptions": { 4 | "jsx": "react-jsx", 5 | "jsxImportSource": "react", 6 | "baseUrl": "./src", 7 | "paths": { 8 | "@/*": ["./*"], 9 | }, 10 | }, 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "command": "./node_modules/.bin/astro dev", 6 | "name": "Development server", 7 | "request": "launch", 8 | "type": "node-terminal" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM docker.io/node:20-alpine3.18 AS build 2 | WORKDIR /app 3 | COPY package*.json ./ 4 | RUN npm install 5 | COPY . . 6 | RUN npm run build 7 | 8 | FROM docker.io/caddy:latest 9 | COPY ./Caddyfile /etc/caddy/Caddyfile 10 | COPY --from=build /app/dist /var/www/html 11 | 12 | EXPOSE 8080 13 | -------------------------------------------------------------------------------- /astro.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'astro/config'; 2 | import react from '@astrojs/react'; 3 | 4 | import sitemap from '@astrojs/sitemap'; 5 | 6 | // https://astro.build/config 7 | export default defineConfig({ 8 | integrations: [react(), sitemap()], 9 | site: 'https://calmness.mvze.net', 10 | }); 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | 4 | # generated types 5 | .astro/ 6 | 7 | # dependencies 8 | node_modules/ 9 | 10 | # logs 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # environment variables 17 | .env 18 | .env.production 19 | 20 | # macOS-specific files 21 | .DS_Store 22 | -------------------------------------------------------------------------------- /src/components/container/container.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/helpers/styles'; 2 | 3 | import styles from './container.module.css'; 4 | 5 | interface ContainerProps { 6 | children: React.ReactNode; 7 | wide?: boolean; 8 | } 9 | 10 | export function Container({ children, wide }: ContainerProps) { 11 | return ( 12 |
{children}
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/styles/base/base.css: -------------------------------------------------------------------------------- 1 | *, 2 | *::before, 3 | *::after { 4 | box-sizing: border-box; 5 | padding: 0; 6 | margin: 0; 7 | font: inherit; 8 | } 9 | 10 | html { 11 | scroll-behavior: smooth; 12 | } 13 | 14 | body { 15 | font-family: var(--font-sans); 16 | font-size: var(--font-base); 17 | color: var(--color-foreground); 18 | background-color: var(--color-neutral-50); 19 | } 20 | 21 | ::selection { 22 | color: var(--color-foreground); 23 | background-color: var(--color-neutral-300); 24 | } 25 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "stylelint-config-standard", 4 | "stylelint-config-recess-order", 5 | "stylelint-config-html", 6 | "stylelint-prettier/recommended" 7 | ], 8 | 9 | "rules": { 10 | "import-notation": "string", 11 | "selector-class-pattern": null, 12 | "no-descending-specificity": null 13 | }, 14 | 15 | "overrides": [ 16 | { 17 | "files": ["*.astro"], 18 | "rules": { 19 | "prettier/prettier": null 20 | } 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /src/hooks/use-sound.ts: -------------------------------------------------------------------------------- 1 | import { useMemo, useCallback, useEffect } from 'react'; 2 | 3 | export function useSound(url: string, volume: number = 0.5) { 4 | const audio = useMemo(() => { 5 | if (typeof window !== 'undefined') return new Audio(url); 6 | 7 | return null; 8 | }, [url]); 9 | 10 | useEffect(() => { 11 | if (audio) { 12 | audio.volume = volume; 13 | } 14 | }, [volume, audio]); 15 | 16 | const play = useCallback(() => { 17 | if (audio) audio.play(); 18 | }, [audio]); 19 | 20 | return play; 21 | } 22 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.eol": "\n", 3 | "eslint.validate": [ 4 | "javascript", 5 | "javascriptreact", 6 | "typescript", 7 | "typescriptreact", 8 | "astro" 9 | ], 10 | "stylelint.validate": ["css", "html", "astro"], 11 | "editor.defaultFormatter": "esbenp.prettier-vscode", 12 | "editor.formatOnSave": true, 13 | "editor.codeActionsOnSave": { 14 | "source.fixAll.eslint": "explicit", 15 | "source.fixAll.stylelint": "explicit" 16 | }, 17 | "[javascript][javascriptreact][typescript][typescriptreact][astro]": { 18 | "editor.formatOnSave": false 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/styles/variables/color.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --color-neutral-50: #09090b; 3 | --color-neutral-100: #18181b; 4 | --color-neutral-200: #27272a; 5 | --color-neutral-300: #3f3f46; 6 | --color-neutral-400: #52525b; 7 | --color-neutral-500: #71717a; 8 | --color-neutral-600: #a1a1aa; 9 | --color-neutral-700: #d4d4d8; 10 | --color-neutral-800: #e4e4e7; 11 | --color-neutral-900: #f4f4f5; 12 | --color-neutral-950: #fafafa; 13 | 14 | /* Foreground */ 15 | --color-foreground: var(--color-neutral-950); 16 | --color-foreground-subtle: var(--color-neutral-600); 17 | --color-foreground-subtler: var(--color-neutral-500); 18 | } 19 | -------------------------------------------------------------------------------- /src/helpers/random.ts: -------------------------------------------------------------------------------- 1 | export function random(min: number, max: number): number { 2 | return Math.random() * (max - min) + min; 3 | } 4 | 5 | export function randomInt(min: number, max: number): number { 6 | return Math.floor(random(min, max)); 7 | } 8 | 9 | export function pick(array: Array): T { 10 | const randomIndex = randomInt(0, array.length); 11 | 12 | return array[randomIndex]; 13 | } 14 | 15 | export function pickMany(array: Array, count: number): Array { 16 | const shuffled = shuffle(array); 17 | 18 | return shuffled.slice(0, count); 19 | } 20 | 21 | export function shuffle(array: Array): Array { 22 | return array 23 | .map(value => ({ sort: Math.random(), value })) 24 | .sort((a, b) => a.sort - b.sort) 25 | .map(({ value }) => value); 26 | } 27 | -------------------------------------------------------------------------------- /.versionrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "types": [ 3 | { 4 | "type": "feat", 5 | "section": "✨ Features" 6 | }, 7 | { 8 | "type": "fix", 9 | "section": "🐛 Bug Fixes" 10 | }, 11 | { 12 | "type": "chore", 13 | "hidden": false, 14 | "section": "🚚 Chores" 15 | }, 16 | { 17 | "type": "docs", 18 | "hidden": false, 19 | "section": "📝 Documentation" 20 | }, 21 | { 22 | "type": "style", 23 | "hidden": false, 24 | "section": "💄 Styling" 25 | }, 26 | { 27 | "type": "refactor", 28 | "hidden": false, 29 | "section": "♻️ Code Refactoring" 30 | }, 31 | { 32 | "type": "perf", 33 | "hidden": false, 34 | "section": "⚡️ Performance Improvements" 35 | }, 36 | { 37 | "type": "test", 38 | "hidden": false, 39 | "section": "✅ Testing" 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /src/styles/variables/typography.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --font-serif: 'Lora', serif; 3 | --font-display: 'Instrument Serif', serif; 4 | --font-sans: 'Inter', sans-serif; 5 | --font-mono: 'Space Mono', monospace; 6 | 7 | /* Font Sizes */ 8 | --font-base-size: 1rem; 9 | --font-pos-ratio: 1.2; 10 | --font-neg-ratio: 1.125; 11 | --font-3xlg: calc(var(--font-xxlg) * var(--font-pos-ratio)); 12 | --font-2xlg: calc(var(--font-xlg) * var(--font-pos-ratio)); 13 | --font-xlg: calc(var(--font-lg) * var(--font-pos-ratio)); 14 | --font-lg: calc(var(--font-md) * var(--font-pos-ratio)); 15 | --font-md: calc(var(--font-base) * var(--font-pos-ratio)); 16 | --font-base: var(--font-base-size); 17 | --font-sm: calc(var(--font-base) / var(--font-neg-ratio)); 18 | --font-xsm: calc(var(--font-sm) / var(--font-neg-ratio)); 19 | --font-2xsm: calc(var(--font-xsm) / var(--font-neg-ratio)); 20 | --font-3xsm: calc(var(--font-xxsm) / var(--font-neg-ratio)); 21 | } 22 | -------------------------------------------------------------------------------- /.github/workflows/build_docker.yml: -------------------------------------------------------------------------------- 1 | name: Build and push main image 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | push-store-image: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: 'Checkout GitHub Action' 14 | uses: actions/checkout@main 15 | 16 | - name: 'Login to GitHub Container Registry' 17 | uses: docker/login-action@v1 18 | with: 19 | registry: ghcr.io 20 | username: ${{github.actor}} 21 | password: ${{secrets.ACCESS_TOKEN}} 22 | 23 | - name: Set up QEMU 24 | uses: docker/setup-qemu-action@v1 25 | 26 | - name: Set up Docker Buildx 27 | uses: docker/setup-buildx-action@v1 28 | 29 | - name: 'Build and push Inventory Image' 30 | run: | 31 | IMAGE_NAME="ghcr.io/remvze/calmness" 32 | 33 | GIT_TAG=${{ github.ref }} 34 | GIT_TAG=${GIT_TAG#refs/tags/} 35 | 36 | docker buildx build \ 37 | --platform linux/amd64,linux/arm64 \ 38 | -t $IMAGE_NAME:latest \ 39 | -t $IMAGE_NAME:$GIT_TAG \ 40 | --push . 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 MAZE 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/hooks/use-local-storage.ts: -------------------------------------------------------------------------------- 1 | import { type Dispatch, type SetStateAction, useEffect, useState } from 'react'; 2 | 3 | type SetValue = Dispatch>; 4 | 5 | /** 6 | * A custom React hook to manage state with localStorage persistence. 7 | * 8 | * @template T 9 | * @param {string} key - The key under which the value is stored in localStorage. 10 | * @param {T} fallback - The fallback value to use if there is no value in localStorage. 11 | * @returns {[T, SetValue]} An array containing the stateful value and a function to update it. 12 | */ 13 | export function useLocalStorage(key: string, fallback: T): [T, SetValue] { 14 | const [value, setValue] = useState(fallback); 15 | 16 | useEffect(() => { 17 | const value = localStorage.getItem(key); 18 | 19 | if (!value) return; 20 | 21 | let parsed; 22 | 23 | try { 24 | parsed = JSON.parse(value); 25 | } catch (error) { 26 | parsed = fallback; 27 | } 28 | 29 | setValue(parsed); 30 | }, [key, JSON.stringify(fallback)]); 31 | 32 | useEffect(() => { 33 | localStorage.setItem(key, JSON.stringify(value)); 34 | }, [value, key]); 35 | 36 | return [value, setValue]; 37 | } 38 | -------------------------------------------------------------------------------- /src/components/footer.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { Container } from './container'; 3 | --- 4 | 5 | 6 |
7 |
8 |

9 | Created by Maze. 12 |

13 |

14 | Source code on GitHub. 19 |

20 |
21 |
22 | 23 | 54 | -------------------------------------------------------------------------------- /src/components/hero.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { Container } from './container'; 3 | --- 4 | 5 | 6 |
7 | 14 |

Calmness

15 |

Online breathing exercises tool.

16 |
17 |
18 | 19 | 58 | -------------------------------------------------------------------------------- /src/styles/fonts.css: -------------------------------------------------------------------------------- 1 | /* inter-regular - latin */ 2 | @font-face { 3 | font-family: Inter; 4 | font-style: normal; 5 | font-weight: 400; 6 | src: url('/fonts/inter-v13-latin-regular.woff2') format('woff2'); 7 | font-display: swap; 8 | } 9 | 10 | /* inter-500 - latin */ 11 | @font-face { 12 | font-family: Inter; 13 | font-style: normal; 14 | font-weight: 500; 15 | src: url('/fonts/inter-v13-latin-500.woff2') format('woff2'); 16 | font-display: swap; 17 | } 18 | 19 | /* lora-regular - latin */ 20 | @font-face { 21 | font-family: Lora; 22 | font-style: normal; 23 | font-weight: 400; 24 | src: url('/fonts/lora-v35-latin-regular.woff2') format('woff2'); 25 | font-display: swap; 26 | } 27 | 28 | /* space-mono-regular - latin */ 29 | @font-face { 30 | font-family: 'Space Mono'; 31 | font-style: normal; 32 | font-weight: 400; 33 | src: url('/fonts/space-mono-v13-latin-regular.woff2') format('woff2'); 34 | font-display: swap; 35 | } 36 | 37 | /* space-mono-700 - latin */ 38 | @font-face { 39 | font-family: 'Space Mono'; 40 | font-style: normal; 41 | font-weight: 700; 42 | src: url('/fonts/space-mono-v13-latin-700.woff2') format('woff2'); 43 | font-display: swap; 44 | } 45 | 46 | /* instrument-serif-regular - latin */ 47 | @font-face { 48 | font-family: 'Instrument Serif'; 49 | font-style: normal; 50 | font-weight: 400; 51 | src: url('/fonts/instrument-serif-v4-latin-regular.woff2') format('woff2'); 52 | font-display: swap; 53 | } 54 | -------------------------------------------------------------------------------- /src/layouts/layout.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import '@/styles/global.css'; 3 | 4 | interface Props { 5 | description?: string; 6 | og?: string; 7 | title?: string; 8 | } 9 | 10 | const title = Astro.props.title 11 | ? `${Astro.props.title} — Calmness` 12 | : 'Calmness: Online Breathing Exercises Tool'; 13 | 14 | const description = 15 | Astro.props.description || 16 | 'A simple and minimal breathing exercises tool in your browser.'; 17 | 18 | const og = Astro.props.og || '/og.png'; 19 | --- 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | {title} 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /src/components/note.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { Container } from './container'; 3 | --- 4 | 5 | 6 |
7 |

Finding Calm in the Chaos

8 |

9 | In the relentless hustle of modern life, where every tick of the clock 10 | feels like a push to achieve more and move faster, stress wraps around you 11 | like a tightrope you can't escape. The constant pressure to keep up 12 | and outdo yesterday's self can turn each day into a high-stakes game 13 | of survival. 14 |

15 |

16 | But here's your chance to pause the whirlwind. Take a deliberate step 17 | back, hit the pause button on your endless to-do list, and just breathe. 18 | Let your breath cut through the noise, gently pushing away the weight of 19 | your worries. Inhale calm and exhale the chaos, finding a brief, serene 20 | space where it's okay to simply be. 21 |

22 |
23 |
24 | 25 | 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "calmness", 3 | "type": "module", 4 | "version": "0.1.0", 5 | "scripts": { 6 | "dev": "astro dev", 7 | "start": "astro dev", 8 | "build": "astro check && astro build", 9 | "preview": "astro preview", 10 | "astro": "astro", 11 | "lint": "eslint . --ext .js,.jsx,.ts,.tsx,.astro", 12 | "lint:fix": "npm run lint -- --fix", 13 | "lint:style": "stylelint ./**/*.{css,astro,html}", 14 | "lint:style:fix": "npm run lint:style -- --fix", 15 | "format": "prettier . --write", 16 | "prepare": "husky install", 17 | "commit": "git-cz", 18 | "release": "standard-version --no-verify", 19 | "release:major": "npm run release -- --release-as major", 20 | "release:minor": "npm run release -- --release-as minor", 21 | "release:patch": "npm run release -- --release-as patch" 22 | }, 23 | "dependencies": { 24 | "@astrojs/react": "^3.0.9", 25 | "@astrojs/sitemap": "3.1.6", 26 | "@types/react": "^18.2.48", 27 | "@types/react-dom": "^18.2.18", 28 | "astro": "^4.2.1", 29 | "framer-motion": "11.5.4", 30 | "react": "^18.2.0", 31 | "react-dom": "^18.2.0", 32 | "react-icons": "5.0.1" 33 | }, 34 | "devDependencies": { 35 | "@commitlint/cli": "18.4.4", 36 | "@commitlint/config-conventional": "18.4.4", 37 | "@typescript-eslint/eslint-plugin": "6.19.0", 38 | "@typescript-eslint/parser": "6.19.0", 39 | "astro-eslint-parser": "0.16.2", 40 | "autoprefixer": "10.4.17", 41 | "clipboardy": "4.0.0", 42 | "commitizen": "4.3.0", 43 | "cz-conventional-changelog": "3.3.0", 44 | "eslint": "8.56.0", 45 | "eslint-config-prettier": "9.1.0", 46 | "eslint-import-resolver-alias": "1.1.2", 47 | "eslint-import-resolver-typescript": "3.6.1", 48 | "eslint-plugin-astro": "0.31.3", 49 | "eslint-plugin-import": "2.29.1", 50 | "eslint-plugin-jsx-a11y": "6.8.0", 51 | "eslint-plugin-prettier": "5.1.3", 52 | "eslint-plugin-react": "7.33.2", 53 | "eslint-plugin-react-hooks": "4.6.0", 54 | "eslint-plugin-sort-destructure-keys": "1.5.0", 55 | "eslint-plugin-sort-keys-fix": "1.1.2", 56 | "eslint-plugin-typescript-sort-keys": "3.1.0", 57 | "husky": "8.0.3", 58 | "lint-staged": "15.2.0", 59 | "postcss-html": "1.6.0", 60 | "postcss-nesting": "12.0.2", 61 | "prettier": "3.2.4", 62 | "prettier-plugin-astro": "0.13.0", 63 | "standard-version": "9.5.0", 64 | "stylelint": "16.2.0", 65 | "stylelint-config-html": "1.1.0", 66 | "stylelint-config-recess-order": "4.4.0", 67 | "stylelint-config-standard": "36.0.0", 68 | "stylelint-prettier": "5.0.0" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | 4 | "env": { 5 | "browser": true, 6 | "amd": true, 7 | "node": true, 8 | "es2022": true 9 | }, 10 | 11 | "parser": "@typescript-eslint/parser", 12 | 13 | "parserOptions": { 14 | "ecmaVersion": "latest", 15 | "sourceType": "module", 16 | "ecmaFeatures": { 17 | "jsx": true 18 | } 19 | }, 20 | 21 | "extends": [ 22 | "eslint:recommended", 23 | "plugin:@typescript-eslint/recommended", 24 | "plugin:typescript-sort-keys/recommended", 25 | "plugin:import/recommended", 26 | "plugin:react/recommended", 27 | "plugin:react/jsx-runtime", 28 | "plugin:jsx-a11y/recommended", 29 | "plugin:react-hooks/recommended", 30 | "plugin:astro/recommended", 31 | "prettier" 32 | ], 33 | 34 | "plugins": [ 35 | "@typescript-eslint", 36 | "typescript-sort-keys", 37 | "sort-keys-fix", 38 | "sort-destructure-keys", 39 | "prettier" 40 | ], 41 | 42 | "rules": { 43 | "prettier/prettier": "error", 44 | "sort-keys-fix/sort-keys-fix": ["warn", "asc"], 45 | "sort-destructure-keys/sort-destructure-keys": "warn", 46 | "@typescript-eslint/triple-slash-reference": "off", 47 | "jsx-a11y/no-static-element-interactions": "off", 48 | "react/jsx-sort-props": [ 49 | "warn", 50 | { 51 | "callbacksLast": true, 52 | "multiline": "last" 53 | } 54 | ] 55 | }, 56 | 57 | "settings": { 58 | "react": { 59 | "version": "detect" 60 | }, 61 | 62 | "import/parsers": { 63 | "@typescript-eslint/parser": [".ts", ".tsx", ".js", ".jsx"] 64 | }, 65 | 66 | "import/resolver": { 67 | "typescript": true, 68 | "node": true, 69 | "alias": { 70 | "extensions": [".js", ".jsx", ".ts", ".tsx", ".d.ts"], 71 | "map": [["@", "./src"]] 72 | } 73 | } 74 | }, 75 | 76 | "overrides": [ 77 | { 78 | "files": ["**/*.astro"], 79 | "parser": "astro-eslint-parser", 80 | 81 | "parserOptions": { 82 | "parser": "@typescript-eslint/parser", 83 | "extraFileExtensions": [".astro"] 84 | }, 85 | 86 | "rules": { 87 | "prettier/prettier": "error", 88 | "react/no-unknown-property": "off", 89 | "react/jsx-key": "off" 90 | }, 91 | 92 | "globals": { 93 | "Astro": "readonly" 94 | } 95 | }, 96 | 97 | { 98 | "files": ["**/*.astro/*.js"], 99 | "rules": { 100 | "prettier/prettier": "off" 101 | } 102 | } 103 | ] 104 | } 105 | -------------------------------------------------------------------------------- /src/components/app/app.module.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | display: flex; 3 | align-items: center; 4 | justify-content: center; 5 | height: auto; 6 | min-height: 100dvh; 7 | padding: 200px 0; 8 | } 9 | 10 | .srOnly { 11 | position: absolute; 12 | width: 1px; 13 | height: 1px; 14 | padding: 0; 15 | margin: -1px; 16 | overflow: hidden; 17 | clip: rect(0, 0, 0, 0); 18 | white-space: nowrap; 19 | border: 0; 20 | } 21 | 22 | .info { 23 | position: absolute; 24 | bottom: 20px; 25 | left: 0; 26 | width: 100%; 27 | 28 | & .buttons { 29 | display: flex; 30 | column-gap: 4px; 31 | align-items: center; 32 | justify-content: center; 33 | margin-bottom: 12px; 34 | 35 | & button { 36 | height: 32px; 37 | padding: 0 20px; 38 | font-size: var(--font-2xsm); 39 | font-weight: 500; 40 | color: var(--color-foreground); 41 | cursor: pointer; 42 | background: linear-gradient( 43 | var(--color-neutral-50), 44 | var(--color-neutral-100) 45 | ); 46 | border: 1px solid var(--color-neutral-300); 47 | border-radius: 999px; 48 | box-shadow: inset 0 -1px 0 var(--color-neutral-500); 49 | transition: 0.2s; 50 | 51 | &:hover { 52 | transform: translateY(-2px); 53 | } 54 | } 55 | } 56 | 57 | & .tip { 58 | font-size: var(--font-sm); 59 | color: var(--color-foreground-subtler); 60 | text-align: center; 61 | } 62 | } 63 | 64 | .circle { 65 | position: relative; 66 | 67 | & .controls { 68 | position: absolute; 69 | inset: 0; 70 | display: flex; 71 | align-items: center; 72 | justify-content: center; 73 | 74 | & div { 75 | background: radial-gradient( 76 | var(--color-neutral-50), 77 | var(--color-neutral-50), 78 | var(--color-neutral-200) 79 | ); 80 | border: 1px solid var(--color-neutral-300); 81 | border-radius: 50%; 82 | box-shadow: 0 0 20px rgb(0 0 0 / 10%); 83 | } 84 | } 85 | 86 | & .state { 87 | position: absolute; 88 | inset: 0; 89 | display: flex; 90 | flex-direction: column; 91 | align-items: center; 92 | justify-content: center; 93 | 94 | & .phase { 95 | font-family: var(--font-display); 96 | font-size: var(--font-xlg); 97 | } 98 | } 99 | 100 | & .time { 101 | display: flex; 102 | column-gap: 8px; 103 | align-items: center; 104 | justify-content: center; 105 | margin-top: 20px; 106 | font-family: var(--font-mono); 107 | font-size: var(--font-2xsm); 108 | font-weight: 700; 109 | font-variant-numeric: tabular-nums; 110 | color: var(--color-foreground-subtle); 111 | text-align: center; 112 | text-transform: uppercase; 113 | 114 | & span { 115 | display: block; 116 | width: 50px; 117 | height: 1px; 118 | background: var(--color-neutral-200); 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ## [0.1.0](https://github.com/remvze/calmness/compare/v0.0.5...v0.1.0) (2025-12-04) 6 | 7 | 8 | ### 🚚 Chores 9 | 10 | * update the domain ([faae93f](https://github.com/remvze/calmness/commit/faae93f0919a1346696125f7abdeaac8868f131c)) 11 | 12 | 13 | ### ✨ Features 14 | 15 | * remake ([f091a9d](https://github.com/remvze/calmness/commit/f091a9d35b42881769290aba6c6ceb2a7d093763)) 16 | 17 | ### [0.0.5](https://github.com/remvze/calmness/compare/v0.0.4...v0.0.5) (2024-09-19) 18 | 19 | 20 | ### 🐛 Bug Fixes 21 | 22 | * change muted state ([21daeaf](https://github.com/remvze/calmness/commit/21daeaf4d51bd8f510b5c842f95a8d9749e61059)) 23 | 24 | ### [0.0.4](https://github.com/remvze/calmness/compare/v0.0.3...v0.0.4) (2024-09-19) 25 | 26 | 27 | ### 🐛 Bug Fixes 28 | 29 | * change progress logic ([052e119](https://github.com/remvze/calmness/commit/052e11918b711f6d86aad1ec9b6334ff97dae2f2)) 30 | 31 | ### [0.0.3](https://github.com/remvze/calmness/compare/v0.0.2...v0.0.3) (2024-09-19) 32 | 33 | 34 | ### ✨ Features 35 | 36 | * add progress bar ([e79e1e2](https://github.com/remvze/calmness/commit/e79e1e22344cb40d7d3f90fa2586bfd5ab7b3252)) 37 | 38 | ### [0.0.2](https://github.com/remvze/calmness/compare/v0.0.1...v0.0.2) (2024-09-19) 39 | 40 | 41 | ### 💄 Styling 42 | 43 | * change exercise to exercises ([6c48376](https://github.com/remvze/calmness/commit/6c483762cc5f72116f7d4aabd40bb042de23b59d)) 44 | 45 | ### 0.0.1 (2024-09-19) 46 | 47 | 48 | ### 💄 Styling 49 | 50 | * minor changes ([a24d443](https://github.com/remvze/calmness/commit/a24d4439aa5da3ed2600ca17471fe8f56496f713)) 51 | 52 | 53 | ### 🐛 Bug Fixes 54 | 55 | * decrease font weight ([3fc4409](https://github.com/remvze/calmness/commit/3fc44098983665c0b8d38d5e40d89486ddbcc6e7)) 56 | 57 | 58 | ### ✨ Features 59 | 60 | * add custom exercise ([08baf8f](https://github.com/remvze/calmness/commit/08baf8fd1bff747a163251225f965e80e6fa0910)) 61 | * add description for exercises ([4a51210](https://github.com/remvze/calmness/commit/4a512107cdd5783c88e073d942fa43fce8c77e75)) 62 | * add footer ([b32d318](https://github.com/remvze/calmness/commit/b32d318a2960b6e29b694bb5eaab69b2e22b5216)) 63 | * add hero ([b1b1a12](https://github.com/remvze/calmness/commit/b1b1a125c9a8a03a3b71fce9377dfba61c1362dc)) 64 | * add logo ([868be19](https://github.com/remvze/calmness/commit/868be198913e9ca2d353f90db816e72687038135)) 65 | * add more exercises ([b1d081f](https://github.com/remvze/calmness/commit/b1d081fb95d830d88ebe7873ef4adab6ca7fa7ca)) 66 | * add note section ([4cd79cf](https://github.com/remvze/calmness/commit/4cd79cfef713a74fb57698ce5003709468ddfef7)) 67 | * add sound ([b9b4f5f](https://github.com/remvze/calmness/commit/b9b4f5f649f57873896061ee4b722c00306cadec)) 68 | * implement the main functionality ([b4fdd4a](https://github.com/remvze/calmness/commit/b4fdd4a5672ef6f62d52d8c6219197dff1a2da27)) 69 | * initial commit ([c603833](https://github.com/remvze/calmness/commit/c60383332aa0cc40b9392b90da5ec4ce01a933ab)) 70 | * make the custom settings persistent ([44ec900](https://github.com/remvze/calmness/commit/44ec90023f1ea1163b69e1474e10909b49ec428f)) 71 | -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/components/app/app.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef, useState } from 'react'; 2 | import { motion } from 'framer-motion'; 3 | 4 | import styles from './app.module.css'; 5 | import { cn } from '@/helpers/styles'; 6 | import { Container } from '../container'; 7 | 8 | export type Phase = 'inhale' | 'hold1' | 'exhale' | 'hold2'; 9 | 10 | export interface InfiniteBoxBreathingProps { 11 | autoStart?: boolean; 12 | className?: string; 13 | exhale?: number; 14 | hold1?: number; 15 | hold2?: number; 16 | inhale?: number; 17 | onCycle?: () => void; 18 | size?: number; 19 | strokeWidth?: number; 20 | } 21 | 22 | const defaultDurations = { 23 | exhale: 4000, 24 | hold1: 4000, 25 | hold2: 4000, 26 | inhale: 4000, 27 | }; 28 | 29 | const easeInOutSine = (x: number): number => { 30 | return -(Math.cos(Math.PI * x) - 1) / 2; 31 | }; 32 | 33 | export function App({ 34 | autoStart = true, 35 | className = '', 36 | exhale = defaultDurations.exhale, 37 | hold1 = defaultDurations.hold1, 38 | hold2 = defaultDurations.hold2, 39 | inhale = defaultDurations.inhale, 40 | onCycle, 41 | size = 250, 42 | strokeWidth = 12, 43 | }: InfiniteBoxBreathingProps) { 44 | const [phase, setPhase] = useState('inhale'); 45 | const [running, setRunning] = useState(autoStart); 46 | const [timeLeft, setTimeLeft] = useState(inhale); 47 | const [cycleCount, setCycleCount] = useState(0); 48 | 49 | const getPhaseDuration = useCallback( 50 | (p: Phase) => { 51 | switch (p) { 52 | case 'inhale': 53 | return inhale; 54 | case 'hold1': 55 | return hold1; 56 | case 'exhale': 57 | return exhale; 58 | case 'hold2': 59 | return hold2; 60 | } 61 | }, 62 | [inhale, hold1, exhale, hold2], 63 | ); 64 | 65 | const progress = 1 - timeLeft / getPhaseDuration(phase); 66 | const rafRef = useRef(null); 67 | const prevTimeRef = useRef(null); 68 | const liveRef = useRef(null); 69 | 70 | const nextPhase = (p: Phase): Phase => { 71 | switch (p) { 72 | case 'inhale': 73 | return 'hold1'; 74 | case 'hold1': 75 | return 'exhale'; 76 | case 'exhale': 77 | return 'hold2'; 78 | case 'hold2': 79 | return 'inhale'; 80 | } 81 | }; 82 | 83 | const getCurrentScale = () => { 84 | const easedProgress = easeInOutSine(progress); 85 | 86 | switch (phase) { 87 | case 'inhale': 88 | return 0.5 + 0.5 * easedProgress; 89 | case 'exhale': 90 | return 1.0 - 0.5 * easedProgress; 91 | case 'hold1': 92 | return 1.0; 93 | case 'hold2': 94 | return 0.5; 95 | default: 96 | return 0.5; 97 | } 98 | }; 99 | 100 | useEffect(() => { 101 | if (liveRef.current) { 102 | const secondsLeft = Math.ceil(timeLeft / 1000); 103 | liveRef.current.textContent = `${phase.replace(/\d/, '')} — ${secondsLeft}s`; 104 | } 105 | }, [timeLeft, phase]); 106 | 107 | useEffect(() => { 108 | if (!running) { 109 | if (rafRef.current) { 110 | cancelAnimationFrame(rafRef.current); 111 | rafRef.current = null; 112 | } 113 | prevTimeRef.current = null; 114 | return; 115 | } 116 | 117 | const loop = (now: number) => { 118 | if (!prevTimeRef.current) prevTimeRef.current = now; 119 | const delta = now - prevTimeRef.current; 120 | prevTimeRef.current = now; 121 | 122 | setTimeLeft(prev => { 123 | const next = Math.max(0, prev - delta); 124 | return next; 125 | }); 126 | 127 | rafRef.current = requestAnimationFrame(loop); 128 | }; 129 | 130 | rafRef.current = requestAnimationFrame(loop); 131 | 132 | return () => { 133 | if (rafRef.current) cancelAnimationFrame(rafRef.current); 134 | rafRef.current = null; 135 | prevTimeRef.current = null; 136 | }; 137 | }, [running]); 138 | 139 | useEffect(() => { 140 | if (!running) return; 141 | 142 | if (timeLeft <= 0) { 143 | setPhase(p => { 144 | const np = nextPhase(p); 145 | if (p === 'hold2') { 146 | setCycleCount(c => c + 1); 147 | 148 | if (onCycle) onCycle(); 149 | } 150 | 151 | setTimeLeft(getPhaseDuration(np)); 152 | return np; 153 | }); 154 | } 155 | }, [timeLeft, running, getPhaseDuration, onCycle]); 156 | 157 | useEffect(() => { 158 | setTimeLeft(getPhaseDuration(phase)); 159 | }, [getPhaseDuration, phase]); 160 | 161 | const radius = (size - strokeWidth) / 2 - 2; 162 | const circumference = 2 * Math.PI * radius; 163 | const dashOffset = circumference * (1 - progress); 164 | 165 | useEffect(() => { 166 | const onKey = (e: KeyboardEvent) => { 167 | if (e.code === 'Space') { 168 | e.preventDefault(); 169 | setRunning(r => !r); 170 | } 171 | }; 172 | window.addEventListener('keydown', onKey); 173 | return () => window.removeEventListener('keydown', onKey); 174 | }, []); 175 | 176 | const formatSeconds = (ms: number) => `${(ms / 1000).toFixed(1)} s`; 177 | 178 | return ( 179 |
180 |
181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 195 | 206 | 207 | 208 | 209 |
210 | 219 |
220 | 221 |
222 |
223 | {phase.replace(/\d/, '').replace(/^(.)/, s => s.toUpperCase())} 224 |
225 |
226 | 227 |
228 | {formatSeconds(timeLeft)} Cycle {cycleCount} 229 |
230 |
231 | 232 |
233 | 234 |
235 | 238 | 248 |
249 | 250 |
251 | Tip: Press Space to toggle start/pause 252 |
253 |
254 |
255 | 256 |
262 |
263 | ); 264 | } 265 | --------------------------------------------------------------------------------