diff --git a/Dockerfile b/Dockerfile
index 9f926be9b446d0bcb7f6f1a1f6c0cc0823d6966a..a2494d5aeefb9d90acfdde22854324deb7301cd6 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,10 +1,18 @@
-FROM python:3.10
+FROM python:3.11
 
 RUN mkdir /app
 WORKDIR /app
 
-RUN curl -fsSL https://deb.nodesource.com/setup_19.x | bash -
-RUN apt-get install nodejs && rm -rf /var/lib/apt/lists/*
+# Install NodeJS
+ENV NODE_MAJOR=20
+RUN apt-get update
+RUN apt-get install -y ca-certificates curl gnupg
+RUN mkdir -p /etc/apt/keyrings
+RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
+RUN echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list
+RUN apt-get update
+RUN apt-get install -y nodejs
+RUN rm -rf /var/lib/apt/lists/*
 
 COPY . .
 
diff --git a/Makefile b/Makefile
index 0aa344656622e2bcda54d6f306af6721624dc697..13b1223cfb36e3becb48de43196b7f990bc1b3ec 100644
--- a/Makefile
+++ b/Makefile
@@ -29,11 +29,11 @@ venv: .venv/bin/python
 
 install: venv
 	${VENV}/bin/pip install -r requirements/base.txt -r requirements/production.txt
-	${VENV}/bin/npm install
+	npm install
 
 
 build: venv
-	${VENV}/bin/npm run build
+	npm run build
 	${VENV}/bin/python manage.py collectstatic --noinput --settings=${SETTINGS}
 
 install-hooks:
diff --git a/instagram_token/apps.py b/instagram_token/apps.py
index 135a246c66478b2cdb299307c8b08b1c71354cc1..0b34638f148e695ad5b9c1c82c65d66922acb453 100644
--- a/instagram_token/apps.py
+++ b/instagram_token/apps.py
@@ -2,5 +2,5 @@ from django.apps import AppConfig
 
 
 class InstagramTokenConfig(AppConfig):
-    default_auto_field = 'django.db.models.BigAutoField'
-    name = 'instagram_token'
+    default_auto_field = "django.db.models.BigAutoField"
+    name = "instagram_token"
diff --git a/instagram_token/views.py b/instagram_token/views.py
index 9f31e77dd1e0d1d82e3dc15538956ae68eaefb1e..0adee66debfe8429549d9c373f51284b517fb833 100644
--- a/instagram_token/views.py
+++ b/instagram_token/views.py
@@ -1,5 +1,4 @@
 import requests
-
 from django.conf import settings
 from django.shortcuts import render
 from django.urls import reverse
@@ -20,11 +19,7 @@ def index(request):
     )
 
     return render(
-        request,
-        "instagram_token/index.html",
-        {
-            "authorization_url": authorization_url
-        }
+        request, "instagram_token/index.html", {"authorization_url": authorization_url}
     )
 
 
@@ -41,8 +36,10 @@ def exchange(request):
             "client_secret": settings.INSTAGRAM_CLIENT_SECRET,
             "code": code,
             "grant_type": "authorization_code",
-            "redirect_uri": request.build_absolute_uri(reverse("instagram_token:exchange")),
-        }
+            "redirect_uri": request.build_absolute_uri(
+                reverse("instagram_token:exchange")
+            ),
+        },
     )
 
     if not exchange_request.ok:
@@ -56,5 +53,5 @@ def exchange(request):
         {
             "access_token": exchange_request["access_token"],
             "user_id": exchange_request["user_id"],
-        }
+        },
     )
diff --git a/package-lock.json b/package-lock.json
index 1ffe84608e5db3d89a6ec42a46c5e2634989b1b0..4dfc02878e57699d1c2b527aff331c23221a0cb0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,105 +9,29 @@
       "version": "0.0.1",
       "license": "AGPL-3.0-or-later",
       "dependencies": {
-        "@tailwindcss/typography": "^0.4.1",
+        "@tailwindcss/typography": "^0.5.10",
         "alertifyjs": "^1.13.1",
         "css-loader": "^6.7.3",
+        "easytimer.js": "^4.5.4",
         "jquery": "^3.6.4",
         "js-cookie": "^3.0.1",
         "select2": "^4.1.0-rc.0",
         "style-loader": "^3.3.2",
-        "tailwindcss": "^2.2.2",
+        "tailwindcss": "^3.3.3",
         "webpack": "^5.77.0",
         "webpack-bundle-tracker": "^1.8.1",
         "webpack-cli": "^5.0.1"
       }
     },
-    "node_modules/@babel/code-frame": {
-      "version": "7.21.4",
-      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.21.4.tgz",
-      "integrity": "sha512-LYvhNKfwWSPpocw8GI7gpK2nq3HSDuEPC/uSYaALSJu9xjsalaaYFOq0Pwt5KmVqwEbZlDu81aLXwBOmD/Fv9g==",
-      "dependencies": {
-        "@babel/highlight": "^7.18.6"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
-    "node_modules/@babel/helper-validator-identifier": {
-      "version": "7.19.1",
-      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz",
-      "integrity": "sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==",
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
-    "node_modules/@babel/highlight": {
-      "version": "7.18.6",
-      "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.18.6.tgz",
-      "integrity": "sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==",
-      "dependencies": {
-        "@babel/helper-validator-identifier": "^7.18.6",
-        "chalk": "^2.0.0",
-        "js-tokens": "^4.0.0"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
-    "node_modules/@babel/highlight/node_modules/ansi-styles": {
-      "version": "3.2.1",
-      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
-      "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
-      "dependencies": {
-        "color-convert": "^1.9.0"
-      },
-      "engines": {
-        "node": ">=4"
-      }
-    },
-    "node_modules/@babel/highlight/node_modules/chalk": {
-      "version": "2.4.2",
-      "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
-      "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
-      "dependencies": {
-        "ansi-styles": "^3.2.1",
-        "escape-string-regexp": "^1.0.5",
-        "supports-color": "^5.3.0"
-      },
-      "engines": {
-        "node": ">=4"
-      }
-    },
-    "node_modules/@babel/highlight/node_modules/color-convert": {
-      "version": "1.9.3",
-      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
-      "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
-      "dependencies": {
-        "color-name": "1.1.3"
-      }
-    },
-    "node_modules/@babel/highlight/node_modules/color-name": {
-      "version": "1.1.3",
-      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
-      "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="
-    },
-    "node_modules/@babel/highlight/node_modules/has-flag": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
-      "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
+    "node_modules/@alloc/quick-lru": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+      "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
       "engines": {
-        "node": ">=4"
-      }
-    },
-    "node_modules/@babel/highlight/node_modules/supports-color": {
-      "version": "5.5.0",
-      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
-      "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
-      "dependencies": {
-        "has-flag": "^3.0.0"
+        "node": ">=10"
       },
-      "engines": {
-        "node": ">=4"
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
       }
     },
     "node_modules/@discoveryjs/json-ext": {
@@ -118,17 +42,6 @@
         "node": ">=10.0.0"
       }
     },
-    "node_modules/@fullhuman/postcss-purgecss": {
-      "version": "4.1.3",
-      "resolved": "https://registry.npmjs.org/@fullhuman/postcss-purgecss/-/postcss-purgecss-4.1.3.tgz",
-      "integrity": "sha512-jqcsyfvq09VOsMXxJMPLRF6Fhg/NNltzWKnC9qtzva+QKTxerCO4esG6je7hbnmkpZtaDyPTwMBj9bzfWorsrw==",
-      "dependencies": {
-        "purgecss": "^4.1.3"
-      },
-      "peerDependencies": {
-        "postcss": "^8.0.0"
-      }
-    },
     "node_modules/@jridgewell/gen-mapping": {
       "version": "0.3.2",
       "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz",
@@ -214,17 +127,29 @@
       }
     },
     "node_modules/@tailwindcss/typography": {
-      "version": "0.4.1",
-      "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.4.1.tgz",
-      "integrity": "sha512-ovPPLUhs7zAIJfr0y1dbGlyCuPhpuv/jpBoFgqAc658DWGGrOBWBMpAWLw2KlzbNeVk4YBJMzue1ekvIbdw6XA==",
+      "version": "0.5.10",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.10.tgz",
+      "integrity": "sha512-Pe8BuPJQJd3FfRnm6H0ulKIGoMEQS+Vq01R6M5aCrFB/ccR/shT+0kXLjouGC1gFLm9hopTFN+DMP0pfwRWzPw==",
       "dependencies": {
         "lodash.castarray": "^4.4.0",
         "lodash.isplainobject": "^4.0.6",
         "lodash.merge": "^4.6.2",
-        "lodash.uniq": "^4.5.0"
+        "postcss-selector-parser": "6.0.10"
       },
       "peerDependencies": {
-        "tailwindcss": ">=2.0.0"
+        "tailwindcss": ">=3.0.0 || insiders"
+      }
+    },
+    "node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": {
+      "version": "6.0.10",
+      "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
+      "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
+      "dependencies": {
+        "cssesc": "^3.0.0",
+        "util-deprecate": "^1.0.2"
+      },
+      "engines": {
+        "node": ">=4"
       }
     },
     "node_modules/@types/eslint": {
@@ -260,11 +185,6 @@
       "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.18.tgz",
       "integrity": "sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA=="
     },
-    "node_modules/@types/parse-json": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
-      "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA=="
-    },
     "node_modules/@webassemblyjs/ast": {
       "version": "1.11.1",
       "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz",
@@ -447,35 +367,6 @@
       "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz",
       "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="
     },
-    "node_modules/acorn": {
-      "version": "7.4.1",
-      "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz",
-      "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==",
-      "bin": {
-        "acorn": "bin/acorn"
-      },
-      "engines": {
-        "node": ">=0.4.0"
-      }
-    },
-    "node_modules/acorn-node": {
-      "version": "1.8.2",
-      "resolved": "https://registry.npmjs.org/acorn-node/-/acorn-node-1.8.2.tgz",
-      "integrity": "sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==",
-      "dependencies": {
-        "acorn": "^7.0.0",
-        "acorn-walk": "^7.0.0",
-        "xtend": "^4.0.2"
-      }
-    },
-    "node_modules/acorn-walk": {
-      "version": "7.2.0",
-      "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz",
-      "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==",
-      "engines": {
-        "node": ">=0.4.0"
-      }
-    },
     "node_modules/ajv": {
       "version": "6.12.6",
       "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -512,19 +403,10 @@
         "node": ">=8"
       }
     },
-    "node_modules/ansi-styles": {
-      "version": "4.3.0",
-      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
-      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
-      "dependencies": {
-        "color-convert": "^2.0.1"
-      },
-      "engines": {
-        "node": ">=8"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
-      }
+    "node_modules/any-promise": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
+      "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="
     },
     "node_modules/anymatch": {
       "version": "3.1.3",
@@ -543,39 +425,6 @@
       "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
       "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="
     },
-    "node_modules/autoprefixer": {
-      "version": "10.4.14",
-      "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.14.tgz",
-      "integrity": "sha512-FQzyfOsTlwVzjHxKEqRIAdJx9niO6VCBCoEwax/VLSoQF29ggECcPuBqUMZ+u8jCZOPSy8b8/8KnuFbp0SaFZQ==",
-      "funding": [
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/postcss/"
-        },
-        {
-          "type": "tidelift",
-          "url": "https://tidelift.com/funding/github/npm/autoprefixer"
-        }
-      ],
-      "peer": true,
-      "dependencies": {
-        "browserslist": "^4.21.5",
-        "caniuse-lite": "^1.0.30001464",
-        "fraction.js": "^4.2.0",
-        "normalize-range": "^0.1.2",
-        "picocolors": "^1.0.0",
-        "postcss-value-parser": "^4.2.0"
-      },
-      "bin": {
-        "autoprefixer": "bin/autoprefixer"
-      },
-      "engines": {
-        "node": "^10 || ^12 || >=14"
-      },
-      "peerDependencies": {
-        "postcss": "^8.1.0"
-      }
-    },
     "node_modules/balanced-match": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -641,22 +490,6 @@
       "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
       "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="
     },
-    "node_modules/bytes": {
-      "version": "3.1.2",
-      "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
-      "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
-      "engines": {
-        "node": ">= 0.8"
-      }
-    },
-    "node_modules/callsites": {
-      "version": "3.1.0",
-      "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
-      "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
-      "engines": {
-        "node": ">=6"
-      }
-    },
     "node_modules/camelcase-css": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
@@ -666,9 +499,9 @@
       }
     },
     "node_modules/caniuse-lite": {
-      "version": "1.0.30001481",
-      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001481.tgz",
-      "integrity": "sha512-KCqHwRnaa1InZBtqXzP98LPg0ajCVujMKjqKDhZEthIpAsJl/YEIa3YvXjGXPVqzZVguccuu7ga9KOE1J9rKPQ==",
+      "version": "1.0.30001547",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001547.tgz",
+      "integrity": "sha512-W7CrtIModMAxobGhz8iXmDfuJiiKg1WADMO/9x7/CLNin5cpSbuBjooyoIUVB5eyCc36QuTVlkVa1iB2S5+/eA==",
       "funding": [
         {
           "type": "opencollective",
@@ -684,32 +517,6 @@
         }
       ]
     },
-    "node_modules/chalk": {
-      "version": "4.1.2",
-      "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
-      "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
-      "dependencies": {
-        "ansi-styles": "^4.1.0",
-        "supports-color": "^7.1.0"
-      },
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/chalk?sponsor=1"
-      }
-    },
-    "node_modules/chalk/node_modules/supports-color": {
-      "version": "7.2.0",
-      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
-      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
-      "dependencies": {
-        "has-flag": "^4.0.0"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
     "node_modules/chokidar": {
       "version": "3.5.3",
       "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
@@ -768,53 +575,6 @@
         "node": ">=6"
       }
     },
-    "node_modules/color": {
-      "version": "3.2.1",
-      "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz",
-      "integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==",
-      "dependencies": {
-        "color-convert": "^1.9.3",
-        "color-string": "^1.6.0"
-      }
-    },
-    "node_modules/color-convert": {
-      "version": "2.0.1",
-      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
-      "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
-      "dependencies": {
-        "color-name": "~1.1.4"
-      },
-      "engines": {
-        "node": ">=7.0.0"
-      }
-    },
-    "node_modules/color-name": {
-      "version": "1.1.4",
-      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
-      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
-    },
-    "node_modules/color-string": {
-      "version": "1.9.1",
-      "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
-      "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
-      "dependencies": {
-        "color-name": "^1.0.0",
-        "simple-swizzle": "^0.2.2"
-      }
-    },
-    "node_modules/color/node_modules/color-convert": {
-      "version": "1.9.3",
-      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
-      "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
-      "dependencies": {
-        "color-name": "1.1.3"
-      }
-    },
-    "node_modules/color/node_modules/color-name": {
-      "version": "1.1.3",
-      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
-      "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="
-    },
     "node_modules/colorette": {
       "version": "2.0.19",
       "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.19.tgz",
@@ -830,21 +590,6 @@
       "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
       "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="
     },
-    "node_modules/cosmiconfig": {
-      "version": "7.1.0",
-      "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
-      "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==",
-      "dependencies": {
-        "@types/parse-json": "^4.0.0",
-        "import-fresh": "^3.2.1",
-        "parse-json": "^5.0.0",
-        "path-type": "^4.0.0",
-        "yaml": "^1.10.0"
-      },
-      "engines": {
-        "node": ">=10"
-      }
-    },
     "node_modules/cross-spawn": {
       "version": "7.0.3",
       "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@@ -883,11 +628,6 @@
         "webpack": "^5.0.0"
       }
     },
-    "node_modules/css-unit-converter": {
-      "version": "1.1.2",
-      "resolved": "https://registry.npmjs.org/css-unit-converter/-/css-unit-converter-1.1.2.tgz",
-      "integrity": "sha512-IiJwMC8rdZE0+xiEZHeru6YoONC4rfPMqGm2W85jMIbkFvv5nFTwJVFHam2eFrN6txmoUYFAFXiv8ICVeTO0MA=="
-    },
     "node_modules/cssesc": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -899,30 +639,6 @@
         "node": ">=4"
       }
     },
-    "node_modules/defined": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.1.tgz",
-      "integrity": "sha512-hsBd2qSVCRE+5PmNdHt1uzyrFu5d3RwmFDKzyNZMFq/EwDNJF7Ee5+D5oEKF0hU6LhtoUF1macFvOe4AskQC1Q==",
-      "funding": {
-        "url": "https://github.com/sponsors/ljharb"
-      }
-    },
-    "node_modules/detective": {
-      "version": "5.2.1",
-      "resolved": "https://registry.npmjs.org/detective/-/detective-5.2.1.tgz",
-      "integrity": "sha512-v9XE1zRnz1wRtgurGu0Bs8uHKFSTdteYZNbIPFVhUZ39L/S79ppMpdmVOZAnoz1jfEFodc48n6MX483Xo3t1yw==",
-      "dependencies": {
-        "acorn-node": "^1.8.2",
-        "defined": "^1.0.0",
-        "minimist": "^1.2.6"
-      },
-      "bin": {
-        "detective": "bin/detective.js"
-      },
-      "engines": {
-        "node": ">=0.8.0"
-      }
-    },
     "node_modules/didyoumean": {
       "version": "1.2.2",
       "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@@ -933,6 +649,11 @@
       "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
       "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="
     },
+    "node_modules/easytimer.js": {
+      "version": "4.5.4",
+      "resolved": "https://registry.npmjs.org/easytimer.js/-/easytimer.js-4.5.4.tgz",
+      "integrity": "sha512-65STy2sW2z6e9XwfJqSa18JVNWuOu2cb/FXaZ/BbiDiPnTvC53njMS3oY1BsAIm/Dzt9c8YUvcgc8FoPttm1Gw=="
+    },
     "node_modules/electron-to-chromium": {
       "version": "1.4.284",
       "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.284.tgz",
@@ -961,14 +682,6 @@
         "node": ">=4"
       }
     },
-    "node_modules/error-ex": {
-      "version": "1.3.2",
-      "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
-      "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
-      "dependencies": {
-        "is-arrayish": "^0.2.1"
-      }
-    },
     "node_modules/es-module-lexer": {
       "version": "0.9.3",
       "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.9.3.tgz",
@@ -982,14 +695,6 @@
         "node": ">=6"
       }
     },
-    "node_modules/escape-string-regexp": {
-      "version": "1.0.5",
-      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
-      "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
-      "engines": {
-        "node": ">=0.8.0"
-      }
-    },
     "node_modules/eslint-scope": {
       "version": "5.1.1",
       "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
@@ -1043,9 +748,9 @@
       "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
     },
     "node_modules/fast-glob": {
-      "version": "3.2.12",
-      "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz",
-      "integrity": "sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==",
+      "version": "3.3.1",
+      "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
+      "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==",
       "dependencies": {
         "@nodelib/fs.stat": "^2.0.2",
         "@nodelib/fs.walk": "^1.2.3",
@@ -1112,41 +817,15 @@
         "node": ">=8"
       }
     },
-    "node_modules/fraction.js": {
-      "version": "4.2.0",
-      "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz",
-      "integrity": "sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==",
-      "peer": true,
-      "engines": {
-        "node": "*"
-      },
-      "funding": {
-        "type": "patreon",
-        "url": "https://www.patreon.com/infusion"
-      }
-    },
-    "node_modules/fs-extra": {
-      "version": "10.1.0",
-      "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
-      "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
-      "dependencies": {
-        "graceful-fs": "^4.2.0",
-        "jsonfile": "^6.0.1",
-        "universalify": "^2.0.0"
-      },
-      "engines": {
-        "node": ">=12"
-      }
-    },
     "node_modules/fs.realpath": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
       "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="
     },
     "node_modules/fsevents": {
-      "version": "2.3.2",
-      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
-      "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
       "hasInstallScript": true,
       "optional": true,
       "os": [
@@ -1157,19 +836,22 @@
       }
     },
     "node_modules/function-bind": {
-      "version": "1.1.1",
-      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
-      "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
     },
     "node_modules/glob": {
-      "version": "7.2.3",
-      "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
-      "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+      "version": "7.1.6",
+      "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
+      "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
       "dependencies": {
         "fs.realpath": "^1.0.0",
         "inflight": "^1.0.4",
         "inherits": "2",
-        "minimatch": "^3.1.1",
+        "minimatch": "^3.0.4",
         "once": "^1.3.0",
         "path-is-absolute": "^1.0.0"
       },
@@ -1201,17 +883,6 @@
       "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz",
       "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA=="
     },
-    "node_modules/has": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
-      "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
-      "dependencies": {
-        "function-bind": "^1.1.1"
-      },
-      "engines": {
-        "node": ">= 0.4.0"
-      }
-    },
     "node_modules/has-flag": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -1220,15 +891,15 @@
         "node": ">=8"
       }
     },
-    "node_modules/html-tags": {
-      "version": "3.3.1",
-      "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz",
-      "integrity": "sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==",
-      "engines": {
-        "node": ">=8"
+    "node_modules/hasown": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz",
+      "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==",
+      "dependencies": {
+        "function-bind": "^1.1.2"
       },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
+      "engines": {
+        "node": ">= 0.4"
       }
     },
     "node_modules/icss-utils": {
@@ -1242,29 +913,6 @@
         "postcss": "^8.1.0"
       }
     },
-    "node_modules/import-fresh": {
-      "version": "3.3.0",
-      "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
-      "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
-      "dependencies": {
-        "parent-module": "^1.0.0",
-        "resolve-from": "^4.0.0"
-      },
-      "engines": {
-        "node": ">=6"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
-    "node_modules/import-fresh/node_modules/resolve-from": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
-      "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
-      "engines": {
-        "node": ">=4"
-      }
-    },
     "node_modules/import-local": {
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz",
@@ -1305,11 +953,6 @@
         "node": ">=10.13.0"
       }
     },
-    "node_modules/is-arrayish": {
-      "version": "0.2.1",
-      "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
-      "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="
-    },
     "node_modules/is-binary-path": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -1322,11 +965,11 @@
       }
     },
     "node_modules/is-core-module": {
-      "version": "2.11.0",
-      "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz",
-      "integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==",
+      "version": "2.13.1",
+      "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz",
+      "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==",
       "dependencies": {
-        "has": "^1.0.3"
+        "hasown": "^2.0.0"
       },
       "funding": {
         "url": "https://github.com/sponsors/ljharb"
@@ -1396,6 +1039,14 @@
         "node": ">= 10.13.0"
       }
     },
+    "node_modules/jiti": {
+      "version": "1.20.0",
+      "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.20.0.tgz",
+      "integrity": "sha512-3TV69ZbrvV6U5DfQimop50jE9Dl6J8O1ja1dvBbMba/sZ3YBEQqJ2VZRoQPVnhlzjNtU1vaXRZVrVjU4qtm8yA==",
+      "bin": {
+        "jiti": "bin/jiti.js"
+      }
+    },
     "node_modules/jquery": {
       "version": "3.6.4",
       "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.6.4.tgz",
@@ -1409,11 +1060,6 @@
         "node": ">=12"
       }
     },
-    "node_modules/js-tokens": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
-      "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
-    },
     "node_modules/json-parse-even-better-errors": {
       "version": "2.3.1",
       "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
@@ -1424,17 +1070,6 @@
       "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
       "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="
     },
-    "node_modules/jsonfile": {
-      "version": "6.1.0",
-      "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
-      "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
-      "dependencies": {
-        "universalify": "^2.0.0"
-      },
-      "optionalDependencies": {
-        "graceful-fs": "^4.1.6"
-      }
-    },
     "node_modules/kind-of": {
       "version": "6.0.3",
       "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
@@ -1444,9 +1079,9 @@
       }
     },
     "node_modules/lilconfig": {
-      "version": "2.0.6",
-      "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.6.tgz",
-      "integrity": "sha512-9JROoBW7pobfsx+Sq2JsASvCo6Pfo6WWoUW79HuB1BCoBXD4PLWJPqDF6fNj67pqBYTbAHkE57M1kS/+L1neOg==",
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
+      "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==",
       "engines": {
         "node": ">=10"
       }
@@ -1475,11 +1110,6 @@
         "node": ">=8"
       }
     },
-    "node_modules/lodash": {
-      "version": "4.17.21",
-      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
-      "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
-    },
     "node_modules/lodash.assign": {
       "version": "4.2.0",
       "resolved": "https://registry.npmjs.org/lodash.assign/-/lodash.assign-4.2.0.tgz",
@@ -1525,16 +1155,6 @@
       "resolved": "https://registry.npmjs.org/lodash.topairs/-/lodash.topairs-4.3.0.tgz",
       "integrity": "sha512-qrRMbykBSEGdOgQLJJqVSdPWMD7Q+GJJ5jMRfQYb+LTLsw3tYVIabnCzRqTJb2WTo17PG5gNzXuFaZgYH/9SAQ=="
     },
-    "node_modules/lodash.topath": {
-      "version": "4.5.2",
-      "resolved": "https://registry.npmjs.org/lodash.topath/-/lodash.topath-4.5.2.tgz",
-      "integrity": "sha512-1/W4dM+35DwvE/iEd1M9ekewOSTlpFekhw9mhAtrwjVqUr83/ilQiyAvmg4tVX7Unkcfl1KC+i9WdaT4B6aQcg=="
-    },
-    "node_modules/lodash.uniq": {
-      "version": "4.5.0",
-      "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
-      "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ=="
-    },
     "node_modules/lru-cache": {
       "version": "6.0.0",
       "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@@ -1601,29 +1221,26 @@
         "node": "*"
       }
     },
-    "node_modules/minimist": {
-      "version": "1.2.8",
-      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
-      "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
-      "funding": {
-        "url": "https://github.com/sponsors/ljharb"
-      }
-    },
-    "node_modules/modern-normalize": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/modern-normalize/-/modern-normalize-1.1.0.tgz",
-      "integrity": "sha512-2lMlY1Yc1+CUy0gw4H95uNN7vjbpoED7NNRSBHE25nWfLBdmMzFCsPshlzbxHz+gYMcBEUN8V4pU16prcdPSgA==",
-      "engines": {
-        "node": ">=6"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
+    "node_modules/mz": {
+      "version": "2.7.0",
+      "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
+      "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
+      "dependencies": {
+        "any-promise": "^1.0.0",
+        "object-assign": "^4.0.1",
+        "thenify-all": "^1.0.0"
       }
     },
     "node_modules/nanoid": {
-      "version": "3.3.4",
-      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz",
-      "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==",
+      "version": "3.3.6",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz",
+      "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
       "bin": {
         "nanoid": "bin/nanoid.cjs"
       },
@@ -1636,14 +1253,6 @@
       "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
       "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="
     },
-    "node_modules/node-emoji": {
-      "version": "1.11.0",
-      "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz",
-      "integrity": "sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==",
-      "dependencies": {
-        "lodash": "^4.17.21"
-      }
-    },
     "node_modules/node-releases": {
       "version": "2.0.8",
       "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.8.tgz",
@@ -1657,19 +1266,18 @@
         "node": ">=0.10.0"
       }
     },
-    "node_modules/normalize-range": {
-      "version": "0.1.2",
-      "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
-      "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==",
-      "peer": true,
+    "node_modules/object-assign": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+      "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
       "engines": {
         "node": ">=0.10.0"
       }
     },
     "node_modules/object-hash": {
-      "version": "2.2.0",
-      "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
-      "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
+      "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
       "engines": {
         "node": ">= 6"
       }
@@ -1715,34 +1323,6 @@
         "node": ">=6"
       }
     },
-    "node_modules/parent-module": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
-      "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
-      "dependencies": {
-        "callsites": "^3.0.0"
-      },
-      "engines": {
-        "node": ">=6"
-      }
-    },
-    "node_modules/parse-json": {
-      "version": "5.2.0",
-      "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
-      "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
-      "dependencies": {
-        "@babel/code-frame": "^7.0.0",
-        "error-ex": "^1.3.1",
-        "json-parse-even-better-errors": "^2.3.0",
-        "lines-and-columns": "^1.1.6"
-      },
-      "engines": {
-        "node": ">=8"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
     "node_modules/path-exists": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -1772,14 +1352,6 @@
       "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
       "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
     },
-    "node_modules/path-type": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
-      "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
-      "engines": {
-        "node": ">=8"
-      }
-    },
     "node_modules/picocolors": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
@@ -1796,6 +1368,22 @@
         "url": "https://github.com/sponsors/jonschlinkert"
       }
     },
+    "node_modules/pify": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+      "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/pirates": {
+      "version": "4.0.6",
+      "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz",
+      "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==",
+      "engines": {
+        "node": ">= 6"
+      }
+    },
     "node_modules/pkg-dir": {
       "version": "4.2.0",
       "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
@@ -1808,9 +1396,9 @@
       }
     },
     "node_modules/postcss": {
-      "version": "8.4.21",
-      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz",
-      "integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==",
+      "version": "8.4.31",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
+      "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
       "funding": [
         {
           "type": "opencollective",
@@ -1819,10 +1407,14 @@
         {
           "type": "tidelift",
           "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
         }
       ],
       "dependencies": {
-        "nanoid": "^3.3.4",
+        "nanoid": "^3.3.6",
         "picocolors": "^1.0.0",
         "source-map-js": "^1.0.2"
       },
@@ -1830,32 +1422,50 @@
         "node": "^10 || ^12 || >=14"
       }
     },
+    "node_modules/postcss-import": {
+      "version": "15.1.0",
+      "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
+      "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
+      "dependencies": {
+        "postcss-value-parser": "^4.0.0",
+        "read-cache": "^1.0.0",
+        "resolve": "^1.1.7"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      },
+      "peerDependencies": {
+        "postcss": "^8.0.0"
+      }
+    },
     "node_modules/postcss-js": {
-      "version": "3.0.3",
-      "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-3.0.3.tgz",
-      "integrity": "sha512-gWnoWQXKFw65Hk/mi2+WTQTHdPD5UJdDXZmX073EY/B3BWnYjO4F4t0VneTCnCGQ5E5GsCdMkzPaTXwl3r5dJw==",
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz",
+      "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==",
       "dependencies": {
-        "camelcase-css": "^2.0.1",
-        "postcss": "^8.1.6"
+        "camelcase-css": "^2.0.1"
       },
       "engines": {
-        "node": ">=10.0"
+        "node": "^12 || ^14 || >= 16"
       },
       "funding": {
         "type": "opencollective",
         "url": "https://opencollective.com/postcss/"
+      },
+      "peerDependencies": {
+        "postcss": "^8.4.21"
       }
     },
     "node_modules/postcss-load-config": {
-      "version": "3.1.4",
-      "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz",
-      "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==",
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.1.tgz",
+      "integrity": "sha512-vEJIc8RdiBRu3oRAI0ymerOn+7rPuMvRXslTvZUKZonDHFIczxztIyJ1urxM1x9JXEikvpWWTUUqal5j/8QgvA==",
       "dependencies": {
         "lilconfig": "^2.0.5",
-        "yaml": "^1.10.2"
+        "yaml": "^2.1.1"
       },
       "engines": {
-        "node": ">= 10"
+        "node": ">= 14"
       },
       "funding": {
         "type": "opencollective",
@@ -1930,21 +1540,21 @@
       }
     },
     "node_modules/postcss-nested": {
-      "version": "5.0.5",
-      "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-5.0.5.tgz",
-      "integrity": "sha512-GSRXYz5bccobpTzLQZXOnSOfKl6TwVr5CyAQJUPub4nuRJSOECK5AqurxVgmtxP48p0Kc/ndY/YyS1yqldX0Ew==",
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.1.tgz",
+      "integrity": "sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==",
       "dependencies": {
-        "postcss-selector-parser": "^6.0.4"
+        "postcss-selector-parser": "^6.0.11"
       },
       "engines": {
-        "node": ">=10.0"
+        "node": ">=12.0"
       },
       "funding": {
         "type": "opencollective",
         "url": "https://opencollective.com/postcss/"
       },
       "peerDependencies": {
-        "postcss": "^8.1.13"
+        "postcss": "^8.2.14"
       }
     },
     "node_modules/postcss-selector-parser": {
@@ -1964,14 +1574,6 @@
       "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
       "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="
     },
-    "node_modules/pretty-hrtime": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz",
-      "integrity": "sha512-66hKPCr+72mlfiSjlEB1+45IjXSqvVAIy6mocupoww4tBFE9R9IhwwUGoI4G++Tc9Aq+2rxOt0RFU6gPcrte0A==",
-      "engines": {
-        "node": ">= 0.8"
-      }
-    },
     "node_modules/punycode": {
       "version": "2.2.0",
       "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.2.0.tgz",
@@ -1980,28 +1582,6 @@
         "node": ">=6"
       }
     },
-    "node_modules/purgecss": {
-      "version": "4.1.3",
-      "resolved": "https://registry.npmjs.org/purgecss/-/purgecss-4.1.3.tgz",
-      "integrity": "sha512-99cKy4s+VZoXnPxaoM23e5ABcP851nC2y2GROkkjS8eJaJtlciGavd7iYAw2V84WeBqggZ12l8ef44G99HmTaw==",
-      "dependencies": {
-        "commander": "^8.0.0",
-        "glob": "^7.1.7",
-        "postcss": "^8.3.5",
-        "postcss-selector-parser": "^6.0.6"
-      },
-      "bin": {
-        "purgecss": "bin/purgecss.js"
-      }
-    },
-    "node_modules/purgecss/node_modules/commander": {
-      "version": "8.3.0",
-      "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
-      "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
-      "engines": {
-        "node": ">= 12"
-      }
-    },
     "node_modules/queue-microtask": {
       "version": "1.2.3",
       "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -2021,17 +1601,6 @@
         }
       ]
     },
-    "node_modules/quick-lru": {
-      "version": "5.1.1",
-      "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
-      "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
     "node_modules/randombytes": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@@ -2040,6 +1609,14 @@
         "safe-buffer": "^5.1.0"
       }
     },
+    "node_modules/read-cache": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
+      "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
+      "dependencies": {
+        "pify": "^2.3.0"
+      }
+    },
     "node_modules/readdirp": {
       "version": "3.6.0",
       "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@@ -2062,26 +1639,12 @@
         "node": ">= 10.13.0"
       }
     },
-    "node_modules/reduce-css-calc": {
-      "version": "2.1.8",
-      "resolved": "https://registry.npmjs.org/reduce-css-calc/-/reduce-css-calc-2.1.8.tgz",
-      "integrity": "sha512-8liAVezDmUcH+tdzoEGrhfbGcP7nOV4NkGE3a74+qqvE7nt9i4sKLGBuZNOnpI4WiGksiNPklZxva80061QiPg==",
-      "dependencies": {
-        "css-unit-converter": "^1.1.1",
-        "postcss-value-parser": "^3.3.0"
-      }
-    },
-    "node_modules/reduce-css-calc/node_modules/postcss-value-parser": {
-      "version": "3.3.1",
-      "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz",
-      "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ=="
-    },
     "node_modules/resolve": {
-      "version": "1.22.1",
-      "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz",
-      "integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==",
+      "version": "1.22.8",
+      "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz",
+      "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==",
       "dependencies": {
-        "is-core-module": "^2.9.0",
+        "is-core-module": "^2.13.0",
         "path-parse": "^1.0.7",
         "supports-preserve-symlinks-flag": "^1.0.0"
       },
@@ -2120,20 +1683,6 @@
         "node": ">=0.10.0"
       }
     },
-    "node_modules/rimraf": {
-      "version": "3.0.2",
-      "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
-      "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
-      "dependencies": {
-        "glob": "^7.1.3"
-      },
-      "bin": {
-        "rimraf": "bin.js"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/isaacs"
-      }
-    },
     "node_modules/run-parallel": {
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -2198,9 +1747,9 @@
       "integrity": "sha512-Hr9TdhyHCZUtwznEH2CBf7967mEM0idtJ5nMtjvk3Up5tPukOLXbHUNmh10oRfeNIhj+3GD3niu+g6sVK+gK0A=="
     },
     "node_modules/semver": {
-      "version": "7.3.8",
-      "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz",
-      "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==",
+      "version": "7.5.4",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
+      "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
       "dependencies": {
         "lru-cache": "^6.0.0"
       },
@@ -2249,19 +1798,6 @@
         "node": ">=8"
       }
     },
-    "node_modules/simple-swizzle": {
-      "version": "0.2.2",
-      "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
-      "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==",
-      "dependencies": {
-        "is-arrayish": "^0.3.1"
-      }
-    },
-    "node_modules/simple-swizzle/node_modules/is-arrayish": {
-      "version": "0.3.2",
-      "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
-      "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="
-    },
     "node_modules/source-map": {
       "version": "0.6.1",
       "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -2313,6 +1849,35 @@
         "webpack": "^5.0.0"
       }
     },
+    "node_modules/sucrase": {
+      "version": "3.34.0",
+      "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.34.0.tgz",
+      "integrity": "sha512-70/LQEZ07TEcxiU2dz51FKaE6hCTWC6vr7FOk3Gr0U60C3shtAN+H+BFr9XlYe5xqf3RA8nrc+VIwzCfnxuXJw==",
+      "dependencies": {
+        "@jridgewell/gen-mapping": "^0.3.2",
+        "commander": "^4.0.0",
+        "glob": "7.1.6",
+        "lines-and-columns": "^1.1.6",
+        "mz": "^2.7.0",
+        "pirates": "^4.0.1",
+        "ts-interface-checker": "^0.1.9"
+      },
+      "bin": {
+        "sucrase": "bin/sucrase",
+        "sucrase-node": "bin/sucrase-node"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/sucrase/node_modules/commander": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
+      "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
+      "engines": {
+        "node": ">= 6"
+      }
+    },
     "node_modules/supports-color": {
       "version": "8.1.1",
       "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
@@ -2339,52 +1904,39 @@
       }
     },
     "node_modules/tailwindcss": {
-      "version": "2.2.2",
-      "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-2.2.2.tgz",
-      "integrity": "sha512-OzFWhlnfrO3JXZKHQiqZcb0Wwl3oJSmQ7PvT2jdIgCjV5iUoAyql9bb9ZLCSBI5TYXmawujXAoNxXVfP5Auy/Q==",
-      "dependencies": {
-        "@fullhuman/postcss-purgecss": "^4.0.3",
-        "arg": "^5.0.0",
-        "bytes": "^3.0.0",
-        "chalk": "^4.1.1",
-        "chokidar": "^3.5.1",
-        "color": "^3.1.3",
-        "cosmiconfig": "^7.0.0",
-        "detective": "^5.2.0",
-        "didyoumean": "^1.2.1",
+      "version": "3.3.3",
+      "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.3.tgz",
+      "integrity": "sha512-A0KgSkef7eE4Mf+nKJ83i75TMyq8HqY3qmFIJSWy8bNt0v1lG7jUcpGpoTFxAwYcWOphcTBLPPJg+bDfhDf52w==",
+      "dependencies": {
+        "@alloc/quick-lru": "^5.2.0",
+        "arg": "^5.0.2",
+        "chokidar": "^3.5.3",
+        "didyoumean": "^1.2.2",
         "dlv": "^1.1.3",
-        "fast-glob": "^3.2.5",
-        "fs-extra": "^10.0.0",
-        "glob-parent": "^6.0.0",
-        "html-tags": "^3.1.0",
-        "is-glob": "^4.0.1",
-        "lodash": "^4.17.21",
-        "lodash.topath": "^4.5.2",
-        "modern-normalize": "^1.1.0",
-        "node-emoji": "^1.8.1",
+        "fast-glob": "^3.2.12",
+        "glob-parent": "^6.0.2",
+        "is-glob": "^4.0.3",
+        "jiti": "^1.18.2",
+        "lilconfig": "^2.1.0",
+        "micromatch": "^4.0.5",
         "normalize-path": "^3.0.0",
-        "object-hash": "^2.2.0",
-        "postcss-js": "^3.0.3",
-        "postcss-load-config": "^3.0.1",
-        "postcss-nested": "5.0.5",
-        "postcss-selector-parser": "^6.0.6",
-        "postcss-value-parser": "^4.1.0",
-        "pretty-hrtime": "^1.0.3",
-        "quick-lru": "^5.1.1",
-        "reduce-css-calc": "^2.1.8",
-        "resolve": "^1.20.0",
-        "tmp": "^0.2.1"
+        "object-hash": "^3.0.0",
+        "picocolors": "^1.0.0",
+        "postcss": "^8.4.23",
+        "postcss-import": "^15.1.0",
+        "postcss-js": "^4.0.1",
+        "postcss-load-config": "^4.0.1",
+        "postcss-nested": "^6.0.1",
+        "postcss-selector-parser": "^6.0.11",
+        "resolve": "^1.22.2",
+        "sucrase": "^3.32.0"
       },
       "bin": {
         "tailwind": "lib/cli.js",
         "tailwindcss": "lib/cli.js"
       },
       "engines": {
-        "node": ">=12.13.0"
-      },
-      "peerDependencies": {
-        "autoprefixer": "^10.0.2",
-        "postcss": "^8.0.9"
+        "node": ">=14.0.0"
       }
     },
     "node_modules/tapable": {
@@ -2456,15 +2008,23 @@
         "node": ">=0.4.0"
       }
     },
-    "node_modules/tmp": {
-      "version": "0.2.1",
-      "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz",
-      "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==",
+    "node_modules/thenify": {
+      "version": "3.3.1",
+      "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
+      "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
+      "dependencies": {
+        "any-promise": "^1.0.0"
+      }
+    },
+    "node_modules/thenify-all": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
+      "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
       "dependencies": {
-        "rimraf": "^3.0.0"
+        "thenify": ">= 3.1.0 < 4"
       },
       "engines": {
-        "node": ">=8.17.0"
+        "node": ">=0.8"
       }
     },
     "node_modules/to-regex-range": {
@@ -2478,13 +2038,10 @@
         "node": ">=8.0"
       }
     },
-    "node_modules/universalify": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz",
-      "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==",
-      "engines": {
-        "node": ">= 10.0.0"
-      }
+    "node_modules/ts-interface-checker": {
+      "version": "0.1.13",
+      "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
+      "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="
     },
     "node_modules/update-browserslist-db": {
       "version": "1.0.10",
@@ -2711,25 +2268,17 @@
       "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
       "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
     },
-    "node_modules/xtend": {
-      "version": "4.0.2",
-      "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
-      "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
-      "engines": {
-        "node": ">=0.4"
-      }
-    },
     "node_modules/yallist": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
       "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
     },
     "node_modules/yaml": {
-      "version": "1.10.2",
-      "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
-      "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.3.tgz",
+      "integrity": "sha512-zw0VAJxgeZ6+++/su5AFoqBbZbrEakwu+X0M5HmcwUiBL7AzcuPKjj5we4xfQLp78LkEMpD0cOnUhmgOVy3KdQ==",
       "engines": {
-        "node": ">= 6"
+        "node": ">= 14"
       }
     }
   }
diff --git a/package.json b/package.json
index a372dd615e06a6bd4475e6b508f24d3e195023bb..2f24804707eab4899dae691e21d282a8b96e4172 100644
--- a/package.json
+++ b/package.json
@@ -4,14 +4,15 @@
   "description": "",
   "private": true,
   "dependencies": {
-    "@tailwindcss/typography": "^0.4.1",
+    "@tailwindcss/typography": "^0.5.10",
     "alertifyjs": "^1.13.1",
     "css-loader": "^6.7.3",
+    "easytimer.js": "^4.5.4",
     "jquery": "^3.6.4",
     "js-cookie": "^3.0.1",
     "select2": "^4.1.0-rc.0",
     "style-loader": "^3.3.2",
-    "tailwindcss": "^2.2.2",
+    "tailwindcss": "^3.3.3",
     "webpack": "^5.77.0",
     "webpack-bundle-tracker": "^1.8.1",
     "webpack-cli": "^5.0.1"
diff --git a/requirements/base.txt b/requirements/base.txt
index 287bf10146a2e892c511e89d51f443ee0c7ffce0..85f88b99360d1f80e27ae7c9065cb04e59704b87 100644
--- a/requirements/base.txt
+++ b/requirements/base.txt
@@ -1,3 +1,4 @@
+channels[daphne]==4.0.0
 Django==4.1.5
 django-database-url==1.0.3
 django-environ==0.9.0
@@ -6,3 +7,4 @@ django-http-exceptions==1.4.0
 gql[requests]==3.4.0
 psycopg2-binary==2.9.5
 requests==2.28.2
+websockets==11.0.3
diff --git a/requirements/production.txt b/requirements/production.txt
index ce5169e4440b67843ee5d28199ed63e0d8323cfc..088e218c836756413516b33d6f4aaffc0971ef33 100644
--- a/requirements/production.txt
+++ b/requirements/production.txt
@@ -1,2 +1,3 @@
-gunicorn==20.1.0
 whitenoise==6.3.0
+uvicorn==0.23.2
+gunicorn==21.2.0
diff --git a/run.sh b/run.sh
index 5d9a7ada6bf4106d23769d523da59ae1e68e3720..402790eba0a74f9db9121b8607f176a225e70638 100644
--- a/run.sh
+++ b/run.sh
@@ -7,4 +7,4 @@ set -e
 python manage.py migrate
 
 # start webserver
-exec gunicorn -c gunicorn.conf.py rybicka.wsgi
+exec gunicorn -c gunicorn.conf.py rybicka.asgi:application -k uvicorn.workers.UvicornWorker
diff --git a/rybicka/asgi.py b/rybicka/asgi.py
index 851133e205c80ad01650d5120324f179fcb15f90..59bb6e0abd2b1719eb44521b9e544101b7c45b5e 100644
--- a/rybicka/asgi.py
+++ b/rybicka/asgi.py
@@ -1,16 +1,22 @@
-"""
-ASGI config for rybicka project.
-
-It exposes the ASGI callable as a module-level variable named ``application``.
-
-For more information on this file, see
-https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/
-"""
-
 import os
 
+from channels.auth import AuthMiddlewareStack
+from channels.routing import ProtocolTypeRouter, URLRouter
+from channels.security.websocket import AllowedHostsOriginValidator
 from django.core.asgi import get_asgi_application
 
-os.environ.setdefault("DJANGO_SETTINGS_MODULE", "rybicka.settings")
+from timer.routing import websocket_urlpatterns
+
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "rybicka.settings.production")
+# Initialize Django ASGI application early to ensure the AppRegistry
+# is populated before importing code that may import ORM models.
+django_asgi_app = get_asgi_application()
 
-application = get_asgi_application()
+application = ProtocolTypeRouter(
+    {
+        "http": django_asgi_app,
+        "websocket": AllowedHostsOriginValidator(
+            AuthMiddlewareStack(URLRouter(websocket_urlpatterns))
+        ),
+    }
+)
diff --git a/rybicka/settings/base.py b/rybicka/settings/base.py
index fc2d8af8fd05389189842e1926b717c2e84e5e69..0fe4966f7b484930b42a8c5186c5343cf0cf4f94 100644
--- a/rybicka/settings/base.py
+++ b/rybicka/settings/base.py
@@ -42,8 +42,11 @@ SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
 # Application definition
 
 INSTALLED_APPS = [
+    "daphne",
+    "django.contrib.auth",
     "django.contrib.contenttypes",
     "django.contrib.sessions",
+    "django.contrib.sites",
     "django.contrib.messages",
     "django.contrib.staticfiles",
     "webpack_loader",
@@ -51,6 +54,7 @@ INSTALLED_APPS = [
     "member_group_size_calc",
     "rv_voting_calc",
     "mail_signature",
+    "timer",
     "asset_server_resize",
 ]
 
@@ -82,7 +86,7 @@ TEMPLATES = [
     },
 ]
 
-WSGI_APPLICATION = "rybicka.wsgi.application"
+ASGI_APPLICATION = "rybicka.asgi.application"
 
 
 # Database
@@ -124,3 +128,6 @@ CHOBOTNICE_API_URL = env.str(
     "CHOBOTNICE_API_URL", "https://chobotnice.pirati.cz/graphql/"
 )
 CHOBOTNICE_RV_GID = env.str("CHOBOTNICE_RV_GID")
+
+# FIXME
+CHANNEL_LAYERS = {"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"}}
diff --git a/rybicka/urls.py b/rybicka/urls.py
index 1ddf91e069fa46fac44d2a45a5cdec2239df044f..634412f8340996778355ac3b9f22a420639e77af 100644
--- a/rybicka/urls.py
+++ b/rybicka/urls.py
@@ -19,6 +19,7 @@ urlpatterns = [
     path("vypocet-skupiny-clenu/", include("member_group_size_calc.urls")),
     path("hlasovani-rv/", include("rv_voting_calc.urls")),
     path("emailove-podpisy/", include("mail_signature.urls")),
+    path("casovace/", include("timer.urls")),
     path("asset-server/", include("asset_server_resize.urls")),
     path("", include("shared.urls")),
 ]
diff --git a/shared/static/shared/timer.webp b/shared/static/shared/timer.webp
new file mode 100644
index 0000000000000000000000000000000000000000..9671ccf1eab3616ca75d824e4acb77ff52a58823
Binary files /dev/null and b/shared/static/shared/timer.webp differ
diff --git a/shared/templates/shared/base.html b/shared/templates/shared/base.html
index 47bb0c82724e93f5478c6ee079a58d462015e4a0..e0e1da9ef6ce2634bc2f0370041b5ff369c34874 100644
--- a/shared/templates/shared/base.html
+++ b/shared/templates/shared/base.html
@@ -45,40 +45,46 @@
         {% block head %}{% endblock %}
     </head>
     <body>
-        <nav class="navbar navbar--simple __js-root">
-            <ui-app inline-template>
-                <ui-navbar inline-template>
-                    <div>
-                        <div class="container container--default navbar__content navbar__content--initialized">
-                            <div class="navbar__brand flex items-center pr-8 my-4 lg:my-0">
-                                <a href="{% url "shared:index" %}">
-                                    <img src="https://styleguide.pirati.cz/2.3.x/images/logo-round-white.svg" class="w-8">
-                                </a>
-                                <div class="pl-4 font-bold text-xl border-r border-grey-300 pr-8">
-                                    <a href="{% url "shared:index" %}">Rybička</a>
+        {% block nav %}
+            <nav class="navbar navbar--simple __js-root">
+                <ui-app inline-template>
+                    <ui-navbar inline-template>
+                        <div>
+                            <div class="container container--default navbar__content navbar__content--initialized">
+                                <div class="navbar__brand flex items-center pr-8 my-4 lg:my-0">
+                                    <a href="{% url "shared:index" %}">
+                                        <img src="https://styleguide.pirati.cz/2.3.x/images/logo-round-white.svg" class="w-8">
+                                    </a>
+                                    <div class="pl-4 font-bold text-xl border-r border-grey-300 pr-8">
+                                        <a href="{% url "shared:index" %}">Rybička</a>
+                                    </div>
                                 </div>
+                                {% block header_name %}{% endblock %}
                             </div>
-                            {% block header_name %}{% endblock %}
+                        </div>
+                    </ui-navbar>
+                </ui-app>
+            </nav>
+        {% endblock %}
+        {% block raw_content %}
+            <div class="container container--default py-8 lg:py-24">
+                {% block content %}{% endblock %}
+            </div>
+        {% endblock %}
+        {% block footer %}
+            <footer class="footer bg-grey-700 text-white __js-root hidden lg:block">
+                <ui-app inline-template>
+                    <div>
+                        <div class="footer__main py-4 lg:py-16 container container--default">
+                            <section class="footer__brand">
+                                <p class="para text-grey-200">
+                                    <span class="copyleft inline-block">&copy;</span> {% now "Y" %} Piráti. Všechna práva vyhlazena. Sdílejte a nechte ostatní sdílet za stejných podmínek.
+                                </p>
+                            </section>
                         </div>
                     </div>
-                </ui-navbar>
-            </ui-app>
-        </nav>
-        <div class="container container--default py-8 lg:py-24">
-            {% block content %}{% endblock %}
-        </div>
-        <footer class="footer bg-grey-700 text-white __js-root hidden lg:block">
-            <ui-app inline-template>
-                <div>
-                    <div class="footer__main py-4 lg:py-16 container container--default">
-                        <section class="footer__brand">
-                            <p class="para text-grey-200">
-                                <span class="copyleft inline-block">&copy;</span> {% now "Y" %} Piráti. Všechna práva vyhlazena. Sdílejte a nechte ostatní sdílet za stejných podmínek.
-                            </p>
-                        </section>
-                    </div>
-                </div>
-            </ui-app>
-        </footer>
+                </ui-app>
+            </footer>
+        {% endblock %}
     </body>
 </html>
diff --git a/shared/templates/shared/index.html b/shared/templates/shared/index.html
index 680242ceb6d3e01e6f892fd117de382fc69c626a..9c5c7e4d904b120d6d978ab6e68fef66ef5951be 100644
--- a/shared/templates/shared/index.html
+++ b/shared/templates/shared/index.html
@@ -77,6 +77,26 @@
                     </div>
                 </li>
 
+                <li class="card">
+                    <a href="{% url "timer:index" %}">
+                        <img
+                            src="{% static "shared/timer.webp" %}"
+                            alt="Časovač pro řečníky"
+                            class="w-full h-48 object-cover"
+                        >
+                    </a>
+                    <div class="p-4">
+                        <h2 class="mb-2 text-xl font-bold">
+                            <a href="{% url "timer:index" %}">
+                                Časovač pro řečníky
+                            </a>
+                        </h2>
+                        <div class="font-light text-sm break-words">
+                            Vzdáleně ovladatelný časovač pro omezení doby projevu řečníků.
+                        </div>
+                    </div>
+                </li>
+
                 <li class="card">
                     <a href="{% url "asset_server_resize:index" %}">
                         <img
diff --git a/static_src/member_group_size_calc.js b/static_src/member_group_size_calc.js
index be1f821d036ffaaab5e52a701c4bf17e3e3774cf..8c61aef2d2b9229fd228edefe4b96fc5071f9917 100644
--- a/static_src/member_group_size_calc.js
+++ b/static_src/member_group_size_calc.js
@@ -1,4 +1,4 @@
-import $ from "jquery";
+    import $ from "jquery";
 
 $(window).ready(
     () => {
diff --git a/static_src/timer.js b/static_src/timer.js
new file mode 100644
index 0000000000000000000000000000000000000000..7f952babf1747f6ba6eca40b8000b39d7301553b
--- /dev/null
+++ b/static_src/timer.js
@@ -0,0 +1,290 @@
+import $ from "jquery"
+import Timer from "easytimer.js"
+
+import alertify from "alertifyjs";
+import "alertifyjs/build/css/alertify.css";
+
+const disableInputs = () => {
+    $("#pause_play,#minutes,#seconds,#update_time,#reset_time").prop("disabled", true)
+}
+
+const enableInputs = () => {
+    $("#pause_play,#minutes,#seconds,#update_time,#reset_time").prop("disabled", false)
+}
+
+const updateTimeText = () => {
+    const timeValues = window.timer.getTimeValues()
+
+    const hours = String(timeValues.minutes + timeValues.hours * 60 + timeValues.days * 1440).padStart(2, '0')
+    const seconds = String(timeValues.seconds).padStart(2, '0')
+
+    $('#timer .timer-values').html(`${hours}:${seconds}`)
+}
+
+const assignEventListeners = () => {
+    window.timer.addEventListener(
+        'secondsUpdated',
+        (event) => {
+            updateTimeText()
+        }
+    )
+
+    window.timer.addEventListener(
+        'targetAchieved',
+        (event) => {
+            window.timerIsRunning = false
+            $("#is_counting").prop("checked", false)
+            $('#timer .timer-values').html("Konec")
+        }
+    )
+
+    updateTimeText()
+}
+
+const updateTimer = (data, options) => {
+    const timerValues = timer.getTimeValues()
+    const minutes = data["sync_time"]["minutes"]
+    const seconds = data["sync_time"]["seconds"]
+
+    if (window.timerIsRunning && (minutes === 0 && seconds <= 5)) {
+        // Don't update the time if we're running in the final 5 seconds.
+        return
+    }
+
+    if (options.minuteTolerance !== undefined && options.secondTolerance !== undefined) {
+        if (
+            (Math.abs(timerValues.minutes - minutes) < options.minuteTolerance)
+            && (Math.abs(timerValues.seconds - seconds) < options.secondTolerance)
+        ) {
+            // Don't annoy the user with time changes when there is only a 1-2 second difference.
+            return
+        } else {
+            console.warn("Timer out of sync!")
+        }
+    }
+
+    if (minutes === 0 && seconds === 0) {
+        // Let an event listener handle the timer ending.
+        return
+    }
+
+    console.info(`Updating timer: ${minutes}:${seconds}, used to be ${timerValues.minutes}:${timerValues.seconds}`)
+
+    window.startingTime = {
+        minutes: minutes,
+        seconds: seconds
+    }
+
+    window.timer.removeAllEventListeners()
+
+    window.timer = new Timer({
+        countdown: true,
+        startValues: {
+            minutes: window.startingTime.minutes,
+            seconds: window.startingTime.seconds,
+        }
+    })
+
+    assignEventListeners()
+
+    if (window.timerIsRunning) {
+        window.timer.start()
+    }
+}
+
+const syncTime = (timerSocket) => {
+    timerSocket.send(JSON.stringify({
+        "sync": window.timer.getTimeValues()
+    }))
+}
+
+$(window).ready(
+    () => {
+        disableInputs()
+
+        // --- BEGIN Timer ---
+
+        window.timerIsRunning = false
+        window.timer = new Timer({
+            countdown: true,
+            startValues: {
+                minutes: window.startingTime.minutes,
+                seconds: window.startingTime.seconds,
+            }
+        })
+
+        let timerSocket = null
+        let isInitialConnect = true
+
+        const connectToSocket = () => {
+            timerSocket = new WebSocket(
+                (
+                    (window.location.protocol === "https:") ?
+                    "wss://" : "ws://"
+                )
+                + window.location.host
+                + "/ws/timer/"
+                + window.timerId
+                + "/"
+            )
+
+            if (!isInitialConnect) {
+                alertify.success("Obnovování spojení.")
+            }
+
+            isInitialConnect = false
+
+            timerSocket.onmessage = (event) => {
+                enableInputs()
+
+                console.info("Received timer message:", event.data)
+
+                const data = JSON.parse(event.data)
+
+                if ("sync_time" in data) {
+                   updateTimer(
+                        data,
+                        {
+                            minuteTolerance: 1,
+                            secondTolerance: 2
+                        }
+                    )
+                }
+
+                if ("is_running" in data) {
+                    const remainingTime = window.timer.getTimeValues()
+
+                    if (data["is_running"]) {
+                        // Don't do anything if we have reached an inconsistent state,
+                        // where the timer is at 0 but server still reports it playing.
+                        // This will be resolved within a few milliseconds.
+
+                        if (remainingTime.minutes === 0 && remainingTime.seconds === 0) {
+                            return
+                        }
+
+                        window.timer.start()
+
+                        $("#is_counting").prop("checked", true)
+                        $("#pause_play > .btn__body").html("⏸︎")
+                        window.timerIsRunning = true
+                    } else {
+                        if (
+                            !(
+                                window.timerIsRunning
+                                && "sync_time" in data
+                                && data["sync_time"]["minutes"] === 0
+                                && data["sync_time"]["seconds"] === 0
+                            )
+                        ) {
+                            window.timer.pause()
+                            window.timerIsRunning = false
+                        }
+
+                        $("#is_counting").prop("checked", false)
+                        $("#pause_play > .btn__body").html("⏵︎")
+                    }
+                }
+            }
+
+            let interval = null
+
+            timerSocket.onopen = () => {
+                syncTime(timerSocket)
+
+                interval = setInterval(syncTime, 1000, timerSocket)
+
+                // --- BEGIN Controls ---
+
+                $("#pause_play").on(
+                    "click",
+                    (event) => {
+                        if (window.timer.getTotalTimeValues().seconds === 0) {
+                            alertify.error("Prosím, nastav čas.")
+
+                            return
+                        }
+
+                        $("#is_counting").click()
+                    }
+                )
+
+                $("#is_counting").on(
+                    "change",
+                    (event) => {
+                        disableInputs()
+
+                        if (event.target.checked) {
+                            console.info("Starting timer")
+
+                            $("#pause_play > .btn__body").html("⏸︎")
+                            timerSocket.send(JSON.stringify({
+                                "is_running": true
+                            }))
+                        } else {
+                            console.info("Stopping timer")
+
+                            $("#pause_play > .btn__body").html("⏵︎")
+                            timerSocket.send(JSON.stringify({
+                                "is_running": false
+                            }))
+                        }
+                    }
+                )
+
+                $("#update_time").on(
+                    "click",
+                    (event) => {
+                        disableInputs()
+
+                        window.timer.pause()
+
+                        let minutes = Number($("#minutes").val())
+                        let seconds = Number($("#seconds").val())
+
+                        timerSocket.send(JSON.stringify({
+                            "time": {
+                                "minutes": minutes,
+                                "seconds": seconds
+                            },
+                            "is_running": false
+                        }))
+
+                        $("#is_counting").prop("checked", false)
+                    }
+                )
+
+                $("#reset_time").on(
+                    "click",
+                    (event) => {
+                        disableInputs()
+
+                        window.timer.pause()
+
+                        timerSocket.send(JSON.stringify({
+                            "reset": true,
+                            "is_running": false
+                        }))
+                    }
+                )
+
+                // --- END Controls ---
+            }
+
+            timerSocket.onclose = (event) => {
+                disableInputs()
+
+                alertify.error("Ztráta spojení, pokoušíme se o zpětné připojení.")
+
+                setTimeout(connectToSocket, 1000)
+                clearInterval(interval)
+                $("#is_counting,#pause_play,#update_time").unbind("click")
+            }
+        }
+
+        connectToSocket()
+        assignEventListeners()
+
+        // --- END Timer ---
+    }
+)
diff --git a/tailwind.config.js b/tailwind.config.js
index 689576f4a063f1736155aff77c3d3d3f48b9738c..e2101ddc0dc9acc1cce8e705d89c4aea9d585f8f 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -3,8 +3,7 @@ const defaultTheme = require("tailwindcss/defaultTheme");
 /** @type {import('tailwindcss').Config} */
 module.exports = {
   content: [
-    "*/templates/*/*.html",
-    "*/templates/*/*/*.html",
+    "*/templates/**/*.html",
   ],
   theme: {
     extend: {
diff --git a/timer/__init__.py b/timer/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/timer/admin.py b/timer/admin.py
new file mode 100644
index 0000000000000000000000000000000000000000..8c38f3f3dad51e4585f3984282c2a4bec5349c1e
--- /dev/null
+++ b/timer/admin.py
@@ -0,0 +1,3 @@
+from django.contrib import admin
+
+# Register your models here.
diff --git a/timer/apps.py b/timer/apps.py
new file mode 100644
index 0000000000000000000000000000000000000000..2760d843bca5fc9a0b893942a598eba7aeae8929
--- /dev/null
+++ b/timer/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class TimerConfig(AppConfig):
+    default_auto_field = "django.db.models.BigAutoField"
+    name = "timer"
diff --git a/timer/consumers.py b/timer/consumers.py
new file mode 100644
index 0000000000000000000000000000000000000000..057d7c38c2eb1058dc1ef92f18b3098d48c16905
--- /dev/null
+++ b/timer/consumers.py
@@ -0,0 +1,192 @@
+import copy
+import datetime
+import json
+import threading
+import time
+import timeit
+
+from asgiref.sync import sync_to_async
+from channels.generic.websocket import AsyncWebsocketConsumer
+
+running_timer_threads = []
+
+
+def handle_expired_timer(timer) -> bool:
+    if timer.minutes == 0 and timer.seconds == 0:
+        # Stop the thread if we have reached the end
+
+        timer.is_running = False
+        timer.save()
+
+        running_timer_threads.remove(timer.id)
+
+        return True
+
+    return False
+
+
+def tick_timer(timer, iteration: int, total_seconds: int) -> None:
+    second_compensation = 0
+
+    start_time = timeit.default_timer()
+
+    timer.refresh_from_db()
+
+    end_time = timeit.default_timer()
+    second_compensation = end_time - start_time
+
+    while not timer.is_running:
+        time.sleep(0.05)
+
+        timer.refresh_from_db()
+
+        if iteration != timer.iteration:
+            # Stop the thread if there is a new timer
+
+            running_timer_threads.remove(timer.id)
+            return
+
+        handle_expired_timer(timer)
+
+    start_time = timeit.default_timer()
+
+    if iteration != timer.iteration:
+        # Stop the thread if there is a new timer
+
+        running_timer_threads.remove(timer.id)
+        return
+
+    total_seconds -= 1
+
+    seconds = total_seconds % 60
+    minutes = round((total_seconds - seconds) / 60)
+
+    timer.minutes = minutes
+    timer.seconds = seconds
+
+    handle_expired_timer(timer)
+
+    timer.save()
+
+    end_time = timeit.default_timer()
+    second_compensation += end_time - start_time
+
+    threading.Timer(
+        interval=max(0, 1 - second_compensation),
+        function=tick_timer,
+        args=(
+            timer,
+            iteration,
+            total_seconds,
+        ),
+    ).start()
+
+
+class TimerConsumer(AsyncWebsocketConsumer):
+    async def connect(self) -> None:
+        from .models import OngoingTimer
+
+        timer_id = self.scope["url_route"]["kwargs"]["id"]
+        timer = await sync_to_async(OngoingTimer.objects.filter(id=timer_id).first)()
+
+        if timer is None:
+            # Not found
+
+            await self.close()
+            return
+
+        self.timer = timer
+
+        await self.channel_layer.group_add(str(self.timer.id), self.channel_name)
+        await self.accept()
+
+        if self.timer.is_running and self.timer.id not in running_timer_threads:
+            await self.run_timer()
+
+    async def disconnect(self, close_code) -> None:
+        if hasattr(self, "timer"):
+            await self.channel_layer.group_discard(
+                str(self.timer.id), self.channel_name
+            )
+
+    async def run_timer(self, minutes: int = None, seconds: int = None) -> None:
+        self.timer.iteration += 1
+        await sync_to_async(self.timer.save)()
+
+        while self.timer.id in running_timer_threads:
+            # Wait until the thread is freed
+            time.sleep(0.05)
+
+        running_timer_threads.append(self.timer.id)
+
+        total_seconds = (self.timer.minutes * 60) + self.timer.seconds
+
+        time.sleep(1)
+
+        threading.Thread(
+            target=tick_timer,
+            args=(
+                self.timer,
+                self.timer.iteration,
+                total_seconds,
+            ),
+        ).start()
+
+    async def receive(self, text_data: str) -> None:
+        await sync_to_async(self.timer.refresh_from_db)()
+
+        json_data = json.loads(text_data)
+
+        response = {}
+
+        reset_timer = False
+
+        if "is_running" in json_data:
+            if json_data["is_running"]:
+                self.timer.is_running = True
+                await sync_to_async(self.timer.save)()
+            else:
+                self.timer.is_running = False
+                await sync_to_async(self.timer.save)()
+
+        if "time" in json_data:
+            # Don't save here in case there is a tick thread running
+
+            self.timer.minutes = self.timer.initial_minutes = json_data["time"][
+                "minutes"
+            ]
+            self.timer.seconds = self.timer.initial_seconds = json_data["time"][
+                "seconds"
+            ]
+
+            reset_timer = True
+
+        if "reset" in json_data:
+            # Don't save here in case there is a tick thread running
+
+            self.timer.minutes = self.timer.initial_minutes
+            self.timer.seconds = self.timer.initial_seconds
+
+            reset_timer = True
+
+        if reset_timer or (
+            self.timer.id not in running_timer_threads and self.timer.is_running
+        ):
+            await self.run_timer()
+
+        response.update(
+            {
+                "sync_time": {
+                    "minutes": self.timer.minutes,
+                    "seconds": self.timer.seconds,
+                },
+                "is_running": self.timer.is_running,
+            }
+        )
+
+        await self.channel_layer.group_send(
+            str(self.timer.id), {"type": "timer_message", "text": json.dumps(response)}
+        )
+
+    async def timer_message(self, event: dict) -> None:
+        await self.send(text_data=event["text"])
diff --git a/timer/forms.py b/timer/forms.py
new file mode 100644
index 0000000000000000000000000000000000000000..435696700daf25c494908066dc3621c26456aa18
--- /dev/null
+++ b/timer/forms.py
@@ -0,0 +1,30 @@
+from django import forms
+from django.core.validators import (
+    MaxValueValidator,
+    MinLengthValidator,
+    MinValueValidator,
+)
+
+
+class TimeOnlyForm(forms.Form):
+    minutes = forms.IntegerField(
+        label="Minuty",
+        validators=[
+            MinValueValidator(limit_value=0),
+            MaxValueValidator(limit_value=60),
+        ],
+    )
+
+    seconds = forms.IntegerField(
+        label="Sekundy",
+        validators=[
+            MinValueValidator(limit_value=0),
+            MaxValueValidator(limit_value=60),
+        ],
+    )
+
+
+class NewTimerForm(TimeOnlyForm):
+    name = forms.CharField(
+        label="Název", max_length=64, validators=[MinLengthValidator(limit_value=1)]
+    )
diff --git a/timer/migrations/0001_initial.py b/timer/migrations/0001_initial.py
new file mode 100644
index 0000000000000000000000000000000000000000..93e00c45a863e2f6e2f6cb051a5f591145e0c179
--- /dev/null
+++ b/timer/migrations/0001_initial.py
@@ -0,0 +1,57 @@
+# Generated by Django 4.1.5 on 2023-08-24 13:11
+
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    initial = True
+
+    dependencies = []
+
+    operations = [
+        migrations.CreateModel(
+            name="OngoingTimer",
+            fields=[
+                (
+                    "id",
+                    models.BigAutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("name", models.CharField(max_length=64, verbose_name="Název")),
+                (
+                    "hours",
+                    models.IntegerField(
+                        validators=[
+                            django.core.validators.MinValueValidator(limit_value=0)
+                        ],
+                        verbose_name="Hodiny",
+                    ),
+                ),
+                (
+                    "minutes",
+                    models.IntegerField(
+                        validators=[
+                            django.core.validators.MinValueValidator(limit_value=0),
+                            django.core.validators.MaxValueValidator(limit_value=60),
+                        ],
+                        verbose_name="Minuty",
+                    ),
+                ),
+                (
+                    "seconds",
+                    models.IntegerField(
+                        validators=[
+                            django.core.validators.MinValueValidator(limit_value=0),
+                            django.core.validators.MaxValueValidator(limit_value=60),
+                        ],
+                        verbose_name="Minuty",
+                    ),
+                ),
+            ],
+        ),
+    ]
diff --git a/timer/migrations/0002_alter_ongoingtimer_name.py b/timer/migrations/0002_alter_ongoingtimer_name.py
new file mode 100644
index 0000000000000000000000000000000000000000..338b6da52b461b951a137c3f14ca0cb94135ada8
--- /dev/null
+++ b/timer/migrations/0002_alter_ongoingtimer_name.py
@@ -0,0 +1,22 @@
+# Generated by Django 4.1.5 on 2023-08-25 11:53
+
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("timer", "0001_initial"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="ongoingtimer",
+            name="name",
+            field=models.CharField(
+                max_length=64,
+                validators=[django.core.validators.MinLengthValidator(limit_value=1)],
+                verbose_name="Název",
+            ),
+        ),
+    ]
diff --git a/timer/migrations/0003_remove_ongoingtimer_hours.py b/timer/migrations/0003_remove_ongoingtimer_hours.py
new file mode 100644
index 0000000000000000000000000000000000000000..d3f4014da3cdef07cfc53e68d58e9cbef631541c
--- /dev/null
+++ b/timer/migrations/0003_remove_ongoingtimer_hours.py
@@ -0,0 +1,16 @@
+# Generated by Django 4.1.5 on 2023-08-25 16:47
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("timer", "0002_alter_ongoingtimer_name"),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name="ongoingtimer",
+            name="hours",
+        ),
+    ]
diff --git a/timer/migrations/0004_ongoingtimer_is_running_ongoingtimer_iteration.py b/timer/migrations/0004_ongoingtimer_is_running_ongoingtimer_iteration.py
new file mode 100644
index 0000000000000000000000000000000000000000..ad0f3401236e0295162831d1e70eac435c8c1e09
--- /dev/null
+++ b/timer/migrations/0004_ongoingtimer_is_running_ongoingtimer_iteration.py
@@ -0,0 +1,22 @@
+# Generated by Django 4.1.5 on 2023-10-22 09:05
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("timer", "0003_remove_ongoingtimer_hours"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="ongoingtimer",
+            name="is_running",
+            field=models.BooleanField(default=False, verbose_name="Aktuálně běží"),
+        ),
+        migrations.AddField(
+            model_name="ongoingtimer",
+            name="iteration",
+            field=models.IntegerField(default=0, verbose_name="Iterace"),
+        ),
+    ]
diff --git a/timer/migrations/0005_ongoingtimer_initial_minutes_and_more.py b/timer/migrations/0005_ongoingtimer_initial_minutes_and_more.py
new file mode 100644
index 0000000000000000000000000000000000000000..680f363fc912617040264bfd2a5d19a2945b7461
--- /dev/null
+++ b/timer/migrations/0005_ongoingtimer_initial_minutes_and_more.py
@@ -0,0 +1,50 @@
+# Generated by Django 4.1.5 on 2023-10-24 20:06
+
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("timer", "0004_ongoingtimer_is_running_ongoingtimer_iteration"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="ongoingtimer",
+            name="initial_minutes",
+            field=models.IntegerField(
+                default=0,
+                validators=[
+                    django.core.validators.MinValueValidator(limit_value=0),
+                    django.core.validators.MaxValueValidator(limit_value=60),
+                ],
+                verbose_name="Původní Minuty",
+            ),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name="ongoingtimer",
+            name="initial_seconds",
+            field=models.IntegerField(
+                default=0,
+                validators=[
+                    django.core.validators.MinValueValidator(limit_value=0),
+                    django.core.validators.MaxValueValidator(limit_value=60),
+                ],
+                verbose_name="Původní sekundy",
+            ),
+            preserve_default=False,
+        ),
+        migrations.AlterField(
+            model_name="ongoingtimer",
+            name="seconds",
+            field=models.IntegerField(
+                validators=[
+                    django.core.validators.MinValueValidator(limit_value=0),
+                    django.core.validators.MaxValueValidator(limit_value=60),
+                ],
+                verbose_name="Sekundy",
+            ),
+        ),
+    ]
diff --git a/timer/migrations/__init__.py b/timer/migrations/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/timer/models.py b/timer/models.py
new file mode 100644
index 0000000000000000000000000000000000000000..a3b45aabb2a046563e2f8553411690f7907d0cc1
--- /dev/null
+++ b/timer/models.py
@@ -0,0 +1,52 @@
+from django.core.validators import (
+    MaxValueValidator,
+    MinLengthValidator,
+    MinValueValidator,
+)
+from django.db import models
+
+# Create your models here.
+
+
+class OngoingTimer(models.Model):
+    name = models.CharField(
+        max_length=64,
+        verbose_name="Název",
+        validators=[MinLengthValidator(limit_value=1)],
+    )
+
+    initial_minutes = models.IntegerField(
+        verbose_name="Původní Minuty",
+        validators=[
+            MinValueValidator(limit_value=0),
+            MaxValueValidator(limit_value=60),
+        ],
+    )
+
+    initial_seconds = models.IntegerField(
+        verbose_name="Původní sekundy",
+        validators=[
+            MinValueValidator(limit_value=0),
+            MaxValueValidator(limit_value=60),
+        ],
+    )
+
+    minutes = models.IntegerField(
+        verbose_name="Minuty",
+        validators=[
+            MinValueValidator(limit_value=0),
+            MaxValueValidator(limit_value=60),
+        ],
+    )
+
+    seconds = models.IntegerField(
+        verbose_name="Sekundy",
+        validators=[
+            MinValueValidator(limit_value=0),
+            MaxValueValidator(limit_value=60),
+        ],
+    )
+
+    is_running = models.BooleanField(verbose_name="Aktuálně běží", default=False)
+
+    iteration = models.IntegerField(verbose_name="Iterace", default=0)
diff --git a/timer/routing.py b/timer/routing.py
new file mode 100644
index 0000000000000000000000000000000000000000..5efd46f0b127ec81064374b0768113cbab237491
--- /dev/null
+++ b/timer/routing.py
@@ -0,0 +1,7 @@
+from django.urls import path
+
+from . import consumers
+
+websocket_urlpatterns = [
+    path("ws/timer/<int:id>/", consumers.TimerConsumer.as_asgi()),
+]
diff --git a/timer/templates/timer/create.html b/timer/templates/timer/create.html
new file mode 100644
index 0000000000000000000000000000000000000000..4de0a7086d697a64e6bbeb2663ce4728cd86498a
--- /dev/null
+++ b/timer/templates/timer/create.html
@@ -0,0 +1,37 @@
+{% extends "shared/base.html" %}
+
+{% load render_bundle from webpack_loader %}
+
+{% block title %}Nový časovač{% endblock %}
+{% block header_name %}Časovače{% endblock %}
+{% block description %}{% endblock %}
+
+{% block head %}
+    <link
+        rel="stylesheet"
+        href="https://styleguide.pirati.cz/2.12.x/css/styles.css"
+    >
+{% endblock %}
+
+{% block content %}
+    <main>
+        <h1 class="text-6xl font-bebas mb-5">Nový časovač</h1>
+
+        <form
+            class="
+                flex flex-col gap-2
+                [&_label]:w-24 [&_label]:inline-block
+                [&_input]:bg-gray-100 [&_input]:border [&_input]:border-gray-100 [&_input]:px-2 [&_input]:py-1
+            "
+            method="post"
+        >
+            {% csrf_token %}
+
+            {{ form.as_div }}
+
+            <button class="btn mt-4">
+                <div class="btn__body">Vytvořit</div>
+            </button>
+        </form>
+    </main>
+{% endblock %}
diff --git a/timer/templates/timer/delete_timer.html b/timer/templates/timer/delete_timer.html
new file mode 100644
index 0000000000000000000000000000000000000000..62d7582ed141a4d53ab8c31f3a499700dfc22afa
--- /dev/null
+++ b/timer/templates/timer/delete_timer.html
@@ -0,0 +1,54 @@
+{% extends "shared/base.html" %}
+
+{% load render_bundle from webpack_loader %}
+
+{% block title %}Časovače{% endblock %}
+{% block header_name %}Časovače{% endblock %}
+{% block description %}{% endblock %}
+
+{% block head %}
+    <link
+        rel="stylesheet"
+        href="https://styleguide.pirati.cz/2.12.x/css/styles.css"
+    >
+    {% render_bundle "timer" %}
+{% endblock %}
+
+{% block content %}
+    <main>
+        <h1 class="text-6xl font-bebas">Odstranění časovače {{ timer.name }}</h1>
+
+        <div class="prose mb-4">
+            Skutečně chceš odstranit časovač {{ timer.name }}? Tento krok nelze vrátit zpět.
+        </div>
+
+        <div class="flex gap-2">
+            <a
+                class="btn btn--icon btn--black"
+                href="{% url 'timer:edit_timer' timer.id %}"
+            >
+                <div class="btn__body-wrap">
+                    <div class="btn__body">Zrušit</div>
+                    <div class="btn__icon">
+                        <i class="ico--cross"></i>
+                    </div>
+                </div>
+            </a>
+
+            <form method="post">
+                {% csrf_token %}
+                <button
+                    class="btn btn--icon btn--red-600"
+                    href="{% url 'timer:edit_timer' timer.id %}"
+                >
+                    <div class="btn__body-wrap">
+                        <div class="btn__body">Smazat</div>
+                        <div class="btn__icon">
+                            <i class="ico--checkmark"></i>
+                        </div>
+                    </div>
+                </a>
+            </form>
+        </div>
+    </main>
+{% endblock %}
diff --git a/timer/templates/timer/edit_timer.html b/timer/templates/timer/edit_timer.html
new file mode 100644
index 0000000000000000000000000000000000000000..f96249971d361e269d85bf6862d7cd8c535a1ab3
--- /dev/null
+++ b/timer/templates/timer/edit_timer.html
@@ -0,0 +1,108 @@
+{% extends "shared/base.html" %}
+
+{% load render_bundle from webpack_loader %}
+
+{% block title %}Časovače{% endblock %}
+{% block header_name %}Časovače{% endblock %}
+{% block description %}{% endblock %}
+
+{% block head %}
+    <link
+        rel="stylesheet"
+        href="https://styleguide.pirati.cz/2.12.x/css/styles.css"
+    >
+    {% render_bundle "timer" %}
+{% endblock %}
+
+{% block content %}
+    <script>
+        window.startingTime = {
+            minutes: {{ timer.minutes }},
+            seconds: {{ timer.seconds }},
+        }
+
+        window.timerId = "{{ timer.id }}"
+    </script>
+
+    <main>
+        <h1 class="text-6xl font-bebas">Úprava časovače {{ timer.name }}</h1>
+
+        <a class="hover:no-underline" href="{% url 'timer:view_timer' timer.id %}">
+            <i class="ico--chevron-left"></i>
+            <span class="underline">Zpět na zobrazení</span>
+        </a>
+
+        <hr>
+
+        <div class="text-xl">
+            <div id="timer">
+                <div class="timer-values"></div>
+            </div>
+        </div>
+
+        <hr>
+
+        <div class="flex flex-col gap-5">
+            <div>
+                <div class="hidden">
+                    <input
+                        type="checkbox"
+                        id="is_counting"
+                        name="is_counting"
+                        autocomplete="off"
+                        value="false"
+                    >
+                </div>
+                <button
+                    id="pause_play"
+                    class="btn w-64 text-xl"
+                >
+                    <div class="btn__body">⏵︎</div>
+                </button>
+            </div>
+            <div class="flex flex-col gap-2">
+                <input
+                    class="w-64 text-lg"
+                    type="number"
+                    id="minutes"
+                    name="minutes"
+                    min="0"
+                    max="60"
+                    placeholder="Minuty"
+                    autocomplete="off"
+                >
+                <input
+                    class="w-64 text-lg"
+                    type="number"
+                    id="seconds"
+                    name="seconds"
+                    min="0"
+                    max="60"
+                    placeholder="Sekundy"
+                    autocomplete="off"
+                >
+                <button
+                    id="update_time"
+                    class="btn w-64"
+                >
+                    <div class="btn__body">Aktualizovat čas</div>
+                </button>
+                <button
+                    id="reset_time"
+                    class="btn w-64"
+                >
+                    <div class="btn__body">Resetovat čas</div>
+                </button>
+            </div>
+
+            <a
+                id="delete-timer"
+                class="btn btn--red-600 w-64"
+                role="button"
+                href="{% url 'timer:delete_timer' timer.id %}"
+            >
+                <div class="btn__body">Smazat časovač</div>
+            </a>
+        </div>
+    </main>
+{% endblock %}
diff --git a/timer/templates/timer/index.html b/timer/templates/timer/index.html
new file mode 100644
index 0000000000000000000000000000000000000000..dec7857d7c85e0518f325e36d96c81fd43101399
--- /dev/null
+++ b/timer/templates/timer/index.html
@@ -0,0 +1,42 @@
+{% extends "shared/base.html" %}
+
+{% load render_bundle from webpack_loader %}
+
+{% block title %}Časovače{% endblock %}
+{% block header_name %}Časovače{% endblock %}
+{% block description %}{% endblock %}
+
+{% block head %}
+    <link
+        rel="stylesheet"
+        href="https://styleguide.pirati.cz/2.12.x/css/styles.css"
+    >
+{% endblock %}
+
+{% block content %}
+    <main>
+        <h1 class="text-6xl font-bebas mb">Časovače</h1>
+        <h2 class="text-3xl font-bebas mb-5">Seznam aktivních časovačů</h2>
+
+        <div class="prose max-w-none mb-8">
+            <ul>
+                {% for timer in ongoing_timers %}
+                    <li>
+                        <a
+                            href="{% url 'timer:view_timer' timer.id %}"
+                        >{{ timer.name }}</a>
+                    </li>
+                {% endfor %}
+            </ul>
+        </div>
+
+        <a class="btn btn--icon" href="{% url 'timer:create' %}">
+            <div class="btn__body-wrap">
+                <div class="btn__body">Nový časovač</div>
+                <div class="btn__icon">
+                    <i class="ico--chevron-right"></i>
+                </div>
+            </div>
+        </a>
+    </main>
+{% endblock %}
diff --git a/timer/templates/timer/view_timer.html b/timer/templates/timer/view_timer.html
new file mode 100644
index 0000000000000000000000000000000000000000..01ecb2197ea19df370b6bec1ccb12ccb35405419
--- /dev/null
+++ b/timer/templates/timer/view_timer.html
@@ -0,0 +1,54 @@
+{% extends "shared/base.html" %}
+
+{% load render_bundle from webpack_loader %}
+
+{% block title %}Časovače{% endblock %}
+{% block header_name %}Časovače{% endblock %}
+{% block description %}{% endblock %}
+
+{% block head %}
+    <link
+        rel="stylesheet"
+        href="https://styleguide.pirati.cz/2.12.x/css/styles.css"
+    >
+    {% render_bundle "timer" %}
+{% endblock %}
+
+{% block nav %}{% endblock %}
+
+{% block raw_content %}
+    <script>
+        window.startingTime = {
+            minutes: {{ timer.minutes }},
+            seconds: {{ timer.seconds }},
+        }
+
+        window.timerId = "{{ timer.id }}"
+    </script>
+
+    <main class="text-center bg-black text-white h-screen flex flex-col justify-between py-5">
+        <h1 class="text-6xl font-bebas mb">{{ timer.name }}</h1>
+
+        <div
+            class="my-5"
+            style="font-size:15rem" {% comment %}TODO{% endcomment %}
+        >
+            <div id="timer">
+                <div class="timer-values">{{ timer.hours }}:{{ timer.minutes }}:{{ timer.seconds }}</div>
+            </div>
+        </div>
+
+        <div class="flex gap-4 justify-center">
+            <a
+                class="text-white opacity-25 text-sm"
+                href="{% url 'timer:edit_timer' timer.id %}"
+            >Ovládání</a>
+            <a
+                class="text-white opacity-25 text-sm"
+                href="{% url 'timer:index' %}"
+            >Zpět na seznam</a>
+        </div>
+    </main>
+{% endblock %}
+
+{% block footer %}{% endblock %}
diff --git a/timer/tests.py b/timer/tests.py
new file mode 100644
index 0000000000000000000000000000000000000000..7ce503c2dd97ba78597f6ff6e4393132753573f6
--- /dev/null
+++ b/timer/tests.py
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/timer/urls.py b/timer/urls.py
new file mode 100644
index 0000000000000000000000000000000000000000..a58f10cfa69ead034b2eccc39c8a87eb653f79b5
--- /dev/null
+++ b/timer/urls.py
@@ -0,0 +1,12 @@
+from django.urls import path
+
+from . import views
+
+app_name = "timer"
+urlpatterns = [
+    path("", views.index, name="index"),
+    path("novy", views.create, name="create"),
+    path("<int:id>", views.view_timer, name="view_timer"),
+    path("<int:id>/uprava", views.edit_timer, name="edit_timer"),
+    path("<int:id>/smazat", views.delete_timer, name="delete_timer"),
+]
diff --git a/timer/views.py b/timer/views.py
new file mode 100644
index 0000000000000000000000000000000000000000..26b74f472e2794f8178f32fcef94216ba441736d
--- /dev/null
+++ b/timer/views.py
@@ -0,0 +1,57 @@
+from django.shortcuts import get_object_or_404, redirect, render
+
+from .forms import NewTimerForm, TimeOnlyForm
+from .models import OngoingTimer
+
+
+def index(request):
+    return render(
+        request,
+        "timer/index.html",
+        {
+            "ongoing_timers": OngoingTimer.objects.all(),
+        },
+    )
+
+
+def create(request):
+    if request.method == "POST":
+        form = NewTimerForm(request.POST)
+
+        if form.is_valid():
+            timer = OngoingTimer(
+                initial_minutes=form.cleaned_data["minutes"],
+                initial_seconds=form.cleaned_data["seconds"],
+                minutes=form.cleaned_data["minutes"],
+                seconds=form.cleaned_data["seconds"],
+                name=form.cleaned_data["name"],
+            )
+            timer.save()
+
+            return redirect("timer:view_timer", id=timer.id)
+    else:
+        form = NewTimerForm()
+
+    return render(request, "timer/create.html", {"form": form})
+
+
+def view_timer(request, id: int):
+    timer = get_object_or_404(OngoingTimer, id=id)
+
+    return render(request, "timer/view_timer.html", {"timer": timer})
+
+
+def edit_timer(request, id: int):
+    timer = get_object_or_404(OngoingTimer, id=id)
+
+    return render(request, "timer/edit_timer.html", {"timer": timer})
+
+
+def delete_timer(request, id: int):
+    timer = get_object_or_404(OngoingTimer, id=id)
+
+    if request.method == "POST":
+        timer.delete()
+        return redirect("timer:index")
+
+    return render(request, "timer/delete_timer.html", {"timer": timer})
diff --git a/webpack.config.js b/webpack.config.js
index 64386ccabf56b6fcb90831314851d5ee99c0b244..ce84878402ff4474e3758b8f9ef44a1e2b6c21da 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -21,6 +21,10 @@ module.exports = {
       import: path.resolve("static_src", "mail_signature.js"),
       dependOn: "shared",
     },
+    timer: {
+      import: path.resolve("static_src", "timer.js"),
+      dependOn: "shared",
+    },
     asset_server_resize: {
       import: path.resolve("static_src", "asset_server_resize.js"),
       dependOn: "shared",
@@ -29,7 +33,7 @@ module.exports = {
   },
   output: {
     path: path.resolve(__dirname, "shared", "static", "shared"),
-    filename: "[name]-[fullhash].js",
+    filename: "[name].js",
   },
   module: {
     rules: [