diff --git a/.eslintrc.yaml b/.eslintrc.yaml
index 293d435b11..59eff85dc3 100644
--- a/.eslintrc.yaml
+++ b/.eslintrc.yaml
@@ -58,10 +58,10 @@ overrides:
       worker: true
     rules:
       no-restricted-globals: [2, addEventListener, blur, close, closed, confirm, defaultStatus, defaultstatus, error, event, external, find, focus, frameElement, frames, history, innerHeight, innerWidth, isFinite, isNaN, length, locationbar, menubar, moveBy, moveTo, name, onblur, onerror, onfocus, onload, onresize, onunload, open, opener, opera, outerHeight, outerWidth, pageXOffset, pageYOffset, parent, print, removeEventListener, resizeBy, resizeTo, screen, screenLeft, screenTop, screenX, screenY, scroll, scrollbars, scrollBy, scrollTo, scrollX, scrollY, status, statusbar, stop, toolbar, top]
-  - files: ["*.config.*"]
+  - files: ["*.config.*", "**/*.d.ts"]
     rules:
       i/no-unused-modules: [0]
-  - files: ["**/*.test.*", "web_src/js/test/setup.js"]
+  - files: ["**/*.test.*", "web_src/js/test/setup.ts"]
     env:
       vitest-globals/env: true
     rules:
@@ -114,7 +114,7 @@ overrides:
       vitest/valid-describe-callback: [2]
       vitest/valid-expect: [2]
       vitest/valid-title: [2]
-  - files: ["web_src/js/modules/fetch.js", "web_src/js/standalone/**/*"]
+  - files: ["web_src/js/modules/fetch.ts", "web_src/js/standalone/**/*"]
     rules:
       no-restricted-syntax: [2, WithStatement, ForInStatement, LabeledStatement, SequenceExpression]
   - files: ["**/*.vue"]
@@ -467,7 +467,7 @@ rules:
   no-dupe-else-if: [2]
   no-dupe-keys: [2]
   no-duplicate-case: [2]
-  no-duplicate-imports: [2]
+  no-duplicate-imports: [0]
   no-else-return: [2]
   no-empty-character-class: [2]
   no-empty-function: [0]
@@ -619,7 +619,7 @@ rules:
   no-restricted-exports: [0]
   no-restricted-globals: [2, addEventListener, blur, close, closed, confirm, defaultStatus, defaultstatus, error, event, external, find, focus, frameElement, frames, history, innerHeight, innerWidth, isFinite, isNaN, length, location, locationbar, menubar, moveBy, moveTo, name, onblur, onerror, onfocus, onload, onresize, onunload, open, opener, opera, outerHeight, outerWidth, pageXOffset, pageYOffset, parent, print, removeEventListener, resizeBy, resizeTo, screen, screenLeft, screenTop, screenX, screenY, scroll, scrollbars, scrollBy, scrollTo, scrollX, scrollY, self, status, statusbar, stop, toolbar, top, __dirname, __filename]
   no-restricted-imports: [0]
-  no-restricted-syntax: [2, WithStatement, ForInStatement, LabeledStatement, SequenceExpression, {selector: "CallExpression[callee.name='fetch']", message: "use modules/fetch.js instead"}]
+  no-restricted-syntax: [2, WithStatement, ForInStatement, LabeledStatement, SequenceExpression, {selector: "CallExpression[callee.name='fetch']", message: "use modules/fetch.ts instead"}]
   no-return-assign: [0]
   no-script-url: [2]
   no-self-assign: [2, {props: true}]
