├── .commitlintrc.json
├── .czrc
├── .editorconfig
├── .eslintignore
├── .eslintrc.json
├── .github
└── workflows
│ └── build_docker.yml
├── .gitignore
├── .husky
├── commit-msg
└── pre-commit
├── .lintstagedrc.json
├── .npmrc
├── .prettierignore
├── .prettierrc.json
├── .stylelintignore
├── .stylelintrc.json
├── .versionrc.json
├── .vscode
├── extensions.json
├── launch.json
└── settings.json
├── CHANGELOG.md
├── CONTRIBUTING.md
├── Caddyfile
├── Dockerfile
├── LICENSE
├── README.md
├── assets
└── banner.svg
├── astro.config.mjs
├── docker-compose.yml
├── package-lock.json
├── package.json
├── postcss.config.cjs
├── public
├── assets
│ └── pwa
│ │ ├── 128.png
│ │ ├── 144.png
│ │ ├── 152.png
│ │ ├── 192.png
│ │ ├── 256.png
│ │ ├── 512.png
│ │ └── 72.png
├── favicon.svg
├── fonts
│ ├── gloock-v6-latin-regular.woff2
│ ├── inter-v13-latin-500.woff2
│ ├── inter-v13-latin-regular.woff2
│ └── space-mono-v13-latin-700.woff2
├── logo.svg
├── noise.png
├── og.png
└── sounds
│ └── alarm.mp3
├── src
├── components
│ ├── about.astro
│ ├── app
│ │ ├── app.module.css
│ │ ├── app.tsx
│ │ └── index.ts
│ ├── container
│ │ ├── container.module.css
│ │ ├── container.tsx
│ │ └── index.ts
│ ├── footer.astro
│ ├── form
│ │ ├── field
│ │ │ ├── field.module.css
│ │ │ ├── field.tsx
│ │ │ └── index.ts
│ │ ├── form.module.css
│ │ ├── form.tsx
│ │ └── index.ts
│ ├── hero.astro
│ ├── modal
│ │ ├── index.ts
│ │ ├── modal.module.css
│ │ └── modal.tsx
│ ├── pomodoro
│ │ ├── index.tsx
│ │ └── pomodoro.module.css
│ ├── portal
│ │ ├── index.ts
│ │ └── portal.tsx
│ ├── reload
│ │ ├── index.ts
│ │ ├── reload-modal.tsx
│ │ ├── reload.module.css
│ │ └── reload.tsx
│ ├── snackbar
│ │ ├── index.ts
│ │ ├── snackbar.module.css
│ │ └── snackbar.tsx
│ ├── store-consumer
│ │ ├── index.ts
│ │ └── store-consumer.tsx
│ └── timers
│ │ ├── index.ts
│ │ ├── notice
│ │ ├── index.ts
│ │ ├── notice.module.css
│ │ └── notice.tsx
│ │ ├── timer
│ │ ├── index.ts
│ │ ├── timer.module.css
│ │ ├── timer.tsx
│ │ └── toolbar
│ │ │ ├── index.ts
│ │ │ ├── toolbar.module.css
│ │ │ └── toolbar.tsx
│ │ ├── timers.module.css
│ │ └── timers.tsx
├── contexts
│ └── snackbar.tsx
├── env.d.ts
├── helpers
│ ├── number.ts
│ └── styles.ts
├── hooks
│ ├── use-alarm.ts
│ ├── use-local-storage.ts
│ ├── use-sound.ts
│ └── use-ssr.ts
├── layouts
│ └── layout.astro
├── pages
│ └── index.astro
├── stores
│ ├── alarm.ts
│ ├── settings.ts
│ └── timers.ts
└── styles
│ ├── base
│ └── base.css
│ ├── fonts.css
│ ├── global.css
│ └── variables
│ ├── color.css
│ ├── index.css
│ └── typography.css
└── tsconfig.json
/.commitlintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["@commitlint/config-conventional"]
3 | }
4 |
--------------------------------------------------------------------------------
/.czrc:
--------------------------------------------------------------------------------
1 | {
2 | "path": "./node_modules/cz-conventional-changelog"
3 | }
4 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | end_of_line = lf
5 | insert_final_newline = true
6 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | dist/
3 | .output/
4 |
--------------------------------------------------------------------------------
/.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 | "jsx-a11y/no-static-element-interactions": "off",
47 | "jsx-a11y/no-noninteractive-tabindex": "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 |
70 | "alias": {
71 | "extensions": [".js", ".jsx", ".ts", ".tsx", ".d.ts"],
72 | "map": [["@", "./src"]]
73 | }
74 | }
75 | },
76 |
77 | "overrides": [
78 | {
79 | "files": ["**/*.astro"],
80 | "parser": "astro-eslint-parser",
81 |
82 | "parserOptions": {
83 | "parser": "@typescript-eslint/parser",
84 | "extraFileExtensions": [".astro"]
85 | },
86 |
87 | "rules": {
88 | "prettier/prettier": "error",
89 | "react/no-unknown-property": "off",
90 | "react/jsx-no-undef": "off"
91 | },
92 |
93 | "globals": {
94 | "Astro": "readonly"
95 | }
96 | },
97 |
98 | {
99 | "files": ["**/*.astro/*.js"],
100 | "rules": {
101 | "prettier/prettier": "off"
102 | }
103 | }
104 | ]
105 | }
106 |
--------------------------------------------------------------------------------
/.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/timesy"
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 |
--------------------------------------------------------------------------------
/.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 |
23 | # jetbrains setting folder
24 | .idea/
25 |
--------------------------------------------------------------------------------
/.husky/commit-msg:
--------------------------------------------------------------------------------
1 | npx commitlint --edit $1
2 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | npx lint-staged
2 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | save-exact=true
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | dist/
3 | .output/
4 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.stylelintignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | dist/
3 | .output/
4 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 | ### [1.0.2](https://github.com/remvze/timesy/compare/v1.0.1...v1.0.2) (2025-02-19)
6 |
7 |
8 | ### 💄 Styling
9 |
10 | * enforce dark color scheme ([f47eb4d](https://github.com/remvze/timesy/commit/f47eb4d9b1902509598f1f533f380f424778931b))
11 |
12 |
13 | ### ✨ Features
14 |
15 | * keep timer name on tab switch ([9c24806](https://github.com/remvze/timesy/commit/9c24806f45a1c334b1aa31ddb3cf6051ddb53ec1))
16 |
17 | ### [1.0.1](https://github.com/remvze/timesy/compare/v1.0.0...v1.0.1) (2025-02-15)
18 |
19 |
20 | ### ✨ Features
21 |
22 | * add timer to the title ([ac49f3d](https://github.com/remvze/timesy/commit/ac49f3d762b66b656d87c8e8eeed87f8cce3f798))
23 |
24 | ## [1.0.0](https://github.com/remvze/timesy/compare/v0.0.6...v1.0.0) (2025-02-15)
25 |
26 |
27 | ### 🚚 Chores
28 |
29 | * **release:** 0.1.0 ([d4eecd6](https://github.com/remvze/timesy/commit/d4eecd6338759b24811440bfe455043f6df52722))
30 | * update domain ([e56b3e9](https://github.com/remvze/timesy/commit/e56b3e9fc828cb9c74f58706f84c66ba125c9601))
31 | * update domain ([9318aeb](https://github.com/remvze/timesy/commit/9318aebebab06a779ff300349db4f25f37820ad9))
32 | * update README file ([02b6d4c](https://github.com/remvze/timesy/commit/02b6d4c6a352b4870e906b45ff8f5a4b91d3e85b))
33 |
34 |
35 | ### 💄 Styling
36 |
37 | * design the setting modal ([d6f3f56](https://github.com/remvze/timesy/commit/d6f3f5645ce086fabd4ace2d9b4714c28ea345cc))
38 | * improve design ([3c4c187](https://github.com/remvze/timesy/commit/3c4c18727a54fcdaa278e1f46a24ce40ba6385bc))
39 | * improve the hero ([0abc7cd](https://github.com/remvze/timesy/commit/0abc7cd7ed952e9d503b12f165b152a8d4a505d7))
40 | * minor change ([0cebee8](https://github.com/remvze/timesy/commit/0cebee8754b92c99f982537fb2d2ebda53898aa2))
41 | * replace toolbar ([e3a77a4](https://github.com/remvze/timesy/commit/e3a77a48203f1158418009f256d729c615f0ec97))
42 |
43 |
44 | ### ✨ Features
45 |
46 | * add auto start option ([45ec740](https://github.com/remvze/timesy/commit/45ec740b48fc23b7f7577b836bc5197fb7662a67))
47 | * add basic moving mechanism ([161b455](https://github.com/remvze/timesy/commit/161b45574324e4d91fd2689a588185503c0bab13))
48 | * add basic pomodoro timer ([4ded07c](https://github.com/remvze/timesy/commit/4ded07c184918147aa922fe059ef791663d2761b))
49 | * add basic pwa ([4d9de85](https://github.com/remvze/timesy/commit/4d9de851436ba24e4915926981afe9c5fae2bf4f))
50 | * add disabled state to toolbar ([e362dac](https://github.com/remvze/timesy/commit/e362dac3f7090f387582bca30dd6cea98ea4e751))
51 | * add shortcuts ([21e4388](https://github.com/remvze/timesy/commit/21e4388f3c56621f73e343a7909681b0d484f4da))
52 | * add tabs ([8708aea](https://github.com/remvze/timesy/commit/8708aea51e5d2b52e33d53948fd390a06b45805b))
53 | * add toolbar to timer ([c852e3b](https://github.com/remvze/timesy/commit/c852e3b3390b6070e48878ff22e2cda6dbaed9ae))
54 | * add volume settings ([536d6b6](https://github.com/remvze/timesy/commit/536d6b671f6c5aa172cc1746297e5dd31446794c))
55 | * change hero ([11e5a0b](https://github.com/remvze/timesy/commit/11e5a0b9490536d9b2d6408a6aa28a54ed58a4e6))
56 | * change to reload prompt ([1c6726f](https://github.com/remvze/timesy/commit/1c6726f8349c416c36d7a783a806931648fc7f62))
57 | * replace reverse timer ([07fbfbe](https://github.com/remvze/timesy/commit/07fbfbec9de5683ab4bfcb23da0c3988c54aa6d8))
58 |
59 |
60 | ### 🐛 Bug Fixes
61 |
62 | * make the cancel button close the modal ([e7a1b71](https://github.com/remvze/timesy/commit/e7a1b7137f7bb7a7934dc78c46425ce20cf7061c))
63 | * minor change ([cc8e9b7](https://github.com/remvze/timesy/commit/cc8e9b7c06d74530785acbea75f6d2ba5c313c09))
64 | * remove pwa file ([e335527](https://github.com/remvze/timesy/commit/e3355279b419e22bf00af3ba98fc9fb5339e84e7))
65 | * render reload modal only in the browser ([2b263e2](https://github.com/remvze/timesy/commit/2b263e22538d2181ff818648bc46fafcc6be41bf))
66 |
67 | ## [0.1.0](https://github.com/remvze/timesy/compare/v0.0.6...v0.1.0) (2024-09-16)
68 |
69 |
70 | ### 🚚 Chores
71 |
72 | * update README file ([02b6d4c](https://github.com/remvze/timesy/commit/02b6d4c6a352b4870e906b45ff8f5a4b91d3e85b))
73 |
74 |
75 | ### 💄 Styling
76 |
77 | * replace toolbar ([e3a77a4](https://github.com/remvze/timesy/commit/e3a77a48203f1158418009f256d729c615f0ec97))
78 |
79 |
80 | ### 🐛 Bug Fixes
81 |
82 | * remove pwa file ([e335527](https://github.com/remvze/timesy/commit/e3355279b419e22bf00af3ba98fc9fb5339e84e7))
83 | * render reload modal only in the browser ([2b263e2](https://github.com/remvze/timesy/commit/2b263e22538d2181ff818648bc46fafcc6be41bf))
84 |
85 |
86 | ### ✨ Features
87 |
88 | * add auto start option ([45ec740](https://github.com/remvze/timesy/commit/45ec740b48fc23b7f7577b836bc5197fb7662a67))
89 | * add basic moving mechanism ([161b455](https://github.com/remvze/timesy/commit/161b45574324e4d91fd2689a588185503c0bab13))
90 | * add basic pwa ([4d9de85](https://github.com/remvze/timesy/commit/4d9de851436ba24e4915926981afe9c5fae2bf4f))
91 | * add disabled state to toolbar ([e362dac](https://github.com/remvze/timesy/commit/e362dac3f7090f387582bca30dd6cea98ea4e751))
92 | * add shortcuts ([21e4388](https://github.com/remvze/timesy/commit/21e4388f3c56621f73e343a7909681b0d484f4da))
93 | * add toolbar to timer ([c852e3b](https://github.com/remvze/timesy/commit/c852e3b3390b6070e48878ff22e2cda6dbaed9ae))
94 | * change to reload prompt ([1c6726f](https://github.com/remvze/timesy/commit/1c6726f8349c416c36d7a783a806931648fc7f62))
95 | * replace reverse timer ([07fbfbe](https://github.com/remvze/timesy/commit/07fbfbec9de5683ab4bfcb23da0c3988c54aa6d8))
96 |
97 | ### [0.0.6](https://github.com/remvze/timesy/compare/v0.0.5...v0.0.6) (2024-05-25)
98 |
99 | ### 0.0.5 (2024-05-25)
100 |
101 |
102 | ### 💄 Styling
103 |
104 | * add basic global styles ([f4313a2](https://github.com/remvze/timesy/commit/f4313a2360ec343985d656563d10538172a27674))
105 | * add divider ([ea44b04](https://github.com/remvze/timesy/commit/ea44b04a20de7f83a6d72669a0bc6c2f03d79a63))
106 | * add letter spacing ([9691de1](https://github.com/remvze/timesy/commit/9691de1980e92e133bf3bd33a3c9ba8ba5b4f969))
107 | * change logo style ([989f4e4](https://github.com/remvze/timesy/commit/989f4e4c53a1683e07a0b84ee36aea7eac1f71da))
108 | * decrease padding ([18be119](https://github.com/remvze/timesy/commit/18be119735ae2d688d0fe283793c55b830224e6e))
109 | * increase height ([3e4c125](https://github.com/remvze/timesy/commit/3e4c12589399cbc676c68d0a10be5e6f9696f7f9))
110 | * lowercase name ([93b3732](https://github.com/remvze/timesy/commit/93b3732987dcee755e341f56ed13deafd6fbc01c))
111 | * update hero ([7144c76](https://github.com/remvze/timesy/commit/7144c76cebe4a2c30bffb48e4649ca5899b76532))
112 |
113 |
114 | ### ♻️ Code Refactoring
115 |
116 | * lowercase file name ([aabb536](https://github.com/remvze/timesy/commit/aabb53640b46f3a9495a96f679fd8c4dfce4237d))
117 | * make alarm hook ([ba2f7f5](https://github.com/remvze/timesy/commit/ba2f7f5a33ee0a59aed1890055cae3934707b121))
118 | * migrate to Astro components ([fe2672d](https://github.com/remvze/timesy/commit/fe2672d024ad6cd973f27ec4ea42e637b1d12581))
119 | * migrate to Astro components ([29cbb75](https://github.com/remvze/timesy/commit/29cbb7504afe95592400dec71b868add0253398a))
120 | * relocate each component ([5b89e3b](https://github.com/remvze/timesy/commit/5b89e3b2244f08265f039c9d8c5dda6ce4dd6813))
121 |
122 |
123 | ### 🐛 Bug Fixes
124 |
125 | * add color to primary button ([f19b37f](https://github.com/remvze/timesy/commit/f19b37fec5f25fc4c35885580a796b96bf430eba))
126 | * correct timer for inactive tabs (wip) ([f00c5dd](https://github.com/remvze/timesy/commit/f00c5dd1b89780cea1cb6335c9a9a7ae35d79567))
127 | * correct timer for inactive tabs (wip) ([ee85a6c](https://github.com/remvze/timesy/commit/ee85a6c74e346be10a26b8435544ebc3417d8add))
128 | * remove confirmations ([8751ad9](https://github.com/remvze/timesy/commit/8751ad9d7059050de2ab76693785e4c94118e9b8))
129 | * remove the title ([d4ab5b3](https://github.com/remvze/timesy/commit/d4ab5b35ad899447ccfb7005e9d215b968f53da8))
130 | * turn off autocomplete for Firefox ([885443d](https://github.com/remvze/timesy/commit/885443de23e12e8417becb89ca60062bd65b83fb))
131 | * typo ([5344e98](https://github.com/remvze/timesy/commit/5344e98266c319d9febac3d997448a5ac3196f8e))
132 |
133 |
134 | ### 🚚 Chores
135 |
136 | * add Commitizen ([6265fb2](https://github.com/remvze/timesy/commit/6265fb2230d5c40841c6046d3496297cb215524d))
137 | * add Commitlint ([278138e](https://github.com/remvze/timesy/commit/278138e2902d084044c364d72c7d0eb01de63630))
138 | * add contributing guide ([b3a19af](https://github.com/remvze/timesy/commit/b3a19aff551fd352cf062f47f66612e1c33febf1))
139 | * add Editor Config ([f53b132](https://github.com/remvze/timesy/commit/f53b13272c24ff0a43e51cdd811adefd250f7327))
140 | * add ESLint ([531715d](https://github.com/remvze/timesy/commit/531715dc2af2bd9123d8835ebe4e5471a267ba5f))
141 | * add features to README file ([3210879](https://github.com/remvze/timesy/commit/3210879f9833a744d60206315849eb826b8b9f6b))
142 | * add Husky ([f5ebba6](https://github.com/remvze/timesy/commit/f5ebba6218edf4ea4ebaf429686e0f93bfb32e2e))
143 | * add Lint Staged ([4de2b2d](https://github.com/remvze/timesy/commit/4de2b2d2d347281b9c6957c007ab37fe43945cad))
144 | * add npm config ([ec9bf5a](https://github.com/remvze/timesy/commit/ec9bf5a022751d3c8d298f5580476efe2243c72a))
145 | * add OG image ([166e613](https://github.com/remvze/timesy/commit/166e6134533c2e020cd0464d3c6a17eef95034d7))
146 | * add path alias ([3b633df](https://github.com/remvze/timesy/commit/3b633df1c5b648d3e6ddd3e85e8ae8ef4f99a4e5))
147 | * add PostCSS ([17e6b8b](https://github.com/remvze/timesy/commit/17e6b8b22132635a2d2f997ed22ac1bc3910c7c0))
148 | * add Prettier ([12f2653](https://github.com/remvze/timesy/commit/12f2653cef93487a1237bdedc6b1e4f66e02c27d))
149 | * add Standard Version ([7237ab4](https://github.com/remvze/timesy/commit/7237ab41389b0433ac52e00ae3ee63125d163078))
150 | * add Stylelint ([0bce387](https://github.com/remvze/timesy/commit/0bce387b9b9f86088709762b83a743625e9bbe26))
151 | * change favicon ([57e7470](https://github.com/remvze/timesy/commit/57e7470a15558198833c67edbdbed0e8c2a2827b))
152 | * change image ([bf4913b](https://github.com/remvze/timesy/commit/bf4913bee18f49155abf5d1b093fe626689a811c))
153 | * change unsupported emoji ([68168ad](https://github.com/remvze/timesy/commit/68168ad187fbc7a3a15bd00e9a5cc679d18aef05))
154 | * fix error ([5a4ebb0](https://github.com/remvze/timesy/commit/5a4ebb0c55491f8063092d170dd214556c62236f))
155 | * **release:** 0.0.1 ([f76ec96](https://github.com/remvze/timesy/commit/f76ec964146f3c8d614fca64230ec9162b76bf2c))
156 | * **release:** 0.0.2 ([3339589](https://github.com/remvze/timesy/commit/3339589ae7e95162251046abc4a36be40a84d94a))
157 | * **release:** 0.0.3 ([41036a6](https://github.com/remvze/timesy/commit/41036a61e47e04aa7b8a5403948e84597ebc3629))
158 | * **release:** 0.0.4 ([7819b71](https://github.com/remvze/timesy/commit/7819b71ef70af3236f35756033133434547f8a4b))
159 | * update README file ([5dc40d0](https://github.com/remvze/timesy/commit/5dc40d03521bd8620354b390dfd25beb1f7ce64f))
160 |
161 |
162 | ### ✨ Features
163 |
164 | * add about section ([aa56d00](https://github.com/remvze/timesy/commit/aa56d00bee41fdb5febed40d4aa6b31ed00d2bc3))
165 | * add alarm on end ([a2d8b61](https://github.com/remvze/timesy/commit/a2d8b617aaad2f7a3484f938087d077aa1650cad))
166 | * add aria-disabled to disabled buttons ([ea4c269](https://github.com/remvze/timesy/commit/ea4c2695e31009ff022fa09b46f145ed14269db1))
167 | * add auto animation ([a124fd6](https://github.com/remvze/timesy/commit/a124fd6b4f1eac3e03072857851b8a77695dbbfc))
168 | * add basic structure ([3cd9b93](https://github.com/remvze/timesy/commit/3cd9b934d98c8d1879d3644300ac930c8941525b))
169 | * add deleting functionality ([0f43b6c](https://github.com/remvze/timesy/commit/0f43b6c1a362fd31df0c594bbf7e576e8d088f2f))
170 | * add Docker support ([2f815f3](https://github.com/remvze/timesy/commit/2f815f3bd53bb9558a375bab2631af78f1f55647))
171 | * add finished state ([b1bf287](https://github.com/remvze/timesy/commit/b1bf2875edebcc5b794280b9e156e4382c0b7ea6))
172 | * add fixed size to image ([160cbab](https://github.com/remvze/timesy/commit/160cbabff9f91ad15977eadee1800d479036b9fa))
173 | * add footer component ([20764f0](https://github.com/remvze/timesy/commit/20764f06ad4acfd330c6805c9f230bcca1c89a02))
174 | * add hero component ([d6e04e9](https://github.com/remvze/timesy/commit/d6e04e94058dd3511cfcddf78bfd726acab3d7f4))
175 | * add logo to hero ([e06a217](https://github.com/remvze/timesy/commit/e06a21739f7f51d2c0de925b8495e776db5bed9d))
176 | * add more meta tags ([74eb8cc](https://github.com/remvze/timesy/commit/74eb8cc6c78625b050f0357e5c368a29a769a20e))
177 | * add notice ([86554f6](https://github.com/remvze/timesy/commit/86554f6f918b5b95e306113e1cc627a973ad691a))
178 | * add reset functionality ([868bf14](https://github.com/remvze/timesy/commit/868bf1457f2833f795ec6046eb86525b0294c815))
179 | * add reverse timer ([60bd100](https://github.com/remvze/timesy/commit/60bd100a0885c49d3a4e75df3bd20542b4ec2149))
180 | * add support request ([256c5c9](https://github.com/remvze/timesy/commit/256c5c9ff0b9f7cd68aeed3eda1f04e684db360e))
181 | * add total and spent minutes ([325af1c](https://github.com/remvze/timesy/commit/325af1ce0da2e0ad578d12806955077129c917d1))
182 | * add total tracked time ([3b3db7a](https://github.com/remvze/timesy/commit/3b3db7a72bac1ef79a9bb374760c942ed884d1b5))
183 | * change headings ([211ca08](https://github.com/remvze/timesy/commit/211ca0895f3cda9f505c95aa550244cbdb2c3517))
184 | * disable play button on finished ([667308f](https://github.com/remvze/timesy/commit/667308f2359e5938a09b76c2707eb13ccb34f75f))
185 | * implement basic timer ([d3c15a2](https://github.com/remvze/timesy/commit/d3c15a279fa9981e8d4ba7da94faff97b3cc8429))
186 | * implement snackbar ([3cee118](https://github.com/remvze/timesy/commit/3cee118ee8a848811ecdfb1dd9ed93da4468037f))
187 | * make timers persistant ([aaf2eab](https://github.com/remvze/timesy/commit/aaf2eab3e3e71dea78b19fad86a7949a7efd1d1b))
188 |
189 | ### [0.0.4](https://github.com/remvze/timesy/compare/v0.0.3...v0.0.4) (2024-05-23)
190 |
191 |
192 | ### ♻️ Code Refactoring
193 |
194 | * migrate to Astro components ([fe2672d](https://github.com/remvze/timesy/commit/fe2672d024ad6cd973f27ec4ea42e637b1d12581))
195 | * migrate to Astro components ([29cbb75](https://github.com/remvze/timesy/commit/29cbb7504afe95592400dec71b868add0253398a))
196 |
197 |
198 | ### ✨ Features
199 |
200 | * add auto animation ([a124fd6](https://github.com/remvze/timesy/commit/a124fd6b4f1eac3e03072857851b8a77695dbbfc))
201 |
202 |
203 | ### 🐛 Bug Fixes
204 |
205 | * correct timer for inactive tabs (wip) ([f00c5dd](https://github.com/remvze/timesy/commit/f00c5dd1b89780cea1cb6335c9a9a7ae35d79567))
206 | * correct timer for inactive tabs (wip) ([ee85a6c](https://github.com/remvze/timesy/commit/ee85a6c74e346be10a26b8435544ebc3417d8add))
207 |
208 | ### [0.0.3](https://github.com/remvze/timesy/compare/v0.0.2...v0.0.3) (2024-05-13)
209 |
210 | ### 🐛 Bug Fixes
211 |
212 | - add color to primary button ([f19b37f](https://github.com/remvze/timesy/commit/f19b37fec5f25fc4c35885580a796b96bf430eba))
213 | - typo ([5344e98](https://github.com/remvze/timesy/commit/5344e98266c319d9febac3d997448a5ac3196f8e))
214 |
215 | ### 🚚 Chores
216 |
217 | - add contributing guide ([b3a19af](https://github.com/remvze/timesy/commit/b3a19aff551fd352cf062f47f66612e1c33febf1))
218 | - add features to README file ([3210879](https://github.com/remvze/timesy/commit/3210879f9833a744d60206315849eb826b8b9f6b))
219 | - change image ([bf4913b](https://github.com/remvze/timesy/commit/bf4913bee18f49155abf5d1b093fe626689a811c))
220 | - change unsupported emoji ([68168ad](https://github.com/remvze/timesy/commit/68168ad187fbc7a3a15bd00e9a5cc679d18aef05))
221 |
222 | ### ✨ Features
223 |
224 | - add aria-disabled to disabled buttons ([ea4c269](https://github.com/remvze/timesy/commit/ea4c2695e31009ff022fa09b46f145ed14269db1))
225 | - add notice ([86554f6](https://github.com/remvze/timesy/commit/86554f6f918b5b95e306113e1cc627a973ad691a))
226 | - change headings ([211ca08](https://github.com/remvze/timesy/commit/211ca0895f3cda9f505c95aa550244cbdb2c3517))
227 | - implement snackbar ([3cee118](https://github.com/remvze/timesy/commit/3cee118ee8a848811ecdfb1dd9ed93da4468037f))
228 |
229 | ### [0.0.2](https://github.com/remvze/timesy/compare/v0.0.1...v0.0.2) (2024-05-08)
230 |
231 | ### 0.0.1 (2024-05-08)
232 |
233 | ### 💄 Styling
234 |
235 | - add basic global styles ([f4313a2](https://github.com/remvze/timesy/commit/f4313a2360ec343985d656563d10538172a27674))
236 | - add divider ([ea44b04](https://github.com/remvze/timesy/commit/ea44b04a20de7f83a6d72669a0bc6c2f03d79a63))
237 | - add letter spacing ([9691de1](https://github.com/remvze/timesy/commit/9691de1980e92e133bf3bd33a3c9ba8ba5b4f969))
238 | - change logo style ([989f4e4](https://github.com/remvze/timesy/commit/989f4e4c53a1683e07a0b84ee36aea7eac1f71da))
239 | - decrease padding ([18be119](https://github.com/remvze/timesy/commit/18be119735ae2d688d0fe283793c55b830224e6e))
240 | - increase height ([3e4c125](https://github.com/remvze/timesy/commit/3e4c12589399cbc676c68d0a10be5e6f9696f7f9))
241 | - lowercase name ([93b3732](https://github.com/remvze/timesy/commit/93b3732987dcee755e341f56ed13deafd6fbc01c))
242 | - update hero ([7144c76](https://github.com/remvze/timesy/commit/7144c76cebe4a2c30bffb48e4649ca5899b76532))
243 |
244 | ### ♻️ Code Refactoring
245 |
246 | - lowercase file name ([aabb536](https://github.com/remvze/timesy/commit/aabb53640b46f3a9495a96f679fd8c4dfce4237d))
247 | - make alarm hook ([ba2f7f5](https://github.com/remvze/timesy/commit/ba2f7f5a33ee0a59aed1890055cae3934707b121))
248 | - relocate each component ([5b89e3b](https://github.com/remvze/timesy/commit/5b89e3b2244f08265f039c9d8c5dda6ce4dd6813))
249 |
250 | ### 🐛 Bug Fixes
251 |
252 | - remove confirmations ([8751ad9](https://github.com/remvze/timesy/commit/8751ad9d7059050de2ab76693785e4c94118e9b8))
253 | - remove the title ([d4ab5b3](https://github.com/remvze/timesy/commit/d4ab5b35ad899447ccfb7005e9d215b968f53da8))
254 | - turn off autocomplete for Firefox ([885443d](https://github.com/remvze/timesy/commit/885443de23e12e8417becb89ca60062bd65b83fb))
255 |
256 | ### 🚚 Chores
257 |
258 | - add Commitizen ([6265fb2](https://github.com/remvze/timesy/commit/6265fb2230d5c40841c6046d3496297cb215524d))
259 | - add Commitlint ([278138e](https://github.com/remvze/timesy/commit/278138e2902d084044c364d72c7d0eb01de63630))
260 | - add Editor Config ([f53b132](https://github.com/remvze/timesy/commit/f53b13272c24ff0a43e51cdd811adefd250f7327))
261 | - add ESLint ([531715d](https://github.com/remvze/timesy/commit/531715dc2af2bd9123d8835ebe4e5471a267ba5f))
262 | - add Husky ([f5ebba6](https://github.com/remvze/timesy/commit/f5ebba6218edf4ea4ebaf429686e0f93bfb32e2e))
263 | - add Lint Staged ([4de2b2d](https://github.com/remvze/timesy/commit/4de2b2d2d347281b9c6957c007ab37fe43945cad))
264 | - add npm config ([ec9bf5a](https://github.com/remvze/timesy/commit/ec9bf5a022751d3c8d298f5580476efe2243c72a))
265 | - add OG image ([166e613](https://github.com/remvze/timesy/commit/166e6134533c2e020cd0464d3c6a17eef95034d7))
266 | - add path alias ([3b633df](https://github.com/remvze/timesy/commit/3b633df1c5b648d3e6ddd3e85e8ae8ef4f99a4e5))
267 | - add PostCSS ([17e6b8b](https://github.com/remvze/timesy/commit/17e6b8b22132635a2d2f997ed22ac1bc3910c7c0))
268 | - add Prettier ([12f2653](https://github.com/remvze/timesy/commit/12f2653cef93487a1237bdedc6b1e4f66e02c27d))
269 | - add Standard Version ([7237ab4](https://github.com/remvze/timesy/commit/7237ab41389b0433ac52e00ae3ee63125d163078))
270 | - add Stylelint ([0bce387](https://github.com/remvze/timesy/commit/0bce387b9b9f86088709762b83a743625e9bbe26))
271 | - change favicon ([57e7470](https://github.com/remvze/timesy/commit/57e7470a15558198833c67edbdbed0e8c2a2827b))
272 | - fix error ([5a4ebb0](https://github.com/remvze/timesy/commit/5a4ebb0c55491f8063092d170dd214556c62236f))
273 | - update README file ([5dc40d0](https://github.com/remvze/timesy/commit/5dc40d03521bd8620354b390dfd25beb1f7ce64f))
274 |
275 | ### ✨ Features
276 |
277 | - add about section ([aa56d00](https://github.com/remvze/timesy/commit/aa56d00bee41fdb5febed40d4aa6b31ed00d2bc3))
278 | - add alarm on end ([a2d8b61](https://github.com/remvze/timesy/commit/a2d8b617aaad2f7a3484f938087d077aa1650cad))
279 | - add basic structure ([3cd9b93](https://github.com/remvze/timesy/commit/3cd9b934d98c8d1879d3644300ac930c8941525b))
280 | - add deleting functionality ([0f43b6c](https://github.com/remvze/timesy/commit/0f43b6c1a362fd31df0c594bbf7e576e8d088f2f))
281 | - add Docker support ([2f815f3](https://github.com/remvze/timesy/commit/2f815f3bd53bb9558a375bab2631af78f1f55647))
282 | - add finished state ([b1bf287](https://github.com/remvze/timesy/commit/b1bf2875edebcc5b794280b9e156e4382c0b7ea6))
283 | - add fixed size to image ([160cbab](https://github.com/remvze/timesy/commit/160cbabff9f91ad15977eadee1800d479036b9fa))
284 | - add footer component ([20764f0](https://github.com/remvze/timesy/commit/20764f06ad4acfd330c6805c9f230bcca1c89a02))
285 | - add hero component ([d6e04e9](https://github.com/remvze/timesy/commit/d6e04e94058dd3511cfcddf78bfd726acab3d7f4))
286 | - add logo to hero ([e06a217](https://github.com/remvze/timesy/commit/e06a21739f7f51d2c0de925b8495e776db5bed9d))
287 | - add more meta tags ([74eb8cc](https://github.com/remvze/timesy/commit/74eb8cc6c78625b050f0357e5c368a29a769a20e))
288 | - add reset functionality ([868bf14](https://github.com/remvze/timesy/commit/868bf1457f2833f795ec6046eb86525b0294c815))
289 | - add reverse timer ([60bd100](https://github.com/remvze/timesy/commit/60bd100a0885c49d3a4e75df3bd20542b4ec2149))
290 | - add total and spent minutes ([325af1c](https://github.com/remvze/timesy/commit/325af1ce0da2e0ad578d12806955077129c917d1))
291 | - add total tracked time ([3b3db7a](https://github.com/remvze/timesy/commit/3b3db7a72bac1ef79a9bb374760c942ed884d1b5))
292 | - disable play button on finished ([667308f](https://github.com/remvze/timesy/commit/667308f2359e5938a09b76c2707eb13ccb34f75f))
293 | - implement basic timer ([d3c15a2](https://github.com/remvze/timesy/commit/d3c15a279fa9981e8d4ba7da94faff97b3cc8429))
294 | - make timers persistant ([aaf2eab](https://github.com/remvze/timesy/commit/aaf2eab3e3e71dea78b19fad86a7949a7efd1d1b))
295 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing Guidelines
2 |
3 | Thank you for considering contributing to our project! We welcome your contributions.
4 |
5 | ## How to Contribute
6 |
7 | 1. Fork the repository.
8 | 2. Create a new branch: `git checkout -b feature/your-feature-name`.
9 | 3. Make your changes and commit them: `git commit -m 'feat: add some feature'`.
10 | 4. Push to the branch: `git push origin feature/your-feature-name`.
11 | 5. Submit a pull request. ⚡
12 |
13 | ⚠️ **Notice**: Commit messages should follow [Conventional Commits Specification](https://www.conventionalcommits.org/en/v1.0.0/).
14 |
15 | ## Report Bugs
16 |
17 | To report a bug, please open an issue on GitHub and provide detailed information about the bug, including steps to reproduce it.
18 |
19 | ## Request Features
20 |
21 | To request a new feature, open an issue on GitHub and describe the feature you would like to see added.
22 |
23 | ## License
24 |
25 | By contributing, you agree that your contributions will be licensed under the project's [LICENSE](LICENSE).
26 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
7 |
8 | ## Features
9 |
10 | 1. ⌛ Unlimited number of timers.
11 | 1. ⚡ Run multiple timers simultaneously.
12 | 1. 📝 Name timers for better management.
13 | 1. 🛸 Minimal and distraction-free.
14 | 1. ⭐ Privacy friendly; no data collection.
15 | 1. 🪐 Free, open-source, and self-hostable.
16 |
--------------------------------------------------------------------------------
/assets/banner.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/astro.config.mjs:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'astro/config';
2 |
3 | import react from '@astrojs/react';
4 | import AstroPWA from '@vite-pwa/astro';
5 |
6 | // https://astro.build/config
7 | export default defineConfig({
8 | integrations: [
9 | react(),
10 | AstroPWA({
11 | manifest: {
12 | background_color: '#09090b',
13 | description: 'Distraction-free online timer',
14 | display: 'standalone',
15 | icons: [
16 | ...[72, 128, 144, 152, 192, 256, 512].map(size => ({
17 | sizes: `${size}x${size}`,
18 | src: `/assets/pwa/${size}.png`,
19 | type: 'image/png',
20 | })),
21 | ],
22 | name: 'Timesy',
23 | orientation: 'any',
24 | scope: '/',
25 | short_name: 'Timesy',
26 | start_url: '/',
27 | theme_color: '#09090b',
28 | },
29 | registerType: 'prompt',
30 | workbox: {
31 | globPatterns: ['**/*'],
32 | navigateFallback: '/',
33 | },
34 | }),
35 | ],
36 | });
37 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | timesy:
3 | image: ghcr.io/remvze/timesy
4 | logging:
5 | options:
6 | max-size: 1g
7 | restart: always
8 | ports:
9 | - '8080:8080'
10 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "timesy",
3 | "type": "module",
4 | "version": "1.0.2",
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",
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/check": "^0.5.10",
25 | "@astrojs/react": "^3.3.1",
26 | "@formkit/auto-animate": "0.8.2",
27 | "@types/react": "^18.3.1",
28 | "@types/react-dom": "^18.3.0",
29 | "astro": "^4.7.0",
30 | "focus-trap-react": "10.2.3",
31 | "framer-motion": "11.1.9",
32 | "howler": "2.2.4",
33 | "react": "^18.3.1",
34 | "react-dom": "^18.3.1",
35 | "react-hotkeys-hook": "4.5.1",
36 | "react-icons": "5.1.0",
37 | "typescript": "^5.4.5",
38 | "uuid": "9.0.1",
39 | "uvcanvas": "0.2.1",
40 | "zustand": "4.5.2"
41 | },
42 | "devDependencies": {
43 | "@commitlint/cli": "19.3.0",
44 | "@commitlint/config-conventional": "19.2.2",
45 | "@types/howler": "2.2.11",
46 | "@types/uuid": "9.0.8",
47 | "@typescript-eslint/eslint-plugin": "7.7.1",
48 | "@typescript-eslint/parser": "7.7.1",
49 | "@vite-pwa/astro": "0.4.0",
50 | "astro-eslint-parser": "1.0.1",
51 | "autoprefixer": "10.4.19",
52 | "commitizen": "4.3.0",
53 | "cz-conventional-changelog": "3.3.0",
54 | "eslint": "8.57.0",
55 | "eslint-config-prettier": "9.1.0",
56 | "eslint-import-resolver-alias": "1.1.2",
57 | "eslint-import-resolver-typescript": "3.6.1",
58 | "eslint-plugin-astro": "1.1.0",
59 | "eslint-plugin-import": "2.29.1",
60 | "eslint-plugin-jsx-a11y": "6.8.0",
61 | "eslint-plugin-prettier": "5.1.3",
62 | "eslint-plugin-react": "7.34.1",
63 | "eslint-plugin-react-hooks": "4.6.2",
64 | "eslint-plugin-sort-destructure-keys": "2.0.0",
65 | "eslint-plugin-sort-keys-fix": "1.1.2",
66 | "eslint-plugin-typescript-sort-keys": "3.2.0",
67 | "husky": "9.0.11",
68 | "lint-staged": "15.2.2",
69 | "postcss-html": "1.6.0",
70 | "postcss-nesting": "12.1.2",
71 | "prettier": "3.2.5",
72 | "prettier-plugin-astro": "0.13.0",
73 | "standard-version": "9.5.0",
74 | "stylelint": "16.4.0",
75 | "stylelint-config-html": "1.1.0",
76 | "stylelint-config-recess-order": "5.0.1",
77 | "stylelint-config-standard": "36.0.0",
78 | "stylelint-prettier": "5.0.0",
79 | "vite-plugin-pwa": "0.20.0"
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/postcss.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | autoprefixer: {},
4 | 'postcss-nesting': {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/public/assets/pwa/128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/remvze/timesy/8f0218e04d244f9797c017c3f6271dbf480e1469/public/assets/pwa/128.png
--------------------------------------------------------------------------------
/public/assets/pwa/144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/remvze/timesy/8f0218e04d244f9797c017c3f6271dbf480e1469/public/assets/pwa/144.png
--------------------------------------------------------------------------------
/public/assets/pwa/152.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/remvze/timesy/8f0218e04d244f9797c017c3f6271dbf480e1469/public/assets/pwa/152.png
--------------------------------------------------------------------------------
/public/assets/pwa/192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/remvze/timesy/8f0218e04d244f9797c017c3f6271dbf480e1469/public/assets/pwa/192.png
--------------------------------------------------------------------------------
/public/assets/pwa/256.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/remvze/timesy/8f0218e04d244f9797c017c3f6271dbf480e1469/public/assets/pwa/256.png
--------------------------------------------------------------------------------
/public/assets/pwa/512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/remvze/timesy/8f0218e04d244f9797c017c3f6271dbf480e1469/public/assets/pwa/512.png
--------------------------------------------------------------------------------
/public/assets/pwa/72.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/remvze/timesy/8f0218e04d244f9797c017c3f6271dbf480e1469/public/assets/pwa/72.png
--------------------------------------------------------------------------------
/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/public/fonts/gloock-v6-latin-regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/remvze/timesy/8f0218e04d244f9797c017c3f6271dbf480e1469/public/fonts/gloock-v6-latin-regular.woff2
--------------------------------------------------------------------------------
/public/fonts/inter-v13-latin-500.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/remvze/timesy/8f0218e04d244f9797c017c3f6271dbf480e1469/public/fonts/inter-v13-latin-500.woff2
--------------------------------------------------------------------------------
/public/fonts/inter-v13-latin-regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/remvze/timesy/8f0218e04d244f9797c017c3f6271dbf480e1469/public/fonts/inter-v13-latin-regular.woff2
--------------------------------------------------------------------------------
/public/fonts/space-mono-v13-latin-700.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/remvze/timesy/8f0218e04d244f9797c017c3f6271dbf480e1469/public/fonts/space-mono-v13-latin-700.woff2
--------------------------------------------------------------------------------
/public/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/public/noise.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/remvze/timesy/8f0218e04d244f9797c017c3f6271dbf480e1469/public/noise.png
--------------------------------------------------------------------------------
/public/og.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/remvze/timesy/8f0218e04d244f9797c017c3f6271dbf480e1469/public/og.png
--------------------------------------------------------------------------------
/public/sounds/alarm.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/remvze/timesy/8f0218e04d244f9797c017c3f6271dbf480e1469/public/sounds/alarm.mp3
--------------------------------------------------------------------------------
/src/components/about.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import { Container } from './container';
3 | ---
4 |
5 |
6 |
7 | What is Timesy?
8 |
9 | Timesy is a free, open-source online timer designed to maximize focus and
10 | eliminate distractions. Run multiple timers simultaneously and store them
11 | locally for persistence across sessions. Whether you're a student,
12 | professional, or just someone who needs to stay on track, Timesy can help
13 | you achieve your goals.
14 |
15 |
16 | Enjoy Timesy?
17 |
22 | Support with a donation!
23 |
24 |
25 |
26 |
27 |
28 |
59 |
--------------------------------------------------------------------------------
/src/components/app/app.module.css:
--------------------------------------------------------------------------------
1 | .tabs {
2 | display: flex;
3 | column-gap: 4px;
4 | align-items: center;
5 | margin-bottom: 32px;
6 |
7 | & button {
8 | flex-grow: 1;
9 | height: 44px;
10 | font-size: var(--font-sm);
11 | color: var(--color-foreground-subtle);
12 | cursor: pointer;
13 | background: transparent;
14 | border: none;
15 | border-radius: 8px;
16 |
17 | &.active {
18 | font-weight: 500;
19 | color: var(--color-foreground);
20 | }
21 |
22 | &.countdown.active {
23 | background: linear-gradient(
24 | 90deg,
25 | var(--color-neutral-100),
26 | var(--color-neutral-50)
27 | );
28 | box-shadow: inset 3px 0 0 var(--color-neutral-300);
29 | }
30 |
31 | &.pomodoro.active {
32 | background: linear-gradient(
33 | 90deg,
34 | var(--color-neutral-50),
35 | var(--color-neutral-100)
36 | );
37 | box-shadow: inset -3px 0 0 var(--color-neutral-300);
38 | }
39 | }
40 |
41 | & .divider {
42 | width: 1px;
43 | height: 32px;
44 | background-color: var(--color-neutral-200);
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/components/app/app.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 |
3 | import { Container } from '@/components/container';
4 | import { Form } from '@/components/form';
5 | import { Timers } from '@/components/timers';
6 | import { StoreConsumer } from '@/components/store-consumer';
7 | import { SnackbarProvider } from '@/contexts/snackbar';
8 | import { PomodoroTimer } from '../pomodoro';
9 |
10 | import styles from './app.module.css';
11 | import { cn } from '@/helpers/styles';
12 |
13 | export function App() {
14 | const [tab, setTab] = useState('countdown');
15 | const [timerName, setTimerName] = useState('');
16 |
17 | return (
18 |
19 |
20 |
21 |
22 |
setTab('countdown')}
28 | >
29 | Countdown
30 |
31 |
32 |
setTab('pomodoro')}
38 | >
39 | Pomodoro
40 |
41 |
42 |
43 | {tab === 'countdown' && (
44 | <>
45 |
46 |
47 | >
48 | )}
49 |
50 | {tab === 'pomodoro' && }
51 |
52 |
53 |
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/src/components/app/index.ts:
--------------------------------------------------------------------------------
1 | export { App } from './app';
2 |
--------------------------------------------------------------------------------
/src/components/container/container.module.css:
--------------------------------------------------------------------------------
1 | .container {
2 | width: 90%;
3 | max-width: 420px;
4 | margin: 0 auto;
5 |
6 | &.fullWidth {
7 | width: 100%;
8 | max-width: 500px;
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/src/components/container/container.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from '@/helpers/styles';
2 | import styles from './container.module.css';
3 |
4 | interface ContainerProps {
5 | children: React.ReactNode;
6 | fullWidth?: boolean;
7 | }
8 |
9 | export function Container({ children, fullWidth }: ContainerProps) {
10 | return (
11 |
12 | {children}
13 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/src/components/container/index.ts:
--------------------------------------------------------------------------------
1 | export { Container } from './container';
2 |
--------------------------------------------------------------------------------
/src/components/footer.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import { Container } from './container';
3 | ---
4 |
5 |
6 |
15 |
16 |
17 |
39 |
--------------------------------------------------------------------------------
/src/components/form/field/field.module.css:
--------------------------------------------------------------------------------
1 | .field {
2 | flex-grow: 1;
3 |
4 | & .label {
5 | display: block;
6 | margin-bottom: 8px;
7 | font-size: var(--font-sm);
8 | font-weight: 500;
9 |
10 | & .optional {
11 | font-weight: 400;
12 | color: var(--color-foreground-subtle);
13 | }
14 | }
15 |
16 | & .input {
17 | width: 100%;
18 | min-width: 0;
19 | height: 40px;
20 | padding: 0 16px;
21 | color: var(--color-foreground);
22 | background-color: var(--color-neutral-100);
23 | border: 1px solid var(--color-neutral-200);
24 | border-radius: 8px;
25 | outline: none;
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/components/form/field/field.tsx:
--------------------------------------------------------------------------------
1 | import styles from './field.module.css';
2 |
3 | interface FieldProps {
4 | children?: React.ReactNode;
5 | label: string;
6 | onChange: (value: string | number) => void;
7 | optional?: boolean;
8 | type: 'text' | 'select';
9 | value: string | number;
10 | }
11 |
12 | export function Field({
13 | children,
14 | label,
15 | onChange,
16 | optional,
17 | type,
18 | value,
19 | }: FieldProps) {
20 | return (
21 |
22 |
23 | {label}{' '}
24 | {optional && (optional) }
25 |
26 |
27 | {type === 'text' && (
28 | onChange(e.target.value)}
35 | />
36 | )}
37 |
38 | {type === 'select' && (
39 | onChange(parseInt(e.target.value))}
45 | >
46 | {children}
47 |
48 | )}
49 |
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/src/components/form/field/index.ts:
--------------------------------------------------------------------------------
1 | export { Field } from './field';
2 |
--------------------------------------------------------------------------------
/src/components/form/form.module.css:
--------------------------------------------------------------------------------
1 | .form {
2 | display: flex;
3 | flex-direction: column;
4 | row-gap: 24px;
5 |
6 | & .buttons {
7 | display: flex;
8 | column-gap: 8px;
9 | align-items: center;
10 |
11 | & button {
12 | display: flex;
13 | align-items: center;
14 | justify-content: center;
15 | width: 45px;
16 | height: 45px;
17 | font-weight: 500;
18 | color: var(--color-foreground-subtle);
19 | cursor: pointer;
20 | background-color: var(--color-neutral-200);
21 | border: none;
22 | border-radius: 8px;
23 | outline: none;
24 | box-shadow: inset 0 -3px 0 var(--color-neutral-100);
25 | transition: 0.2s;
26 |
27 | &:not(.primary):hover {
28 | color: var(--color-foreground);
29 | }
30 |
31 | &.primary {
32 | flex-grow: 1;
33 | color: var(--color-neutral-50);
34 | background-color: var(--color-neutral-950);
35 | box-shadow: inset 0 -3px 0 var(--color-neutral-700);
36 | }
37 | }
38 | }
39 | }
40 |
41 | .timeFields {
42 | display: flex;
43 | column-gap: 12px;
44 | align-items: flex-end;
45 | justify-content: space-between;
46 | }
47 |
48 | .autoStart {
49 | user-select: none;
50 | color: var(--color-foreground-subtle);
51 |
52 | & label {
53 | display: flex;
54 | align-items: center;
55 | max-width: max-content;
56 | }
57 |
58 | & input {
59 | width: 14px;
60 | height: 14px;
61 | margin-right: 8px;
62 | accent-color: var(--color-foreground-subtler);
63 | }
64 | }
65 |
66 | .settings {
67 | & h2 {
68 | margin-bottom: 20px;
69 | font-family: var(--font-display);
70 | font-size: var(--font-md);
71 | }
72 |
73 | & .field {
74 | & label {
75 | display: block;
76 | margin-bottom: 4px;
77 | font-size: var(--font-sm);
78 | font-weight: 500;
79 | color: var(--color-foreground);
80 | }
81 |
82 | & input {
83 | display: block;
84 | width: 100%;
85 | min-width: 0;
86 | accent-color: var(--color-foreground-subtler);
87 | }
88 | }
89 |
90 | & .notice {
91 | padding: 12px;
92 | margin-top: 20px;
93 | font-size: var(--font-sm);
94 | line-height: 1.6;
95 | color: var(--color-foreground-subtle);
96 | border: 1px dashed var(--color-neutral-200);
97 | border-radius: 4px;
98 |
99 | & strong {
100 | font-weight: 500;
101 | color: var(--color-foreground);
102 | }
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/src/components/form/form.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useMemo } from 'react';
2 | import { IoMdSettings } from 'react-icons/io';
3 |
4 | import { Modal } from '../modal';
5 | import { Field } from './field';
6 |
7 | import { useTimers } from '@/stores/timers';
8 |
9 | import styles from './form.module.css';
10 | import { useSettings } from '@/stores/settings';
11 |
12 | interface FormProps {
13 | onTimerNameChange: (name: string) => void;
14 | timerName: string;
15 | }
16 |
17 | export function Form({ onTimerNameChange, timerName }: FormProps) {
18 | const [showSettings, setShowSettings] = useState(false);
19 |
20 | const volume = useSettings(state => state.volume);
21 | const setVolume = useSettings(state => state.setVolume);
22 |
23 | const [hours, setHours] = useState(0);
24 | const [minutes, setMinutes] = useState(10);
25 | const [seconds, setSeconds] = useState(0);
26 | const [autoStart, setAutoStart] = useState(false);
27 |
28 | const totalSeconds = useMemo(
29 | () => hours * 60 * 60 + minutes * 60 + seconds,
30 | [hours, minutes, seconds],
31 | );
32 |
33 | const add = useTimers(state => state.add);
34 |
35 | const handleSubmit = (e: React.FormEvent) => {
36 | e.preventDefault();
37 |
38 | if (totalSeconds === 0) return;
39 |
40 | add({
41 | autoStart,
42 | name: timerName,
43 | total: totalSeconds,
44 | });
45 |
46 | onTimerNameChange('');
47 | };
48 |
49 | return (
50 | <>
51 |
127 |
128 | setShowSettings(false)}>
129 |
130 |
Settings
131 |
132 |
133 | Volume
134 | setVolume(+e.target.value)}
141 | />
142 |
143 |
144 |
145 | Notice: Changes to these settings will affect all
146 | timers and are saved automatically.
147 |
148 |
149 |
150 | >
151 | );
152 | }
153 |
--------------------------------------------------------------------------------
/src/components/form/index.ts:
--------------------------------------------------------------------------------
1 | export { Form } from './form';
2 |
--------------------------------------------------------------------------------
/src/components/hero.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import { Velustro } from 'uvcanvas';
3 |
4 | import { Container } from './container';
5 | ---
6 |
7 |
8 |
9 |
14 |
15 |
16 |
17 |
18 |
19 |
Timesy
20 |
A distraction-free online timer.
21 |
22 |
23 |
24 |
130 |
--------------------------------------------------------------------------------
/src/components/modal/index.ts:
--------------------------------------------------------------------------------
1 | export { Modal } from './modal';
2 |
--------------------------------------------------------------------------------
/src/components/modal/modal.module.css:
--------------------------------------------------------------------------------
1 | .overlay {
2 | position: fixed;
3 | inset: 0;
4 | z-index: 20;
5 | background-color: rgb(9 9 11 / 40%);
6 | backdrop-filter: blur(5px);
7 | }
8 |
9 | .modal {
10 | position: fixed;
11 | top: 50%;
12 | left: 50%;
13 | z-index: 20;
14 | width: 100%;
15 | max-height: 100%;
16 | padding: 50px 0;
17 | overflow-y: auto;
18 | pointer-events: none;
19 | transform: translate(-50%, -50%);
20 |
21 | & .content {
22 | position: relative;
23 | width: 90%;
24 | max-width: 500px;
25 | padding: 20px;
26 | padding-top: 40px;
27 | margin: 0 auto;
28 | pointer-events: fill;
29 | background-color: var(--color-neutral-100);
30 | border-radius: 8px;
31 |
32 | & .close {
33 | position: absolute;
34 | top: 10px;
35 | right: 10px;
36 | display: flex;
37 | align-items: center;
38 | justify-content: center;
39 | width: 20px;
40 | height: 20px;
41 | font-size: 16px;
42 | color: var(--color-foreground-subtle);
43 | cursor: pointer;
44 | background-color: transparent;
45 | border: none;
46 | border-radius: 4px;
47 | outline: none;
48 |
49 | &:focus-visible {
50 | outline: 2px solid var(--color-neutral-400);
51 | outline-offset: 2px;
52 | }
53 | }
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/components/modal/modal.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import { AnimatePresence, motion } from 'framer-motion';
3 | import { IoClose } from 'react-icons/io5';
4 | import FocusTrap from 'focus-trap-react';
5 |
6 | import { Portal } from '@/components/portal';
7 |
8 | import styles from './modal.module.css';
9 |
10 | interface ModalProps {
11 | children: React.ReactNode;
12 | lockBody?: boolean;
13 | onClose: () => void;
14 | show: boolean;
15 | }
16 |
17 | export function Modal({
18 | children,
19 | lockBody = true,
20 | onClose,
21 | show,
22 | }: ModalProps) {
23 | const variants = {
24 | modal: {
25 | hidden: {
26 | opacity: 0,
27 | y: 20,
28 | },
29 | show: {
30 | opacity: 1,
31 | y: 0,
32 | },
33 | },
34 | overlay: {
35 | hidden: { opacity: 1 },
36 | show: { opacity: 1 },
37 | },
38 | };
39 |
40 | useEffect(() => {
41 | if (show && lockBody) {
42 | document.body.style.overflow = 'hidden';
43 | } else if (lockBody) {
44 | document.body.style.overflow = 'auto';
45 | }
46 | }, [show, lockBody]);
47 |
48 | useEffect(() => {
49 | function keyListener(e: KeyboardEvent) {
50 | if (show && e.key === 'Escape') {
51 | onClose();
52 | }
53 | }
54 |
55 | document.addEventListener('keydown', keyListener);
56 |
57 | return () => document.removeEventListener('keydown', keyListener);
58 | }, [onClose, show]);
59 |
60 | return (
61 |
62 |
63 | {show && (
64 |
65 |
66 |
75 |
76 |
83 |
84 |
85 |
86 | {children}
87 |
88 |
89 |
90 |
91 | )}
92 |
93 |
94 | );
95 | }
96 |
--------------------------------------------------------------------------------
/src/components/pomodoro/index.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useMemo, useEffect, useRef } from 'react';
2 | import { IoRefresh, IoSettingsOutline } from 'react-icons/io5';
3 |
4 | import { useLocalStorage } from '@/hooks/use-local-storage';
5 | import { useAlarm } from '@/hooks/use-alarm';
6 | import { Modal } from '../modal';
7 |
8 | import styles from './pomodoro.module.css';
9 | import { cn } from '@/helpers/styles';
10 |
11 | export function PomodoroTimer() {
12 | const alarm = useAlarm();
13 |
14 | const [selectedTab, setSelectedTab] = useState('pomodoro');
15 | const [isRunning, setIsRunning] = useState(false);
16 | const [timer, setTimer] = useState(0);
17 | const interval = useRef | null>(null);
18 |
19 | const defaultTimes = useMemo(
20 | () => ({
21 | long: 15 * 60,
22 | pomodoro: 25 * 60,
23 | short: 5 * 60,
24 | }),
25 | [],
26 | );
27 |
28 | const [showSettings, setShowSettings] = useState(false);
29 | const [times, setTimes] = useLocalStorage>(
30 | 'timesy-pomodoro-setting',
31 | defaultTimes,
32 | );
33 |
34 | const [completions, setCompletions] = useState>({
35 | long: 0,
36 | pomodoro: 0,
37 | short: 0,
38 | });
39 |
40 | const tabs = useMemo(
41 | () => [
42 | { id: 'pomodoro', label: 'Pomodoro' },
43 | { id: 'short', label: 'Break' },
44 | { id: 'long', label: 'Long Break' },
45 | ],
46 | [],
47 | );
48 |
49 | useEffect(() => {
50 | if (isRunning) {
51 | if (interval.current) clearInterval(interval.current);
52 |
53 | interval.current = setInterval(() => {
54 | setTimer(prev => prev - 1);
55 | }, 1000);
56 | } else {
57 | if (interval.current) clearInterval(interval.current);
58 | }
59 | }, [isRunning]);
60 |
61 | const completionHandled = useRef(false);
62 |
63 | useEffect(() => {
64 | if (timer <= 0 && isRunning && !completionHandled.current) {
65 | if (interval.current) clearInterval(interval.current);
66 |
67 | alarm();
68 |
69 | setIsRunning(false);
70 | setCompletions(prev => ({
71 | ...prev,
72 | [selectedTab]: prev[selectedTab] + 1,
73 | }));
74 |
75 | completionHandled.current = true;
76 | } else if (timer > 0) {
77 | completionHandled.current = false;
78 | }
79 | }, [timer, selectedTab, isRunning, alarm]);
80 |
81 | useEffect(() => {
82 | const time = times[selectedTab] || 10;
83 |
84 | if (interval.current) clearInterval(interval.current);
85 |
86 | setIsRunning(false);
87 | setTimer(time);
88 | }, [selectedTab, times]);
89 |
90 | const toggleRunning = () => {
91 | if (isRunning) setIsRunning(false);
92 | else if (timer <= 0) {
93 | const time = times[selectedTab] || 10;
94 |
95 | setTimer(time);
96 | setIsRunning(true);
97 | } else setIsRunning(true);
98 | };
99 |
100 | const restart = () => {
101 | if (interval.current) clearInterval(interval.current);
102 |
103 | const time = times[selectedTab] || 10;
104 |
105 | setIsRunning(false);
106 | setTimer(time);
107 | };
108 |
109 | useEffect(() => {
110 | const minutes = Math.floor(timer / 60);
111 | const seconds = timer % 60;
112 |
113 | document.title = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')} // Timesy`;
114 |
115 | return () => {
116 | document.title = 'Timesy: A Distraction-Free Online Timer';
117 | };
118 | }, [timer]);
119 |
120 | return (
121 | <>
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 | {isRunning ? 'Pause' : 'Play'}
130 |
131 |
132 |
133 |
134 | setShowSettings(true)}
137 | >
138 |
139 |
140 |
141 |
142 |
143 |
144 | setShowSettings(false)}>
145 | setShowSettings(false)}
148 | onChange={times => {
149 | setShowSettings(false);
150 | setTimes(times);
151 | }}
152 | />
153 |
154 | >
155 | );
156 | }
157 |
158 | interface Tab {
159 | id: string;
160 | label: string;
161 | }
162 |
163 | interface TabsProps {
164 | onSelect: (id: string) => void;
165 | selectedTab: string;
166 | tabs: Tab[];
167 | }
168 |
169 | export function Tabs({ onSelect, selectedTab, tabs }: TabsProps) {
170 | return (
171 |
172 | {tabs.map(tab => (
173 | onSelect(tab.id)}
177 | >
178 | {tab.label}
179 |
180 | ))}
181 |
182 | );
183 | }
184 |
185 | interface TimerProps {
186 | completed: number;
187 | timer: number;
188 | }
189 |
190 | export function Timer({ completed, timer }: TimerProps) {
191 | const minutes = Math.floor(timer / 60);
192 | const seconds = timer % 60;
193 |
194 | return (
195 |
196 |
{completed} completed
197 | {String(minutes).padStart(2, '0')}:{String(seconds).padStart(2, '0')}
198 |
199 | );
200 | }
201 |
202 | interface SettingsProps {
203 | onCancel: () => void;
204 | onChange: (newTimes: Record) => void;
205 | times: Record;
206 | }
207 |
208 | export function Settings({ onCancel, onChange, times }: SettingsProps) {
209 | const [values, setValues] = useState>(times);
210 |
211 | useEffect(() => {
212 | setValues(times);
213 | }, [times]);
214 |
215 | const handleChange = (id: string) => (value: number | string) => {
216 | setValues(prev => ({
217 | ...prev,
218 | [id]: typeof value === 'number' ? value * 60 : '',
219 | }));
220 | };
221 |
222 | const handleSubmit = (e: React.FormEvent) => {
223 | e.preventDefault();
224 |
225 | const newValues: Record = {};
226 |
227 | Object.keys(values).forEach(name => {
228 | const value = values[name];
229 | newValues[name] = typeof value === 'number' ? value : times[name];
230 | });
231 |
232 | onChange(newValues);
233 | };
234 |
235 | const handleCancel = (e: React.MouseEvent) => {
236 | e.preventDefault();
237 |
238 | onCancel();
239 | };
240 |
241 | return (
242 |
243 |
Change Times
244 |
245 |
274 |
275 | );
276 | }
277 |
278 | interface FieldProps {
279 | id: string;
280 | label: string;
281 | onChange: (value: number | string) => void;
282 | value: number | string;
283 | }
284 |
285 | function Field({ id, label, onChange, value }: FieldProps) {
286 | return (
287 |
288 |
289 | {label} (minutes)
290 |
291 | {
299 | onChange(e.target.value === '' ? '' : Number(e.target.value));
300 | }}
301 | />
302 |
303 | );
304 | }
305 |
--------------------------------------------------------------------------------
/src/components/pomodoro/pomodoro.module.css:
--------------------------------------------------------------------------------
1 | .tabs {
2 | position: relative;
3 | top: 1px;
4 | z-index: 2;
5 | display: flex;
6 | column-gap: 8px;
7 | align-items: center;
8 | width: 100%;
9 | height: 45px;
10 |
11 | & button {
12 | display: flex;
13 | flex-grow: 1;
14 | align-items: center;
15 | justify-content: center;
16 | height: 100%;
17 | font-size: var(--font-sm);
18 | font-weight: 500;
19 | color: var(--color-foreground-subtle);
20 | cursor: pointer;
21 | background-color: var(--color-neutral-50);
22 | border: 1px solid var(--color-neutral-100);
23 | border-bottom-color: var(--color-neutral-200);
24 | border-radius: 8px 8px 0 0;
25 | transition: 0.2s;
26 |
27 | &:hover {
28 | border-color: var(--color-neutral-200);
29 | border-bottom-color: var(--color-neutral-200);
30 | }
31 |
32 | &.selected {
33 | color: var(--color-foreground);
34 | background: linear-gradient(
35 | var(--color-neutral-100),
36 | var(--color-neutral-50)
37 | );
38 | border-color: var(--color-neutral-200);
39 | border-bottom-color: var(--color-neutral-50);
40 | }
41 | }
42 | }
43 |
44 | .timer {
45 | position: relative;
46 | display: flex;
47 | align-items: center;
48 | justify-content: center;
49 | width: 100%;
50 | height: 160px;
51 | font-family: var(--font-mono);
52 | font-size: var(--font-2xlg);
53 | font-weight: 700;
54 | background-color: var(--color-neutral-50);
55 | border: 1px solid var(--color-neutral-200);
56 | border-radius: 0 0 8px 8px;
57 |
58 | & p {
59 | position: absolute;
60 | top: 8px;
61 | left: 50%;
62 | padding: 4px 16px;
63 | font-family: var(--font-body);
64 | font-size: var(--font-2xsm);
65 | font-weight: 400;
66 | color: var(--color-foreground-subtle);
67 | background-color: var(--color-neutral-100);
68 | border: 1px solid var(--color-neutral-200);
69 | border-radius: 50px;
70 | transform: translateX(-50%);
71 | }
72 | }
73 |
74 | .buttons {
75 | display: flex;
76 | column-gap: 4px;
77 | align-items: center;
78 | margin-top: 12px;
79 |
80 | & button {
81 | display: flex;
82 | align-items: center;
83 | justify-content: center;
84 | height: 40px;
85 | font-weight: 500;
86 | cursor: pointer;
87 | border: none;
88 | border-radius: 8px;
89 |
90 | &.play {
91 | flex-grow: 1;
92 | color: var(--color-neutral-50);
93 | background-color: var(--color-neutral-950);
94 | box-shadow: inset 0 -3px 0 var(--color-neutral-700);
95 | }
96 |
97 | &.reset,
98 | &.setting {
99 | width: 40px;
100 | color: var(--color-foreground-subtle);
101 | background-color: var(--color-neutral-200);
102 | box-shadow: inset 0 -3px 0 var(--color-neutral-100);
103 | }
104 | }
105 | }
106 |
107 | .setting {
108 | & .title {
109 | margin-bottom: 16px;
110 | font-family: var(--font-display);
111 | font-size: var(--font-lg);
112 | }
113 |
114 | & .form {
115 | display: flex;
116 | flex-direction: column;
117 |
118 | & .field {
119 | display: flex;
120 | flex-direction: column;
121 | row-gap: 8px;
122 | margin-bottom: 16px;
123 |
124 | & .label {
125 | font-size: var(--font-sm);
126 | color: var(--color-foreground);
127 |
128 | & span {
129 | color: var(--color-foreground-subtle);
130 | }
131 | }
132 |
133 | & .input {
134 | display: block;
135 | height: 44px;
136 | padding: 0 8px;
137 | color: var(--color-foreground);
138 | background-color: var(--color-neutral-50);
139 | border: 1px solid var(--color-neutral-200);
140 | border-radius: 8px;
141 | outline: none;
142 |
143 | &:focus-visible {
144 | outline: 2px solid var(--color-neutral-400);
145 | outline-offset: 2px;
146 | }
147 | }
148 | }
149 |
150 | & .buttons {
151 | display: flex;
152 | column-gap: 8px;
153 | align-items: center;
154 | justify-content: flex-end;
155 |
156 | & button {
157 | display: flex;
158 | align-items: center;
159 | justify-content: center;
160 | height: 36px;
161 | padding: 0 16px;
162 | font-size: var(--font-sm);
163 | font-weight: 500;
164 | color: var(--color-foreground);
165 | cursor: pointer;
166 | background-color: var(--color-neutral-200);
167 | border: none;
168 | border-radius: 8px;
169 | outline: none;
170 |
171 | &:focus-visible {
172 | outline: 2px solid var(--color-neutral-400);
173 | outline-offset: 2px;
174 | }
175 |
176 | &.primary {
177 | color: var(--color-neutral-100);
178 | background-color: var(--color-neutral-950);
179 | }
180 | }
181 | }
182 | }
183 | }
184 |
--------------------------------------------------------------------------------
/src/components/portal/index.ts:
--------------------------------------------------------------------------------
1 | export { Portal } from './portal';
2 |
--------------------------------------------------------------------------------
/src/components/portal/portal.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import { createPortal } from 'react-dom';
3 |
4 | interface PortalProps {
5 | children: React.ReactNode;
6 | }
7 |
8 | export function Portal({ children }: PortalProps) {
9 | const [isClientSide, setIsClientSide] = useState(false);
10 |
11 | useEffect(() => setIsClientSide(true), []);
12 |
13 | return isClientSide ? createPortal(children, document.body) : null;
14 | }
15 |
--------------------------------------------------------------------------------
/src/components/reload/index.ts:
--------------------------------------------------------------------------------
1 | export { Reload } from './reload';
2 |
--------------------------------------------------------------------------------
/src/components/reload/reload-modal.tsx:
--------------------------------------------------------------------------------
1 | import { useRegisterSW } from 'virtual:pwa-register/react'; // eslint-disable-line
2 |
3 | import { Modal } from '@/components/modal';
4 |
5 | import styles from './reload.module.css';
6 |
7 | export function ReloadModal() {
8 | const {
9 | needRefresh: [needRefresh, setNeedRefresh],
10 | updateServiceWorker,
11 | } = useRegisterSW();
12 |
13 | const close = () => {
14 | setNeedRefresh(false);
15 | };
16 |
17 | return (
18 |
19 | New Content
20 |
21 | New content available, click on reload button to update.
22 |
23 |
24 |
25 | Close
26 |
27 | updateServiceWorker(true)}
30 | >
31 | Reload
32 |
33 |
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/src/components/reload/reload.module.css:
--------------------------------------------------------------------------------
1 | .title {
2 | font-family: var(--font-display);
3 | font-size: var(--font-lg);
4 | }
5 |
6 | .desc {
7 | margin-top: 8px;
8 | color: var(--color-foreground-subtle);
9 | }
10 |
11 | .buttons {
12 | display: flex;
13 | column-gap: 8px;
14 | align-items: center;
15 | justify-content: flex-end;
16 | margin-top: 16px;
17 |
18 | & button {
19 | display: flex;
20 | align-items: center;
21 | justify-content: center;
22 | height: 40px;
23 | padding: 0 12px;
24 | font-size: var(--font-sm);
25 | font-weight: 500;
26 | color: var(--color-foreground-subtle);
27 | background-color: var(--color-neutral-200);
28 | border: none;
29 | border-radius: 8px;
30 |
31 | &.primary {
32 | color: var(--color-neutral-50);
33 | background-color: var(--color-neutral-950);
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/components/reload/reload.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 |
3 | import { ReloadModal } from './reload-modal';
4 |
5 | export function Reload() {
6 | const [isBrowser, setIsBrowser] = useState(false);
7 |
8 | useEffect(() => setIsBrowser(true), []);
9 |
10 | return isBrowser ? : null;
11 | }
12 |
--------------------------------------------------------------------------------
/src/components/snackbar/index.ts:
--------------------------------------------------------------------------------
1 | export { Snackbar } from './snackbar';
2 |
--------------------------------------------------------------------------------
/src/components/snackbar/snackbar.module.css:
--------------------------------------------------------------------------------
1 | .wrapper {
2 | position: fixed;
3 | bottom: 20px;
4 | left: 0;
5 | z-index: 100;
6 | width: 100%;
7 | pointer-events: none;
8 |
9 | & .snackbar {
10 | width: max-content;
11 | max-width: 90%;
12 | padding: 12px 16px;
13 | margin: 0 auto;
14 | font-size: var(--font-sm);
15 | pointer-events: fill;
16 | background-color: var(--color-neutral-100);
17 | border: 1px solid var(--color-neutral-200);
18 | border-radius: 8px;
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/components/snackbar/snackbar.tsx:
--------------------------------------------------------------------------------
1 | import { motion } from 'framer-motion';
2 |
3 | import styles from './snackbar.module.css';
4 |
5 | interface SnackbarProps {
6 | message: string;
7 | }
8 |
9 | export function Snackbar({ message }: SnackbarProps) {
10 | const variants = {
11 | hidden: { opacity: 0, y: 20 },
12 | show: { opacity: 1, y: 0 },
13 | };
14 |
15 | return (
16 |
17 |
24 | {message}
25 |
26 |
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/src/components/store-consumer/index.ts:
--------------------------------------------------------------------------------
1 | export { StoreConsumer } from './store-consumer';
2 |
--------------------------------------------------------------------------------
/src/components/store-consumer/store-consumer.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 |
3 | import { useTimers } from '@/stores/timers';
4 | import { useSettings } from '@/stores/settings';
5 |
6 | interface StoreConsumerProps {
7 | children: React.ReactNode;
8 | }
9 |
10 | export function StoreConsumer({ children }: StoreConsumerProps) {
11 | useEffect(() => {
12 | useTimers.persist.rehydrate();
13 | useSettings.persist.rehydrate();
14 | }, []);
15 |
16 | return <>{children}>;
17 | }
18 |
--------------------------------------------------------------------------------
/src/components/timers/index.ts:
--------------------------------------------------------------------------------
1 | export { Timers } from './timers';
2 |
--------------------------------------------------------------------------------
/src/components/timers/notice/index.ts:
--------------------------------------------------------------------------------
1 | export { Notice } from './notice';
2 |
--------------------------------------------------------------------------------
/src/components/timers/notice/notice.module.css:
--------------------------------------------------------------------------------
1 | .notice {
2 | padding: 16px;
3 | margin-top: 16px;
4 | font-size: var(--font-sm);
5 | line-height: 1.65;
6 | color: var(--color-foreground-subtle);
7 | text-align: center;
8 | border: 1px dashed var(--color-neutral-200);
9 | border-radius: 8px;
10 | }
11 |
--------------------------------------------------------------------------------
/src/components/timers/notice/notice.tsx:
--------------------------------------------------------------------------------
1 | import styles from './notice.module.css';
2 |
3 | export function Notice() {
4 | return (
5 |
6 | Please do not close this tab while timers are running, otherwise all
7 | timers will be stopped.
8 |
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/src/components/timers/timer/index.ts:
--------------------------------------------------------------------------------
1 | export { Timer } from './timer';
2 |
--------------------------------------------------------------------------------
/src/components/timers/timer/timer.module.css:
--------------------------------------------------------------------------------
1 | .timer {
2 | position: relative;
3 | padding: 8px;
4 | overflow: hidden;
5 | background-color: var(--color-neutral-100);
6 | border: 1px solid var(--color-neutral-200);
7 | border-radius: 8px;
8 |
9 | &:not(:last-of-type) {
10 | margin-bottom: 24px;
11 | }
12 |
13 | & .header {
14 | position: relative;
15 | top: -8px;
16 | width: 100%;
17 |
18 | & .bar {
19 | height: 2px;
20 | margin: 0 -8px;
21 | background-color: var(--color-neutral-200);
22 |
23 | & .completed {
24 | height: 100%;
25 | background-color: var(--color-neutral-500);
26 | transition: 0.2s;
27 | }
28 | }
29 | }
30 |
31 | & .footer {
32 | display: flex;
33 | column-gap: 4px;
34 | align-items: center;
35 |
36 | & .control {
37 | display: flex;
38 | flex-grow: 1;
39 | column-gap: 4px;
40 | align-items: center;
41 | height: 40px;
42 | padding: 4px;
43 | background-color: var(--color-neutral-50);
44 | border: 1px solid var(--color-neutral-200);
45 | border-radius: 4px;
46 |
47 | & .input {
48 | flex-grow: 1;
49 | width: 100%;
50 | min-width: 0;
51 | height: 100%;
52 | padding: 0 8px;
53 | color: var(--color-foreground-subtle);
54 | background-color: transparent;
55 | border: none;
56 | border-radius: 4px;
57 | outline: none;
58 |
59 | &.finished {
60 | text-decoration: line-through;
61 | }
62 | }
63 |
64 | & .button {
65 | display: flex;
66 | align-items: center;
67 | justify-content: center;
68 | height: 100%;
69 | aspect-ratio: 1 / 1;
70 | color: var(--color-foreground);
71 | cursor: pointer;
72 | background-color: var(--color-neutral-200);
73 | border: 1px solid var(--color-neutral-300);
74 | border-radius: 2px;
75 | outline: none;
76 | transition: 0.2s;
77 |
78 | &.reset {
79 | background-color: var(--color-neutral-100);
80 | border: none;
81 | }
82 |
83 | &:disabled,
84 | &.disabled {
85 | cursor: not-allowed;
86 | opacity: 0.6;
87 | }
88 | }
89 | }
90 |
91 | & .delete {
92 | display: flex;
93 | align-items: center;
94 | justify-content: center;
95 | width: 38px;
96 | height: 38px;
97 | color: #f43f5e;
98 | cursor: pointer;
99 | background-color: rgb(244 63 94 / 10%);
100 | border: none;
101 | border-radius: 4px;
102 | outline: none;
103 | transition: 0.2s;
104 |
105 | &.disabled {
106 | cursor: not-allowed;
107 | opacity: 0.6;
108 | }
109 | }
110 | }
111 |
112 | & .left {
113 | display: flex;
114 | align-items: center;
115 | justify-content: center;
116 | height: 120px;
117 | font-family: var(--font-mono);
118 | font-size: var(--font-2xlg);
119 | font-weight: 700;
120 | cursor: pointer;
121 |
122 | & span {
123 | color: var(--color-foreground-subtle);
124 | }
125 | }
126 | }
127 |
128 | .shortcut {
129 | display: flex;
130 | column-gap: 8px;
131 | align-items: center;
132 | padding: 8px;
133 | margin-top: 8px;
134 | font-size: var(--font-xsm);
135 | color: var(--color-foreground-subtle);
136 | border: 1px dashed var(--color-neutral-300);
137 | border-radius: 4px;
138 |
139 | & .key {
140 | padding: 4px 8px;
141 | font-size: var(--font-2xsm);
142 | font-weight: 500;
143 | color: var(--color-foreground);
144 | background-color: var(--color-neutral-50);
145 | border: 1px solid var(--color-neutral-200);
146 | border-radius: 4px;
147 | }
148 | }
149 |
--------------------------------------------------------------------------------
/src/components/timers/timer/timer.tsx:
--------------------------------------------------------------------------------
1 | import { useRef, useMemo, useState, useEffect, useCallback } from 'react';
2 | import { IoPlay, IoPause, IoRefresh, IoTrashOutline } from 'react-icons/io5';
3 | import { useHotkeys } from 'react-hotkeys-hook';
4 |
5 | import { Toolbar } from './toolbar';
6 |
7 | import { useTimers } from '@/stores/timers';
8 | import { useAlarm } from '@/hooks/use-alarm';
9 | import { useSnackbar } from '@/contexts/snackbar';
10 | import { padNumber } from '@/helpers/number';
11 | import { cn } from '@/helpers/styles';
12 |
13 | import styles from './timer.module.css';
14 |
15 | interface TimerProps {
16 | id: string;
17 | index: number;
18 | }
19 |
20 | export function Timer({ id, index }: TimerProps) {
21 | const intervalRef = useRef | null>(null);
22 | const lastActiveTimeRef = useRef(null);
23 | const lastStateRef = useRef<{ spent: number; total: number } | null>(null);
24 |
25 | const [isRunning, setIsRunning] = useState(false);
26 |
27 | const { autoStart, first, last, name, spent, total } = useTimers(state =>
28 | state.getTimer(id),
29 | );
30 | const tick = useTimers(state => state.tick);
31 | const rename = useTimers(state => state.rename);
32 | const reset = useTimers(state => state.reset);
33 | const deleteTimer = useTimers(state => state.delete);
34 | const removeAutoStart = useTimers(state => state.removeAutoStart);
35 |
36 | const left = useMemo(() => total - spent, [total, spent]);
37 |
38 | const hours = useMemo(() => Math.floor(left / 3600), [left]);
39 | const minutes = useMemo(() => Math.floor((left % 3600) / 60), [left]);
40 | const seconds = useMemo(() => left % 60, [left]);
41 |
42 | const [isReversed, setIsReversed] = useState(false);
43 |
44 | const spentHours = useMemo(() => Math.floor(spent / 3600), [spent]);
45 | const spentMinutes = useMemo(() => Math.floor((spent % 3600) / 60), [spent]);
46 | const spentSeconds = useMemo(() => spent % 60, [spent]);
47 |
48 | const playAlarm = useAlarm();
49 |
50 | const showSnackbar = useSnackbar();
51 |
52 | const handleStart = useCallback(() => {
53 | if (left > 0) setIsRunning(true);
54 | }, [left]);
55 |
56 | const handlePause = () => setIsRunning(false);
57 |
58 | const handleToggle = () => {
59 | if (isRunning) handlePause();
60 | else handleStart();
61 | };
62 |
63 | const handleReset = () => {
64 | if (spent === 0) return;
65 |
66 | if (isRunning) return showSnackbar('Please first stop the timer.');
67 |
68 | setIsRunning(false);
69 | reset(id);
70 | };
71 |
72 | const handleDelete = () => {
73 | if (isRunning) return showSnackbar('Please first stop the timer.');
74 |
75 | deleteTimer(id);
76 | };
77 |
78 | useEffect(() => {
79 | if (isRunning) {
80 | if (intervalRef.current) clearInterval(intervalRef.current);
81 |
82 | intervalRef.current = setInterval(() => tick(id), 1000);
83 | }
84 |
85 | return () => {
86 | if (intervalRef.current) clearInterval(intervalRef.current);
87 | };
88 | }, [isRunning, tick, id]);
89 |
90 | useEffect(() => {
91 | if (left === 0 && isRunning) {
92 | setIsRunning(false);
93 | playAlarm();
94 |
95 | if (intervalRef.current) clearInterval(intervalRef.current);
96 | }
97 | }, [left, isRunning, playAlarm]);
98 |
99 | useEffect(() => {
100 | const handleBlur = () => {
101 | if (isRunning) {
102 | lastActiveTimeRef.current = Date.now();
103 | lastStateRef.current = { spent, total };
104 | }
105 | };
106 |
107 | const handleFocus = () => {
108 | if (isRunning && lastActiveTimeRef.current && lastStateRef.current) {
109 | const elapsed = Math.floor(
110 | (Date.now() - lastActiveTimeRef.current) / 1000,
111 | );
112 | const previousLeft =
113 | lastStateRef.current.total - lastStateRef.current.spent;
114 | const currentLeft = left;
115 | const correctedLeft = previousLeft - elapsed;
116 |
117 | if (correctedLeft < currentLeft) {
118 | tick(id, currentLeft - correctedLeft);
119 | }
120 |
121 | lastActiveTimeRef.current = null;
122 | lastStateRef.current = null;
123 | }
124 | };
125 |
126 | window.addEventListener('blur', handleBlur);
127 | window.addEventListener('focus', handleFocus);
128 |
129 | return () => {
130 | window.removeEventListener('blur', handleBlur);
131 | window.removeEventListener('focus', handleFocus);
132 | };
133 | }, [isRunning, tick, id, spent, total, left]);
134 |
135 | useEffect(() => {
136 | if (autoStart) {
137 | handleStart();
138 | removeAutoStart(id);
139 | }
140 | }, [autoStart, handleStart, removeAutoStart, id]);
141 |
142 | useHotkeys(`shift+${index + 1}`, () => handleToggle(), {
143 | enabled: index < 9,
144 | });
145 |
146 | useHotkeys(`shift+alt+${index + 1}`, () => handleReset(), {
147 | enabled: index < 9,
148 | });
149 |
150 | return (
151 |
152 |
160 |
161 |
162 |
163 |
setIsReversed(prev => !prev)}
167 | onKeyDown={() => setIsReversed(prev => !prev)}
168 | >
169 | {!isReversed ? (
170 | <>
171 | {padNumber(hours)}
172 | :
173 | {padNumber(minutes)}
174 | :
175 | {padNumber(seconds)}
176 | >
177 | ) : (
178 | <>
179 | -
180 | {padNumber(spentHours)}
181 | :
182 | {padNumber(spentMinutes)}
183 | :
184 | {padNumber(spentSeconds)}
185 | >
186 | )}
187 |
188 |
189 |
228 |
229 | {index < 9 && (
230 |
231 | Shift
232 | +
233 | {index + 1}
234 | to toggle play.
235 |
236 | )}
237 |
238 | );
239 | }
240 |
--------------------------------------------------------------------------------
/src/components/timers/timer/toolbar/index.ts:
--------------------------------------------------------------------------------
1 | export { Toolbar } from './toolbar';
2 |
--------------------------------------------------------------------------------
/src/components/timers/timer/toolbar/toolbar.module.css:
--------------------------------------------------------------------------------
1 | .toolbar {
2 | position: absolute;
3 | top: 12px;
4 | right: 12px;
5 | display: flex;
6 | column-gap: 4px;
7 | align-items: center;
8 | height: 30px;
9 | padding: 4px;
10 | background-color: var(--color-neutral-50);
11 | border: 1px solid var(--color-neutral-200);
12 | border-radius: 4px;
13 |
14 | & button {
15 | display: flex;
16 | align-items: center;
17 | justify-content: center;
18 | height: 100%;
19 | aspect-ratio: 1 / 1;
20 | font-size: var(--font-xsm);
21 | color: var(--color-foreground-subtle);
22 | cursor: pointer;
23 | background-color: transparent;
24 | border: none;
25 | transition: 0.2s;
26 |
27 | &:disabled {
28 | cursor: not-allowed;
29 | opacity: 0.2;
30 | }
31 |
32 | &:not(:disabled):hover {
33 | color: var(--color-foreground);
34 | background-color: var(--color-neutral-100);
35 | }
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/components/timers/timer/toolbar/toolbar.tsx:
--------------------------------------------------------------------------------
1 | import { IoIosArrowDown, IoIosArrowUp } from 'react-icons/io';
2 |
3 | import { useTimers } from '@/stores/timers';
4 |
5 | import styles from './toolbar.module.css';
6 |
7 | interface ToolbarProps {
8 | first: boolean;
9 | id: string;
10 | last: boolean;
11 | }
12 |
13 | export function Toolbar({ first, id, last }: ToolbarProps) {
14 | const moveUp = useTimers(state => state.moveUp);
15 | const moveDown = useTimers(state => state.moveDown);
16 |
17 | return (
18 |
19 | {
22 | e.stopPropagation();
23 | moveUp(id);
24 | }}
25 | >
26 |
27 |
28 | {
31 | e.stopPropagation();
32 | moveDown(id);
33 | }}
34 | >
35 |
36 |
37 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/src/components/timers/timers.module.css:
--------------------------------------------------------------------------------
1 | .timers {
2 | margin-top: 48px;
3 |
4 | & > header {
5 | display: flex;
6 | column-gap: 12px;
7 | align-items: center;
8 | margin-bottom: 16px;
9 |
10 | & .title {
11 | font-family: var(--font-display);
12 | font-size: var(--font-lg);
13 | line-height: 1;
14 | }
15 |
16 | & .line {
17 | flex-grow: 1;
18 | height: 0;
19 | border-top: 1px dashed var(--color-neutral-200);
20 | }
21 |
22 | & .spent {
23 | font-size: var(--font-sm);
24 | color: var(--color-foreground-subtle);
25 | }
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/components/timers/timers.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react';
2 | import { useAutoAnimate } from '@formkit/auto-animate/react';
3 |
4 | import { Timer } from './timer';
5 | import { Notice } from './notice';
6 |
7 | import { useTimers } from '@/stores/timers';
8 |
9 | import styles from './timers.module.css';
10 |
11 | export function Timers() {
12 | const [animationParent] = useAutoAnimate();
13 | const [animationList] = useAutoAnimate();
14 |
15 | const timers = useTimers(state => state.timers);
16 | const spent = useTimers(state => state.spent());
17 | const total = useTimers(state => state.total());
18 |
19 | const spentMinutes = useMemo(() => Math.floor(spent / 60), [spent]);
20 | const totalMinutes = useMemo(() => Math.floor(total / 60), [total]);
21 |
22 | return (
23 |
24 | {timers.length > 0 ? (
25 |
26 |
36 |
37 | {timers.map((timer, index) => (
38 |
39 | ))}
40 |
41 |
42 |
43 | ) : null}
44 |
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/src/contexts/snackbar.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | createContext,
3 | useState,
4 | useCallback,
5 | useRef,
6 | useContext,
7 | } from 'react';
8 | import { AnimatePresence } from 'framer-motion';
9 |
10 | import { Snackbar } from '@/components/snackbar';
11 |
12 | export const SnackbarContext = createContext<
13 | (message: string, duration?: number) => void
14 | >(() => {});
15 |
16 | export const useSnackbar = () => useContext(SnackbarContext);
17 |
18 | interface SnackbarProviderProps {
19 | children: React.ReactNode;
20 | }
21 |
22 | export function SnackbarProvider({ children }: SnackbarProviderProps) {
23 | const [message, setMessage] = useState('');
24 | const [isVisible, setIsVisible] = useState(false);
25 | const timeout = useRef | null>(null);
26 |
27 | const show = useCallback((message: string, duration = 5000) => {
28 | setMessage(message);
29 | setIsVisible(true);
30 |
31 | if (timeout.current) clearTimeout(timeout.current);
32 |
33 | timeout.current = setTimeout(() => {
34 | setMessage('');
35 | setIsVisible(false);
36 | }, duration);
37 | }, []);
38 |
39 | return (
40 |
41 | {children}
42 |
43 |
44 | {isVisible && }
45 |
46 |
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/src/env.d.ts:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line
2 | ///
3 | ///
4 |
--------------------------------------------------------------------------------
/src/helpers/number.ts:
--------------------------------------------------------------------------------
1 | export function padNumber(number: number, maxLength: number = 2): string {
2 | return number.toString().padStart(maxLength, '0');
3 | }
4 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/src/hooks/use-alarm.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect } from 'react';
2 |
3 | import { useSound } from './use-sound';
4 | import { useAlarmStore } from '@/stores/alarm';
5 | import { useSettings } from '@/stores/settings';
6 |
7 | export function useAlarm() {
8 | const { play: playSound, setVolume } = useSound('/sounds/alarm.mp3', 1);
9 | const isPlaying = useAlarmStore(state => state.isPlaying);
10 | const play = useAlarmStore(state => state.play);
11 | const stop = useAlarmStore(state => state.stop);
12 |
13 | const volume = useSettings(state => state.volume);
14 |
15 | useEffect(() => {
16 | setVolume(volume);
17 | }, [volume, setVolume]);
18 |
19 | const playAlarm = useCallback(() => {
20 | if (!isPlaying) {
21 | playSound(stop);
22 | play();
23 | }
24 | }, [isPlaying, playSound, play, stop]);
25 |
26 | return playAlarm;
27 | }
28 |
--------------------------------------------------------------------------------
/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, fallback]);
31 |
32 | useEffect(() => {
33 | localStorage.setItem(key, JSON.stringify(value));
34 | }, [value, key]);
35 |
36 | return [value, setValue];
37 | }
38 |
--------------------------------------------------------------------------------
/src/hooks/use-sound.ts:
--------------------------------------------------------------------------------
1 | import { useMemo, useEffect, useCallback } from 'react';
2 | import { Howl } from 'howler';
3 |
4 | import { useSSR } from './use-ssr';
5 |
6 | export function useSound(src: string, volume: number = 1) {
7 | const { isBrowser } = useSSR();
8 |
9 | const sound = useMemo(() => {
10 | let sound: Howl | null = null;
11 |
12 | if (isBrowser) {
13 | sound = new Howl({
14 | html5: true,
15 | src: src,
16 | });
17 | }
18 |
19 | return sound;
20 | }, [src, isBrowser]);
21 |
22 | useEffect(() => {
23 | if (sound) sound.volume(typeof volume === 'number' ? volume : 1);
24 | }, [sound, volume]);
25 |
26 | const play = useCallback(
27 | (cb?: () => void) => {
28 | if (sound) {
29 | if (!sound.playing()) {
30 | sound.play();
31 |
32 | if (typeof cb === 'function') sound.once('end', cb);
33 | }
34 | }
35 | },
36 | [sound],
37 | );
38 |
39 | const stop = useCallback(() => {
40 | if (sound) sound.stop();
41 | }, [sound]);
42 |
43 | const pause = useCallback(() => {
44 | if (sound) sound.pause();
45 | }, [sound]);
46 |
47 | const setVolume = useCallback(
48 | (volume: number) => {
49 | if (sound) sound.volume(volume);
50 | },
51 | [sound],
52 | );
53 |
54 | const control = useMemo(
55 | () => ({ pause, play, setVolume, stop }),
56 | [play, stop, pause, setVolume],
57 | );
58 |
59 | return control;
60 | }
61 |
--------------------------------------------------------------------------------
/src/hooks/use-ssr.ts:
--------------------------------------------------------------------------------
1 | export function useSSR() {
2 | const isDOM =
3 | typeof window !== 'undefined' &&
4 | window.document &&
5 | window.document.documentElement;
6 |
7 | return {
8 | isBrowser: isDOM,
9 | isServer: !isDOM,
10 | };
11 | }
12 |
--------------------------------------------------------------------------------
/src/layouts/layout.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import { pwaInfo } from 'virtual:pwa-info'; // eslint-disable-line
3 |
4 | import { Reload } from '@/components/reload';
5 |
6 | import '@/styles/global.css';
7 |
8 | interface Props {
9 | description?: string;
10 | title?: string;
11 | }
12 |
13 | const title = Astro.props.title || 'Timesy: A Distraction-Free Online Timer';
14 | const description =
15 | Astro.props.description || `A Distraction-free Online Timer.`;
16 | ---
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | {title}
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | {pwaInfo && }
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
--------------------------------------------------------------------------------
/src/pages/index.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import Layout from '@/layouts/layout.astro';
3 |
4 | import Hero from '@/components/hero.astro';
5 | import About from '@/components/about.astro';
6 | import Footer from '@/components/footer.astro';
7 |
8 | import { App } from '@/components/app';
9 | ---
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/src/stores/alarm.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand';
2 |
3 | interface AlarmStore {
4 | isPlaying: boolean;
5 | play: () => void;
6 | stop: () => void;
7 | }
8 |
9 | export const useAlarmStore = create()(set => ({
10 | isPlaying: false,
11 |
12 | play() {
13 | set({ isPlaying: true });
14 | },
15 |
16 | stop() {
17 | set({ isPlaying: false });
18 | },
19 | }));
20 |
--------------------------------------------------------------------------------
/src/stores/settings.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand';
2 | import { createJSONStorage, persist } from 'zustand/middleware';
3 |
4 | interface State {
5 | volume: number;
6 | }
7 |
8 | interface Actions {
9 | setVolume: (volume: number) => void;
10 | }
11 |
12 | export const useSettings = create()(
13 | persist(
14 | set => ({
15 | setVolume(volume) {
16 | set({ volume });
17 | },
18 |
19 | volume: 0.5,
20 | }),
21 | {
22 | name: 'timesy-settings',
23 | partialize: state => ({
24 | volume: state.volume,
25 | }),
26 | skipHydration: true,
27 | storage: createJSONStorage(() => localStorage),
28 | version: 0,
29 | },
30 | ),
31 | );
32 |
--------------------------------------------------------------------------------
/src/stores/timers.ts:
--------------------------------------------------------------------------------
1 | import { v4 as uuid } from 'uuid';
2 | import { create } from 'zustand';
3 | import { createJSONStorage, persist } from 'zustand/middleware';
4 |
5 | interface Timer {
6 | autoStart?: boolean;
7 | id: string;
8 | name: string;
9 | spent: number;
10 | total: number;
11 | }
12 |
13 | interface State {
14 | spent: () => number;
15 | timers: Array;
16 | total: () => number;
17 | }
18 |
19 | interface Actions {
20 | add: (timer: { autoStart: boolean; name: string; total: number }) => void;
21 | delete: (id: string) => void;
22 | getTimer: (id: string) => Timer & { first: boolean; last: boolean };
23 | moveDown: (id: string) => void;
24 | moveUp: (id: string) => void;
25 | removeAutoStart: (id: string) => void;
26 | rename: (id: string, newName: string) => void;
27 | reset: (id: string) => void;
28 | tick: (id: string, amount?: number) => void;
29 | }
30 |
31 | export const useTimers = create()(
32 | persist(
33 | (set, get) => ({
34 | add({ autoStart, name, total }) {
35 | set(state => ({
36 | timers: [
37 | {
38 | autoStart: !!autoStart,
39 | id: uuid(),
40 | name,
41 | spent: 0,
42 | total,
43 | },
44 | ...state.timers,
45 | ],
46 | }));
47 | },
48 |
49 | delete(id) {
50 | set(state => ({
51 | timers: state.timers.filter(timer => timer.id !== id),
52 | }));
53 | },
54 |
55 | getTimer(id) {
56 | const timers = get().timers;
57 | const timer = timers.filter(timer => timer.id === id)[0];
58 | const index = timers.indexOf(timer);
59 |
60 | return {
61 | ...timer,
62 | first: index === 0,
63 | last: index === timers.length - 1,
64 | };
65 | },
66 |
67 | moveDown(id) {
68 | set(state => {
69 | const index = state.timers.findIndex(timer => timer.id === id);
70 |
71 | if (index < state.timers.length - 1) {
72 | const newTimers = [...state.timers];
73 |
74 | [newTimers[index + 1], newTimers[index]] = [
75 | newTimers[index],
76 | newTimers[index + 1],
77 | ];
78 |
79 | return { timers: newTimers };
80 | }
81 |
82 | return state;
83 | });
84 | },
85 |
86 | moveUp(id) {
87 | set(state => {
88 | const index = state.timers.findIndex(timer => timer.id === id);
89 |
90 | if (index > 0) {
91 | const newTimers = [...state.timers];
92 |
93 | [newTimers[index - 1], newTimers[index]] = [
94 | newTimers[index],
95 | newTimers[index - 1],
96 | ];
97 |
98 | return { timers: newTimers };
99 | }
100 |
101 | return state;
102 | });
103 | },
104 |
105 | removeAutoStart(id) {
106 | set(state => ({
107 | timers: state.timers.map(timer => {
108 | if (timer.id !== id) return timer;
109 |
110 | delete timer.autoStart;
111 |
112 | return timer;
113 | }),
114 | }));
115 | },
116 |
117 | rename(id, newName) {
118 | set(state => ({
119 | timers: state.timers.map(timer => {
120 | if (timer.id !== id) return timer;
121 |
122 | return { ...timer, name: newName };
123 | }),
124 | }));
125 | },
126 |
127 | reset(id) {
128 | set(state => ({
129 | timers: state.timers.map(timer => {
130 | if (timer.id !== id) return timer;
131 |
132 | return { ...timer, spent: 0 };
133 | }),
134 | }));
135 | },
136 |
137 | spent() {
138 | return get().timers.reduce((prev, curr) => prev + curr.spent, 0);
139 | },
140 |
141 | tick(id, amount = 1) {
142 | set(state => ({
143 | timers: state.timers.map(timer => {
144 | if (timer.id !== id) return timer;
145 |
146 | const updatedSpent =
147 | timer.spent + amount > timer.total
148 | ? timer.total
149 | : timer.spent + amount;
150 |
151 | return { ...timer, spent: updatedSpent };
152 | }),
153 | }));
154 | },
155 |
156 | timers: [],
157 |
158 | total() {
159 | return get().timers.reduce((prev, curr) => prev + curr.total, 0);
160 | },
161 | }),
162 | {
163 | name: 'timesy-timers',
164 | partialize: state => ({
165 | timers: state.timers,
166 | }),
167 | skipHydration: true,
168 | storage: createJSONStorage(() => localStorage),
169 | version: 0,
170 | },
171 | ),
172 | );
173 |
--------------------------------------------------------------------------------
/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 | color-scheme: dark;
12 | scrollbar-width: thin;
13 | scroll-behavior: smooth;
14 | scrollbar-gutter: stable both-edges;
15 | }
16 |
17 | body {
18 | font-family: var(--font-body);
19 | font-size: var(--font-base);
20 | color: var(--color-foreground);
21 | background-color: var(--color-neutral-50);
22 | }
23 |
24 | ::selection {
25 | color: var(--color-foreground);
26 | background-color: var(--color-neutral-300);
27 | }
28 |
29 | select {
30 | scrollbar-width: thin;
31 | }
32 |
--------------------------------------------------------------------------------
/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 | /* space-mono-700 - latin */
20 | @font-face {
21 | font-family: 'Space Mono';
22 | font-style: normal;
23 | font-weight: 700;
24 | src: url('/fonts/space-mono-v13-latin-700.woff2') format('woff2');
25 | font-display: swap;
26 | }
27 |
28 | /* gloock-regular - latin */
29 | @font-face {
30 | font-family: Gloock;
31 | font-style: normal;
32 | font-weight: 400;
33 | src: url('/fonts/gloock-v6-latin-regular.woff2') format('woff2');
34 | font-display: swap;
35 | }
36 |
--------------------------------------------------------------------------------
/src/styles/global.css:
--------------------------------------------------------------------------------
1 | @import 'variables/index.css';
2 | @import 'base/base.css';
3 | @import 'fonts.css';
4 |
--------------------------------------------------------------------------------
/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/styles/variables/index.css:
--------------------------------------------------------------------------------
1 | @import 'color.css';
2 | @import 'typography.css';
3 |
--------------------------------------------------------------------------------
/src/styles/variables/typography.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --font-body: 'Inter', sans-serif;
3 | --font-display: 'Gloock', serif;
4 | --font-mono: 'Space Mono', monospace;
5 |
6 | /* Font Sizes */
7 | --font-base-size: 1rem;
8 | --font-pos-ratio: 1.2;
9 | --font-neg-ratio: 1.125;
10 | --font-3xlg: calc(var(--font-xxlg) * var(--font-pos-ratio));
11 | --font-2xlg: calc(var(--font-xlg) * var(--font-pos-ratio));
12 | --font-xlg: calc(var(--font-lg) * var(--font-pos-ratio));
13 | --font-lg: calc(var(--font-md) * var(--font-pos-ratio));
14 | --font-md: calc(var(--font-base) * var(--font-pos-ratio));
15 | --font-base: var(--font-base-size);
16 | --font-sm: calc(var(--font-base) / var(--font-neg-ratio));
17 | --font-xsm: calc(var(--font-sm) / var(--font-neg-ratio));
18 | --font-2xsm: calc(var(--font-xsm) / var(--font-neg-ratio));
19 | --font-3xsm: calc(var(--font-xxsm) / var(--font-neg-ratio));
20 | }
21 |
--------------------------------------------------------------------------------
/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 | "types": ["vite-plugin-pwa/react", "vite-plugin-pwa/info"]
11 | }
12 | }
13 |
--------------------------------------------------------------------------------