├── .cz.json
├── .husky
└── pre-commit
├── src
├── index.css
├── components
│ ├── LoadingOrError.tsx
│ ├── UriState.tsx
│ └── Radii.tsx
├── main.tsx
└── App.tsx
├── .postcssrc.json
├── .stylelintrc.json
├── .vscode
├── extensions.json
└── settings.json
├── cypress.config.ts
├── .prettierrc.json
├── tsconfig.node.json
├── README.md
├── tailwind.config.js
├── renovate.json
├── .github
└── workflows
│ ├── codeql-analysis.yml
│ ├── test.yml
│ └── deploy.yml
├── tsconfig.json
├── .gitignore
├── LICENSE
├── index.html
├── vite.config.ts
├── package.json
└── .eslintrc.json
/.cz.json:
--------------------------------------------------------------------------------
1 | {
2 | "path": "cz-conventional-changelog"
3 | }
4 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | pnpm lint-staged
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
--------------------------------------------------------------------------------
/.postcssrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": {
3 | "tailwindcss": {},
4 | "autoprefixer": {}
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/.stylelintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["stylelint-config-standard", "stylelint-config-prettier"],
3 | "rules": {
4 | "at-rule-no-unknown": [
5 | true,
6 | {
7 | "ignoreAtRules": ["tailwind", "layer"]
8 | }
9 | ]
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "bradlc.vscode-tailwindcss",
4 | "dbaeumer.vscode-eslint",
5 | "esbenp.prettier-vscode",
6 | "stylelint.vscode-stylelint",
7 | "visualstudioexptteam.vscodeintellicode"
8 | ]
9 | }
10 |
--------------------------------------------------------------------------------
/cypress.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'cypress'
2 |
3 | export default defineConfig({
4 | fileServerFolder: 'dist',
5 | fixturesFolder: false,
6 | projectId: 'etow1b',
7 | e2e: {
8 | baseUrl: 'http://localhost:4173/',
9 | specPattern: 'cypress/e2e/**/*.ts'
10 | }
11 | })
12 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "arrowParens": "avoid",
3 | "bracketSameLine": false,
4 | "bracketSpacing": true,
5 | "jsxSingleQuote": true,
6 | "plugins": ["prettier-plugin-tailwindcss"],
7 | "printWidth": 80,
8 | "quoteProps": "as-needed",
9 | "semi": false,
10 | "singleQuote": true,
11 | "tabWidth": 2,
12 | "trailingComma": "none",
13 | "useTabs": true
14 | }
15 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "allowSyntheticDefaultImports": true,
4 | "composite": true,
5 | "forceConsistentCasingInFileNames": true,
6 | "isolatedModules": true,
7 | "module": "ESNext",
8 | "moduleResolution": "Node",
9 | "skipLibCheck": true,
10 | "strict": true,
11 | "target": "ESNext"
12 | },
13 | "include": ["vite.config.ts", "cypress.config.ts"]
14 | }
15 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # gmaps-radius
2 |
3 | This very simple little web app allows you to draw circles on top of a ~Google Map~ OpenStreetMap, with a radius that you specify.
4 |
5 | [Try it out](//obeattie.github.io/gmaps-radius/).
6 |
7 | ## Query parameters
8 |
9 | This tool supports the following optional query parameters:
10 |
11 | - `lat`: Viewport center latitude
12 | - `lng`: Viewport center longitude
13 | - `z`: Default zoom level
14 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | const defaultConfig = require('tailwindcss/defaultConfig')
2 | const formsPlugin = require('@tailwindcss/forms')
3 |
4 | /** @type {import('tailwindcss/types').Config} */
5 | const config = {
6 | content: ['index.html', 'src/**/*.tsx'],
7 | theme: {
8 | fontFamily: {
9 | sans: ['Inter', ...defaultConfig.theme.fontFamily.sans]
10 | }
11 | },
12 | experimental: { optimizeUniversalDefaults: true },
13 | plugins: [formsPlugin]
14 | }
15 | module.exports = config
16 |
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "config:js-app",
4 | ":automergeMinor",
5 | ":automergeDigest",
6 | ":prHourlyLimitNone",
7 | ":prImmediately"
8 | ],
9 | "automerge": true,
10 | "platformAutomerge": true,
11 | "automergeStrategy": "squash",
12 | "labels": [
13 | "renovate"
14 | ],
15 | "assignees": [
16 | "obeattie"
17 | ],
18 | "lockFileMaintenance": {
19 | "enabled": true,
20 | "automerge": true
21 | },
22 | "prHourlyLimit": 0,
23 | "prConcurrentLimit": 0
24 | }
--------------------------------------------------------------------------------
/src/components/LoadingOrError.tsx:
--------------------------------------------------------------------------------
1 | import type { ReactElement } from 'react'
2 |
3 | interface Properties {
4 | error?: Error
5 | }
6 | export default function LoadingOrError({ error }: Properties): ReactElement {
7 | return (
8 |
9 |
10 | {error ? error.message : 'Loading...'}
11 |
12 |
13 | )
14 | }
15 | LoadingOrError.defaultProps = {
16 | error: undefined
17 | }
18 |
--------------------------------------------------------------------------------
/.github/workflows/codeql-analysis.yml:
--------------------------------------------------------------------------------
1 | name: CodeQL
2 |
3 | on:
4 | push:
5 | branches: [main]
6 | pull_request:
7 | branches: [main]
8 |
9 | jobs:
10 | analyze:
11 | runs-on: ubuntu-latest
12 |
13 | permissions:
14 | security-events: write
15 |
16 | steps:
17 | - name: Checkout repository
18 | uses: actions/checkout@v6
19 |
20 | - name: Initialize CodeQL
21 | uses: github/codeql-action/init@v4
22 | with:
23 | languages: javascript
24 |
25 | - name: Perform CodeQL Analysis
26 | uses: github/codeql-action/analyze@v4
27 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "allowSyntheticDefaultImports": true,
4 | "baseUrl": "src",
5 | "forceConsistentCasingInFileNames": true,
6 | "isolatedModules": true,
7 | "jsx": "react-jsx",
8 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
9 | "module": "ESNext",
10 | "moduleResolution": "Node",
11 | "noEmit": true,
12 | "resolveJsonModule": true,
13 | "skipLibCheck": true,
14 | "strict": true,
15 | "target": "ESNext",
16 | "types": ["vite/client", "vitest/globals", "vite-plugin-pwa/client"]
17 | },
18 | "include": ["src"],
19 | "references": [{ "path": "./tsconfig.node.json" }]
20 | }
21 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Test
2 |
3 | on:
4 | push:
5 | branches: [main]
6 | pull_request:
7 | branches: [main]
8 |
9 | jobs:
10 | Test:
11 | runs-on: ubuntu-latest
12 |
13 | steps:
14 | - uses: actions/checkout@v6
15 |
16 | - uses: pnpm/action-setup@v4.2.0
17 | with:
18 | version: latest
19 |
20 | - uses: actions/setup-node@v6
21 | with:
22 | node-version: '24.12.0'
23 | cache: 'pnpm'
24 |
25 | - name: Install dependencies
26 | run: pnpm i && pnpm cypress install
27 |
28 | - name: Validate
29 | run: pnpm run-p lint
30 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 | /cypress/videos
11 | /cypress/screenshots
12 |
13 | # next.js
14 | /.next/
15 | /out/
16 |
17 | # production
18 | /build
19 | /dist/
20 |
21 | # misc
22 | .DS_Store
23 | *.pem
24 |
25 | # debug
26 | npm-debug.log*
27 | yarn-debug.log*
28 | yarn-error.log*
29 |
30 | # local env files
31 | .env*.local
32 |
33 | # vercel
34 | .vercel
35 |
36 | # typescript
37 | *.tsbuildinfo
38 | next-env.d.ts
39 | *.eslintcache
40 |
41 | # generated files
42 | /src/__generated__/
43 | .stylelintcache
44 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
2 | import App from 'App'
3 | import 'leaflet/dist/leaflet.css'
4 | import { StrictMode } from 'react'
5 | import { createRoot } from 'react-dom/client'
6 | import { registerSW } from 'virtual:pwa-register'
7 | import './index.css'
8 |
9 | registerSW()
10 |
11 | const MAX_RETRIES = 1
12 | const queryClient = new QueryClient({
13 | defaultOptions: {
14 | queries: {
15 | staleTime: Number.POSITIVE_INFINITY,
16 | retry: MAX_RETRIES
17 | }
18 | }
19 | })
20 |
21 | const container = document.querySelector('#root')
22 | if (container) {
23 | const root = createRoot(container)
24 | root.render(
25 |
26 |
27 |
28 |
29 |
30 | )
31 | }
32 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "css.validate": false,
3 | "diffEditor.ignoreTrimWhitespace": false,
4 | "editor.codeActionsOnSave": [
5 | "source.addMissingImports",
6 | "source.fixAll",
7 | "source.organizeImports"
8 | ],
9 | "editor.defaultFormatter": "esbenp.prettier-vscode",
10 | "editor.foldingImportsByDefault": true,
11 | "editor.formatOnSave": true,
12 | "editor.tabSize": 2,
13 | "files.exclude": {
14 | "**/.stylelintcache": true,
15 | "**/.eslintcache": true,
16 | "**/.nyc_output": true,
17 | "**/dist": true,
18 | "**/node_modules": true,
19 | "**/yarn.lock": true,
20 | "**/coverage": true
21 | },
22 | "files.watcherExclude": {
23 | "**/dist": true
24 | },
25 | "git.enableCommitSigning": true,
26 | "javascript.format.enable": false,
27 | "stylelint.validate": [
28 | "css"
29 | ],
30 | "npm.packageManager": "pnpm",
31 | "typescript.disableAutomaticTypeAcquisition": true,
32 | "typescript.tsdk": "node_modules/typescript/lib"
33 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Oliver Beattie
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 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
8 | Draw radius circles on a map
9 |
10 |
11 |
15 |
19 |
23 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/src/components/UriState.tsx:
--------------------------------------------------------------------------------
1 | import { useMountEffect } from '@react-hookz/web'
2 | import type { ReactElement } from 'react'
3 | import { useMap, useMapEvents } from 'react-leaflet'
4 |
5 | export default function UriState(): ReactElement {
6 | const map = useMap()
7 | function update(): void {
8 | const url = new URL(window.location.href)
9 | url.searchParams.set('lat', map.getCenter().lat.toString())
10 | url.searchParams.set('lng', map.getCenter().lng.toString())
11 | url.searchParams.set('z', map.getZoom().toString())
12 | window.history.replaceState(undefined, '', url.toString())
13 | }
14 | useMapEvents({
15 | moveend: update,
16 | zoomend: update
17 | })
18 |
19 | // Set the bounds on first load, if the relevant params are in the URL
20 | useMountEffect(() => {
21 | const parameters = new URL(window.location.href).searchParams
22 | const lat = Number.parseFloat(parameters.get('lat') ?? '')
23 | const lng = Number.parseFloat(parameters.get('lng') ?? '')
24 | const zoom = Number.parseFloat(parameters.get('z') ?? '') || undefined
25 | if (!Number.isNaN(lat) && !Number.isNaN(lng)) {
26 | map.setView([lat, lng], zoom, { animate: false })
27 | } else if (zoom) {
28 | map.setZoom(zoom, { animate: false })
29 | }
30 | })
31 |
32 | // eslint-disable-next-line react/jsx-no-useless-fragment
33 | return <>>
34 | }
35 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | ///
2 | import eslintPlugin from '@nabla/vite-plugin-eslint'
3 | import react from '@vitejs/plugin-react'
4 | import { defineConfig } from 'vite'
5 | import { VitePWA } from 'vite-plugin-pwa'
6 | import tsconfigPaths from 'vite-tsconfig-paths'
7 |
8 | export default defineConfig(({ mode }) => ({
9 | base: '/gmaps-radius/',
10 | test: {
11 | css: false,
12 | include: ['src/**/__tests__/*'],
13 | globals: true,
14 | environment: 'jsdom',
15 | setupFiles: 'src/setupTests.ts',
16 | clearMocks: true,
17 | coverage: {
18 | provider: 'istanbul',
19 | enabled: true,
20 | '100': true,
21 | reporter: ['text', 'lcov'],
22 | reportsDirectory: 'coverage'
23 | }
24 | },
25 | plugins: [
26 | tsconfigPaths(),
27 | react(),
28 | ...(mode === 'test'
29 | ? []
30 | : [
31 | eslintPlugin(),
32 | VitePWA({
33 | registerType: 'autoUpdate',
34 | includeAssets: [
35 | 'favicon.png',
36 | 'robots.txt',
37 | 'apple-touch-icon.png',
38 | 'icons/*.svg',
39 | 'fonts/*.woff2'
40 | ],
41 | manifest: {
42 | theme_color: '#BD34FE',
43 | icons: [
44 | {
45 | src: '/android-chrome-192x192.png',
46 | sizes: '192x192',
47 | type: 'image/png',
48 | purpose: 'any maskable'
49 | },
50 | {
51 | src: '/android-chrome-512x512.png',
52 | sizes: '512x512',
53 | type: 'image/png'
54 | }
55 | ]
56 | }
57 | })
58 | ])
59 | ]
60 | }))
61 |
--------------------------------------------------------------------------------
/.github/workflows/deploy.yml:
--------------------------------------------------------------------------------
1 | name: Deploy static content to Pages
2 |
3 | on:
4 | # Runs on pushes targeting the default branch
5 | push:
6 | branches: ['main']
7 |
8 | # Allows you to run this workflow manually from the Actions tab
9 | workflow_dispatch:
10 |
11 | # Sets the GITHUB_TOKEN permissions to allow deployment to GitHub Pages
12 | permissions:
13 | contents: read
14 | pages: write
15 | id-token: write
16 |
17 | # Allow one concurrent deployment
18 | concurrency:
19 | group: 'pages'
20 | cancel-in-progress: true
21 |
22 | jobs:
23 | # Single deploy job since we're just deploying
24 | deploy:
25 | environment:
26 | name: github-pages
27 | url: ${{ steps.deployment.outputs.page_url }}
28 | runs-on: ubuntu-latest
29 | steps:
30 | - name: Checkout
31 | uses: actions/checkout@v6
32 | - uses: pnpm/action-setup@v2
33 | with:
34 | version: 10.26.0
35 | - name: Set up Node
36 | uses: actions/setup-node@v6
37 | with:
38 | node-version: 24.12.0
39 | cache: 'pnpm'
40 | - name: Install dependencies
41 | run: pnpm install
42 | - name: Build
43 | run: pnpm build
44 | - name: Setup Pages
45 | uses: actions/configure-pages@v5
46 | - name: Upload artifact
47 | uses: actions/upload-pages-artifact@v4
48 | with:
49 | # Upload dist repository
50 | path: './dist'
51 | - name: Deploy to GitHub Pages
52 | id: deployment
53 | uses: actions/deploy-pages@v4
54 |
--------------------------------------------------------------------------------
/src/components/Radii.tsx:
--------------------------------------------------------------------------------
1 | import { useList } from '@react-hookz/web'
2 | import type { LatLngExpression } from 'leaflet'
3 | import { nanoid } from 'nanoid'
4 | import type { ReactElement } from 'react'
5 | import { Circle as MapCircle, useMapEvent } from 'react-leaflet'
6 |
7 | /** Radius of the earth in various units */
8 | export const EarthRadii = {
9 | mi: 3963.1676,
10 | km: 6378.1,
11 | ft: 20_925_524.9,
12 | mt: 6_378_100,
13 | in: 251_106_299,
14 | yd: 6_975_174.98,
15 | fa: 3_487_587.49,
16 | na: 3443.898_49,
17 | ch: 317_053.408,
18 | rd: 1_268_213.63,
19 | fr: 31_705.3408,
20 | lms: 21.25
21 | }
22 |
23 | export type RadiusUnit = keyof typeof EarthRadii
24 |
25 | interface Circle {
26 | id: string
27 | center: LatLngExpression
28 | radius: number
29 | radiusUnit: RadiusUnit
30 | }
31 |
32 | interface Properties {
33 | radius: number
34 | unit: RadiusUnit
35 | }
36 |
37 | export default function Radii({
38 | radius,
39 | unit: radiusUnit
40 | }: Properties): ReactElement {
41 | const [circles, circlesActions] = useList([])
42 |
43 | // Add a circle on click
44 | useMapEvent('click', event => {
45 | circlesActions.push({
46 | radius,
47 | radiusUnit,
48 | id: nanoid(),
49 | center: event.latlng
50 | })
51 | })
52 |
53 | return (
54 | <>
55 | {circles.map(c => (
56 |
63 | circlesActions.filter(candidate => candidate !== c)
64 | }}
65 | />
66 | ))}
67 | >
68 | )
69 | }
70 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/jsx-handler-names */
2 | /* eslint-disable @typescript-eslint/no-magic-numbers */
3 | import { useLocalStorageValue } from '@react-hookz/web'
4 | import type { RadiusUnit } from 'components/Radii'
5 | import Radii from 'components/Radii'
6 | import UriState from 'components/UriState'
7 | import type { LatLngExpression } from 'leaflet'
8 | import { useState, type ReactElement } from 'react'
9 | import { MapContainer, TileLayer, ZoomControl } from 'react-leaflet'
10 |
11 | export default function App(): ReactElement {
12 | const center = useLocalStorageValue('mapCenter', {
13 | defaultValue: [51.500_731_404_772_77, -0.124_639_923_858_087_11]
14 | })
15 | const zoom = useLocalStorageValue('mapZoom', { defaultValue: 13 })
16 |
17 | const [radiusValue, setRadiusValue] = useState(5)
18 | const [radiusUnit, setRadiusUnit] = useState('mi')
19 |
20 | return (
21 | <>
22 |
29 |
30 |
36 |
37 |
38 |
39 |
40 |
70 |
71 |
72 | Click to place a circle, right click to remove
73 |
74 |
78 | GitHub
79 |
80 |
81 |
82 | >
83 | )
84 | }
85 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "vitamin",
3 | "license": "MIT",
4 | "private": true,
5 | "version": "0.0.0",
6 | "scripts": {
7 | "build": "vite build",
8 | "commit": "cz",
9 | "dev": "vite --open",
10 | "preview": "vite preview",
11 | "preview:test": "start-server-and-test preview http://localhost:4173",
12 | "test": "vitest",
13 | "test:ci": "vitest run",
14 | "test:e2e": "pnpm preview:test 'cypress open'",
15 | "test:e2e:headless": "pnpm preview:test 'cypress run'",
16 | "test:e2e:ci": "vite build && pnpm preview:test 'cypress run --record'",
17 | "format": "prettier -uw --cache --ignore-path .gitignore .",
18 | "run-tsc": "tsc",
19 | "run-eslint": "eslint --cache --fix --ignore-path .gitignore --ext .ts,.tsx .",
20 | "run-stylelint": "stylelint --cache --fix --ignore-path .gitignore **/*.css",
21 | "lint": "run-p run-tsc run-eslint run-stylelint",
22 | "validate": "run-p lint test:ci test:e2e:headless"
23 | },
24 | "dependencies": {
25 | "@react-hookz/web": "25.2.0",
26 | "@tanstack/react-query": "5.90.12",
27 | "leaflet": "1.9.4",
28 | "nanoid": "5.1.6",
29 | "react": "19.2.3",
30 | "react-dom": "19.2.3",
31 | "react-leaflet": "5.0.0",
32 | "react-router-dom": "7.10.1"
33 | },
34 | "devDependencies": {
35 | "@nabla/vite-plugin-eslint": "2.0.6",
36 | "@tailwindcss/forms": "0.5.10",
37 | "@testing-library/cypress": "10.1.0",
38 | "@testing-library/dom": "10.4.1",
39 | "@testing-library/jest-dom": "6.9.1",
40 | "@testing-library/react": "16.3.1",
41 | "@testing-library/user-event": "14.6.1",
42 | "@types/css-mediaquery": "0.1.4",
43 | "@types/leaflet": "1.9.21",
44 | "@types/react": "19.2.7",
45 | "@types/react-dom": "19.2.3",
46 | "@types/react-router-dom": "5.3.3",
47 | "@types/testing-library__jest-dom": "6.0.0",
48 | "@typescript-eslint/eslint-plugin": "7.18.0",
49 | "@typescript-eslint/parser": "7.18.0",
50 | "@vitejs/plugin-react": "4.7.0",
51 | "@vitest/coverage-istanbul": "4.0.15",
52 | "autoprefixer": "10.4.23",
53 | "commitizen": "4.3.1",
54 | "css-mediaquery": "0.1.2",
55 | "cypress": "15.7.1",
56 | "cz-conventional-changelog": "3.3.0",
57 | "eslint": "8.57.1",
58 | "eslint-config-airbnb": "19.0.4",
59 | "eslint-config-airbnb-base": "15.0.0",
60 | "eslint-config-airbnb-typescript": "18.0.0",
61 | "eslint-config-prettier": "10.1.8",
62 | "eslint-plugin-cypress": "5.2.0",
63 | "eslint-plugin-import": "2.32.0",
64 | "eslint-plugin-jsx-a11y": "6.10.2",
65 | "eslint-plugin-react": "7.37.5",
66 | "eslint-plugin-react-hooks": "7.0.1",
67 | "eslint-plugin-react-prefer-function-component": "5.0.0",
68 | "eslint-plugin-testing-library": "7.14.0",
69 | "eslint-plugin-unicorn": "56.0.1",
70 | "husky": "9.1.7",
71 | "jsdom": "27.3.0",
72 | "lint-staged": "16.2.7",
73 | "msw": "2.12.4",
74 | "npm-run-all2": "8.0.4",
75 | "postcss": "8.5.6",
76 | "prettier": "3.7.4",
77 | "prettier-plugin-tailwindcss": "0.7.2",
78 | "start-server-and-test": "2.1.3",
79 | "stylelint": "15.11.0",
80 | "stylelint-config-prettier": "9.0.5",
81 | "stylelint-config-standard": "35.0.0",
82 | "tailwindcss": "4.1.18",
83 | "typescript": "5.9.3",
84 | "vite": "7.3.0",
85 | "vite-plugin-pwa": "1.2.0",
86 | "vite-tsconfig-paths": "6.0.1",
87 | "vitest": "4.0.15",
88 | "whatwg-fetch": "3.6.20",
89 | "workbox-build": "7.4.0",
90 | "workbox-window": "7.4.0"
91 | },
92 | "browserslist": {
93 | "production": "Edge >= 18, Firefox >= 60, Chrome >= 61, Safari >= 11, Opera >= 48",
94 | "development": [
95 | "last 1 chrome version",
96 | "last 1 firefox version"
97 | ]
98 | },
99 | "lint-staged": {
100 | "*": "prettier -uw --cache",
101 | "*.css": "stylelint --cache --fix",
102 | "*.{ts,tsx}": [
103 | "eslint --cache --fix",
104 | "vitest related --run --coverage=false"
105 | ]
106 | },
107 | "pnpm": {
108 | "overrides": {
109 | "headers-polyfill": "4.0.3"
110 | }
111 | }
112 | }
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "env": {
4 | "browser": true,
5 | "node": true,
6 | "es2021": true
7 | },
8 | "parser": "@typescript-eslint/parser",
9 | "plugins": [
10 | "react-prefer-function-component"
11 | ],
12 | "extends": [
13 | "eslint:all",
14 | "plugin:@typescript-eslint/all",
15 | "plugin:import/recommended",
16 | "plugin:import/typescript",
17 | "plugin:react/all",
18 | "plugin:jsx-a11y/recommended",
19 | "airbnb",
20 | "airbnb-typescript",
21 | "airbnb/hooks",
22 | "plugin:react/jsx-runtime",
23 | "plugin:unicorn/all",
24 | "plugin:react-prefer-function-component/recommended",
25 | "prettier"
26 | ],
27 | "rules": {
28 | "no-dupe-else-if": "error",
29 | "no-promise-executor-return": "error",
30 | "no-unreachable-loop": "error",
31 | "no-useless-backreference": "error",
32 | "require-atomic-updates": "error",
33 | "default-case-last": "error",
34 | "grouped-accessor-pairs": "error",
35 | "no-constructor-return": "error",
36 | "no-implicit-coercion": "error",
37 | "prefer-regex-literals": "error",
38 | "capitalized-comments": "error",
39 | "no-restricted-syntax": [
40 | "error",
41 | {
42 | "selector": "ForInStatement",
43 | "message": "for..in loops iterate over the entire prototype chain, which is virtually never what you want. Use Object.{keys,values,entries}, and iterate over the resulting array."
44 | },
45 | {
46 | "selector": "LabeledStatement",
47 | "message": "Labels are a form of GOTO; using them makes code confusing and hard to maintain and understand."
48 | },
49 | {
50 | "selector": "WithStatement",
51 | "message": "`with` is disallowed in strict mode because it makes code impossible to predict and optimize."
52 | }
53 | ],
54 | "no-void": "off",
55 | "no-magic-numbers": "off",
56 | "@typescript-eslint/padding-line-between-statements": "off",
57 | "@typescript-eslint/prefer-enum-initializers": "off",
58 | "@typescript-eslint/prefer-readonly-parameter-types": "off",
59 | "@typescript-eslint/prefer-regexp-exec": "off",
60 | "@typescript-eslint/no-magic-numbers": "off",
61 | "@typescript-eslint/explicit-module-boundary-types": "off",
62 | "@typescript-eslint/init-declarations": "off",
63 | "@typescript-eslint/no-confusing-void-expression": [
64 | "error",
65 | {
66 | "ignoreArrowShorthand": true
67 | }
68 | ],
69 | "@typescript-eslint/non-nullable-type-assertion-style": "off",
70 | "@typescript-eslint/strict-boolean-expressions": "off",
71 | "@typescript-eslint/no-implicit-any-catch": "off",
72 | "@typescript-eslint/member-ordering": "off",
73 | "@typescript-eslint/prefer-includes": "off",
74 | "@typescript-eslint/no-restricted-imports": "off",
75 | "import/no-deprecated": "error",
76 | "import/order": "off",
77 | "import/no-extraneous-dependencies": [
78 | "error",
79 | {
80 | "devDependencies": [
81 | "cypress.config.ts",
82 | "vite.config.ts",
83 | "src/setupTests.ts",
84 | "src/testUtils.tsx",
85 | "src/mocks/**",
86 | "**/__tests__/*.{ts,tsx}"
87 | ]
88 | }
89 | ],
90 | "react/no-did-update-set-state": "off",
91 | "react/no-find-dom-node": "off",
92 | "react/no-is-mounted": "off",
93 | "react/no-redundant-should-component-update": "off",
94 | "react/no-render-return-value": "off",
95 | "react/no-string-refs": "off",
96 | "react/no-this-in-sfc": "off",
97 | "react/no-will-update-set-state": "off",
98 | "react/prefer-es6-class": "off",
99 | "react/no-unused-state": "off",
100 | "react/prefer-stateless-function": "off",
101 | "react/require-render-return": "off",
102 | "react/sort-comp": "off",
103 | "react/state-in-constructor": "off",
104 | "react/static-property-placement": "off",
105 | "react/boolean-prop-naming": [
106 | "error",
107 | {
108 | "validateNested": true
109 | }
110 | ],
111 | "react/function-component-definition": [
112 | "error",
113 | {
114 | "namedComponents": "function-declaration"
115 | }
116 | ],
117 | "react/no-unstable-nested-components": "error",
118 | "react/jsx-handler-names": [
119 | "error",
120 | {
121 | "eventHandlerPrefix": "on",
122 | "eventHandlerPropPrefix": "on",
123 | "checkLocalVariables": true,
124 | "checkInlineFunction": true
125 | }
126 | ],
127 | "react/jsx-key": "error",
128 | "react/jsx-no-bind": [
129 | "error",
130 | {
131 | "ignoreRefs": false,
132 | "allowArrowFunctions": true,
133 | "allowFunctions": true,
134 | "allowBind": false,
135 | "ignoreDOMComponents": false
136 | }
137 | ],
138 | "react/jsx-no-constructed-context-values": "error",
139 | "react/jsx-no-script-url": "error",
140 | "react/jsx-no-useless-fragment": "error",
141 | "unicorn/filename-case": [
142 | "error",
143 | {
144 | "cases": {
145 | "camelCase": true,
146 | "pascalCase": true
147 | }
148 | }
149 | ],
150 | "unicorn/no-nested-ternary": [
151 | "error"
152 | ]
153 | },
154 | "settings": {
155 | "react": {
156 | "version": "detect"
157 | }
158 | },
159 | "overrides": [
160 | {
161 | "files": [
162 | "src/**/*.ts?(x)"
163 | ],
164 | "parserOptions": {
165 | "project": [
166 | "./tsconfig.json"
167 | ]
168 | }
169 | },
170 | {
171 | "files": [
172 | "vite.config.ts",
173 | "cypress.config.ts"
174 | ],
175 | "parserOptions": {
176 | "project": [
177 | "./tsconfig.node.json"
178 | ]
179 | }
180 | },
181 | {
182 | "files": [
183 | "**/__tests__/**/*.ts?(x)"
184 | ],
185 | "extends": [
186 | "plugin:testing-library/react"
187 | ],
188 | "rules": {
189 | "@typescript-eslint/no-magic-numbers": [
190 | "off"
191 | ],
192 | "testing-library/no-await-sync-events": [
193 | "error",
194 | {
195 | "eventModules": [
196 | "fire-event"
197 | ]
198 | }
199 | ],
200 | "testing-library/no-manual-cleanup": "error",
201 | "testing-library/prefer-explicit-assert": "error",
202 | "testing-library/prefer-user-event": "error"
203 | }
204 | }
205 | ]
206 | }
--------------------------------------------------------------------------------