diff --git a/Makefile b/Makefile
index d329a4ac3b..1432467bcc 100644
--- a/Makefile
+++ b/Makefile
@@ -144,7 +144,7 @@ TAR_EXCLUDES := .git data indexers queues log node_modules $(EXECUTABLE) $(FOMAN
 GO_DIRS := build cmd models modules routers services tests
 WEB_DIRS := web_src/js web_src/css
 
-ESLINT_FILES := web_src/js tools *.js tests/e2e
+ESLINT_FILES := web_src/js tools *.js *.ts tests/e2e
 STYLELINT_FILES := web_src/css web_src/js/components/*.vue
 SPELLCHECK_FILES := $(GO_DIRS) $(WEB_DIRS) docs/content templates options/locale/locale_en-US.ini .github $(filter-out CHANGELOG.md, $(wildcard *.go *.js *.md *.yml *.yaml *.toml))
 EDITORCONFIG_FILES := templates .github/workflows options/locale/locale_en-US.ini
@@ -376,12 +376,12 @@ lint-backend-fix: lint-go-fix lint-go-vet lint-editorconfig
 .PHONY: lint-js
 lint-js: node_modules
 	npx eslint --color --max-warnings=0 --ext js,ts,vue $(ESLINT_FILES)
-	npx tsc
+#	npx tsc
 
 .PHONY: lint-js-fix
 lint-js-fix: node_modules
 	npx eslint --color --max-warnings=0 --ext js,ts,vue $(ESLINT_FILES) --fix
-	npx tsc
+#	npx tsc
 
 .PHONY: lint-css
 lint-css: node_modules
diff --git a/package-lock.json b/package-lock.json
index 502489e726..3b84ebaa88 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -69,6 +69,16 @@
         "@stoplight/spectral-cli": "6.11.1",
         "@stylistic/eslint-plugin-js": "2.2.1",
         "@stylistic/stylelint-plugin": "2.1.2",
+        "@types/dropzone": "5.7.8",
+        "@types/jquery": "3.5.30",
+        "@types/katex": "0.16.7",
+        "@types/license-checker-webpack-plugin": "0.2.4",
+        "@types/pdfobject": "2.2.5",
+        "@types/sortablejs": "1.15.8",
+        "@types/swagger-ui-dist": "3.30.5",
+        "@types/throttle-debounce": "5.0.2",
+        "@types/tinycolor2": "1.4.6",
+        "@types/toastify-js": "1.12.3",
         "@typescript-eslint/eslint-plugin": "7.14.1",
         "@typescript-eslint/parser": "7.14.1",
         "@vitejs/plugin-vue": "5.0.5",
@@ -2271,6 +2281,16 @@
         "@types/ms": "*"
       }
     },
+    "node_modules/@types/dropzone": {
+      "version": "5.7.8",
+      "resolved": "https://registry.npmjs.org/@types/dropzone/-/dropzone-5.7.8.tgz",
+      "integrity": "sha512-+L0/KRMuB8cIiCe5AfF448nGMpY+gHiSakqsqT3plEIfgqSV+gcVs1AkngM9zZG8hi6lgMxy4iYEuGXXmqjYvg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/jquery": "*"
+      }
+    },
     "node_modules/@types/es-aggregate-error": {
       "version": "1.0.6",
       "resolved": "https://registry.npmjs.org/@types/es-aggregate-error/-/es-aggregate-error-1.0.6.tgz",
@@ -2303,6 +2323,16 @@
       "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz",
       "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw=="
     },
+    "node_modules/@types/jquery": {
+      "version": "3.5.30",
+      "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.30.tgz",
+      "integrity": "sha512-nbWKkkyb919DOUxjmRVk8vwtDb0/k8FKncmUKFi+NY+QXqWltooxTrswvz4LspQwxvLdvzBN1TImr6cw3aQx2A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/sizzle": "*"
+      }
+    },
     "node_modules/@types/json-schema": {
       "version": "7.0.15",
       "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -2314,6 +2344,23 @@
       "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
       "dev": true
     },
+    "node_modules/@types/katex": {
+      "version": "0.16.7",
+      "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.7.tgz",
+      "integrity": "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/license-checker-webpack-plugin": {
+      "version": "0.2.4",
+      "resolved": "https://registry.npmjs.org/@types/license-checker-webpack-plugin/-/license-checker-webpack-plugin-0.2.4.tgz",
+      "integrity": "sha512-QTWqHJ5T9lgm3vPwWSZnBwAB+15zl4QBfGoNDcjnthHQEP8VTV87fYfp1HVeCtrDip73xWMtasQeA4QHQ0nFLw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/webpack": "^4"
+      }
+    },
     "node_modules/@types/marked": {
       "version": "4.3.2",
       "resolved": "https://registry.npmjs.org/@types/marked/-/marked-4.3.2.tgz",
@@ -2346,12 +2393,54 @@
       "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==",
       "dev": true
     },
+    "node_modules/@types/pdfobject": {
+      "version": "2.2.5",
+      "resolved": "https://registry.npmjs.org/@types/pdfobject/-/pdfobject-2.2.5.tgz",
+      "integrity": "sha512-7gD5tqc/RUDq0PyoLemL0vEHxBYi+zY0WVaFAx/Y0jBsXFgot1vB9No1GhDZGwRGJMCIZbgAb74QG9MTyTNU/g==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/@types/sarif": {
       "version": "2.1.7",
       "resolved": "https://registry.npmjs.org/@types/sarif/-/sarif-2.1.7.tgz",
       "integrity": "sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==",
       "dev": true
     },
+    "node_modules/@types/sizzle": {
+      "version": "2.3.8",
+      "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.8.tgz",
+      "integrity": "sha512-0vWLNK2D5MT9dg0iOo8GlKguPAU02QjmZitPEsXRuJXU/OGIOt9vT9Fc26wtYuavLxtO45v9PGleoL9Z0k1LHg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/sortablejs": {
+      "version": "1.15.8",
+      "resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.8.tgz",
+      "integrity": "sha512-b79830lW+RZfwaztgs1aVPgbasJ8e7AXtZYHTELNXZPsERt4ymJdjV4OccDbHQAvHrCcFpbF78jkm0R6h/pZVg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/source-list-map": {
+      "version": "0.1.6",
+      "resolved": "https://registry.npmjs.org/@types/source-list-map/-/source-list-map-0.1.6.tgz",
+      "integrity": "sha512-5JcVt1u5HDmlXkwOD2nslZVllBBc7HDuOICfiZah2Z0is8M8g+ddAEawbmd3VjedfDHBzxCaXLs07QEmb7y54g==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/swagger-ui-dist": {
+      "version": "3.30.5",
+      "resolved": "https://registry.npmjs.org/@types/swagger-ui-dist/-/swagger-ui-dist-3.30.5.tgz",
+      "integrity": "sha512-SrXhD9L8qeIxJzN+o1kmf3wXeVf/+Km3jIdRM1+Yq3I5b/dlF5TcGr5WCVM7I/cBYpgf43/gCPIucQ13AhICiw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/tapable": {
+      "version": "1.0.12",
+      "resolved": "https://registry.npmjs.org/@types/tapable/-/tapable-1.0.12.tgz",
+      "integrity": "sha512-bTHG8fcxEqv1M9+TD14P8ok8hjxoOCkfKc8XXLaaD05kI7ohpeI956jtDOD3XHKBQrlyPughUtzm1jtVhHpA5Q==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/@types/tern": {
       "version": "0.23.9",
       "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz",
@@ -2360,6 +2449,37 @@
         "@types/estree": "*"
       }
     },
+    "node_modules/@types/throttle-debounce": {
+      "version": "5.0.2",
+      "resolved": "https://registry.npmjs.org/@types/throttle-debounce/-/throttle-debounce-5.0.2.tgz",
+      "integrity": "sha512-pDzSNulqooSKvSNcksnV72nk8p7gRqN8As71Sp28nov1IgmPKWbOEIwAWvBME5pPTtaXJAvG3O4oc76HlQ4kqQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/tinycolor2": {
+      "version": "1.4.6",
+      "resolved": "https://registry.npmjs.org/@types/tinycolor2/-/tinycolor2-1.4.6.tgz",
+      "integrity": "sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/toastify-js": {
+      "version": "1.12.3",
+      "resolved": "https://registry.npmjs.org/@types/toastify-js/-/toastify-js-1.12.3.tgz",
+      "integrity": "sha512-9RjLlbAHMSaae/KZNHGv19VG4gcLIm3YjvacCXBtfMfYn26h76YP5oxXI8k26q4iKXCB9LNfv18lsoS0JnFPTg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/uglify-js": {
+      "version": "3.17.5",
+      "resolved": "https://registry.npmjs.org/@types/uglify-js/-/uglify-js-3.17.5.tgz",
+      "integrity": "sha512-TU+fZFBTBcXj/GpDpDaBmgWk/gn96kMZ+uocaFUlV2f8a6WdMzzI44QBCmGcCiYR0Y6ZlNRiyUyKKt5nl/lbzQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "source-map": "^0.6.1"
+      }
+    },
     "node_modules/@types/unist": {
       "version": "2.0.10",
       "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz",
@@ -2371,6 +2491,43 @@
       "integrity": "sha512-XOfUup9r3Y06nFAZh3WvO0rBU4OtlfPB/vgxpjg+NRdGU6CN6djdc6OEiH+PcqHCY6eFLo9Ista73uarf4gnBg==",
       "dev": true
     },
+    "node_modules/@types/webpack": {
+      "version": "4.41.38",
+      "resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.38.tgz",
+      "integrity": "sha512-oOW7E931XJU1mVfCnxCVgv8GLFL768pDO5u2Gzk82i8yTIgX6i7cntyZOkZYb/JtYM8252SN9bQp9tgkVDSsRw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*",
+        "@types/tapable": "^1",
+        "@types/uglify-js": "*",
+        "@types/webpack-sources": "*",
+        "anymatch": "^3.0.0",
+        "source-map": "^0.6.0"
+      }
+    },
+    "node_modules/@types/webpack-sources": {
+      "version": "3.2.3",
+      "resolved": "https://registry.npmjs.org/@types/webpack-sources/-/webpack-sources-3.2.3.tgz",
+      "integrity": "sha512-4nZOdMwSPHZ4pTEZzSp0AsTM4K7Qmu40UKW4tJDiOVs20UzYF9l+qUe4s0ftfN0pin06n+5cWWDJXH+sbhAiDw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*",
+        "@types/source-list-map": "*",
+        "source-map": "^0.7.3"
+      }
+    },
+    "node_modules/@types/webpack-sources/node_modules/source-map": {
+      "version": "0.7.4",
+      "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz",
+      "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">= 8"
+      }
+    },
     "node_modules/@typescript-eslint/eslint-plugin": {
       "version": "7.14.1",
       "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.14.1.tgz",
diff --git a/package.json b/package.json
index e44415ee32..4ad52d368e 100644
--- a/package.json
+++ b/package.json
@@ -68,6 +68,16 @@
     "@stoplight/spectral-cli": "6.11.1",
     "@stylistic/eslint-plugin-js": "2.2.1",
     "@stylistic/stylelint-plugin": "2.1.2",
+    "@types/dropzone": "5.7.8",
+    "@types/jquery": "3.5.30",
+    "@types/katex": "0.16.7",
+    "@types/license-checker-webpack-plugin": "0.2.4",
+    "@types/pdfobject": "2.2.5",
+    "@types/sortablejs": "1.15.8",
+    "@types/swagger-ui-dist": "3.30.5",
+    "@types/throttle-debounce": "5.0.2",
+    "@types/tinycolor2": "1.4.6",
+    "@types/toastify-js": "1.12.3",
     "@typescript-eslint/eslint-plugin": "7.14.1",
     "@typescript-eslint/parser": "7.14.1",
     "@vitejs/plugin-vue": "5.0.5",
diff --git a/tsconfig.json b/tsconfig.json
index 7ddbada765..5640c8e741 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -26,5 +26,10 @@
     "noUnusedParameters": true,
     "noPropertyAccessFromIndexSignature": false,
     "exactOptionalPropertyTypes": false,
+    "sourceMap": true,
+    "types": [
+      "vitest/globals",
+      "./types.d.ts",
+    ],
   }
 }
diff --git a/types.d.ts b/types.d.ts
new file mode 100644
index 0000000000..9348424371
--- /dev/null
+++ b/types.d.ts
@@ -0,0 +1,4 @@
+declare module '*.svg' {
+  const value: string;
+  export default value;
+}
diff --git a/vitest.config.ts b/vitest.config.ts
index ea0fafeee8..da61450764 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -4,8 +4,8 @@ import {stringPlugin} from 'vite-string-plugin';
 
 export default defineConfig({
   test: {
-    include: ['web_src/**/*.test.js'],
-    setupFiles: ['web_src/js/vitest.setup.js'],
+    include: ['web_src/**/*.test.ts'],
+    setupFiles: ['web_src/js/vitest.setup.ts'],
     environment: 'happy-dom',
     testTimeout: 20000,
     open: false,
diff --git a/web_src/js/bootstrap.test.js b/web_src/js/bootstrap.test.ts
similarity index 88%
rename from web_src/js/bootstrap.test.js
rename to web_src/js/bootstrap.test.ts
index a6b901b92c..2938678027 100644
--- a/web_src/js/bootstrap.test.js
+++ b/web_src/js/bootstrap.test.ts
@@ -1,4 +1,4 @@
-import {showGlobalErrorMessage} from './bootstrap.js';
+import {showGlobalErrorMessage} from './bootstrap.ts';
 
 test('showGlobalErrorMessage', () => {
   document.body.innerHTML = '<div class="page-content"></div>';
diff --git a/web_src/js/bootstrap.js b/web_src/js/bootstrap.ts
similarity index 100%
rename from web_src/js/bootstrap.js
rename to web_src/js/bootstrap.ts
diff --git a/web_src/js/components/ActionRunStatus.vue b/web_src/js/components/ActionRunStatus.vue
index 7ada543fea..5181c2c475 100644
--- a/web_src/js/components/ActionRunStatus.vue
+++ b/web_src/js/components/ActionRunStatus.vue
@@ -2,8 +2,8 @@
     Please also update the template file above if this vue is modified.
     action status accepted: success, skipped, waiting, blocked, running, failure, cancelled, unknown
 -->
-<script>
-import {SvgIcon} from '../svg.js';
+<script lang="ts">
+import {SvgIcon} from '../svg.ts';
 
 export default {
   components: {SvgIcon},
diff --git a/web_src/js/components/ActivityHeatmap.vue b/web_src/js/components/ActivityHeatmap.vue
index 71f41dda1a..2d84a718e4 100644
--- a/web_src/js/components/ActivityHeatmap.vue
+++ b/web_src/js/components/ActivityHeatmap.vue
@@ -1,4 +1,4 @@
-<script>
+<script lang="ts">
 // TODO: Switch to upstream after https://github.com/razorness/vue3-calendar-heatmap/pull/34 is merged
 import {CalendarHeatmap} from '@silverwind/vue3-calendar-heatmap';
 
diff --git a/web_src/js/components/ContextPopup.vue b/web_src/js/components/ContextPopup.vue
index 8f389ea003..2963412893 100644
--- a/web_src/js/components/ContextPopup.vue
+++ b/web_src/js/components/ContextPopup.vue
@@ -1,6 +1,6 @@
-<script>
-import {SvgIcon} from '../svg.js';
-import {GET} from '../modules/fetch.js';
+<script lang="ts">
+import {SvgIcon} from '../svg.ts';
+import {GET} from '../modules/fetch.ts';
 
 const {appSubUrl, i18n} = window.config;
 
diff --git a/web_src/js/components/DashboardRepoList.vue b/web_src/js/components/DashboardRepoList.vue
index 23984b3164..afc58d3689 100644
--- a/web_src/js/components/DashboardRepoList.vue
+++ b/web_src/js/components/DashboardRepoList.vue
@@ -1,8 +1,8 @@
-<script>
+<script lang="ts">
 import {createApp, nextTick} from 'vue';
 import $ from 'jquery';
-import {SvgIcon} from '../svg.js';
-import {GET} from '../modules/fetch.js';
+import {SvgIcon} from '../svg.ts';
+import {GET} from '../modules/fetch.ts';
 
 const {appSubUrl, assetUrlPrefix, pageData} = window.config;
 
diff --git a/web_src/js/components/DiffCommitSelector.vue b/web_src/js/components/DiffCommitSelector.vue
index 53e0bce76c..a523952773 100644
--- a/web_src/js/components/DiffCommitSelector.vue
+++ b/web_src/js/components/DiffCommitSelector.vue
@@ -1,6 +1,6 @@
-<script>
-import {SvgIcon} from '../svg.js';
-import {GET} from '../modules/fetch.js';
+<script lang="ts">
+import {SvgIcon} from '../svg.ts';
+import {GET} from '../modules/fetch.ts';
 
 export default {
   components: {SvgIcon},
diff --git a/web_src/js/components/DiffFileList.vue b/web_src/js/components/DiffFileList.vue
index 806c8385bb..677afd72a3 100644
--- a/web_src/js/components/DiffFileList.vue
+++ b/web_src/js/components/DiffFileList.vue
@@ -1,6 +1,6 @@
-<script>
-import {loadMoreFiles} from '../features/repo-diff.js';
-import {diffTreeStore} from '../modules/stores.js';
+<script lang="ts">
+import {loadMoreFiles} from '../features/repo-diff.ts';
+import {diffTreeStore} from '../modules/stores.ts';
 
 export default {
   data: () => {
diff --git a/web_src/js/components/DiffFileTree.vue b/web_src/js/components/DiffFileTree.vue
index fd5120f18b..2262e3e643 100644
--- a/web_src/js/components/DiffFileTree.vue
+++ b/web_src/js/components/DiffFileTree.vue
@@ -1,9 +1,9 @@
-<script>
+<script lang="ts">
 import DiffFileTreeItem from './DiffFileTreeItem.vue';
-import {loadMoreFiles} from '../features/repo-diff.js';
-import {toggleElem} from '../utils/dom.js';
-import {diffTreeStore} from '../modules/stores.js';
-import {setFileFolding} from '../features/file-fold.js';
+import {loadMoreFiles} from '../features/repo-diff.ts';
+import {toggleElem} from '../utils/dom.ts';
+import {diffTreeStore} from '../modules/stores.ts';
+import {setFileFolding} from '../features/file-fold.ts';
 
 const LOCAL_STORAGE_KEY = 'diff_file_tree_visible';
 
diff --git a/web_src/js/components/DiffFileTreeItem.vue b/web_src/js/components/DiffFileTreeItem.vue
index 0f6e54363f..d5293af519 100644
--- a/web_src/js/components/DiffFileTreeItem.vue
+++ b/web_src/js/components/DiffFileTreeItem.vue
@@ -1,6 +1,6 @@
-<script>
-import {SvgIcon} from '../svg.js';
-import {diffTreeStore} from '../modules/stores.js';
+<script lang="ts">
+import {SvgIcon} from '../svg.ts';
+import {diffTreeStore} from '../modules/stores.ts';
 
 export default {
   components: {SvgIcon},
diff --git a/web_src/js/components/PullRequestMergeForm.vue b/web_src/js/components/PullRequestMergeForm.vue
index 9efa8840ac..fc9541b6a6 100644
--- a/web_src/js/components/PullRequestMergeForm.vue
+++ b/web_src/js/components/PullRequestMergeForm.vue
@@ -1,6 +1,6 @@
-<script>
-import {SvgIcon} from '../svg.js';
-import {toggleElem} from '../utils/dom.js';
+<script lang="ts">
+import {SvgIcon} from '../svg.ts';
+import {toggleElem} from '../utils/dom.ts';
 
 const {csrfToken, pageData} = window.config;
 
diff --git a/web_src/js/components/RepoActionView.vue b/web_src/js/components/RepoActionView.vue
index e751018f90..7b5d00aa0f 100644
--- a/web_src/js/components/RepoActionView.vue
+++ b/web_src/js/components/RepoActionView.vue
@@ -1,11 +1,11 @@
-<script>
-import {SvgIcon} from '../svg.js';
+<script lang="ts">
+import {SvgIcon} from '../svg.ts';
 import ActionRunStatus from './ActionRunStatus.vue';
 import {createApp} from 'vue';
-import {toggleElem} from '../utils/dom.js';
-import {formatDatetime} from '../utils/time.js';
-import {renderAnsi} from '../render/ansi.js';
-import {GET, POST, DELETE} from '../modules/fetch.js';
+import {toggleElem} from '../utils/dom.ts';
+import {formatDatetime} from '../utils/time.ts';
+import {renderAnsi} from '../render/ansi.ts';
+import {GET, POST, DELETE} from '../modules/fetch.ts';
 
 const sfc = {
   name: 'RepoActionView',
diff --git a/web_src/js/components/RepoActivityTopAuthors.vue b/web_src/js/components/RepoActivityTopAuthors.vue
index 295641f7e5..9d24310b16 100644
--- a/web_src/js/components/RepoActivityTopAuthors.vue
+++ b/web_src/js/components/RepoActivityTopAuthors.vue
@@ -1,4 +1,4 @@
-<script>
+<script lang="ts">
 import VueBarGraph from 'vue-bar-graph';
 import {createApp} from 'vue';
 
diff --git a/web_src/js/components/RepoBranchTagSelector.vue b/web_src/js/components/RepoBranchTagSelector.vue
index d18378bea1..00c2955f55 100644
--- a/web_src/js/components/RepoBranchTagSelector.vue
+++ b/web_src/js/components/RepoBranchTagSelector.vue
@@ -1,10 +1,10 @@
-<script>
+<script lang="ts">
 import {createApp, nextTick} from 'vue';
 import $ from 'jquery';
-import {SvgIcon} from '../svg.js';
-import {pathEscapeSegments} from '../utils/url.js';
-import {showErrorToast} from '../modules/toast.js';
-import {GET} from '../modules/fetch.js';
+import {SvgIcon} from '../svg.ts';
+import {pathEscapeSegments} from '../utils/url.ts';
+import {showErrorToast} from '../modules/toast.ts';
+import {GET} from '../modules/fetch.ts';
 
 const sfc = {
   components: {SvgIcon},
diff --git a/web_src/js/components/RepoCodeFrequency.vue b/web_src/js/components/RepoCodeFrequency.vue
index 1d40d6d417..c30b32405d 100644
--- a/web_src/js/components/RepoCodeFrequency.vue
+++ b/web_src/js/components/RepoCodeFrequency.vue
@@ -1,5 +1,5 @@
-<script>
-import {SvgIcon} from '../svg.js';
+<script lang="ts">
+import {SvgIcon} from '../svg.ts';
 import {
   Chart,
   Legend,
@@ -9,15 +9,15 @@ import {
   LineElement,
   Filler,
 } from 'chart.js';
-import {GET} from '../modules/fetch.js';
+import {GET} from '../modules/fetch.ts';
 import {Line as ChartLine} from 'vue-chartjs';
 import {
   startDaysBetween,
   firstStartDateAfterDate,
   fillEmptyStartDaysWithZeroes,
-} from '../utils/time.js';
-import {chartJsColors} from '../utils/color.js';
-import {sleep} from '../utils.js';
+} from '../utils/time.ts';
+import {chartJsColors} from '../utils/color.ts';
+import {sleep} from '../utils.ts';
 import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
 
 const {pageData} = window.config;
diff --git a/web_src/js/components/RepoContributors.vue b/web_src/js/components/RepoContributors.vue
index dec2599c0d..d44d0cd22d 100644
--- a/web_src/js/components/RepoContributors.vue
+++ b/web_src/js/components/RepoContributors.vue
@@ -1,5 +1,5 @@
-<script>
-import {SvgIcon} from '../svg.js';
+<script lang="ts">
+import {SvgIcon} from '../svg.ts';
 import {
   Chart,
   Title,
@@ -10,16 +10,16 @@ import {
   LineElement,
   Filler,
 } from 'chart.js';
-import {GET} from '../modules/fetch.js';
+import {GET} from '../modules/fetch.ts';
 import zoomPlugin from 'chartjs-plugin-zoom';
 import {Line as ChartLine} from 'vue-chartjs';
 import {
   startDaysBetween,
   firstStartDateAfterDate,
   fillEmptyStartDaysWithZeroes,
-} from '../utils/time.js';
-import {chartJsColors} from '../utils/color.js';
-import {sleep} from '../utils.js';
+} from '../utils/time.ts';
+import {chartJsColors} from '../utils/color.ts';
+import {sleep} from '../utils.ts';
 import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
 import $ from 'jquery';
 
diff --git a/web_src/js/components/RepoRecentCommits.vue b/web_src/js/components/RepoRecentCommits.vue
index 8759978e78..c3515caba3 100644
--- a/web_src/js/components/RepoRecentCommits.vue
+++ b/web_src/js/components/RepoRecentCommits.vue
@@ -1,5 +1,5 @@
-<script>
-import {SvgIcon} from '../svg.js';
+<script lang="ts">
+import {SvgIcon} from '../svg.ts';
 import {
   Chart,
   Tooltip,
@@ -7,15 +7,15 @@ import {
   LinearScale,
   TimeScale,
 } from 'chart.js';
-import {GET} from '../modules/fetch.js';
+import {GET} from '../modules/fetch.ts';
 import {Bar} from 'vue-chartjs';
 import {
   startDaysBetween,
   firstStartDateAfterDate,
   fillEmptyStartDaysWithZeroes,
-} from '../utils/time.js';
-import {chartJsColors} from '../utils/color.js';
-import {sleep} from '../utils.js';
+} from '../utils/time.ts';
+import {chartJsColors} from '../utils/color.ts';
+import {sleep} from '../utils.ts';
 import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
 
 const {pageData} = window.config;
diff --git a/web_src/js/components/ScopedAccessTokenSelector.vue b/web_src/js/components/ScopedAccessTokenSelector.vue
index 9ff3627c11..b2fda05260 100644
--- a/web_src/js/components/ScopedAccessTokenSelector.vue
+++ b/web_src/js/components/ScopedAccessTokenSelector.vue
@@ -1,6 +1,6 @@
-<script>
+<script lang="ts">
 import {createApp} from 'vue';
-import {hideElem, showElem} from '../utils/dom.js';
+import {hideElem, showElem} from '../utils/dom.ts';
 
 const sfc = {
   props: {
diff --git a/web_src/js/features/admin/common.js b/web_src/js/features/admin/common.ts
similarity index 98%
rename from web_src/js/features/admin/common.js
rename to web_src/js/features/admin/common.ts
index 0f26ce6bc1..934a30a3ee 100644
--- a/web_src/js/features/admin/common.js
+++ b/web_src/js/features/admin/common.ts
@@ -1,7 +1,7 @@
 import $ from 'jquery';
-import {checkAppUrl} from '../common-page.js';
-import {hideElem, showElem, toggleElem} from '../../utils/dom.js';
-import {POST} from '../../modules/fetch.js';
+import {checkAppUrl} from '../common-page.ts';
+import {hideElem, showElem, toggleElem} from '../../utils/dom.ts';
+import {POST} from '../../modules/fetch.ts';
 
 const {appSubUrl} = window.config;
 
diff --git a/web_src/js/features/admin/config.js b/web_src/js/features/admin/config.ts
similarity index 87%
rename from web_src/js/features/admin/config.js
rename to web_src/js/features/admin/config.ts
index c3823425ad..4ccbbacd5b 100644
--- a/web_src/js/features/admin/config.js
+++ b/web_src/js/features/admin/config.ts
@@ -1,5 +1,5 @@
-import {showTemporaryTooltip} from '../../modules/tippy.js';
-import {POST} from '../../modules/fetch.js';
+import {showTemporaryTooltip} from '../../modules/tippy.ts';
+import {POST} from '../../modules/fetch.ts';
 
 const {appSubUrl} = window.config;
 
diff --git a/web_src/js/features/admin/emails.js b/web_src/js/features/admin/emails.ts
similarity index 100%
rename from web_src/js/features/admin/emails.js
rename to web_src/js/features/admin/emails.ts
diff --git a/web_src/js/features/admin/selfcheck.js b/web_src/js/features/admin/selfcheck.ts
similarity index 92%
rename from web_src/js/features/admin/selfcheck.js
rename to web_src/js/features/admin/selfcheck.ts
index 699395b363..498c52ffb5 100644
--- a/web_src/js/features/admin/selfcheck.js
+++ b/web_src/js/features/admin/selfcheck.ts
@@ -1,5 +1,5 @@
-import {toggleElem} from '../../utils/dom.js';
-import {POST} from '../../modules/fetch.js';
+import {toggleElem} from '../../utils/dom.ts';
+import {POST} from '../../modules/fetch.ts';
 
 const {appSubUrl} = window.config;
 
diff --git a/web_src/js/features/admin/users.js b/web_src/js/features/admin/users.ts
similarity index 100%
rename from web_src/js/features/admin/users.js
rename to web_src/js/features/admin/users.ts
diff --git a/web_src/js/features/autofocus-end.js b/web_src/js/features/autofocus-end.ts
similarity index 100%
rename from web_src/js/features/autofocus-end.js
rename to web_src/js/features/autofocus-end.ts
diff --git a/web_src/js/features/captcha.js b/web_src/js/features/captcha.ts
similarity index 96%
rename from web_src/js/features/captcha.js
rename to web_src/js/features/captcha.ts
index c803a5006b..23dbae3740 100644
--- a/web_src/js/features/captcha.js
+++ b/web_src/js/features/captcha.ts
@@ -1,4 +1,4 @@
-import {isDarkTheme} from '../utils.js';
+import {isDarkTheme} from '../utils.ts';
 
 export async function initCaptcha() {
   const captchaEl = document.querySelector('#captcha');
diff --git a/web_src/js/features/citation.js b/web_src/js/features/citation.ts
similarity index 98%
rename from web_src/js/features/citation.js
rename to web_src/js/features/citation.ts
index 245ba56f81..ffab75c389 100644
--- a/web_src/js/features/citation.js
+++ b/web_src/js/features/citation.ts
@@ -1,5 +1,5 @@
 import $ from 'jquery';
-import {getCurrentLocale} from '../utils.js';
+import {getCurrentLocale} from '../utils.ts';
 
 const {pageData} = window.config;
 
diff --git a/web_src/js/features/clipboard.js b/web_src/js/features/clipboard.ts
similarity index 91%
rename from web_src/js/features/clipboard.js
rename to web_src/js/features/clipboard.ts
index daf7e2ae2d..8de150d0f9 100644
--- a/web_src/js/features/clipboard.js
+++ b/web_src/js/features/clipboard.ts
@@ -1,5 +1,5 @@
-import {showTemporaryTooltip} from '../modules/tippy.js';
-import {toAbsoluteUrl} from '../utils.js';
+import {showTemporaryTooltip} from '../modules/tippy.ts';
+import {toAbsoluteUrl} from '../utils.ts';
 import {clippie} from 'clippie';
 
 const {copy_success, copy_error} = window.config.i18n;
diff --git a/web_src/js/features/code-frequency.js b/web_src/js/features/code-frequency.ts
similarity index 100%
rename from web_src/js/features/code-frequency.js
rename to web_src/js/features/code-frequency.ts
diff --git a/web_src/js/features/codeeditor.js b/web_src/js/features/codeeditor.ts
similarity index 99%
rename from web_src/js/features/codeeditor.js
rename to web_src/js/features/codeeditor.ts
index 2b1e64c10d..539db0e969 100644
--- a/web_src/js/features/codeeditor.js
+++ b/web_src/js/features/codeeditor.ts
@@ -1,6 +1,6 @@
 import tinycolor from 'tinycolor2';
-import {basename, extname, isObject, isDarkTheme} from '../utils.js';
-import {onInputDebounce} from '../utils/dom.js';
+import {basename, extname, isObject, isDarkTheme} from '../utils.ts';
+import {onInputDebounce} from '../utils/dom.ts';
 
 const languagesByFilename = {};
 const languagesByExt = {};
diff --git a/web_src/js/features/colorpicker.js b/web_src/js/features/colorpicker.ts
similarity index 97%
rename from web_src/js/features/colorpicker.js
rename to web_src/js/features/colorpicker.ts
index a85c04de41..3b5f8c66eb 100644
--- a/web_src/js/features/colorpicker.js
+++ b/web_src/js/features/colorpicker.ts
@@ -1,4 +1,4 @@
-import {createTippy} from '../modules/tippy.js';
+import {createTippy} from '../modules/tippy.ts';
 
 export async function initColorPickers() {
   const els = document.querySelectorAll('.js-color-picker-input');
diff --git a/web_src/js/features/common-button.js b/web_src/js/features/common-button.ts
similarity index 97%
rename from web_src/js/features/common-button.js
rename to web_src/js/features/common-button.ts
index 2db39871f0..7e335ce7b4 100644
--- a/web_src/js/features/common-button.js
+++ b/web_src/js/features/common-button.ts
@@ -1,7 +1,7 @@
 import $ from 'jquery';
-import {POST} from '../modules/fetch.js';
-import {hideElem, showElem, toggleElem} from '../utils/dom.js';
-import {showErrorToast} from '../modules/toast.js';
+import {POST} from '../modules/fetch.ts';
+import {hideElem, showElem, toggleElem} from '../utils/dom.ts';
+import {showErrorToast} from '../modules/toast.ts';
 
 export function initGlobalButtonClickOnEnter() {
   $(document).on('keypress', 'div.ui.button,span.ui.button', (e) => {
diff --git a/web_src/js/features/common-fetch-action.js b/web_src/js/features/common-fetch-action.ts
similarity index 95%
rename from web_src/js/features/common-fetch-action.js
rename to web_src/js/features/common-fetch-action.ts
index ddf1ec2e79..76973d8ce7 100644
--- a/web_src/js/features/common-fetch-action.js
+++ b/web_src/js/features/common-fetch-action.ts
@@ -1,8 +1,8 @@
-import {request} from '../modules/fetch.js';
-import {showErrorToast} from '../modules/toast.js';
-import {submitEventSubmitter} from '../utils/dom.js';
+import {request} from '../modules/fetch.ts';
+import {showErrorToast} from '../modules/toast.ts';
+import {submitEventSubmitter} from '../utils/dom.ts';
 import {htmlEscape} from 'escape-goat';
-import {confirmModal} from './comp/ConfirmModal.js';
+import {confirmModal} from './comp/ConfirmModal.ts';
 
 const {appSubUrl, i18n} = window.config;
 
diff --git a/web_src/js/features/common-form.js b/web_src/js/features/common-form.ts
similarity index 88%
rename from web_src/js/features/common-form.js
rename to web_src/js/features/common-form.ts
index 96ba06ff80..719a5170b4 100644
--- a/web_src/js/features/common-form.js
+++ b/web_src/js/features/common-form.ts
@@ -1,6 +1,6 @@
 import $ from 'jquery';
-import {initAreYouSure} from '../vendor/jquery.are-you-sure.js';
-import {handleGlobalEnterQuickSubmit} from './comp/QuickSubmit.js';
+import {initAreYouSure} from '../vendor/jquery.are-you-sure.ts';
+import {handleGlobalEnterQuickSubmit} from './comp/QuickSubmit.ts';
 
 export function initGlobalFormDirtyLeaveConfirm() {
   initAreYouSure(window.jQuery);
diff --git a/web_src/js/features/common-issue-list.test.js b/web_src/js/features/common-issue-list.test.ts
similarity index 92%
rename from web_src/js/features/common-issue-list.test.js
rename to web_src/js/features/common-issue-list.test.ts
index da7ea64b7c..a4c49a297e 100644
--- a/web_src/js/features/common-issue-list.test.js
+++ b/web_src/js/features/common-issue-list.test.ts
@@ -1,4 +1,4 @@
-import {parseIssueListQuickGotoLink} from './common-issue-list.js';
+import {parseIssueListQuickGotoLink} from './common-issue-list.ts';
 
 test('parseIssueListQuickGotoLink', () => {
   expect(parseIssueListQuickGotoLink('/link', '')).toEqual('');
diff --git a/web_src/js/features/common-issue-list.js b/web_src/js/features/common-issue-list.ts
similarity index 97%
rename from web_src/js/features/common-issue-list.js
rename to web_src/js/features/common-issue-list.ts
index 707776487b..e8a47eabad 100644
--- a/web_src/js/features/common-issue-list.js
+++ b/web_src/js/features/common-issue-list.ts
@@ -1,5 +1,5 @@
-import {isElemHidden, onInputDebounce, submitEventSubmitter, toggleElem} from '../utils/dom.js';
-import {GET} from '../modules/fetch.js';
+import {isElemHidden, onInputDebounce, submitEventSubmitter, toggleElem} from '../utils/dom.ts';
+import {GET} from '../modules/fetch.ts';
 
 const {appSubUrl} = window.config;
 const reIssueIndex = /^(\d+)$/; // eg: "123"
diff --git a/web_src/js/features/common-organization.js b/web_src/js/features/common-organization.ts
similarity index 82%
rename from web_src/js/features/common-organization.js
rename to web_src/js/features/common-organization.ts
index 442714a3d6..085be5fe71 100644
--- a/web_src/js/features/common-organization.js
+++ b/web_src/js/features/common-organization.ts
@@ -1,5 +1,5 @@
-import {initCompLabelEdit} from './comp/LabelEdit.js';
-import {toggleElem} from '../utils/dom.js';
+import {initCompLabelEdit} from './comp/LabelEdit.ts';
+import {toggleElem} from '../utils/dom.ts';
 
 export function initCommonOrganization() {
   if (!document.querySelectorAll('.organization').length) {
diff --git a/web_src/js/features/common-page.js b/web_src/js/features/common-page.ts
similarity index 97%
rename from web_src/js/features/common-page.js
rename to web_src/js/features/common-page.ts
index 7e89807df4..1a4decd752 100644
--- a/web_src/js/features/common-page.js
+++ b/web_src/js/features/common-page.ts
@@ -1,6 +1,6 @@
 import $ from 'jquery';
-import {GET} from '../modules/fetch.js';
-import {showGlobalErrorMessage} from '../bootstrap.js';
+import {GET} from '../modules/fetch.ts';
+import {showGlobalErrorMessage} from '../bootstrap.ts';
 
 const {appUrl} = window.config;
 
diff --git a/web_src/js/features/comp/ComboMarkdownEditor.js b/web_src/js/features/comp/ComboMarkdownEditor.ts
similarity index 96%
rename from web_src/js/features/comp/ComboMarkdownEditor.js
rename to web_src/js/features/comp/ComboMarkdownEditor.ts
index f511adab1a..69fe34269b 100644
--- a/web_src/js/features/comp/ComboMarkdownEditor.js
+++ b/web_src/js/features/comp/ComboMarkdownEditor.ts
@@ -1,17 +1,17 @@
 import '@github/markdown-toolbar-element';
 import '@github/text-expander-element';
 import $ from 'jquery';
-import {attachTribute} from '../tribute.js';
-import {hideElem, showElem, autosize, isElemVisible} from '../../utils/dom.js';
-import {initEasyMDEPaste, initTextareaUpload} from './EditorUpload.js';
-import {handleGlobalEnterQuickSubmit} from './QuickSubmit.js';
-import {renderPreviewPanelContent} from '../repo-editor.js';
-import {easyMDEToolbarActions} from './EasyMDEToolbarActions.js';
-import {initTextExpander} from './TextExpander.js';
-import {showErrorToast} from '../../modules/toast.js';
-import {POST} from '../../modules/fetch.js';
-import {initTextareaMarkdown} from './EditorMarkdown.js';
-import {DropzoneCustomEventReloadFiles, initDropzone} from '../dropzone.js';
+import {attachTribute} from '../tribute.ts';
+import {hideElem, showElem, autosize, isElemVisible} from '../../utils/dom.ts';
+import {initEasyMDEPaste, initTextareaUpload} from './EditorUpload.ts';
+import {handleGlobalEnterQuickSubmit} from './QuickSubmit.ts';
+import {renderPreviewPanelContent} from '../repo-editor.ts';
+import {easyMDEToolbarActions} from './EasyMDEToolbarActions.ts';
+import {initTextExpander} from './TextExpander.ts';
+import {showErrorToast} from '../../modules/toast.ts';
+import {POST} from '../../modules/fetch.ts';
+import {initTextareaMarkdown} from './EditorMarkdown.ts';
+import {DropzoneCustomEventReloadFiles, initDropzone} from '../dropzone.ts';
 
 let elementIdCounter = 0;
 
diff --git a/web_src/js/features/comp/ConfirmModal.js b/web_src/js/features/comp/ConfirmModal.ts
similarity index 90%
rename from web_src/js/features/comp/ConfirmModal.js
rename to web_src/js/features/comp/ConfirmModal.ts
index f9ad5c39cc..9541226a7d 100644
--- a/web_src/js/features/comp/ConfirmModal.js
+++ b/web_src/js/features/comp/ConfirmModal.ts
@@ -1,7 +1,7 @@
 import $ from 'jquery';
-import {svg} from '../../svg.js';
+import {svg} from '../../svg.ts';
 import {htmlEscape} from 'escape-goat';
-import {createElementFromHTML} from '../../utils/dom.js';
+import {createElementFromHTML} from '../../utils/dom.ts';
 
 const {i18n} = window.config;
 
diff --git a/web_src/js/features/comp/EasyMDEToolbarActions.js b/web_src/js/features/comp/EasyMDEToolbarActions.ts
similarity index 99%
rename from web_src/js/features/comp/EasyMDEToolbarActions.js
rename to web_src/js/features/comp/EasyMDEToolbarActions.ts
index c97d683704..d91dd23d11 100644
--- a/web_src/js/features/comp/EasyMDEToolbarActions.js
+++ b/web_src/js/features/comp/EasyMDEToolbarActions.ts
@@ -1,4 +1,4 @@
-import {svg} from '../../svg.js';
+import {svg} from '../../svg.ts';
 
 export function easyMDEToolbarActions(EasyMDE, editor) {
   const actions = {
diff --git a/web_src/js/features/comp/EditorMarkdown.js b/web_src/js/features/comp/EditorMarkdown.ts
similarity index 100%
rename from web_src/js/features/comp/EditorMarkdown.js
rename to web_src/js/features/comp/EditorMarkdown.ts
diff --git a/web_src/js/features/comp/EditorUpload.test.js b/web_src/js/features/comp/EditorUpload.test.ts
similarity index 99%
rename from web_src/js/features/comp/EditorUpload.test.js
rename to web_src/js/features/comp/EditorUpload.test.ts
index caecfb91ea..55f3f74389 100644
--- a/web_src/js/features/comp/EditorUpload.test.js
+++ b/web_src/js/features/comp/EditorUpload.test.ts
@@ -1,4 +1,4 @@
-import {removeAttachmentLinksFromMarkdown} from './EditorUpload.js';
+import {removeAttachmentLinksFromMarkdown} from './EditorUpload.ts';
 
 test('removeAttachmentLinksFromMarkdown', () => {
   expect(removeAttachmentLinksFromMarkdown('a foo b', 'foo')).toBe('a foo b');
diff --git a/web_src/js/features/comp/EditorUpload.js b/web_src/js/features/comp/EditorUpload.ts
similarity index 96%
rename from web_src/js/features/comp/EditorUpload.js
rename to web_src/js/features/comp/EditorUpload.ts
index 8861abfe03..e572692cbf 100644
--- a/web_src/js/features/comp/EditorUpload.js
+++ b/web_src/js/features/comp/EditorUpload.ts
@@ -1,12 +1,12 @@
-import {imageInfo} from '../../utils/image.js';
-import {replaceTextareaSelection} from '../../utils/dom.js';
-import {isUrl} from '../../utils/url.js';
-import {triggerEditorContentChanged} from './EditorMarkdown.js';
+import {imageInfo} from '../../utils/image.ts';
+import {replaceTextareaSelection} from '../../utils/dom.ts';
+import {isUrl} from '../../utils/url.ts';
+import {triggerEditorContentChanged} from './EditorMarkdown.ts';
 import {
   DropzoneCustomEventRemovedFile,
   DropzoneCustomEventUploadDone,
   generateMarkdownLinkForAttachment,
-} from '../dropzone.js';
+} from '../dropzone.ts';
 
 let uploadIdCounter = 0;
 
diff --git a/web_src/js/features/comp/LabelEdit.js b/web_src/js/features/comp/LabelEdit.ts
similarity index 100%
rename from web_src/js/features/comp/LabelEdit.js
rename to web_src/js/features/comp/LabelEdit.ts
diff --git a/web_src/js/features/comp/QuickSubmit.js b/web_src/js/features/comp/QuickSubmit.ts
similarity index 100%
rename from web_src/js/features/comp/QuickSubmit.js
rename to web_src/js/features/comp/QuickSubmit.ts
diff --git a/web_src/js/features/comp/ReactionSelector.js b/web_src/js/features/comp/ReactionSelector.ts
similarity index 97%
rename from web_src/js/features/comp/ReactionSelector.js
rename to web_src/js/features/comp/ReactionSelector.ts
index e507b89632..e1dd84bb14 100644
--- a/web_src/js/features/comp/ReactionSelector.js
+++ b/web_src/js/features/comp/ReactionSelector.ts
@@ -1,5 +1,5 @@
 import $ from 'jquery';
-import {POST} from '../../modules/fetch.js';
+import {POST} from '../../modules/fetch.ts';
 
 export function initCompReactionSelector() {
   for (const container of document.querySelectorAll('.issue-content, .diff-file-body')) {
diff --git a/web_src/js/features/comp/SearchUserBox.js b/web_src/js/features/comp/SearchUserBox.ts
similarity index 100%
rename from web_src/js/features/comp/SearchUserBox.js
rename to web_src/js/features/comp/SearchUserBox.ts
diff --git a/web_src/js/features/comp/TextExpander.js b/web_src/js/features/comp/TextExpander.ts
similarity index 95%
rename from web_src/js/features/comp/TextExpander.js
rename to web_src/js/features/comp/TextExpander.ts
index 128a2ddff0..5afe025d38 100644
--- a/web_src/js/features/comp/TextExpander.js
+++ b/web_src/js/features/comp/TextExpander.ts
@@ -1,5 +1,5 @@
-import {matchEmoji, matchMention} from '../../utils/match.js';
-import {emojiString} from '../emoji.js';
+import {matchEmoji, matchMention} from '../../utils/match.ts';
+import {emojiString} from '../emoji.ts';
 
 export function initTextExpander(expander) {
   expander?.addEventListener('text-expander-change', ({detail: {key, provide, text}}) => {
diff --git a/web_src/js/features/comp/WebHookEditor.js b/web_src/js/features/comp/WebHookEditor.ts
similarity index 92%
rename from web_src/js/features/comp/WebHookEditor.js
rename to web_src/js/features/comp/WebHookEditor.ts
index 38ff75e5a3..b13a2ffca3 100644
--- a/web_src/js/features/comp/WebHookEditor.js
+++ b/web_src/js/features/comp/WebHookEditor.ts
@@ -1,5 +1,5 @@
-import {POST} from '../../modules/fetch.js';
-import {hideElem, showElem, toggleElem} from '../../utils/dom.js';
+import {POST} from '../../modules/fetch.ts';
+import {hideElem, showElem, toggleElem} from '../../utils/dom.ts';
 
 export function initCompWebHookEditor() {
   if (!document.querySelectorAll('.new.webhook').length) {
diff --git a/web_src/js/features/contextpopup.js b/web_src/js/features/contextpopup.ts
similarity index 92%
rename from web_src/js/features/contextpopup.js
rename to web_src/js/features/contextpopup.ts
index 6a9325ed1c..5af7d176b6 100644
--- a/web_src/js/features/contextpopup.js
+++ b/web_src/js/features/contextpopup.ts
@@ -1,7 +1,7 @@
 import {createApp} from 'vue';
 import ContextPopup from '../components/ContextPopup.vue';
-import {parseIssueHref} from '../utils.js';
-import {createTippy} from '../modules/tippy.js';
+import {parseIssueHref} from '../utils.ts';
+import {createTippy} from '../modules/tippy.ts';
 
 export function initContextPopups() {
   const refIssues = document.querySelectorAll('.ref-issue');
diff --git a/web_src/js/features/contributors.js b/web_src/js/features/contributors.ts
similarity index 100%
rename from web_src/js/features/contributors.js
rename to web_src/js/features/contributors.ts
diff --git a/web_src/js/features/copycontent.js b/web_src/js/features/copycontent.ts
similarity index 93%
rename from web_src/js/features/copycontent.js
rename to web_src/js/features/copycontent.ts
index ea1e5cf7d0..af867463b2 100644
--- a/web_src/js/features/copycontent.js
+++ b/web_src/js/features/copycontent.ts
@@ -1,7 +1,7 @@
 import {clippie} from 'clippie';
-import {showTemporaryTooltip} from '../modules/tippy.js';
-import {convertImage} from '../utils.js';
-import {GET} from '../modules/fetch.js';
+import {showTemporaryTooltip} from '../modules/tippy.ts';
+import {convertImage} from '../utils.ts';
+import {GET} from '../modules/fetch.ts';
 
 const {i18n} = window.config;
 
diff --git a/web_src/js/features/dropzone.js b/web_src/js/features/dropzone.ts
similarity index 96%
rename from web_src/js/features/dropzone.js
rename to web_src/js/features/dropzone.ts
index f25a613718..392bc1db66 100644
--- a/web_src/js/features/dropzone.js
+++ b/web_src/js/features/dropzone.ts
@@ -1,11 +1,11 @@
-import {svg} from '../svg.js';
+import {svg} from '../svg.ts';
 import {htmlEscape} from 'escape-goat';
 import {clippie} from 'clippie';
-import {showTemporaryTooltip} from '../modules/tippy.js';
-import {GET, POST} from '../modules/fetch.js';
-import {showErrorToast} from '../modules/toast.js';
-import {createElementFromHTML, createElementFromAttrs} from '../utils/dom.js';
-import {isImageFile, isVideoFile} from '../utils.js';
+import {showTemporaryTooltip} from '../modules/tippy.ts';
+import {GET, POST} from '../modules/fetch.ts';
+import {showErrorToast} from '../modules/toast.ts';
+import {createElementFromHTML, createElementFromAttrs} from '../utils/dom.ts';
+import {isImageFile, isVideoFile} from '../utils.ts';
 
 const {csrfToken, i18n} = window.config;
 
diff --git a/web_src/js/features/emoji.js b/web_src/js/features/emoji.ts
similarity index 100%
rename from web_src/js/features/emoji.js
rename to web_src/js/features/emoji.ts
diff --git a/web_src/js/features/eventsource.sharedworker.js b/web_src/js/features/eventsource.sharedworker.ts
similarity index 100%
rename from web_src/js/features/eventsource.sharedworker.js
rename to web_src/js/features/eventsource.sharedworker.ts
diff --git a/web_src/js/features/file-fold.js b/web_src/js/features/file-fold.ts
similarity index 96%
rename from web_src/js/features/file-fold.js
rename to web_src/js/features/file-fold.ts
index 3efefaf339..6fe068341a 100644
--- a/web_src/js/features/file-fold.js
+++ b/web_src/js/features/file-fold.ts
@@ -1,4 +1,4 @@
-import {svg} from '../svg.js';
+import {svg} from '../svg.ts';
 
 // Hides the file if newFold is true, and shows it otherwise. The actual hiding is performed using CSS.
 //
diff --git a/web_src/js/features/heatmap.js b/web_src/js/features/heatmap.ts
similarity index 96%
rename from web_src/js/features/heatmap.js
rename to web_src/js/features/heatmap.ts
index 9155e844a2..69cd069a94 100644
--- a/web_src/js/features/heatmap.js
+++ b/web_src/js/features/heatmap.ts
@@ -1,6 +1,6 @@
 import {createApp} from 'vue';
 import ActivityHeatmap from '../components/ActivityHeatmap.vue';
-import {translateMonth, translateDay} from '../utils.js';
+import {translateMonth, translateDay} from '../utils.ts';
 
 export function initHeatmap() {
   const el = document.querySelector('#user-heatmap');
diff --git a/web_src/js/features/imagediff.js b/web_src/js/features/imagediff.ts
similarity index 99%
rename from web_src/js/features/imagediff.js
rename to web_src/js/features/imagediff.ts
index 5934a4b9f4..a6b1f48fb3 100644
--- a/web_src/js/features/imagediff.js
+++ b/web_src/js/features/imagediff.ts
@@ -1,7 +1,7 @@
 import $ from 'jquery';
-import {GET} from '../modules/fetch.js';
-import {hideElem, loadElem, queryElemChildren, queryElems} from '../utils/dom.js';
-import {parseDom} from '../utils.js';
+import {GET} from '../modules/fetch.ts';
+import {hideElem, loadElem, queryElemChildren, queryElems} from '../utils/dom.ts';
+import {parseDom} from '../utils.ts';
 
 function getDefaultSvgBoundsIfUndefined(text, src) {
   const defaultSize = 300;
diff --git a/web_src/js/features/install.js b/web_src/js/features/install.ts
similarity index 97%
rename from web_src/js/features/install.js
rename to web_src/js/features/install.ts
index 6354db6cdc..3defb7904a 100644
--- a/web_src/js/features/install.js
+++ b/web_src/js/features/install.ts
@@ -1,5 +1,5 @@
-import {hideElem, showElem} from '../utils/dom.js';
-import {GET} from '../modules/fetch.js';
+import {hideElem, showElem} from '../utils/dom.ts';
+import {GET} from '../modules/fetch.ts';
 
 export function initInstall() {
   const page = document.querySelector('.page-content.install');
diff --git a/web_src/js/features/notification.js b/web_src/js/features/notification.ts
similarity index 97%
rename from web_src/js/features/notification.js
rename to web_src/js/features/notification.ts
index c22fc17306..539f779056 100644
--- a/web_src/js/features/notification.js
+++ b/web_src/js/features/notification.ts
@@ -1,7 +1,7 @@
 import $ from 'jquery';
-import {GET} from '../modules/fetch.js';
-import {toggleElem} from '../utils/dom.js';
-import {logoutFromWorker} from '../modules/worker.js';
+import {GET} from '../modules/fetch.ts';
+import {toggleElem} from '../utils/dom.ts';
+import {logoutFromWorker} from '../modules/worker.ts';
 
 const {appSubUrl, notificationSettings, assetVersionEncoded} = window.config;
 let notificationSequenceNumber = 0;
diff --git a/web_src/js/features/org-team.js b/web_src/js/features/org-team.ts
similarity index 95%
rename from web_src/js/features/org-team.js
rename to web_src/js/features/org-team.ts
index c216fdf6a2..e4e98fd990 100644
--- a/web_src/js/features/org-team.js
+++ b/web_src/js/features/org-team.ts
@@ -1,5 +1,5 @@
 import $ from 'jquery';
-import {hideElem, showElem} from '../utils/dom.js';
+import {hideElem, showElem} from '../utils/dom.ts';
 
 const {appSubUrl} = window.config;
 
diff --git a/web_src/js/features/pull-view-file.js b/web_src/js/features/pull-view-file.ts
similarity index 96%
rename from web_src/js/features/pull-view-file.js
rename to web_src/js/features/pull-view-file.ts
index 84c5eddb45..9a052207d5 100644
--- a/web_src/js/features/pull-view-file.js
+++ b/web_src/js/features/pull-view-file.ts
@@ -1,6 +1,6 @@
-import {diffTreeStore} from '../modules/stores.js';
-import {setFileFolding} from './file-fold.js';
-import {POST} from '../modules/fetch.js';
+import {diffTreeStore} from '../modules/stores.ts';
+import {setFileFolding} from './file-fold.ts';
+import {POST} from '../modules/fetch.ts';
 
 const {pageData} = window.config;
 const prReview = pageData.prReview || {};
diff --git a/web_src/js/features/recent-commits.js b/web_src/js/features/recent-commits.ts
similarity index 100%
rename from web_src/js/features/recent-commits.js
rename to web_src/js/features/recent-commits.ts
diff --git a/web_src/js/features/repo-branch.js b/web_src/js/features/repo-branch.ts
similarity index 97%
rename from web_src/js/features/repo-branch.js
rename to web_src/js/features/repo-branch.ts
index b9ffc6127f..3261bfe37e 100644
--- a/web_src/js/features/repo-branch.js
+++ b/web_src/js/features/repo-branch.ts
@@ -1,5 +1,5 @@
 import $ from 'jquery';
-import {toggleElem} from '../utils/dom.js';
+import {toggleElem} from '../utils/dom.ts';
 
 export function initRepoBranchButton() {
   initRepoCreateBranchButton();
diff --git a/web_src/js/features/repo-code.test.js b/web_src/js/features/repo-code.test.ts
similarity index 91%
rename from web_src/js/features/repo-code.test.js
rename to web_src/js/features/repo-code.test.ts
index 0e0062a787..27554aa847 100644
--- a/web_src/js/features/repo-code.test.js
+++ b/web_src/js/features/repo-code.test.ts
@@ -1,4 +1,4 @@
-import {singleAnchorRegex, rangeAnchorRegex} from './repo-code.js';
+import {singleAnchorRegex, rangeAnchorRegex} from './repo-code.ts';
 
 test('singleAnchorRegex', () => {
   expect(singleAnchorRegex.test('#L0')).toEqual(false);
diff --git a/web_src/js/features/repo-code.js b/web_src/js/features/repo-code.ts
similarity index 97%
rename from web_src/js/features/repo-code.js
rename to web_src/js/features/repo-code.ts
index 658fa7e11c..0068d6c0b5 100644
--- a/web_src/js/features/repo-code.js
+++ b/web_src/js/features/repo-code.ts
@@ -1,9 +1,9 @@
 import $ from 'jquery';
-import {svg} from '../svg.js';
-import {invertFileFolding} from './file-fold.js';
-import {createTippy} from '../modules/tippy.js';
+import {svg} from '../svg.ts';
+import {invertFileFolding} from './file-fold.ts';
+import {createTippy} from '../modules/tippy.ts';
 import {clippie} from 'clippie';
-import {toAbsoluteUrl} from '../utils.js';
+import {toAbsoluteUrl} from '../utils.ts';
 
 export const singleAnchorRegex = /^#(L|n)([1-9][0-9]*)$/;
 export const rangeAnchorRegex = /^#(L[1-9][0-9]*)-(L[1-9][0-9]*)$/;
diff --git a/web_src/js/features/repo-commit.js b/web_src/js/features/repo-commit.ts
similarity index 90%
rename from web_src/js/features/repo-commit.js
rename to web_src/js/features/repo-commit.ts
index f61ea08a42..56493443d9 100644
--- a/web_src/js/features/repo-commit.js
+++ b/web_src/js/features/repo-commit.ts
@@ -1,5 +1,5 @@
-import {createTippy} from '../modules/tippy.js';
-import {toggleElem} from '../utils/dom.js';
+import {createTippy} from '../modules/tippy.ts';
+import {toggleElem} from '../utils/dom.ts';
 
 export function initRepoEllipsisButton() {
   for (const button of document.querySelectorAll('.js-toggle-commit-body')) {
diff --git a/web_src/js/features/repo-common.js b/web_src/js/features/repo-common.ts
similarity index 92%
rename from web_src/js/features/repo-common.js
rename to web_src/js/features/repo-common.ts
index 88aa93d850..ac63ef2145 100644
--- a/web_src/js/features/repo-common.js
+++ b/web_src/js/features/repo-common.ts
@@ -1,8 +1,8 @@
 import $ from 'jquery';
-import {hideElem, queryElems, showElem} from '../utils/dom.js';
-import {POST} from '../modules/fetch.js';
-import {showErrorToast} from '../modules/toast.js';
-import {sleep} from '../utils.js';
+import {hideElem, queryElems, showElem} from '../utils/dom.ts';
+import {POST} from '../modules/fetch.ts';
+import {showErrorToast} from '../modules/toast.ts';
+import {sleep} from '../utils.ts';
 
 async function onDownloadArchive(e) {
   e.preventDefault();
diff --git a/web_src/js/features/repo-diff-commit.js b/web_src/js/features/repo-diff-commit.ts
similarity index 94%
rename from web_src/js/features/repo-diff-commit.js
rename to web_src/js/features/repo-diff-commit.ts
index aa7fc38360..c115e6300a 100644
--- a/web_src/js/features/repo-diff-commit.js
+++ b/web_src/js/features/repo-diff-commit.ts
@@ -1,5 +1,5 @@
-import {hideElem, showElem, toggleElem} from '../utils/dom.js';
-import {GET} from '../modules/fetch.js';
+import {hideElem, showElem, toggleElem} from '../utils/dom.ts';
+import {GET} from '../modules/fetch.ts';
 
 async function loadBranchesAndTags(area, loadingButton) {
   loadingButton.classList.add('disabled');
diff --git a/web_src/js/features/repo-diff-commitselect.js b/web_src/js/features/repo-diff-commitselect.ts
similarity index 100%
rename from web_src/js/features/repo-diff-commitselect.js
rename to web_src/js/features/repo-diff-commitselect.ts
diff --git a/web_src/js/features/repo-diff-filetree.js b/web_src/js/features/repo-diff-filetree.ts
similarity index 100%
rename from web_src/js/features/repo-diff-filetree.js
rename to web_src/js/features/repo-diff-filetree.ts
diff --git a/web_src/js/features/repo-diff.js b/web_src/js/features/repo-diff.ts
similarity index 95%
rename from web_src/js/features/repo-diff.js
rename to web_src/js/features/repo-diff.ts
index 279f6da757..5d6388a43e 100644
--- a/web_src/js/features/repo-diff.js
+++ b/web_src/js/features/repo-diff.ts
@@ -1,14 +1,14 @@
 import $ from 'jquery';
-import {initCompReactionSelector} from './comp/ReactionSelector.js';
-import {initRepoIssueContentHistory} from './repo-issue-content.js';
-import {initDiffFileTree} from './repo-diff-filetree.js';
-import {initDiffCommitSelect} from './repo-diff-commitselect.js';
-import {validateTextareaNonEmpty} from './comp/ComboMarkdownEditor.js';
-import {initViewedCheckboxListenerFor, countAndUpdateViewedFiles, initExpandAndCollapseFilesButton} from './pull-view-file.js';
-import {initImageDiff} from './imagediff.js';
-import {showErrorToast} from '../modules/toast.js';
-import {submitEventSubmitter, queryElemSiblings, hideElem, showElem, animateOnce} from '../utils/dom.js';
-import {POST, GET} from '../modules/fetch.js';
+import {initCompReactionSelector} from './comp/ReactionSelector.ts';
+import {initRepoIssueContentHistory} from './repo-issue-content.ts';
+import {initDiffFileTree} from './repo-diff-filetree.ts';
+import {initDiffCommitSelect} from './repo-diff-commitselect.ts';
+import {validateTextareaNonEmpty} from './comp/ComboMarkdownEditor.ts';
+import {initViewedCheckboxListenerFor, countAndUpdateViewedFiles, initExpandAndCollapseFilesButton} from './pull-view-file.ts';
+import {initImageDiff} from './imagediff.ts';
+import {showErrorToast} from '../modules/toast.ts';
+import {submitEventSubmitter, queryElemSiblings, hideElem, showElem, animateOnce} from '../utils/dom.ts';
+import {POST, GET} from '../modules/fetch.ts';
 
 const {pageData, i18n} = window.config;
 
diff --git a/web_src/js/features/repo-editor.js b/web_src/js/features/repo-editor.ts
similarity index 94%
rename from web_src/js/features/repo-editor.js
rename to web_src/js/features/repo-editor.ts
index f25da911df..8bf23fc60e 100644
--- a/web_src/js/features/repo-editor.js
+++ b/web_src/js/features/repo-editor.ts
@@ -1,11 +1,11 @@
 import $ from 'jquery';
 import {htmlEscape} from 'escape-goat';
-import {createCodeEditor} from './codeeditor.js';
-import {hideElem, queryElems, showElem} from '../utils/dom.js';
-import {initMarkupContent} from '../markup/content.js';
-import {attachRefIssueContextPopup} from './contextpopup.js';
-import {POST} from '../modules/fetch.js';
-import {initDropzone} from './dropzone.js';
+import {createCodeEditor} from './codeeditor.ts';
+import {hideElem, queryElems, showElem} from '../utils/dom.ts';
+import {initMarkupContent} from '../markup/content.ts';
+import {attachRefIssueContextPopup} from './contextpopup.ts';
+import {POST} from '../modules/fetch.ts';
+import {initDropzone} from './dropzone.ts';
 
 function initEditPreviewTab($form) {
   const $tabMenu = $form.find('.repo-editor-menu');
diff --git a/web_src/js/features/repo-findfile.test.js b/web_src/js/features/repo-findfile.test.ts
similarity index 98%
rename from web_src/js/features/repo-findfile.test.js
rename to web_src/js/features/repo-findfile.test.ts
index 2d96ed4463..3ff1a796e2 100644
--- a/web_src/js/features/repo-findfile.test.js
+++ b/web_src/js/features/repo-findfile.test.ts
@@ -1,4 +1,4 @@
-import {strSubMatch, calcMatchedWeight, filterRepoFilesWeighted} from './repo-findfile.js';
+import {strSubMatch, calcMatchedWeight, filterRepoFilesWeighted} from './repo-findfile.ts';
 
 describe('Repo Find Files', () => {
   test('strSubMatch', () => {
diff --git a/web_src/js/features/repo-findfile.js b/web_src/js/features/repo-findfile.ts
similarity index 96%
rename from web_src/js/features/repo-findfile.js
rename to web_src/js/features/repo-findfile.ts
index 828acfa65c..1f151d7056 100644
--- a/web_src/js/features/repo-findfile.js
+++ b/web_src/js/features/repo-findfile.ts
@@ -1,7 +1,7 @@
-import {svg} from '../svg.js';
-import {toggleElem} from '../utils/dom.js';
-import {pathEscapeSegments} from '../utils/url.js';
-import {GET} from '../modules/fetch.js';
+import {svg} from '../svg.ts';
+import {toggleElem} from '../utils/dom.ts';
+import {pathEscapeSegments} from '../utils/url.ts';
+import {GET} from '../modules/fetch.ts';
 
 const threshold = 50;
 let files = [];
diff --git a/web_src/js/features/repo-graph.js b/web_src/js/features/repo-graph.ts
similarity index 98%
rename from web_src/js/features/repo-graph.js
rename to web_src/js/features/repo-graph.ts
index 7084e40977..ff64d0854b 100644
--- a/web_src/js/features/repo-graph.js
+++ b/web_src/js/features/repo-graph.ts
@@ -1,6 +1,6 @@
 import $ from 'jquery';
-import {hideElem, showElem} from '../utils/dom.js';
-import {GET} from '../modules/fetch.js';
+import {hideElem, showElem} from '../utils/dom.ts';
+import {GET} from '../modules/fetch.ts';
 
 export function initRepoGraphGit() {
   const graphContainer = document.querySelector('#git-graph-container');
diff --git a/web_src/js/features/repo-home.js b/web_src/js/features/repo-home.ts
similarity index 97%
rename from web_src/js/features/repo-home.js
rename to web_src/js/features/repo-home.ts
index 3a8e41bde1..f3e39ddb3c 100644
--- a/web_src/js/features/repo-home.js
+++ b/web_src/js/features/repo-home.ts
@@ -1,8 +1,8 @@
 import $ from 'jquery';
-import {stripTags} from '../utils.js';
-import {hideElem, queryElemChildren, showElem} from '../utils/dom.js';
-import {POST} from '../modules/fetch.js';
-import {showErrorToast} from '../modules/toast.js';
+import {stripTags} from '../utils.ts';
+import {hideElem, queryElemChildren, showElem} from '../utils/dom.ts';
+import {POST} from '../modules/fetch.ts';
+import {showErrorToast} from '../modules/toast.ts';
 
 const {appSubUrl} = window.config;
 
diff --git a/web_src/js/features/repo-issue-content.js b/web_src/js/features/repo-issue-content.ts
similarity index 96%
rename from web_src/js/features/repo-issue-content.js
rename to web_src/js/features/repo-issue-content.ts
index cef2f49008..7aaf9765ee 100644
--- a/web_src/js/features/repo-issue-content.js
+++ b/web_src/js/features/repo-issue-content.ts
@@ -1,8 +1,8 @@
 import $ from 'jquery';
-import {svg} from '../svg.js';
-import {showErrorToast} from '../modules/toast.js';
-import {GET, POST} from '../modules/fetch.js';
-import {showElem} from '../utils/dom.js';
+import {svg} from '../svg.ts';
+import {showErrorToast} from '../modules/toast.ts';
+import {GET, POST} from '../modules/fetch.ts';
+import {showElem} from '../utils/dom.ts';
 
 const {appSubUrl} = window.config;
 let i18nTextEdited;
diff --git a/web_src/js/features/repo-issue-edit.js b/web_src/js/features/repo-issue-edit.ts
similarity index 94%
rename from web_src/js/features/repo-issue-edit.js
rename to web_src/js/features/repo-issue-edit.ts
index 8bc0c02bcb..33a7a10923 100644
--- a/web_src/js/features/repo-issue-edit.js
+++ b/web_src/js/features/repo-issue-edit.ts
@@ -1,11 +1,11 @@
 import $ from 'jquery';
-import {handleReply} from './repo-issue.js';
-import {getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js';
-import {POST} from '../modules/fetch.js';
-import {showErrorToast} from '../modules/toast.js';
-import {hideElem, showElem} from '../utils/dom.js';
-import {attachRefIssueContextPopup} from './contextpopup.js';
-import {initCommentContent, initMarkupContent} from '../markup/content.js';
+import {handleReply} from './repo-issue.ts';
+import {getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
+import {POST} from '../modules/fetch.ts';
+import {showErrorToast} from '../modules/toast.ts';
+import {hideElem, showElem} from '../utils/dom.ts';
+import {attachRefIssueContextPopup} from './contextpopup.ts';
+import {initCommentContent, initMarkupContent} from '../markup/content.ts';
 
 async function onEditContent(event) {
   event.preventDefault();
diff --git a/web_src/js/features/repo-issue-list.js b/web_src/js/features/repo-issue-list.ts
similarity index 95%
rename from web_src/js/features/repo-issue-list.js
rename to web_src/js/features/repo-issue-list.ts
index c8ae91d453..1e4a880f2e 100644
--- a/web_src/js/features/repo-issue-list.js
+++ b/web_src/js/features/repo-issue-list.ts
@@ -1,12 +1,12 @@
 import $ from 'jquery';
-import {updateIssuesMeta} from './repo-issue.js';
-import {toggleElem, hideElem, isElemHidden} from '../utils/dom.js';
+import {updateIssuesMeta} from './repo-issue.ts';
+import {toggleElem, hideElem, isElemHidden} from '../utils/dom.ts';
 import {htmlEscape} from 'escape-goat';
-import {confirmModal} from './comp/ConfirmModal.js';
-import {showErrorToast} from '../modules/toast.js';
-import {createSortable} from '../modules/sortable.js';
-import {DELETE, POST} from '../modules/fetch.js';
-import {parseDom} from '../utils.js';
+import {confirmModal} from './comp/ConfirmModal.ts';
+import {showErrorToast} from '../modules/toast.ts';
+import {createSortable} from '../modules/sortable.ts';
+import {DELETE, POST} from '../modules/fetch.ts';
+import {parseDom} from '../utils.ts';
 
 function initRepoIssueListCheckboxes() {
   const issueSelectAll = document.querySelector('.issue-checkbox-all');
diff --git a/web_src/js/features/repo-issue-pr-form.js b/web_src/js/features/repo-issue-pr-form.ts
similarity index 100%
rename from web_src/js/features/repo-issue-pr-form.js
rename to web_src/js/features/repo-issue-pr-form.ts
diff --git a/web_src/js/features/repo-issue-pr-status.js b/web_src/js/features/repo-issue-pr-status.ts
similarity index 100%
rename from web_src/js/features/repo-issue-pr-status.js
rename to web_src/js/features/repo-issue-pr-status.ts
diff --git a/web_src/js/features/repo-issue.js b/web_src/js/features/repo-issue.ts
similarity index 98%
rename from web_src/js/features/repo-issue.js
rename to web_src/js/features/repo-issue.ts
index 57c4f19163..4377292a64 100644
--- a/web_src/js/features/repo-issue.js
+++ b/web_src/js/features/repo-issue.ts
@@ -1,12 +1,12 @@
 import $ from 'jquery';
 import {htmlEscape} from 'escape-goat';
-import {createTippy, showTemporaryTooltip} from '../modules/tippy.js';
-import {hideElem, showElem, toggleElem} from '../utils/dom.js';
-import {setFileFolding} from './file-fold.js';
-import {getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js';
-import {toAbsoluteUrl} from '../utils.js';
-import {GET, POST} from '../modules/fetch.js';
-import {showErrorToast} from '../modules/toast.js';
+import {createTippy, showTemporaryTooltip} from '../modules/tippy.ts';
+import {hideElem, showElem, toggleElem} from '../utils/dom.ts';
+import {setFileFolding} from './file-fold.ts';
+import {getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
+import {toAbsoluteUrl} from '../utils.ts';
+import {GET, POST} from '../modules/fetch.ts';
+import {showErrorToast} from '../modules/toast.ts';
 
 const {appSubUrl} = window.config;
 
@@ -19,7 +19,7 @@ export function initRepoIssueTimeTracking() {
       },
     }).modal('show');
     $('.issue-start-time-modal input').on('keydown', (e) => {
-      if ((e.keyCode || e.key) === 13) {
+      if (e.key === 'Enter') {
         $('#add_time_manual_form').trigger('submit');
       }
     });
@@ -146,7 +146,7 @@ export function initRepoIssueSidebarList() {
   });
 
   $('.menu .ui.dropdown.label-filter').on('keydown', (e) => {
-    if (e.altKey && e.keyCode === 13) {
+    if (e.altKey && e.key === 'Enter') {
       const selectedItem = document.querySelector('.menu .ui.dropdown.label-filter .menu .item.selected');
       if (selectedItem) {
         excludeLabel(selectedItem);
diff --git a/web_src/js/features/repo-legacy.js b/web_src/js/features/repo-legacy.ts
similarity index 96%
rename from web_src/js/features/repo-legacy.js
rename to web_src/js/features/repo-legacy.ts
index de4f611b5d..b26803c76d 100644
--- a/web_src/js/features/repo-legacy.js
+++ b/web_src/js/features/repo-legacy.ts
@@ -4,24 +4,24 @@ import {
   initRepoIssueComments, initRepoIssueDependencyDelete, initRepoIssueReferenceIssue,
   initRepoIssueTitleEdit, initRepoIssueWipToggle,
   initRepoPullRequestUpdate, updateIssuesMeta, initIssueTemplateCommentEditors, initSingleCommentEditor,
-} from './repo-issue.js';
-import {initUnicodeEscapeButton} from './repo-unicode-escape.js';
-import {svg} from '../svg.js';
+} from './repo-issue.ts';
+import {initUnicodeEscapeButton} from './repo-unicode-escape.ts';
+import {svg} from '../svg.ts';
 import {htmlEscape} from 'escape-goat';
 import {initRepoBranchTagSelector} from '../components/RepoBranchTagSelector.vue';
 import {
   initRepoCloneLink, initRepoCommonBranchOrTagDropdown, initRepoCommonFilterSearchDropdown,
-} from './repo-common.js';
-import {initCitationFileCopyContent} from './citation.js';
-import {initCompLabelEdit} from './comp/LabelEdit.js';
-import {initRepoDiffConversationNav} from './repo-diff.js';
-import {initCompReactionSelector} from './comp/ReactionSelector.js';
-import {initRepoSettingBranches} from './repo-settings.js';
-import {initRepoPullRequestMergeForm} from './repo-issue-pr-form.js';
-import {initRepoPullRequestCommitStatus} from './repo-issue-pr-status.js';
-import {hideElem, queryElemChildren, showElem} from '../utils/dom.js';
-import {POST} from '../modules/fetch.js';
-import {initRepoIssueCommentEdit} from './repo-issue-edit.js';
+} from './repo-common.ts';
+import {initCitationFileCopyContent} from './citation.ts';
+import {initCompLabelEdit} from './comp/LabelEdit.ts';
+import {initRepoDiffConversationNav} from './repo-diff.ts';
+import {initCompReactionSelector} from './comp/ReactionSelector.ts';
+import {initRepoSettingBranches} from './repo-settings.ts';
+import {initRepoPullRequestMergeForm} from './repo-issue-pr-form.ts';
+import {initRepoPullRequestCommitStatus} from './repo-issue-pr-status.ts';
+import {hideElem, queryElemChildren, showElem} from '../utils/dom.ts';
+import {POST} from '../modules/fetch.ts';
+import {initRepoIssueCommentEdit} from './repo-issue-edit.ts';
 
 // if there are draft comments, confirm before reloading, to avoid losing comments
 function reloadConfirmDraftComment() {
diff --git a/web_src/js/features/repo-migrate.js b/web_src/js/features/repo-migrate.ts
similarity index 94%
rename from web_src/js/features/repo-migrate.js
rename to web_src/js/features/repo-migrate.ts
index b8157e2dad..dc36177940 100644
--- a/web_src/js/features/repo-migrate.js
+++ b/web_src/js/features/repo-migrate.ts
@@ -1,5 +1,5 @@
-import {hideElem, showElem} from '../utils/dom.js';
-import {GET, POST} from '../modules/fetch.js';
+import {hideElem, showElem} from '../utils/dom.ts';
+import {GET, POST} from '../modules/fetch.ts';
 
 const {appSubUrl} = window.config;
 
diff --git a/web_src/js/features/repo-migration.js b/web_src/js/features/repo-migration.ts
similarity index 97%
rename from web_src/js/features/repo-migration.js
rename to web_src/js/features/repo-migration.ts
index 7f7aa237ee..8f79ee1423 100644
--- a/web_src/js/features/repo-migration.js
+++ b/web_src/js/features/repo-migration.ts
@@ -1,4 +1,4 @@
-import {hideElem, showElem, toggleElem} from '../utils/dom.js';
+import {hideElem, showElem, toggleElem} from '../utils/dom.ts';
 
 const service = document.querySelector('#service_type');
 const user = document.querySelector('#auth_username');
diff --git a/web_src/js/features/repo-projects.js b/web_src/js/features/repo-projects.ts
similarity index 97%
rename from web_src/js/features/repo-projects.js
rename to web_src/js/features/repo-projects.ts
index 706942363d..950d78fec7 100644
--- a/web_src/js/features/repo-projects.js
+++ b/web_src/js/features/repo-projects.ts
@@ -1,7 +1,7 @@
 import $ from 'jquery';
-import {contrastColor} from '../utils/color.js';
-import {createSortable} from '../modules/sortable.js';
-import {POST, DELETE, PUT} from '../modules/fetch.js';
+import {contrastColor} from '../utils/color.ts';
+import {createSortable} from '../modules/sortable.ts';
+import {POST, DELETE, PUT} from '../modules/fetch.ts';
 
 function updateIssueCount(cards) {
   const parent = cards.parentElement;
diff --git a/web_src/js/features/repo-release.js b/web_src/js/features/repo-release.ts
similarity index 97%
rename from web_src/js/features/repo-release.js
rename to web_src/js/features/repo-release.ts
index 2be1ec58c6..2928376c7f 100644
--- a/web_src/js/features/repo-release.js
+++ b/web_src/js/features/repo-release.ts
@@ -1,5 +1,5 @@
-import {hideElem, showElem} from '../utils/dom.js';
-import {initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js';
+import {hideElem, showElem} from '../utils/dom.ts';
+import {initComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
 
 export function initRepoRelease() {
   document.addEventListener('click', (e) => {
diff --git a/web_src/js/features/repo-search.js b/web_src/js/features/repo-search.ts
similarity index 100%
rename from web_src/js/features/repo-search.js
rename to web_src/js/features/repo-search.ts
diff --git a/web_src/js/features/repo-settings.js b/web_src/js/features/repo-settings.ts
similarity index 97%
rename from web_src/js/features/repo-settings.js
rename to web_src/js/features/repo-settings.ts
index 6590c2b56c..97211d035e 100644
--- a/web_src/js/features/repo-settings.js
+++ b/web_src/js/features/repo-settings.ts
@@ -1,8 +1,8 @@
 import $ from 'jquery';
 import {minimatch} from 'minimatch';
-import {createMonaco} from './codeeditor.js';
-import {onInputDebounce, queryElems, toggleElem} from '../utils/dom.js';
-import {POST} from '../modules/fetch.js';
+import {createMonaco} from './codeeditor.ts';
+import {onInputDebounce, queryElems, toggleElem} from '../utils/dom.ts';
+import {POST} from '../modules/fetch.ts';
 
 const {appSubUrl, csrfToken} = window.config;
 
diff --git a/web_src/js/features/repo-template.js b/web_src/js/features/repo-template.ts
similarity index 96%
rename from web_src/js/features/repo-template.js
rename to web_src/js/features/repo-template.ts
index 5f63e8b3ba..fbd7b656ed 100644
--- a/web_src/js/features/repo-template.js
+++ b/web_src/js/features/repo-template.ts
@@ -1,6 +1,6 @@
 import $ from 'jquery';
 import {htmlEscape} from 'escape-goat';
-import {hideElem, showElem} from '../utils/dom.js';
+import {hideElem, showElem} from '../utils/dom.ts';
 
 const {appSubUrl} = window.config;
 
diff --git a/web_src/js/features/repo-unicode-escape.js b/web_src/js/features/repo-unicode-escape.ts
similarity index 98%
rename from web_src/js/features/repo-unicode-escape.js
rename to web_src/js/features/repo-unicode-escape.ts
index d878532001..7a9bca7a37 100644
--- a/web_src/js/features/repo-unicode-escape.js
+++ b/web_src/js/features/repo-unicode-escape.ts
@@ -1,4 +1,4 @@
-import {hideElem, queryElemSiblings, showElem, toggleElem} from '../utils/dom.js';
+import {hideElem, queryElemSiblings, showElem, toggleElem} from '../utils/dom.ts';
 
 export function initUnicodeEscapeButton() {
   document.addEventListener('click', (e) => {
diff --git a/web_src/js/features/repo-wiki.js b/web_src/js/features/repo-wiki.ts
similarity index 94%
rename from web_src/js/features/repo-wiki.js
rename to web_src/js/features/repo-wiki.ts
index 03a2c68c5a..2c7fb1b1b8 100644
--- a/web_src/js/features/repo-wiki.js
+++ b/web_src/js/features/repo-wiki.ts
@@ -1,7 +1,7 @@
-import {initMarkupContent} from '../markup/content.js';
-import {validateTextareaNonEmpty, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js';
-import {fomanticMobileScreen} from '../modules/fomantic.js';
-import {POST} from '../modules/fetch.js';
+import {initMarkupContent} from '../markup/content.ts';
+import {validateTextareaNonEmpty, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
+import {fomanticMobileScreen} from '../modules/fomantic.ts';
+import {POST} from '../modules/fetch.ts';
 
 async function initRepoWikiFormEditor() {
   const editArea = document.querySelector('.repository.wiki .combo-markdown-editor textarea');
diff --git a/web_src/js/features/sshkey-helper.js b/web_src/js/features/sshkey-helper.ts
similarity index 100%
rename from web_src/js/features/sshkey-helper.js
rename to web_src/js/features/sshkey-helper.ts
diff --git a/web_src/js/features/stopwatch.js b/web_src/js/features/stopwatch.ts
similarity index 96%
rename from web_src/js/features/stopwatch.js
rename to web_src/js/features/stopwatch.ts
index 79d9892b74..d89aa4bfac 100644
--- a/web_src/js/features/stopwatch.js
+++ b/web_src/js/features/stopwatch.ts
@@ -1,7 +1,7 @@
-import {createTippy} from '../modules/tippy.js';
-import {GET} from '../modules/fetch.js';
-import {hideElem, showElem} from '../utils/dom.js';
-import {logoutFromWorker} from '../modules/worker.js';
+import {createTippy} from '../modules/tippy.ts';
+import {GET} from '../modules/fetch.ts';
+import {hideElem, showElem} from '../utils/dom.ts';
+import {logoutFromWorker} from '../modules/worker.ts';
 
 const {appSubUrl, notificationSettings, enableTimeTracking, assetVersionEncoded} = window.config;
 
diff --git a/web_src/js/features/tablesort.js b/web_src/js/features/tablesort.ts
similarity index 100%
rename from web_src/js/features/tablesort.js
rename to web_src/js/features/tablesort.ts
diff --git a/web_src/js/features/tribute.js b/web_src/js/features/tribute.ts
similarity index 96%
rename from web_src/js/features/tribute.js
rename to web_src/js/features/tribute.ts
index 30e957f2e6..193c65076f 100644
--- a/web_src/js/features/tribute.js
+++ b/web_src/js/features/tribute.ts
@@ -1,4 +1,4 @@
-import {emojiKeys, emojiHTML, emojiString} from './emoji.js';
+import {emojiKeys, emojiHTML, emojiString} from './emoji.ts';
 import {htmlEscape} from 'escape-goat';
 
 function makeCollections({mentions, emoji}) {
diff --git a/web_src/js/features/user-auth-webauthn.js b/web_src/js/features/user-auth-webauthn.ts
similarity index 98%
rename from web_src/js/features/user-auth-webauthn.js
rename to web_src/js/features/user-auth-webauthn.ts
index a317fee7e2..7b7508c4f1 100644
--- a/web_src/js/features/user-auth-webauthn.js
+++ b/web_src/js/features/user-auth-webauthn.ts
@@ -1,6 +1,6 @@
-import {encodeURLEncodedBase64, decodeURLEncodedBase64} from '../utils.js';
-import {showElem} from '../utils/dom.js';
-import {GET, POST} from '../modules/fetch.js';
+import {encodeURLEncodedBase64, decodeURLEncodedBase64} from '../utils.ts';
+import {showElem} from '../utils/dom.ts';
+import {GET, POST} from '../modules/fetch.ts';
 
 const {appSubUrl} = window.config;
 
diff --git a/web_src/js/features/user-auth.js b/web_src/js/features/user-auth.ts
similarity index 93%
rename from web_src/js/features/user-auth.js
rename to web_src/js/features/user-auth.ts
index 355843ccf2..f1f34bc806 100644
--- a/web_src/js/features/user-auth.js
+++ b/web_src/js/features/user-auth.ts
@@ -1,4 +1,4 @@
-import {checkAppUrl} from './common-page.js';
+import {checkAppUrl} from './common-page.ts';
 
 export function initUserAuthOauth2() {
   const outer = document.querySelector('#oauth2-login-navigator');
diff --git a/web_src/js/features/user-settings.js b/web_src/js/features/user-settings.ts
similarity index 92%
rename from web_src/js/features/user-settings.js
rename to web_src/js/features/user-settings.ts
index 8cb1f0582f..41939c0f52 100644
--- a/web_src/js/features/user-settings.js
+++ b/web_src/js/features/user-settings.ts
@@ -1,4 +1,4 @@
-import {hideElem, showElem} from '../utils/dom.js';
+import {hideElem, showElem} from '../utils/dom.ts';
 
 export function initUserSettings() {
   if (!document.querySelectorAll('.user.settings.profile').length) return;
diff --git a/web_src/js/globals.js b/web_src/js/globals.ts
similarity index 100%
rename from web_src/js/globals.js
rename to web_src/js/globals.ts
diff --git a/web_src/js/htmx.js b/web_src/js/htmx.ts
similarity index 93%
rename from web_src/js/htmx.js
rename to web_src/js/htmx.ts
index 6169d2f82f..ac7e540fe4 100644
--- a/web_src/js/htmx.js
+++ b/web_src/js/htmx.ts
@@ -1,4 +1,4 @@
-import {showErrorToast} from './modules/toast.js';
+import {showErrorToast} from './modules/toast.ts';
 
 // https://github.com/bigskysoftware/idiomorph#htmx
 import 'idiomorph/dist/idiomorph-ext.js';
diff --git a/web_src/js/index.js b/web_src/js/index.ts
similarity index 66%
rename from web_src/js/index.js
rename to web_src/js/index.ts
index 8aff052664..12120d3892 100644
--- a/web_src/js/index.js
+++ b/web_src/js/index.ts
@@ -1,31 +1,31 @@
 // bootstrap module must be the first one to be imported, it handles webpack lazy-loading and global errors
-import './bootstrap.js';
-import './htmx.js';
+import './bootstrap.ts';
+import './htmx.ts';
 
 import {initRepoActivityTopAuthorsChart} from './components/RepoActivityTopAuthors.vue';
 import {initScopedAccessTokenCategories} from './components/ScopedAccessTokenSelector.vue';
 import {initDashboardRepoList} from './components/DashboardRepoList.vue';
 
-import {initGlobalCopyToClipboardListener} from './features/clipboard.js';
-import {initContextPopups} from './features/contextpopup.js';
-import {initRepoGraphGit} from './features/repo-graph.js';
-import {initHeatmap} from './features/heatmap.js';
-import {initImageDiff} from './features/imagediff.js';
-import {initRepoMigration} from './features/repo-migration.js';
-import {initRepoProject} from './features/repo-projects.js';
-import {initTableSort} from './features/tablesort.js';
-import {initAutoFocusEnd} from './features/autofocus-end.js';
-import {initAdminUserListSearchForm} from './features/admin/users.js';
-import {initAdminConfigs} from './features/admin/config.js';
-import {initMarkupAnchors} from './markup/anchors.js';
-import {initNotificationCount, initNotificationsTable} from './features/notification.js';
-import {initRepoIssueContentHistory} from './features/repo-issue-content.js';
-import {initStopwatch} from './features/stopwatch.js';
-import {initFindFileInRepo} from './features/repo-findfile.js';
-import {initCommentContent, initMarkupContent} from './markup/content.js';
-import {initPdfViewer} from './render/pdf.js';
+import {initGlobalCopyToClipboardListener} from './features/clipboard.ts';
+import {initContextPopups} from './features/contextpopup.ts';
+import {initRepoGraphGit} from './features/repo-graph.ts';
+import {initHeatmap} from './features/heatmap.ts';
+import {initImageDiff} from './features/imagediff.ts';
+import {initRepoMigration} from './features/repo-migration.ts';
+import {initRepoProject} from './features/repo-projects.ts';
+import {initTableSort} from './features/tablesort.ts';
+import {initAutoFocusEnd} from './features/autofocus-end.ts';
+import {initAdminUserListSearchForm} from './features/admin/users.ts';
+import {initAdminConfigs} from './features/admin/config.ts';
+import {initMarkupAnchors} from './markup/anchors.ts';
+import {initNotificationCount, initNotificationsTable} from './features/notification.ts';
+import {initRepoIssueContentHistory} from './features/repo-issue-content.ts';
+import {initStopwatch} from './features/stopwatch.ts';
+import {initFindFileInRepo} from './features/repo-findfile.ts';
+import {initCommentContent, initMarkupContent} from './markup/content.ts';
+import {initPdfViewer} from './render/pdf.ts';
 
-import {initUserAuthOauth2} from './features/user-auth.js';
+import {initUserAuthOauth2} from './features/user-auth.ts';
 import {
   initRepoIssueDue,
   initRepoIssueReferenceRepositorySearch,
@@ -34,64 +34,64 @@ import {
   initRepoPullRequestMergeInstruction,
   initRepoPullRequestAllowMaintainerEdit,
   initRepoPullRequestReview, initRepoIssueSidebarList, initArchivedLabelHandler,
-} from './features/repo-issue.js';
-import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.js';
-import {initRepoTopicBar} from './features/repo-home.js';
-import {initAdminEmails} from './features/admin/emails.js';
-import {initAdminCommon} from './features/admin/common.js';
-import {initRepoTemplateSearch} from './features/repo-template.js';
-import {initRepoCodeView} from './features/repo-code.js';
-import {initSshKeyFormParser} from './features/sshkey-helper.js';
-import {initUserSettings} from './features/user-settings.js';
-import {initRepoArchiveLinks} from './features/repo-common.js';
-import {initRepoMigrationStatusChecker} from './features/repo-migrate.js';
+} from './features/repo-issue.ts';
+import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.ts';
+import {initRepoTopicBar} from './features/repo-home.ts';
+import {initAdminEmails} from './features/admin/emails.ts';
+import {initAdminCommon} from './features/admin/common.ts';
+import {initRepoTemplateSearch} from './features/repo-template.ts';
+import {initRepoCodeView} from './features/repo-code.ts';
+import {initSshKeyFormParser} from './features/sshkey-helper.ts';
+import {initUserSettings} from './features/user-settings.ts';
+import {initRepoArchiveLinks} from './features/repo-common.ts';
+import {initRepoMigrationStatusChecker} from './features/repo-migrate.ts';
 import {
   initRepoSettingGitHook,
   initRepoSettingsCollaboration,
   initRepoSettingSearchTeamBox,
-} from './features/repo-settings.js';
-import {initRepoDiffView} from './features/repo-diff.js';
-import {initOrgTeamSearchRepoBox, initOrgTeamSettings} from './features/org-team.js';
-import {initUserAuthWebAuthn, initUserAuthWebAuthnRegister} from './features/user-auth-webauthn.js';
-import {initRepoRelease, initRepoReleaseNew} from './features/repo-release.js';
-import {initRepoEditor} from './features/repo-editor.js';
-import {initCompSearchUserBox} from './features/comp/SearchUserBox.js';
-import {initInstall} from './features/install.js';
-import {initCompWebHookEditor} from './features/comp/WebHookEditor.js';
-import {initRepoBranchButton} from './features/repo-branch.js';
-import {initCommonOrganization} from './features/common-organization.js';
-import {initRepoWikiForm} from './features/repo-wiki.js';
-import {initRepoCommentForm, initRepository} from './features/repo-legacy.js';
-import {initCopyContent} from './features/copycontent.js';
-import {initCaptcha} from './features/captcha.js';
+} from './features/repo-settings.ts';
+import {initRepoDiffView} from './features/repo-diff.ts';
+import {initOrgTeamSearchRepoBox, initOrgTeamSettings} from './features/org-team.ts';
+import {initUserAuthWebAuthn, initUserAuthWebAuthnRegister} from './features/user-auth-webauthn.ts';
+import {initRepoRelease, initRepoReleaseNew} from './features/repo-release.ts';
+import {initRepoEditor} from './features/repo-editor.ts';
+import {initCompSearchUserBox} from './features/comp/SearchUserBox.ts';
+import {initInstall} from './features/install.ts';
+import {initCompWebHookEditor} from './features/comp/WebHookEditor.ts';
+import {initRepoBranchButton} from './features/repo-branch.ts';
+import {initCommonOrganization} from './features/common-organization.ts';
+import {initRepoWikiForm} from './features/repo-wiki.ts';
+import {initRepoCommentForm, initRepository} from './features/repo-legacy.ts';
+import {initCopyContent} from './features/copycontent.ts';
+import {initCaptcha} from './features/captcha.ts';
 import {initRepositoryActionView} from './components/RepoActionView.vue';
-import {initGlobalTooltips} from './modules/tippy.js';
-import {initGiteaFomantic} from './modules/fomantic.js';
-import {initSubmitEventPolyfill, onDomReady} from './utils/dom.js';
-import {initRepoIssueList} from './features/repo-issue-list.js';
-import {initCommonIssueListQuickGoto} from './features/common-issue-list.js';
-import {initRepoContributors} from './features/contributors.js';
-import {initRepoCodeFrequency} from './features/code-frequency.js';
-import {initRepoRecentCommits} from './features/recent-commits.js';
-import {initRepoDiffCommitBranchesAndTags} from './features/repo-diff-commit.js';
-import {initDirAuto} from './modules/dirauto.js';
-import {initRepositorySearch} from './features/repo-search.js';
-import {initColorPickers} from './features/colorpicker.js';
-import {initAdminSelfCheck} from './features/admin/selfcheck.js';
-import {initGlobalFetchAction} from './features/common-fetch-action.js';
+import {initGlobalTooltips} from './modules/tippy.ts';
+import {initGiteaFomantic} from './modules/fomantic.ts';
+import {initSubmitEventPolyfill, onDomReady} from './utils/dom.ts';
+import {initRepoIssueList} from './features/repo-issue-list.ts';
+import {initCommonIssueListQuickGoto} from './features/common-issue-list.ts';
+import {initRepoContributors} from './features/contributors.ts';
+import {initRepoCodeFrequency} from './features/code-frequency.ts';
+import {initRepoRecentCommits} from './features/recent-commits.ts';
+import {initRepoDiffCommitBranchesAndTags} from './features/repo-diff-commit.ts';
+import {initDirAuto} from './modules/dirauto.ts';
+import {initRepositorySearch} from './features/repo-search.ts';
+import {initColorPickers} from './features/colorpicker.ts';
+import {initAdminSelfCheck} from './features/admin/selfcheck.ts';
+import {initGlobalFetchAction} from './features/common-fetch-action.ts';
 import {
   initFootLanguageMenu,
   initGlobalDropdown,
   initGlobalTabularMenu,
   initHeadNavbarContentToggle,
-} from './features/common-page.js';
+} from './features/common-page.ts';
 import {
   initGlobalButtonClickOnEnter,
   initGlobalButtons,
   initGlobalDeleteButton,
   initGlobalShowModal,
-} from './features/common-button.js';
-import {initGlobalEnterQuickSubmit, initGlobalFormDirtyLeaveConfirm} from './features/common-form.js';
+} from './features/common-button.ts';
+import {initGlobalEnterQuickSubmit, initGlobalFormDirtyLeaveConfirm} from './features/common-form.ts';
 
 initGiteaFomantic();
 initDirAuto();
diff --git a/web_src/js/markup/anchors.js b/web_src/js/markup/anchors.ts
similarity index 98%
rename from web_src/js/markup/anchors.js
rename to web_src/js/markup/anchors.ts
index 6f36d09683..8f0a88f130 100644
--- a/web_src/js/markup/anchors.js
+++ b/web_src/js/markup/anchors.ts
@@ -1,4 +1,4 @@
-import {svg} from '../svg.js';
+import {svg} from '../svg.ts';
 
 const addPrefix = (str) => `user-content-${str}`;
 const removePrefix = (str) => str.replace(/^user-content-/, '');
diff --git a/web_src/js/markup/asciicast.js b/web_src/js/markup/asciicast.ts
similarity index 100%
rename from web_src/js/markup/asciicast.js
rename to web_src/js/markup/asciicast.ts
diff --git a/web_src/js/markup/codecopy.js b/web_src/js/markup/codecopy.ts
similarity index 95%
rename from web_src/js/markup/codecopy.js
rename to web_src/js/markup/codecopy.ts
index 078d741253..0fac4a0a39 100644
--- a/web_src/js/markup/codecopy.js
+++ b/web_src/js/markup/codecopy.ts
@@ -1,4 +1,4 @@
-import {svg} from '../svg.js';
+import {svg} from '../svg.ts';
 
 export function makeCodeCopyButton() {
   const button = document.createElement('button');
diff --git a/web_src/js/markup/common.js b/web_src/js/markup/common.ts
similarity index 100%
rename from web_src/js/markup/common.js
rename to web_src/js/markup/common.ts
diff --git a/web_src/js/markup/content.js b/web_src/js/markup/content.ts
similarity index 53%
rename from web_src/js/markup/content.js
rename to web_src/js/markup/content.ts
index 1d29dc07f2..e7f7b7f0c4 100644
--- a/web_src/js/markup/content.js
+++ b/web_src/js/markup/content.ts
@@ -1,8 +1,8 @@
-import {renderMermaid} from './mermaid.js';
-import {renderMath} from './math.js';
-import {renderCodeCopy} from './codecopy.js';
-import {renderAsciicast} from './asciicast.js';
-import {initMarkupTasklist} from './tasklist.js';
+import {renderMermaid} from './mermaid.ts';
+import {renderMath} from './math.ts';
+import {renderCodeCopy} from './codecopy.ts';
+import {renderAsciicast} from './asciicast.ts';
+import {initMarkupTasklist} from './tasklist.ts';
 
 // code that runs for all markup content
 export function initMarkupContent() {
diff --git a/web_src/js/markup/math.js b/web_src/js/markup/math.ts
similarity index 97%
rename from web_src/js/markup/math.js
rename to web_src/js/markup/math.ts
index 872e50a452..e10d90fa2a 100644
--- a/web_src/js/markup/math.js
+++ b/web_src/js/markup/math.ts
@@ -1,4 +1,4 @@
-import {displayError} from './common.js';
+import {displayError} from './common.ts';
 
 function targetElement(el) {
   // The target element is either the current element if it has the
diff --git a/web_src/js/markup/mermaid.js b/web_src/js/markup/mermaid.ts
similarity index 94%
rename from web_src/js/markup/mermaid.js
rename to web_src/js/markup/mermaid.ts
index 0549fb3e31..da07161ed1 100644
--- a/web_src/js/markup/mermaid.js
+++ b/web_src/js/markup/mermaid.ts
@@ -1,6 +1,6 @@
-import {isDarkTheme} from '../utils.js';
-import {makeCodeCopyButton} from './codecopy.js';
-import {displayError} from './common.js';
+import {isDarkTheme} from '../utils.ts';
+import {makeCodeCopyButton} from './codecopy.ts';
+import {displayError} from './common.ts';
 
 const {mermaidMaxSourceCharacters} = window.config;
 
diff --git a/web_src/js/markup/tasklist.js b/web_src/js/markup/tasklist.ts
similarity index 97%
rename from web_src/js/markup/tasklist.js
rename to web_src/js/markup/tasklist.ts
index a40b5e4abd..93896ccf07 100644
--- a/web_src/js/markup/tasklist.js
+++ b/web_src/js/markup/tasklist.ts
@@ -1,5 +1,5 @@
-import {POST} from '../modules/fetch.js';
-import {showErrorToast} from '../modules/toast.js';
+import {POST} from '../modules/fetch.ts';
+import {showErrorToast} from '../modules/toast.ts';
 
 const preventListener = (e) => e.preventDefault();
 
diff --git a/web_src/js/modules/dirauto.js b/web_src/js/modules/dirauto.ts
similarity index 95%
rename from web_src/js/modules/dirauto.js
rename to web_src/js/modules/dirauto.ts
index cd90f8155b..855bae1ca8 100644
--- a/web_src/js/modules/dirauto.js
+++ b/web_src/js/modules/dirauto.ts
@@ -1,4 +1,4 @@
-import {isDocumentFragmentOrElementNode} from '../utils/dom.js';
+import {isDocumentFragmentOrElementNode} from '../utils/dom.ts';
 
 // for performance considerations, it only uses performant syntax
 function attachDirAuto(el) {
diff --git a/web_src/js/modules/fetch.test.js b/web_src/js/modules/fetch.test.ts
similarity index 80%
rename from web_src/js/modules/fetch.test.js
rename to web_src/js/modules/fetch.test.ts
index e4bec3ced9..c40016b748 100644
--- a/web_src/js/modules/fetch.test.js
+++ b/web_src/js/modules/fetch.test.ts
@@ -1,4 +1,4 @@
-import {GET, POST, PATCH, PUT, DELETE} from './fetch.js';
+import {GET, POST, PATCH, PUT, DELETE} from './fetch.ts';
 
 // tests here are only to satisfy the linter for unused functions
 test('exports', () => {
diff --git a/web_src/js/modules/fetch.js b/web_src/js/modules/fetch.ts
similarity index 97%
rename from web_src/js/modules/fetch.js
rename to web_src/js/modules/fetch.ts
index 2191a8d4db..b70a4cb304 100644
--- a/web_src/js/modules/fetch.js
+++ b/web_src/js/modules/fetch.ts
@@ -1,4 +1,4 @@
-import {isObject} from '../utils.js';
+import {isObject} from '../utils.ts';
 
 const {csrfToken} = window.config;
 
diff --git a/web_src/js/modules/fomantic.js b/web_src/js/modules/fomantic.ts
similarity index 71%
rename from web_src/js/modules/fomantic.js
rename to web_src/js/modules/fomantic.ts
index 06e4e97c48..d4c71ede57 100644
--- a/web_src/js/modules/fomantic.js
+++ b/web_src/js/modules/fomantic.ts
@@ -1,12 +1,12 @@
 import $ from 'jquery';
-import {initFomanticApiPatch} from './fomantic/api.js';
-import {initAriaCheckboxPatch} from './fomantic/checkbox.js';
-import {initAriaFormFieldPatch} from './fomantic/form.js';
-import {initAriaDropdownPatch} from './fomantic/dropdown.js';
-import {initAriaModalPatch} from './fomantic/modal.js';
-import {initFomanticTransition} from './fomantic/transition.js';
-import {initFomanticDimmer} from './fomantic/dimmer.js';
-import {svg} from '../svg.js';
+import {initFomanticApiPatch} from './fomantic/api.ts';
+import {initAriaCheckboxPatch} from './fomantic/checkbox.ts';
+import {initAriaFormFieldPatch} from './fomantic/form.ts';
+import {initAriaDropdownPatch} from './fomantic/dropdown.ts';
+import {initAriaModalPatch} from './fomantic/modal.ts';
+import {initFomanticTransition} from './fomantic/transition.ts';
+import {initFomanticDimmer} from './fomantic/dimmer.ts';
+import {svg} from '../svg.ts';
 
 export const fomanticMobileScreen = window.matchMedia('only screen and (max-width: 767.98px)');
 
diff --git a/web_src/js/modules/fomantic/api.js b/web_src/js/modules/fomantic/api.ts
similarity index 100%
rename from web_src/js/modules/fomantic/api.js
rename to web_src/js/modules/fomantic/api.ts
diff --git a/web_src/js/modules/fomantic/base.js b/web_src/js/modules/fomantic/base.ts
similarity index 100%
rename from web_src/js/modules/fomantic/base.js
rename to web_src/js/modules/fomantic/base.ts
diff --git a/web_src/js/modules/fomantic/checkbox.js b/web_src/js/modules/fomantic/checkbox.ts
similarity index 91%
rename from web_src/js/modules/fomantic/checkbox.js
rename to web_src/js/modules/fomantic/checkbox.ts
index ed77406cc3..68c1d4199c 100644
--- a/web_src/js/modules/fomantic/checkbox.js
+++ b/web_src/js/modules/fomantic/checkbox.ts
@@ -1,4 +1,4 @@
-import {linkLabelAndInput} from './base.js';
+import {linkLabelAndInput} from './base.ts';
 
 export function initAriaCheckboxPatch() {
   // link the label and the input element so it's clickable and accessible
diff --git a/web_src/js/modules/fomantic/dimmer.js b/web_src/js/modules/fomantic/dimmer.ts
similarity index 95%
rename from web_src/js/modules/fomantic/dimmer.js
rename to web_src/js/modules/fomantic/dimmer.ts
index e027838d4a..e59b6e9eea 100644
--- a/web_src/js/modules/fomantic/dimmer.js
+++ b/web_src/js/modules/fomantic/dimmer.ts
@@ -1,5 +1,5 @@
 import $ from 'jquery';
-import {queryElemChildren} from '../../utils/dom.js';
+import {queryElemChildren} from '../../utils/dom.ts';
 
 export function initFomanticDimmer() {
   // stand-in for removed dimmer module
diff --git a/web_src/js/modules/fomantic/dropdown.js b/web_src/js/modules/fomantic/dropdown.ts
similarity index 99%
rename from web_src/js/modules/fomantic/dropdown.js
rename to web_src/js/modules/fomantic/dropdown.ts
index bbffb59152..7ef6aed62b 100644
--- a/web_src/js/modules/fomantic/dropdown.js
+++ b/web_src/js/modules/fomantic/dropdown.ts
@@ -1,5 +1,5 @@
 import $ from 'jquery';
-import {generateAriaId} from './base.js';
+import {generateAriaId} from './base.ts';
 
 const ariaPatchKey = '_giteaAriaPatchDropdown';
 const fomanticDropdownFn = $.fn.dropdown;
diff --git a/web_src/js/modules/fomantic/form.js b/web_src/js/modules/fomantic/form.ts
similarity index 91%
rename from web_src/js/modules/fomantic/form.js
rename to web_src/js/modules/fomantic/form.ts
index 3bb0058902..841d945353 100644
--- a/web_src/js/modules/fomantic/form.js
+++ b/web_src/js/modules/fomantic/form.ts
@@ -1,4 +1,4 @@
-import {linkLabelAndInput} from './base.js';
+import {linkLabelAndInput} from './base.ts';
 
 export function initAriaFormFieldPatch() {
   // link the label and the input element so it's clickable and accessible
diff --git a/web_src/js/modules/fomantic/modal.js b/web_src/js/modules/fomantic/modal.ts
similarity index 100%
rename from web_src/js/modules/fomantic/modal.js
rename to web_src/js/modules/fomantic/modal.ts
diff --git a/web_src/js/modules/fomantic/transition.js b/web_src/js/modules/fomantic/transition.ts
similarity index 100%
rename from web_src/js/modules/fomantic/transition.js
rename to web_src/js/modules/fomantic/transition.ts
diff --git a/web_src/js/modules/sortable.js b/web_src/js/modules/sortable.ts
similarity index 100%
rename from web_src/js/modules/sortable.js
rename to web_src/js/modules/sortable.ts
diff --git a/web_src/js/modules/stores.js b/web_src/js/modules/stores.ts
similarity index 100%
rename from web_src/js/modules/stores.js
rename to web_src/js/modules/stores.ts
diff --git a/web_src/js/modules/tippy.js b/web_src/js/modules/tippy.ts
similarity index 98%
rename from web_src/js/modules/tippy.js
rename to web_src/js/modules/tippy.ts
index a18c94cafb..a18bad5db7 100644
--- a/web_src/js/modules/tippy.js
+++ b/web_src/js/modules/tippy.ts
@@ -1,6 +1,6 @@
 import tippy, {followCursor} from 'tippy.js';
-import {isDocumentFragmentOrElementNode} from '../utils/dom.js';
-import {formatDatetime} from '../utils/time.js';
+import {isDocumentFragmentOrElementNode} from '../utils/dom.ts';
+import {formatDatetime} from '../utils/time.ts';
 
 const visibleInstances = new Set();
 const arrowSvg = `<svg width="16" height="7"><path d="m0 7 8-7 8 7Z" class="tippy-svg-arrow-outer"/><path d="m0 8 8-7 8 7Z" class="tippy-svg-arrow-inner"/></svg>`;
diff --git a/web_src/js/modules/toast.test.js b/web_src/js/modules/toast.test.ts
similarity index 97%
rename from web_src/js/modules/toast.test.js
rename to web_src/js/modules/toast.test.ts
index 357f18dbcd..b29e9a32bd 100644
--- a/web_src/js/modules/toast.test.js
+++ b/web_src/js/modules/toast.test.ts
@@ -1,4 +1,4 @@
-import {showInfoToast, showErrorToast, showWarningToast} from './toast.js';
+import {showInfoToast, showErrorToast, showWarningToast} from './toast.ts';
 
 test('showInfoToast', async () => {
   showInfoToast('success 😀', {duration: -1});
diff --git a/web_src/js/modules/toast.js b/web_src/js/modules/toast.ts
similarity index 96%
rename from web_src/js/modules/toast.js
rename to web_src/js/modules/toast.ts
index 627e24a1ff..cded48e6c2 100644
--- a/web_src/js/modules/toast.js
+++ b/web_src/js/modules/toast.ts
@@ -1,6 +1,6 @@
 import {htmlEscape} from 'escape-goat';
-import {svg} from '../svg.js';
-import {animateOnce, showElem} from '../utils/dom.js';
+import {svg} from '../svg.ts';
+import {animateOnce, showElem} from '../utils/dom.ts';
 import Toastify from 'toastify-js'; // don't use "async import", because when network error occurs, the "async import" also fails and nothing is shown
 
 const levels = {
diff --git a/web_src/js/modules/worker.js b/web_src/js/modules/worker.ts
similarity index 86%
rename from web_src/js/modules/worker.js
rename to web_src/js/modules/worker.ts
index ef3f1dea48..ddc02d7a7d 100644
--- a/web_src/js/modules/worker.js
+++ b/web_src/js/modules/worker.ts
@@ -1,4 +1,4 @@
-import {sleep} from '../utils.js';
+import {sleep} from '../utils.ts';
 
 const {appSubUrl} = window.config;
 
diff --git a/web_src/js/render/ansi.test.js b/web_src/js/render/ansi.test.ts
similarity index 96%
rename from web_src/js/render/ansi.test.js
rename to web_src/js/render/ansi.test.ts
index 5afff71c29..21b7523994 100644
--- a/web_src/js/render/ansi.test.js
+++ b/web_src/js/render/ansi.test.ts
@@ -1,4 +1,4 @@
-import {renderAnsi} from './ansi.js';
+import {renderAnsi} from './ansi.ts';
 
 test('renderAnsi', () => {
   expect(renderAnsi('abc')).toEqual('abc');
diff --git a/web_src/js/render/ansi.js b/web_src/js/render/ansi.ts
similarity index 100%
rename from web_src/js/render/ansi.js
rename to web_src/js/render/ansi.ts
diff --git a/web_src/js/render/pdf.js b/web_src/js/render/pdf.ts
similarity index 100%
rename from web_src/js/render/pdf.js
rename to web_src/js/render/pdf.ts
diff --git a/web_src/js/standalone/devtest.js b/web_src/js/standalone/devtest.ts
similarity index 95%
rename from web_src/js/standalone/devtest.js
rename to web_src/js/standalone/devtest.ts
index d3e3e13a87..3489697a2f 100644
--- a/web_src/js/standalone/devtest.js
+++ b/web_src/js/standalone/devtest.ts
@@ -1,4 +1,4 @@
-import {showInfoToast, showWarningToast, showErrorToast} from '../modules/toast.js';
+import {showInfoToast, showWarningToast, showErrorToast} from '../modules/toast.ts';
 
 function initDevtestToast() {
   const levelMap = {info: showInfoToast, warning: showWarningToast, error: showErrorToast};
diff --git a/web_src/js/standalone/swagger.js b/web_src/js/standalone/swagger.ts
similarity index 100%
rename from web_src/js/standalone/swagger.js
rename to web_src/js/standalone/swagger.ts
diff --git a/web_src/js/svg.test.js b/web_src/js/svg.test.ts
similarity index 94%
rename from web_src/js/svg.test.js
rename to web_src/js/svg.test.ts
index 06b320c860..015758a271 100644
--- a/web_src/js/svg.test.js
+++ b/web_src/js/svg.test.ts
@@ -1,4 +1,4 @@
-import {svg, SvgIcon, svgParseOuterInner} from './svg.js';
+import {svg, SvgIcon, svgParseOuterInner} from './svg.ts';
 import {createApp, h} from 'vue';
 
 test('svg', () => {
diff --git a/web_src/js/svg.js b/web_src/js/svg.ts
similarity index 99%
rename from web_src/js/svg.js
rename to web_src/js/svg.ts
index 913d26779f..a0fe52a7be 100644
--- a/web_src/js/svg.js
+++ b/web_src/js/svg.ts
@@ -1,5 +1,5 @@
 import {h} from 'vue';
-import {parseDom, serializeXml} from './utils.js';
+import {parseDom, serializeXml} from './utils.ts';
 import giteaDoubleChevronLeft from '../../public/assets/img/svg/gitea-double-chevron-left.svg';
 import giteaDoubleChevronRight from '../../public/assets/img/svg/gitea-double-chevron-right.svg';
 import giteaEmptyCheckbox from '../../public/assets/img/svg/gitea-empty-checkbox.svg';
diff --git a/web_src/js/utils.test.js b/web_src/js/utils.test.ts
similarity index 99%
rename from web_src/js/utils.test.js
rename to web_src/js/utils.test.ts
index 1ec3d3630b..4c09f49ba8 100644
--- a/web_src/js/utils.test.js
+++ b/web_src/js/utils.test.ts
@@ -2,7 +2,7 @@ import {
   basename, extname, isObject, stripTags, parseIssueHref,
   parseUrl, translateMonth, translateDay, blobToDataURI,
   toAbsoluteUrl, encodeURLEncodedBase64, decodeURLEncodedBase64, isImageFile, isVideoFile,
-} from './utils.js';
+} from './utils.ts';
 
 test('basename', () => {
   expect(basename('/path/to/file.js')).toEqual('file.js');
diff --git a/web_src/js/utils.js b/web_src/js/utils.ts
similarity index 100%
rename from web_src/js/utils.js
rename to web_src/js/utils.ts
diff --git a/web_src/js/utils/color.test.js b/web_src/js/utils/color.test.ts
similarity index 95%
rename from web_src/js/utils/color.test.js
rename to web_src/js/utils/color.test.ts
index fee9afc776..9e4fbcca64 100644
--- a/web_src/js/utils/color.test.js
+++ b/web_src/js/utils/color.test.ts
@@ -1,4 +1,4 @@
-import {contrastColor} from './color.js';
+import {contrastColor} from './color.ts';
 
 test('contrastColor', () => {
   expect(contrastColor('#d73a4a')).toBe('#fff');
diff --git a/web_src/js/utils/color.js b/web_src/js/utils/color.ts
similarity index 100%
rename from web_src/js/utils/color.js
rename to web_src/js/utils/color.ts
diff --git a/web_src/js/utils/dom.test.js b/web_src/js/utils/dom.test.ts
similarity index 98%
rename from web_src/js/utils/dom.test.js
rename to web_src/js/utils/dom.test.ts
index b9212ec284..d873484969 100644
--- a/web_src/js/utils/dom.test.js
+++ b/web_src/js/utils/dom.test.ts
@@ -1,4 +1,4 @@
-import {createElementFromAttrs, createElementFromHTML} from './dom.js';
+import {createElementFromAttrs, createElementFromHTML} from './dom.ts';
 
 test('createElementFromHTML', () => {
   expect(createElementFromHTML('<a>foo<span>bar</span></a>').outerHTML).toEqual('<a>foo<span>bar</span></a>');
diff --git a/web_src/js/utils/dom.js b/web_src/js/utils/dom.ts
similarity index 100%
rename from web_src/js/utils/dom.js
rename to web_src/js/utils/dom.ts
diff --git a/web_src/js/utils/image.test.js b/web_src/js/utils/image.test.ts
similarity index 96%
rename from web_src/js/utils/image.test.js
rename to web_src/js/utils/image.test.ts
index af56ed2331..da0605f1d0 100644
--- a/web_src/js/utils/image.test.js
+++ b/web_src/js/utils/image.test.ts
@@ -1,4 +1,4 @@
-import {pngChunks, imageInfo} from './image.js';
+import {pngChunks, imageInfo} from './image.ts';
 
 const pngNoPhys = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAADUlEQVQIHQECAP3/AAAAAgABzePRKwAAAABJRU5ErkJggg==';
 const pngPhys = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAACXBIWXMAABYlAAAWJQFJUiTwAAAAEElEQVQI12OQNZcAIgYIBQAL8gGxdzzM0A==';
diff --git a/web_src/js/utils/image.js b/web_src/js/utils/image.ts
similarity index 100%
rename from web_src/js/utils/image.js
rename to web_src/js/utils/image.ts
diff --git a/web_src/js/utils/match.test.js b/web_src/js/utils/match.test.ts
similarity index 94%
rename from web_src/js/utils/match.test.js
rename to web_src/js/utils/match.test.ts
index 1e30b451d4..d3b2d9fe99 100644
--- a/web_src/js/utils/match.test.js
+++ b/web_src/js/utils/match.test.ts
@@ -1,4 +1,4 @@
-import {matchEmoji, matchMention} from './match.js';
+import {matchEmoji, matchMention} from './match.ts';
 
 test('matchEmoji', () => {
   expect(matchEmoji('')).toEqual([
diff --git a/web_src/js/utils/match.js b/web_src/js/utils/match.ts
similarity index 100%
rename from web_src/js/utils/match.js
rename to web_src/js/utils/match.ts
diff --git a/web_src/js/utils/time.test.js b/web_src/js/utils/time.test.ts
similarity index 87%
rename from web_src/js/utils/time.test.js
rename to web_src/js/utils/time.test.ts
index dd1114ce7f..3cd23df557 100644
--- a/web_src/js/utils/time.test.js
+++ b/web_src/js/utils/time.test.ts
@@ -1,4 +1,4 @@
-import {startDaysBetween} from './time.js';
+import {startDaysBetween} from './time.ts';
 
 test('startDaysBetween', () => {
   expect(startDaysBetween(new Date('2024-02-15'), new Date('2024-04-18'))).toEqual([
diff --git a/web_src/js/utils/time.js b/web_src/js/utils/time.ts
similarity index 97%
rename from web_src/js/utils/time.js
rename to web_src/js/utils/time.ts
index 7c7eabd1a3..d3a986e736 100644
--- a/web_src/js/utils/time.js
+++ b/web_src/js/utils/time.ts
@@ -1,6 +1,6 @@
 import dayjs from 'dayjs';
 import utc from 'dayjs/plugin/utc.js';
-import {getCurrentLocale} from '../utils.js';
+import {getCurrentLocale} from '../utils.ts';
 
 dayjs.extend(utc);
 
diff --git a/web_src/js/utils/url.test.js b/web_src/js/utils/url.test.ts
similarity index 88%
rename from web_src/js/utils/url.test.js
rename to web_src/js/utils/url.test.ts
index 08c6373ffb..25fda79b19 100644
--- a/web_src/js/utils/url.test.js
+++ b/web_src/js/utils/url.test.ts
@@ -1,4 +1,4 @@
-import {pathEscapeSegments, isUrl} from './url.js';
+import {pathEscapeSegments, isUrl} from './url.ts';
 
 test('pathEscapeSegments', () => {
   expect(pathEscapeSegments('a/b/c')).toEqual('a/b/c');
diff --git a/web_src/js/utils/url.js b/web_src/js/utils/url.ts
similarity index 100%
rename from web_src/js/utils/url.js
rename to web_src/js/utils/url.ts
diff --git a/web_src/js/vendor/jquery.are-you-sure.js b/web_src/js/vendor/jquery.are-you-sure.ts
similarity index 100%
rename from web_src/js/vendor/jquery.are-you-sure.js
rename to web_src/js/vendor/jquery.are-you-sure.ts
diff --git a/web_src/js/vitest.setup.js b/web_src/js/vitest.setup.ts
similarity index 100%
rename from web_src/js/vitest.setup.js
rename to web_src/js/vitest.setup.ts
diff --git a/web_src/js/webcomponents/absolute-date.test.js b/web_src/js/webcomponents/absolute-date.test.ts
similarity index 85%
rename from web_src/js/webcomponents/absolute-date.test.js
rename to web_src/js/webcomponents/absolute-date.test.ts
index ba04451b65..a591df7c10 100644
--- a/web_src/js/webcomponents/absolute-date.test.js
+++ b/web_src/js/webcomponents/absolute-date.test.ts
@@ -1,4 +1,4 @@
-import {toAbsoluteLocaleDate} from './absolute-date.js';
+import {toAbsoluteLocaleDate} from './absolute-date.ts';
 
 test('toAbsoluteLocaleDate', () => {
   expect(toAbsoluteLocaleDate('2024-03-15', 'en-US', {
diff --git a/web_src/js/webcomponents/absolute-date.js b/web_src/js/webcomponents/absolute-date.ts
similarity index 100%
rename from web_src/js/webcomponents/absolute-date.js
rename to web_src/js/webcomponents/absolute-date.ts
diff --git a/web_src/js/webcomponents/index.js b/web_src/js/webcomponents/index.js
deleted file mode 100644
index 7cec9da734..0000000000
--- a/web_src/js/webcomponents/index.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import './polyfills.js';
-import '@github/relative-time-element';
-import './origin-url.js';
-import './overflow-menu.js';
-import './absolute-date.js';
diff --git a/web_src/js/webcomponents/index.ts b/web_src/js/webcomponents/index.ts
new file mode 100644
index 0000000000..6c0f555864
--- /dev/null
+++ b/web_src/js/webcomponents/index.ts
@@ -0,0 +1,5 @@
+import './polyfills.ts';
+import '@github/relative-time-element';
+import './origin-url.ts';
+import './overflow-menu.ts';
+import './absolute-date.ts';
diff --git a/web_src/js/webcomponents/origin-url.test.js b/web_src/js/webcomponents/origin-url.test.ts
similarity index 94%
rename from web_src/js/webcomponents/origin-url.test.js
rename to web_src/js/webcomponents/origin-url.test.ts
index 3b2ab89f2a..4082e53aea 100644
--- a/web_src/js/webcomponents/origin-url.test.js
+++ b/web_src/js/webcomponents/origin-url.test.ts
@@ -1,4 +1,4 @@
-import {toOriginUrl} from './origin-url.js';
+import {toOriginUrl} from './origin-url.ts';
 
 test('toOriginUrl', () => {
   const oldLocation = window.location;
diff --git a/web_src/js/webcomponents/origin-url.js b/web_src/js/webcomponents/origin-url.ts
similarity index 100%
rename from web_src/js/webcomponents/origin-url.js
rename to web_src/js/webcomponents/origin-url.ts
diff --git a/web_src/js/webcomponents/overflow-menu.js b/web_src/js/webcomponents/overflow-menu.ts
similarity index 98%
rename from web_src/js/webcomponents/overflow-menu.js
rename to web_src/js/webcomponents/overflow-menu.ts
index 0e91db6575..2efeb9222b 100644
--- a/web_src/js/webcomponents/overflow-menu.js
+++ b/web_src/js/webcomponents/overflow-menu.ts
@@ -1,6 +1,6 @@
 import {throttle} from 'throttle-debounce';
-import {createTippy} from '../modules/tippy.js';
-import {isDocumentFragmentOrElementNode} from '../utils/dom.js';
+import {createTippy} from '../modules/tippy.ts';
+import {isDocumentFragmentOrElementNode} from '../utils/dom.ts';
 import octiconKebabHorizontal from '../../../public/assets/img/svg/octicon-kebab-horizontal.svg';
 
 window.customElements.define('overflow-menu', class extends HTMLElement {
diff --git a/web_src/js/webcomponents/polyfills.js b/web_src/js/webcomponents/polyfills.ts
similarity index 100%
rename from web_src/js/webcomponents/polyfills.js
rename to web_src/js/webcomponents/polyfills.ts
diff --git a/webpack.config.js b/webpack.config.js
index 80703c7448..fa24354a5b 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -76,26 +76,26 @@ export default {
   mode: isProduction ? 'production' : 'development',
   entry: {
     index: [
-      fileURLToPath(new URL('web_src/js/globals.js', import.meta.url)),
+      fileURLToPath(new URL('web_src/js/globals.ts', import.meta.url)),
       fileURLToPath(new URL('web_src/fomantic/build/semantic.js', import.meta.url)),
-      fileURLToPath(new URL('web_src/js/index.js', import.meta.url)),
+      fileURLToPath(new URL('web_src/js/index.ts', import.meta.url)),
       fileURLToPath(new URL('node_modules/easymde/dist/easymde.min.css', import.meta.url)),
       fileURLToPath(new URL('web_src/fomantic/build/semantic.css', import.meta.url)),
       fileURLToPath(new URL('web_src/css/index.css', import.meta.url)),
     ],
     webcomponents: [
-      fileURLToPath(new URL('web_src/js/webcomponents/index.js', import.meta.url)),
+      fileURLToPath(new URL('web_src/js/webcomponents/index.ts', import.meta.url)),
     ],
     swagger: [
-      fileURLToPath(new URL('web_src/js/standalone/swagger.js', import.meta.url)),
+      fileURLToPath(new URL('web_src/js/standalone/swagger.ts', import.meta.url)),
       fileURLToPath(new URL('web_src/css/standalone/swagger.css', import.meta.url)),
     ],
     'eventsource.sharedworker': [
-      fileURLToPath(new URL('web_src/js/features/eventsource.sharedworker.js', import.meta.url)),
+      fileURLToPath(new URL('web_src/js/features/eventsource.sharedworker.ts', import.meta.url)),
     ],
     ...(!isProduction && {
       devtest: [
-        fileURLToPath(new URL('web_src/js/standalone/devtest.js', import.meta.url)),
+        fileURLToPath(new URL('web_src/js/standalone/devtest.ts', import.meta.url)),
         fileURLToPath(new URL('web_src/css/standalone/devtest.css', import.meta.url)),
       ],
     }),
@@ -140,13 +140,13 @@ export default {
         },
       },
       {
-        test: /\.js$/i,
+        test: /\.ts$/i,
         exclude: /node_modules/,
         use: [
           {
             loader: 'esbuild-loader',
             options: {
-              loader: 'js',
+              loader: 'ts',
               target: 'es2020',
             },
           },