diff --git a/web/package-lock.json b/web/package-lock.json
index ae97bdbdfd0c5..dc409b3522714 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -23,6 +23,7 @@
         "lodash-es": "^4.17.21",
         "luxon": "^3.4.4",
         "socket.io-client": "^4.7.4",
+        "svelte-i18n": "^4.0.0",
         "svelte-local-storage-store": "^0.6.4",
         "svelte-maplibre": "^0.9.0",
         "thumbhash": "^0.1.1"
@@ -916,6 +917,50 @@
         "npm": ">=6.14.13"
       }
     },
+    "node_modules/@formatjs/ecma402-abstract": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.0.0.tgz",
+      "integrity": "sha512-rRqXOqdFmk7RYvj4khklyqzcfQl9vEL/usogncBHRZfZBDOwMGuSRNFl02fu5KGHXdbinju+YXyuR+Nk8xlr/g==",
+      "dependencies": {
+        "@formatjs/intl-localematcher": "0.5.4",
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@formatjs/fast-memoize": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.0.tgz",
+      "integrity": "sha512-hnk/nY8FyrL5YxwP9e4r9dqeM6cAbo8PeU9UjyXojZMNvVad2Z06FAVHyR3Ecw6fza+0GH7vdJgiKIVXTMbSBA==",
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@formatjs/icu-messageformat-parser": {
+      "version": "2.7.8",
+      "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.7.8.tgz",
+      "integrity": "sha512-nBZJYmhpcSX0WeJ5SDYUkZ42AgR3xiyhNCsQweFx3cz/ULJjym8bHAzWKvG5e2+1XO98dBYC0fWeeAECAVSwLA==",
+      "dependencies": {
+        "@formatjs/ecma402-abstract": "2.0.0",
+        "@formatjs/icu-skeleton-parser": "1.8.2",
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@formatjs/icu-skeleton-parser": {
+      "version": "1.8.2",
+      "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.2.tgz",
+      "integrity": "sha512-k4ERKgw7aKGWJZgTarIcNEmvyTVD9FYh0mTrrBMHZ1b8hUu6iOJ4SzsZlo3UNAvHYa+PnvntIwRPt1/vy4nA9Q==",
+      "dependencies": {
+        "@formatjs/ecma402-abstract": "2.0.0",
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@formatjs/intl-localematcher": {
+      "version": "0.5.4",
+      "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.5.4.tgz",
+      "integrity": "sha512-zTwEpWOzZ2CiKcB93BLngUX59hQkuZjT2+SAQEscSm52peDW/getsawMcWF1rGRpMCX6D7nSJA3CzJ8gn13N/g==",
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
     "node_modules/@humanwhocodes/config-array": {
       "version": "0.11.14",
       "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz",
@@ -3305,6 +3350,21 @@
         "node": ">=4"
       }
     },
+    "node_modules/cli-color": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/cli-color/-/cli-color-2.0.4.tgz",
+      "integrity": "sha512-zlnpg0jNcibNrO7GG9IeHH7maWFeCz+Ja1wx/7tZNU5ASSSSZ+/qZciM0/LHCYxSdqv5h2sdbQ/PXYdOuetXvA==",
+      "dependencies": {
+        "d": "^1.0.1",
+        "es5-ext": "^0.10.64",
+        "es6-iterator": "^2.0.3",
+        "memoizee": "^0.4.15",
+        "timers-ext": "^0.1.7"
+      },
+      "engines": {
+        "node": ">=0.10"
+      }
+    },
     "node_modules/cliui": {
       "version": "8.0.1",
       "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
@@ -3523,6 +3583,18 @@
         "node": ">=14"
       }
     },
+    "node_modules/d": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz",
+      "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==",
+      "dependencies": {
+        "es5-ext": "^0.10.64",
+        "type": "^2.7.2"
+      },
+      "engines": {
+        "node": ">=0.12"
+      }
+    },
     "node_modules/d3-array": {
       "version": "3.2.4",
       "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
@@ -3635,7 +3707,6 @@
       "version": "4.3.1",
       "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
       "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
-      "dev": true,
       "engines": {
         "node": ">=0.10.0"
       }
@@ -3880,12 +3951,60 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/es5-ext": {
+      "version": "0.10.64",
+      "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz",
+      "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==",
+      "hasInstallScript": true,
+      "dependencies": {
+        "es6-iterator": "^2.0.3",
+        "es6-symbol": "^3.1.3",
+        "esniff": "^2.0.1",
+        "next-tick": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=0.10"
+      }
+    },
+    "node_modules/es6-iterator": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz",
+      "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==",
+      "dependencies": {
+        "d": "1",
+        "es5-ext": "^0.10.35",
+        "es6-symbol": "^3.1.1"
+      }
+    },
     "node_modules/es6-promise": {
       "version": "3.3.1",
       "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.3.1.tgz",
       "integrity": "sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==",
       "dev": true
     },
+    "node_modules/es6-symbol": {
+      "version": "3.1.4",
+      "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz",
+      "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==",
+      "dependencies": {
+        "d": "^1.0.2",
+        "ext": "^1.7.0"
+      },
+      "engines": {
+        "node": ">=0.12"
+      }
+    },
+    "node_modules/es6-weak-map": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.3.tgz",
+      "integrity": "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA==",
+      "dependencies": {
+        "d": "1",
+        "es5-ext": "^0.10.46",
+        "es6-iterator": "^2.0.3",
+        "es6-symbol": "^3.1.1"
+      }
+    },
     "node_modules/esbuild": {
       "version": "0.20.2",
       "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz",
@@ -4358,6 +4477,20 @@
       "integrity": "sha512-Cf6VksWPsTuW01vU9Mk/3vRue91Zevka5SjyNf3nEpokFRuqt/KjUQoGAwq9qMmhpLTHmXzSIrFRw8zxWzmFBA==",
       "dev": true
     },
+    "node_modules/esniff": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz",
+      "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==",
+      "dependencies": {
+        "d": "^1.0.1",
+        "es5-ext": "^0.10.62",
+        "event-emitter": "^0.3.5",
+        "type": "^2.7.2"
+      },
+      "engines": {
+        "node": ">=0.10"
+      }
+    },
     "node_modules/espree": {
       "version": "9.6.1",
       "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
@@ -4425,6 +4558,15 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/event-emitter": {
+      "version": "0.3.5",
+      "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz",
+      "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==",
+      "dependencies": {
+        "d": "1",
+        "es5-ext": "~0.10.14"
+      }
+    },
     "node_modules/execa": {
       "version": "8.0.1",
       "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz",
@@ -4460,6 +4602,14 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/ext": {
+      "version": "1.7.0",
+      "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz",
+      "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==",
+      "dependencies": {
+        "type": "^2.7.2"
+      }
+    },
     "node_modules/extend-shallow": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
@@ -4820,8 +4970,7 @@
     "node_modules/globalyzer": {
       "version": "0.1.0",
       "resolved": "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.0.tgz",
-      "integrity": "sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==",
-      "dev": true
+      "integrity": "sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q=="
     },
     "node_modules/globby": {
       "version": "11.1.0",
@@ -4847,8 +4996,7 @@
     "node_modules/globrex": {
       "version": "0.1.2",
       "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz",
-      "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==",
-      "dev": true
+      "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="
     },
     "node_modules/gopd": {
       "version": "1.0.1",
@@ -5191,6 +5339,17 @@
         "node": ">=12"
       }
     },
+    "node_modules/intl-messageformat": {
+      "version": "10.5.14",
+      "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.5.14.tgz",
+      "integrity": "sha512-IjC6sI0X7YRjjyVH9aUgdftcmZK7WXdHeil4KwbjDnRWjnVitKpAx3rr6t6di1joFp5188VqKcobOPA6mCLG/w==",
+      "dependencies": {
+        "@formatjs/ecma402-abstract": "2.0.0",
+        "@formatjs/fast-memoize": "2.2.0",
+        "@formatjs/icu-messageformat-parser": "2.7.8",
+        "tslib": "^2.4.0"
+      }
+    },
     "node_modules/is-arguments": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz",
@@ -5435,6 +5594,11 @@
       "optional": true,
       "peer": true
     },
+    "node_modules/is-promise": {
+      "version": "2.2.2",
+      "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz",
+      "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ=="
+    },
     "node_modules/is-reference": {
       "version": "3.0.2",
       "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.2.tgz",
@@ -5947,6 +6111,14 @@
         "yallist": "^3.0.2"
       }
     },
+    "node_modules/lru-queue": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/lru-queue/-/lru-queue-0.1.0.tgz",
+      "integrity": "sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ==",
+      "dependencies": {
+        "es5-ext": "~0.10.2"
+      }
+    },
     "node_modules/luxon": {
       "version": "3.4.4",
       "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz",
@@ -6077,6 +6249,24 @@
       "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
       "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA=="
     },
+    "node_modules/memoizee": {
+      "version": "0.4.17",
+      "resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.17.tgz",
+      "integrity": "sha512-DGqD7Hjpi/1or4F/aYAspXKNm5Yili0QDAFAY4QYvpqpgiY6+1jOfqpmByzjxbWd/T9mChbCArXAbDAsTm5oXA==",
+      "dependencies": {
+        "d": "^1.0.2",
+        "es5-ext": "^0.10.64",
+        "es6-weak-map": "^2.0.3",
+        "event-emitter": "^0.3.5",
+        "is-promise": "^2.2.2",
+        "lru-queue": "^0.1.0",
+        "next-tick": "^1.1.0",
+        "timers-ext": "^0.1.7"
+      },
+      "engines": {
+        "node": ">=0.12"
+      }
+    },
     "node_modules/merge-stream": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@@ -6199,7 +6389,6 @@
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
       "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
-      "dev": true,
       "engines": {
         "node": ">=4"
       }
@@ -6263,6 +6452,11 @@
       "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
       "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="
     },
+    "node_modules/next-tick": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz",
+      "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ=="
+    },
     "node_modules/node-releases": {
       "version": "2.0.14",
       "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz",
@@ -7381,7 +7575,6 @@
       "version": "1.8.1",
       "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
       "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
-      "dev": true,
       "dependencies": {
         "mri": "^1.1.0"
       },
@@ -8088,6 +8281,416 @@
         "svelte": "^3.19.0 || ^4.0.0"
       }
     },
+    "node_modules/svelte-i18n": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/svelte-i18n/-/svelte-i18n-4.0.0.tgz",
+      "integrity": "sha512-4vivjKZADUMRIhTs38JuBNy3unbnh9AFRxWFLxq62P4NHic+/BaIZZlAsvqsCdnp7IdJf5EoSiH6TNdItcjA6g==",
+      "dependencies": {
+        "cli-color": "^2.0.3",
+        "deepmerge": "^4.2.2",
+        "esbuild": "^0.19.2",
+        "estree-walker": "^2",
+        "intl-messageformat": "^10.5.3",
+        "sade": "^1.8.1",
+        "tiny-glob": "^0.2.9"
+      },
+      "bin": {
+        "svelte-i18n": "dist/cli.js"
+      },
+      "engines": {
+        "node": ">= 16"
+      },
+      "peerDependencies": {
+        "svelte": "^3 || ^4"
+      }
+    },
+    "node_modules/svelte-i18n/node_modules/@esbuild/aix-ppc64": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz",
+      "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/svelte-i18n/node_modules/@esbuild/android-arm": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz",
+      "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==",
+      "cpu": [
+        "arm"
+      ],
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/svelte-i18n/node_modules/@esbuild/android-arm64": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz",
+      "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==",
+      "cpu": [
+        "arm64"
+      ],
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/svelte-i18n/node_modules/@esbuild/android-x64": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz",
+      "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==",
+      "cpu": [
+        "x64"
+      ],
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/svelte-i18n/node_modules/@esbuild/darwin-arm64": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz",
+      "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==",
+      "cpu": [
+        "arm64"
+      ],
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/svelte-i18n/node_modules/@esbuild/darwin-x64": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz",
+      "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==",
+      "cpu": [
+        "x64"
+      ],
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/svelte-i18n/node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz",
+      "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==",
+      "cpu": [
+        "arm64"
+      ],
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/svelte-i18n/node_modules/@esbuild/freebsd-x64": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz",
+      "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==",
+      "cpu": [
+        "x64"
+      ],
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/svelte-i18n/node_modules/@esbuild/linux-arm": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz",
+      "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==",
+      "cpu": [
+        "arm"
+      ],
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/svelte-i18n/node_modules/@esbuild/linux-arm64": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz",
+      "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==",
+      "cpu": [
+        "arm64"
+      ],
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/svelte-i18n/node_modules/@esbuild/linux-ia32": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz",
+      "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==",
+      "cpu": [
+        "ia32"
+      ],
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/svelte-i18n/node_modules/@esbuild/linux-loong64": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz",
+      "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==",
+      "cpu": [
+        "loong64"
+      ],
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/svelte-i18n/node_modules/@esbuild/linux-mips64el": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz",
+      "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==",
+      "cpu": [
+        "mips64el"
+      ],
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/svelte-i18n/node_modules/@esbuild/linux-ppc64": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz",
+      "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==",
+      "cpu": [
+        "ppc64"
+      ],
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/svelte-i18n/node_modules/@esbuild/linux-riscv64": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz",
+      "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==",
+      "cpu": [
+        "riscv64"
+      ],
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/svelte-i18n/node_modules/@esbuild/linux-s390x": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz",
+      "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==",
+      "cpu": [
+        "s390x"
+      ],
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/svelte-i18n/node_modules/@esbuild/linux-x64": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz",
+      "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==",
+      "cpu": [
+        "x64"
+      ],
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/svelte-i18n/node_modules/@esbuild/netbsd-x64": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz",
+      "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==",
+      "cpu": [
+        "x64"
+      ],
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/svelte-i18n/node_modules/@esbuild/openbsd-x64": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz",
+      "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==",
+      "cpu": [
+        "x64"
+      ],
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/svelte-i18n/node_modules/@esbuild/sunos-x64": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz",
+      "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==",
+      "cpu": [
+        "x64"
+      ],
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/svelte-i18n/node_modules/@esbuild/win32-arm64": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz",
+      "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==",
+      "cpu": [
+        "arm64"
+      ],
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/svelte-i18n/node_modules/@esbuild/win32-ia32": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz",
+      "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/svelte-i18n/node_modules/@esbuild/win32-x64": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz",
+      "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==",
+      "cpu": [
+        "x64"
+      ],
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/svelte-i18n/node_modules/esbuild": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz",
+      "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==",
+      "hasInstallScript": true,
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.19.12",
+        "@esbuild/android-arm": "0.19.12",
+        "@esbuild/android-arm64": "0.19.12",
+        "@esbuild/android-x64": "0.19.12",
+        "@esbuild/darwin-arm64": "0.19.12",
+        "@esbuild/darwin-x64": "0.19.12",
+        "@esbuild/freebsd-arm64": "0.19.12",
+        "@esbuild/freebsd-x64": "0.19.12",
+        "@esbuild/linux-arm": "0.19.12",
+        "@esbuild/linux-arm64": "0.19.12",
+        "@esbuild/linux-ia32": "0.19.12",
+        "@esbuild/linux-loong64": "0.19.12",
+        "@esbuild/linux-mips64el": "0.19.12",
+        "@esbuild/linux-ppc64": "0.19.12",
+        "@esbuild/linux-riscv64": "0.19.12",
+        "@esbuild/linux-s390x": "0.19.12",
+        "@esbuild/linux-x64": "0.19.12",
+        "@esbuild/netbsd-x64": "0.19.12",
+        "@esbuild/openbsd-x64": "0.19.12",
+        "@esbuild/sunos-x64": "0.19.12",
+        "@esbuild/win32-arm64": "0.19.12",
+        "@esbuild/win32-ia32": "0.19.12",
+        "@esbuild/win32-x64": "0.19.12"
+      }
+    },
+    "node_modules/svelte-i18n/node_modules/estree-walker": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+      "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
+    },
     "node_modules/svelte-local-storage-store": {
       "version": "0.6.4",
       "resolved": "https://registry.npmjs.org/svelte-local-storage-store/-/svelte-local-storage-store-0.6.4.tgz",
@@ -8359,11 +8962,19 @@
       "resolved": "https://registry.npmjs.org/thumbhash/-/thumbhash-0.1.1.tgz",
       "integrity": "sha512-kH5pKeIIBPQXAOni2AiY/Cu/NKdkFREdpH+TLdM0g6WA7RriCv0kPLgP731ady67MhTAqrVG/4mnEeibVuCJcg=="
     },
+    "node_modules/timers-ext": {
+      "version": "0.1.7",
+      "resolved": "https://registry.npmjs.org/timers-ext/-/timers-ext-0.1.7.tgz",
+      "integrity": "sha512-b85NUNzTSdodShTIbky6ZF02e8STtVVfD+fu4aXXShEELpozH+bCpJLYMPZbsABN2wDH7fJpqIoXxJpzbf0NqQ==",
+      "dependencies": {
+        "es5-ext": "~0.10.46",
+        "next-tick": "1"
+      }
+    },
     "node_modules/tiny-glob": {
       "version": "0.2.9",
       "resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz",
       "integrity": "sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==",
-      "dev": true,
       "dependencies": {
         "globalyzer": "0.1.0",
         "globrex": "^0.1.2"
@@ -8480,8 +9091,12 @@
     "node_modules/tslib": {
       "version": "2.6.2",
       "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
-      "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==",
-      "dev": true
+      "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
+    },
+    "node_modules/type": {
+      "version": "2.7.2",
+      "resolved": "https://registry.npmjs.org/type/-/type-2.7.2.tgz",
+      "integrity": "sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw=="
     },
     "node_modules/type-check": {
       "version": "0.4.0",
diff --git a/web/package.json b/web/package.json
index 3d1d0ee164318..8e4c574e066f3 100644
--- a/web/package.json
+++ b/web/package.json
@@ -75,6 +75,7 @@
     "luxon": "^3.4.4",
     "socket.io-client": "^4.7.4",
     "svelte-local-storage-store": "^0.6.4",
+    "svelte-i18n": "^4.0.0",
     "svelte-maplibre": "^0.9.0",
     "thumbhash": "^0.1.1"
   },
diff --git a/web/src/lib/components/admin-page/delete-confirm-dialogue.svelte b/web/src/lib/components/admin-page/delete-confirm-dialogue.svelte
index 004f8deeed7c8..caca5c156a554 100644
--- a/web/src/lib/components/admin-page/delete-confirm-dialogue.svelte
+++ b/web/src/lib/components/admin-page/delete-confirm-dialogue.svelte
@@ -5,6 +5,7 @@
   import { serverConfig } from '$lib/stores/server-config.store';
   import { createEventDispatcher } from 'svelte';
   import Checkbox from '$lib/components/elements/checkbox.svelte';
+  import { t } from 'svelte-i18n';
 
   export let user: UserResponseDto;
 
@@ -31,7 +32,7 @@
         dispatch('success');
       }
     } catch (error) {
-      handleError(error, 'Unable to delete user');
+      handleError(error, $t('errors.unable_to_delete_user'));
       dispatch('fail');
     }
   };
@@ -43,8 +44,8 @@
 </script>
 
 <ConfirmDialog
-  title="Delete user"
-  confirmText={forceDelete ? 'Permanently Delete' : 'Delete'}
+  title={$t('delete_user')}
+  confirmText={forceDelete ? $t('permanently_delete') : $t('delete')}
   onConfirm={handleDeleteUser}
   onCancel={() => dispatch('cancel')}
   disabled={deleteButtonDisabled}
diff --git a/web/src/lib/components/admin-page/jobs/job-tile.svelte b/web/src/lib/components/admin-page/jobs/job-tile.svelte
index b7750846614bd..1d6667072ec01 100644
--- a/web/src/lib/components/admin-page/jobs/job-tile.svelte
+++ b/web/src/lib/components/admin-page/jobs/job-tile.svelte
@@ -16,6 +16,7 @@
   import JobTileButton from './job-tile-button.svelte';
   import JobTileStatus from './job-tile-status.svelte';
   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
+  import { t } from 'svelte-i18n';
 
   export let title: string;
   export let subtitle: string | undefined;
@@ -43,9 +44,9 @@
 >
   <div class="flex w-full flex-col">
     {#if queueStatus.isPaused}
-      <JobTileStatus color="warning">Paused</JobTileStatus>
+      <JobTileStatus color="warning">{$t('paused')}</JobTileStatus>
     {:else if queueStatus.isActive}
-      <JobTileStatus color="success">Active</JobTileStatus>
+      <JobTileStatus color="success">{$t('active')}</JobTileStatus>
     {/if}
     <div class="flex flex-col gap-2 p-5 sm:p-7 md:p-9">
       <div class="flex items-center gap-4 text-xl font-semibold text-immich-primary dark:text-immich-dark-primary">
@@ -63,7 +64,7 @@
                 <CircleIconButton
                   color="primary"
                   icon={mdiClose}
-                  title="Clear message"
+                  title={$t('clear_message')}
                   size="12"
                   padding="1"
                   on:click={() => dispatch('command', { command: JobCommand.ClearFailed, force: false })}
@@ -95,7 +96,7 @@
         <div
           class="{commonClasses} rounded-t-lg bg-immich-primary text-white dark:bg-immich-dark-primary dark:text-immich-dark-gray sm:rounded-l-lg sm:rounded-r-none"
         >
-          <p>Active</p>
+          <p>{$t('active')}</p>
           <p class="text-2xl">
             {jobCounts.active.toLocaleString($locale)}
           </p>
@@ -107,7 +108,7 @@
           <p class="text-2xl">
             {waitingCount.toLocaleString($locale)}
           </p>
-          <p>Waiting</p>
+          <p>{$t('waiting')}</p>
         </div>
       </div>
     </div>
diff --git a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte
index dd1a3f7ab136b..b3c0a1c593a91 100644
--- a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte
+++ b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte
@@ -23,6 +23,7 @@
   import JobTile from './job-tile.svelte';
   import StorageMigrationDescription from './storage-migration-description.svelte';
   import { dialogController } from '$lib/components/shared-components/dialog/dialog';
+  import { t } from 'svelte-i18n';
 
   export let jobs: AllJobStatusResponseDto;
 
@@ -60,38 +61,38 @@
     [JobName.ThumbnailGeneration]: {
       icon: mdiFileJpgBox,
       title: getJobName(JobName.ThumbnailGeneration),
-      subtitle: 'Generate large, small and blurred thumbnails for each asset, as well as thumbnails for each person',
+      subtitle: $t('thumbnail_generation_job_description'),
     },
     [JobName.MetadataExtraction]: {
       icon: mdiTable,
       title: getJobName(JobName.MetadataExtraction),
-      subtitle: 'Extract metadata information from each asset, such as GPS and resolution',
+      subtitle: $t('metadata_extraction_job_description'),
     },
     [JobName.Library]: {
       icon: mdiLibraryShelves,
       title: getJobName(JobName.Library),
-      subtitle: 'Perform library tasks',
-      allText: 'ALL',
-      missingText: 'REFRESH',
+      subtitle: $t('perform_library_tasks'),
+      allText: $t('all').toUpperCase(),
+      missingText: $t('refresh').toUpperCase(),
     },
     [JobName.Sidecar]: {
       title: getJobName(JobName.Sidecar),
       icon: mdiFileXmlBox,
-      subtitle: 'Discover or synchronize sidecar metadata from the filesystem',
-      allText: 'SYNC',
-      missingText: 'DISCOVER',
+      subtitle: $t('sidecar_job_description'),
+      allText: $t('sync').toUpperCase(),
+      missingText: $t('discover').toUpperCase(),
       disabled: !$featureFlags.sidecar,
     },
     [JobName.SmartSearch]: {
       icon: mdiImageSearch,
       title: getJobName(JobName.SmartSearch),
-      subtitle: 'Run machine learning on assets to support smart search',
+      subtitle: $t('smart_search_job_description'),
       disabled: !$featureFlags.smartSearch,
     },
     [JobName.DuplicateDetection]: {
       icon: mdiContentDuplicate,
       title: getJobName(JobName.DuplicateDetection),
-      subtitle: 'Run machine learning on assets to detect similar images. Relies on Smart Search',
+      subtitle: $t('duplicate_detection_job_description'),
       disabled: !$featureFlags.duplicateDetection,
     },
     [JobName.FaceDetection]: {
@@ -113,7 +114,7 @@
     [JobName.VideoConversion]: {
       icon: mdiVideo,
       title: getJobName(JobName.VideoConversion),
-      subtitle: 'Transcode videos for wider compatibility with browsers and devices',
+      subtitle: $t('video_conversion_job_description'),
     },
     [JobName.StorageTemplateMigration]: {
       icon: mdiFolderMove,
@@ -124,7 +125,7 @@
     [JobName.Migration]: {
       icon: mdiFolderMove,
       title: getJobName(JobName.Migration),
-      subtitle: 'Migrate thumbnails for assets and faces to the latest folder structure',
+      subtitle: $t('migration_job_description'),
       allowForceCommand: false,
     },
   };
@@ -159,8 +160,8 @@
       {title}
       {disabled}
       {subtitle}
-      allText={allText || 'ALL'}
-      missingText={missingText || 'MISSING'}
+      allText={allText || $t('all').toUpperCase()}
+      missingText={missingText || $t('missing').toUpperCase()}
       {allowForceCommand}
       {jobCounts}
       {queueStatus}
diff --git a/web/src/lib/components/admin-page/jobs/storage-migration-description.svelte b/web/src/lib/components/admin-page/jobs/storage-migration-description.svelte
index cc8ebddec5e56..a33ad1b7ff74c 100644
--- a/web/src/lib/components/admin-page/jobs/storage-migration-description.svelte
+++ b/web/src/lib/components/admin-page/jobs/storage-migration-description.svelte
@@ -1,9 +1,10 @@
 <script lang="ts">
   import { AppRoute } from '$lib/constants';
+  import { t } from 'svelte-i18n';
 </script>
 
 Apply the current
 <a href="{AppRoute.ADMIN_SETTINGS}?open=storageTemplate" class="text-immich-primary dark:text-immich-dark-primary"
-  >Storage template</a
+  >{$t('storage_template_settings')}</a
 >
 to previously uploaded assets
diff --git a/web/src/lib/components/admin-page/restore-dialogue.svelte b/web/src/lib/components/admin-page/restore-dialogue.svelte
index b606caf4e7db7..82c28ef931413 100644
--- a/web/src/lib/components/admin-page/restore-dialogue.svelte
+++ b/web/src/lib/components/admin-page/restore-dialogue.svelte
@@ -3,6 +3,7 @@
   import { handleError } from '$lib/utils/handle-error';
   import { restoreUserAdmin, type UserResponseDto } from '@immich/sdk';
   import { createEventDispatcher } from 'svelte';
+  import { t } from 'svelte-i18n';
 
   export let user: UserResponseDto;
 
@@ -21,15 +22,15 @@
         dispatch('fail');
       }
     } catch (error) {
-      handleError(error, 'Unable to restore user');
+      handleError(error, $t('errors.unable_to_restore_user'));
       dispatch('fail');
     }
   };
 </script>
 
 <ConfirmDialog
-  title="Restore user"
-  confirmText="Continue"
+  title={$t('restore_user')}
+  confirmText={$t('continue')}
   confirmColor="green"
   onConfirm={handleRestoreUser}
   onCancel={() => dispatch('cancel')}
diff --git a/web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte b/web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte
index e8fabbe9c17f8..5a4c2f9c9c234 100644
--- a/web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte
+++ b/web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte
@@ -5,6 +5,7 @@
   import type { ServerStatsResponseDto } from '@immich/sdk';
   import { mdiCameraIris, mdiChartPie, mdiPlayCircle } from '@mdi/js';
   import StatsCard from './stats-card.svelte';
+  import { t } from 'svelte-i18n';
 
   export let stats: ServerStatsResponseDto = {
     photos: 0,
@@ -27,19 +28,19 @@
 
 <div class="flex flex-col gap-5">
   <div>
-    <p class="text-sm dark:text-immich-dark-fg">TOTAL USAGE</p>
+    <p class="text-sm dark:text-immich-dark-fg">{$t('total_usage').toUpperCase()}</p>
 
     <div class="mt-5 hidden justify-between lg:flex">
-      <StatsCard icon={mdiCameraIris} title="PHOTOS" value={stats.photos} />
-      <StatsCard icon={mdiPlayCircle} title="VIDEOS" value={stats.videos} />
-      <StatsCard icon={mdiChartPie} title="STORAGE" value={statsUsage} unit={statsUsageUnit} />
+      <StatsCard icon={mdiCameraIris} title={$t('photos').toUpperCase()} value={stats.photos} />
+      <StatsCard icon={mdiPlayCircle} title={$t('videos').toUpperCase()} value={stats.videos} />
+      <StatsCard icon={mdiChartPie} title={$t('storage').toUpperCase()} value={statsUsage} unit={statsUsageUnit} />
     </div>
     <div class="mt-5 flex lg:hidden">
       <div class="flex flex-col justify-between rounded-3xl bg-immich-gray p-5 dark:bg-immich-dark-gray">
         <div class="flex flex-wrap gap-x-12">
           <div class="flex place-items-center gap-4 text-immich-primary dark:text-immich-dark-primary">
             <Icon path={mdiCameraIris} size="25" />
-            <p>PHOTOS</p>
+            <p>{$t('photos').toUpperCase()}</p>
           </div>
 
           <div class="relative text-center font-mono text-2xl font-semibold">
@@ -51,7 +52,7 @@
         <div class="flex flex-wrap gap-x-12">
           <div class="flex place-items-center gap-4 text-immich-primary dark:text-immich-dark-primary">
             <Icon path={mdiPlayCircle} size="25" />
-            <p>VIDEOS</p>
+            <p>{$t('videos').toUpperCase()}</p>
           </div>
 
           <div class="relative text-center font-mono text-2xl font-semibold">
@@ -63,7 +64,7 @@
         <div class="flex flex-wrap gap-x-7">
           <div class="flex place-items-center gap-4 text-immich-primary dark:text-immich-dark-primary">
             <Icon path={mdiChartPie} size="25" />
-            <p>STORAGE</p>
+            <p>{$t('storage').toUpperCase()}</p>
           </div>
 
           <div class="relative flex text-center font-mono text-2xl font-semibold">
@@ -78,16 +79,16 @@
   </div>
 
   <div>
-    <p class="text-sm dark:text-immich-dark-fg">USER USAGE DETAIL</p>
+    <p class="text-sm dark:text-immich-dark-fg">{$t('user_usage_detail').toUpperCase()}</p>
     <table class="mt-5 w-full text-left">
       <thead
         class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
       >
         <tr class="flex w-full place-items-center">
-          <th class="w-1/4 text-center text-sm font-medium">User</th>
-          <th class="w-1/4 text-center text-sm font-medium">Photos</th>
-          <th class="w-1/4 text-center text-sm font-medium">Videos</th>
-          <th class="w-1/4 text-center text-sm font-medium">Usage</th>
+          <th class="w-1/4 text-center text-sm font-medium">{$t('user')}</th>
+          <th class="w-1/4 text-center text-sm font-medium">{$t('photos')}</th>
+          <th class="w-1/4 text-center text-sm font-medium">{$t('videos')}</th>
+          <th class="w-1/4 text-center text-sm font-medium">{$t('usage')}</th>
         </tr>
       </thead>
       <tbody
diff --git a/web/src/lib/components/admin-page/settings/admin-settings.svelte b/web/src/lib/components/admin-page/settings/admin-settings.svelte
index 79ed46947d292..641b3a68ca0cc 100644
--- a/web/src/lib/components/admin-page/settings/admin-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/admin-settings.svelte
@@ -11,6 +11,7 @@
   import { cloneDeep } from 'lodash-es';
   import { createEventDispatcher, onMount } from 'svelte';
   import type { SettingsEventType } from './admin-settings';
+  import { t } from 'svelte-i18n';
 
   export let config: SystemConfigDto;
 
@@ -34,13 +35,13 @@
 
       config = cloneDeep(newConfig);
       savedConfig = cloneDeep(newConfig);
-      notificationController.show({ message: 'Settings saved', type: NotificationType.Info });
+      notificationController.show({ message: $t('settings_saved'), type: NotificationType.Info });
 
       await loadConfig();
 
       dispatch('save');
     } catch (error) {
-      handleError(error, 'Unable to save settings');
+      handleError(error, $t('errors.unable_to_save_settings'));
     }
   };
 
@@ -63,7 +64,7 @@
     }
 
     notificationController.show({
-      message: 'Reset settings to default',
+      message: $t('reset_settings_to_default'),
       type: NotificationType.Info,
     });
   };
diff --git a/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte b/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte
index a71de88941e18..7b3cb7297725a 100644
--- a/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte
@@ -11,6 +11,7 @@
   import { createEventDispatcher } from 'svelte';
   import { fade } from 'svelte/transition';
   import type { SettingsEventType } from '../admin-settings';
+  import { t } from 'svelte-i18n';
 
   export let savedConfig: SystemConfigDto;
   export let defaultConfig: SystemConfigDto;
@@ -42,7 +43,11 @@
 </script>
 
 {#if isConfirmOpen}
-  <ConfirmDialog title="Disable login" onCancel={() => (isConfirmOpen = false)} onConfirm={() => handleSave(true)}>
+  <ConfirmDialog
+    title={$t('admin.disable_login')}
+    onCancel={() => (isConfirmOpen = false)}
+    onConfirm={() => handleSave(true)}
+  >
     <svelte:fragment slot="prompt">
       <div class="flex flex-col gap-4">
         <p>Are you sure you want to disable all login methods? Login will be completely disabled.</p>
@@ -66,7 +71,11 @@
   <div in:fade={{ duration: 500 }}>
     <form autocomplete="off" on:submit|preventDefault>
       <div class="ml-4 mt-4 flex flex-col gap-4">
-        <SettingAccordion key="oauth" title="OAuth" subtitle="Manage OAuth login settings">
+        <SettingAccordion
+          key="oauth"
+          title={$t('admin.oauth_settings')}
+          subtitle={$t('admin.oauth_settings_description')}
+        >
           <div class="ml-4 mt-4 flex flex-col gap-4">
             <p class="text-sm dark:text-immich-dark-fg">
               For more details about this feature, refer to the <a
@@ -77,13 +86,18 @@
               >.
             </p>
 
-            <SettingSwitch {disabled} title="ENABLE" subtitle="Login with OAuth" bind:checked={config.oauth.enabled} />
+            <SettingSwitch
+              {disabled}
+              title={$t('enable').toUpperCase()}
+              subtitle={$t('admin.oauth_enable_description')}
+              bind:checked={config.oauth.enabled}
+            />
 
             {#if config.oauth.enabled}
               <hr />
               <SettingInputField
                 inputType={SettingInputFieldType.TEXT}
-                label="ISSUER URL"
+                label={$t('admin.oauth_issuer_url').toUpperCase()}
                 bind:value={config.oauth.issuerUrl}
                 required={true}
                 disabled={disabled || !config.oauth.enabled}
@@ -92,7 +106,7 @@
 
               <SettingInputField
                 inputType={SettingInputFieldType.TEXT}
-                label="CLIENT ID"
+                label={$t('admin.oauth_client_id').toUpperCase()}
                 bind:value={config.oauth.clientId}
                 required={true}
                 disabled={disabled || !config.oauth.enabled}
@@ -101,7 +115,7 @@
 
               <SettingInputField
                 inputType={SettingInputFieldType.TEXT}
-                label="CLIENT SECRET"
+                label={$t('admin.oauth_client_secret').toUpperCase()}
                 bind:value={config.oauth.clientSecret}
                 required={true}
                 disabled={disabled || !config.oauth.enabled}
@@ -110,7 +124,7 @@
 
               <SettingInputField
                 inputType={SettingInputFieldType.TEXT}
-                label="SCOPE"
+                label={$t('admin.oauth_scope').toUpperCase()}
                 bind:value={config.oauth.scope}
                 required={true}
                 disabled={disabled || !config.oauth.enabled}
@@ -119,7 +133,7 @@
 
               <SettingInputField
                 inputType={SettingInputFieldType.TEXT}
-                label="SIGNING ALGORITHM"
+                label={$t('admin.oauth_signing_algorithm').toUpperCase()}
                 bind:value={config.oauth.signingAlgorithm}
                 required={true}
                 disabled={disabled || !config.oauth.enabled}
@@ -128,8 +142,8 @@
 
               <SettingInputField
                 inputType={SettingInputFieldType.TEXT}
-                label="STORAGE LABEL CLAIM"
-                desc="Automatically set the user's storage label to the value of this claim."
+                label={$t('admin.oauth_storage_label_claim').toUpperCase()}
+                desc={$t('admin.oauth_storage_label_claim_description')}
                 bind:value={config.oauth.storageLabelClaim}
                 required={true}
                 disabled={disabled || !config.oauth.enabled}
@@ -138,8 +152,8 @@
 
               <SettingInputField
                 inputType={SettingInputFieldType.TEXT}
-                label="STORAGE QUOTA CLAIM"
-                desc="Automatically set the user's storage quota to the value of this claim."
+                label={$t('admin.oauth_storage_quota_claim').toUpperCase()}
+                desc={$t('admin.oauth_storage_quota_claim_description')}
                 bind:value={config.oauth.storageQuotaClaim}
                 required={true}
                 disabled={disabled || !config.oauth.enabled}
@@ -148,8 +162,8 @@
 
               <SettingInputField
                 inputType={SettingInputFieldType.NUMBER}
-                label="DEFAULT STORAGE QUOTA (GiB)"
-                desc="Quota in GiB to be used when no claim is provided (Enter 0 for unlimited quota)."
+                label={$t('admin.oauth_storage_quota_default').toUpperCase()}
+                desc={$t('admin.oauth_storage_quota_default_description')}
                 bind:value={config.oauth.defaultStorageQuota}
                 required={true}
                 disabled={disabled || !config.oauth.enabled}
@@ -158,7 +172,7 @@
 
               <SettingInputField
                 inputType={SettingInputFieldType.TEXT}
-                label="BUTTON TEXT"
+                label={$t('admin.oauth_button_text').toUpperCase()}
                 bind:value={config.oauth.buttonText}
                 required={false}
                 disabled={disabled || !config.oauth.enabled}
@@ -166,22 +180,22 @@
               />
 
               <SettingSwitch
-                title="AUTO REGISTER"
-                subtitle="Automatically register new users after signing in with OAuth"
+                title={$t('admin.oauth_auto_register').toUpperCase()}
+                subtitle={$t('admin.oauth_auto_register_description')}
                 bind:checked={config.oauth.autoRegister}
                 disabled={disabled || !config.oauth.enabled}
               />
 
               <SettingSwitch
-                title="AUTO LAUNCH"
-                subtitle="Start the OAuth login flow automatically upon navigating to the login page"
+                title={$t('admin.oauth_auto_launch').toUpperCase()}
+                subtitle={$t('admin.oauth_auto_launch_description')}
                 disabled={disabled || !config.oauth.enabled}
                 bind:checked={config.oauth.autoLaunch}
               />
 
               <SettingSwitch
-                title="MOBILE REDIRECT URI OVERRIDE"
-                subtitle="Enable when 'app.immich:/' is an invalid redirect URI."
+                title={$t('admin.oauth_mobile_redirect_uri_override').toUpperCase()}
+                subtitle={$t('admin.oauth_mobile_redirect_uri_override_description')}
                 disabled={disabled || !config.oauth.enabled}
                 on:click={() => handleToggleOverride()}
                 bind:checked={config.oauth.mobileOverrideEnabled}
@@ -190,7 +204,7 @@
               {#if config.oauth.mobileOverrideEnabled}
                 <SettingInputField
                   inputType={SettingInputFieldType.TEXT}
-                  label="MOBILE REDIRECT URI"
+                  label={$t('admin.oauth_mobile_redirect_uri').toUpperCase()}
                   bind:value={config.oauth.mobileRedirectUri}
                   required={true}
                   disabled={disabled || !config.oauth.enabled}
@@ -201,13 +215,17 @@
           </div>
         </SettingAccordion>
 
-        <SettingAccordion key="password" title="Password" subtitle="Manage password login settings">
+        <SettingAccordion
+          key="password"
+          title={$t('admin.password_settings')}
+          subtitle={$t('admin.password_settings_description')}
+        >
           <div class="ml-4 mt-4 flex flex-col gap-4">
             <div class="ml-4 mt-4 flex flex-col">
               <SettingSwitch
-                title="ENABLED"
+                title={$t('enabled')}
                 {disabled}
-                subtitle="Login with email and password"
+                subtitle={$t('admin.password_enable_description')}
                 bind:checked={config.passwordLogin.enabled}
               />
             </div>
diff --git a/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte b/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte
index 684eafc2a5fb6..ac2d354523921 100644
--- a/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte
@@ -22,6 +22,7 @@
   import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
   import SettingCheckboxes from '$lib/components/shared-components/settings/setting-checkboxes.svelte';
   import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
+  import { t } from 'svelte-i18n';
 
   export let savedConfig: SystemConfigDto;
   export let defaultConfig: SystemConfigDto;
@@ -42,7 +43,7 @@
             >H.264 codec</a
           >,
           <a href="https://trac.ffmpeg.org/wiki/Encode/H.265" class="underline" target="_blank" rel="noreferrer"
-            >HEVC codec</a
+            >{$t('admin.transcoding_hevc_codec')}</a
           >
           and
           <a href="https://trac.ffmpeg.org/wiki/Encode/VP9" class="underline" target="_blank" rel="noreferrer"
@@ -53,17 +54,17 @@
         <SettingInputField
           inputType={SettingInputFieldType.NUMBER}
           {disabled}
-          label="CONSTANT RATE FACTOR (-crf)"
-          desc="Video quality level. Typical values are 23 for H.264, 28 for HEVC, 31 for VP9, and 35 for AV1. Lower is better, but produces larger files."
+          label={$t('admin.transcoding_constant_rate_factor')}
+          desc={$t('admin.transcoding_constant_rate_factor_description')}
           bind:value={config.ffmpeg.crf}
           required={true}
           isEdited={config.ffmpeg.crf !== savedConfig.ffmpeg.crf}
         />
 
         <SettingSelect
-          label="PRESET (-preset)"
+          label={$t('admin.transcoding_preset_preset')}
           {disabled}
-          desc="Compression speed. Slower presets produce smaller files, and increase quality when targeting a certain bitrate. VP9 ignores speeds above faster."
+          desc={$t('admin.transcoding_preset_preset_description')}
           bind:value={config.ffmpeg.preset}
           name="preset"
           options={[
@@ -81,9 +82,9 @@
         />
 
         <SettingSelect
-          label="AUDIO CODEC"
+          label={$t('admin.transcoding_audio_codec').toUpperCase()}
           {disabled}
-          desc="Opus is the highest quality option, but has lower compatibility with old devices or software."
+          desc={$t('admin.transcoding_audio_codec_description')}
           bind:value={config.ffmpeg.targetAudioCodec}
           options={[
             { value: AudioCodec.Aac, text: 'aac' },
@@ -99,9 +100,9 @@
         />
 
         <SettingCheckboxes
-          label="ACCEPTED AUDIO CODECS"
+          label={$t('admin.transcoding_accepted_audio_codecs').toUpperCase()}
           {disabled}
-          desc="Select which audio codecs do not need to be transcoded. Only used for certain transcode policies."
+          desc={$t('admin.transcoding_accepted_audio_codecs_description')}
           bind:value={config.ffmpeg.acceptedAudioCodecs}
           name="audioCodecs"
           options={[
@@ -113,9 +114,9 @@
         />
 
         <SettingSelect
-          label="VIDEO CODEC"
+          label={$t('admin.transcoding_video_codec').toUpperCase()}
           {disabled}
-          desc="VP9 has high efficiency and web compatibility, but takes longer to transcode. HEVC performs similarly, but has lower web compatibility. H.264 is widely compatible and quick to transcode, but produces much larger files. AV1 is the most efficient codec but lacks support on older devices."
+          desc={$t('admin.transcoding_video_codec_description')}
           bind:value={config.ffmpeg.targetVideoCodec}
           options={[
             { value: VideoCodec.H264, text: 'h264' },
@@ -129,9 +130,9 @@
         />
 
         <SettingCheckboxes
-          label="ACCEPTED VIDEO CODECS"
+          label={$t('admin.transcoding_accepted_video_codecs').toUpperCase()}
           {disabled}
-          desc="Select which video codecs do not need to be transcoded. Only used for certain transcode policies."
+          desc={$t('admin.transcoding_accepted_video_codecs_description')}
           bind:value={config.ffmpeg.acceptedVideoCodecs}
           name="videoCodecs"
           options={[
@@ -144,9 +145,9 @@
         />
 
         <SettingSelect
-          label="TARGET RESOLUTION"
+          label={$t('admin.transcoding_target_resolution').toUpperCase()}
           {disabled}
-          desc="Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness."
+          desc={$t('admin.transcoding_target_resolution_description')}
           bind:value={config.ffmpeg.targetResolution}
           options={[
             { value: '2160', text: '4k' },
@@ -163,8 +164,8 @@
         <SettingInputField
           inputType={SettingInputFieldType.TEXT}
           {disabled}
-          label="MAX BITRATE"
-          desc="Setting a max bitrate can make file sizes more predictable at a minor cost to quality. At 720p, typical values are 2600k for VP9 or HEVC, or 4500k for H.264. Disabled if set to 0."
+          label={$t('admin.transcoding_max_bitrate').toUpperCase()}
+          desc={$t('admin.transcoding_max_bitrate_description')}
           bind:value={config.ffmpeg.maxBitrate}
           isEdited={config.ffmpeg.maxBitrate !== savedConfig.ffmpeg.maxBitrate}
         />
@@ -172,44 +173,44 @@
         <SettingInputField
           inputType={SettingInputFieldType.NUMBER}
           {disabled}
-          label="THREADS"
-          desc="Higher values lead to faster encoding, but leave less room for the server to process other tasks while active. This value should not be more than the number of CPU cores. Maximizes utilization if set to 0."
+          label={$t('admin.transcoding_threads').toUpperCase()}
+          desc={$t('admin.transcoding_threads_description')}
           bind:value={config.ffmpeg.threads}
           isEdited={config.ffmpeg.threads !== savedConfig.ffmpeg.threads}
         />
 
         <SettingSelect
-          label="TRANSCODE POLICY"
+          label={$t('admin.transcoding_transcode_policy').toUpperCase()}
           {disabled}
-          desc="Policy for when a video should be transcoded. HDR videos will always be transcoded (except if transcoding is disabled)."
+          desc={$t('admin.transcoding_transcode_policy_description')}
           bind:value={config.ffmpeg.transcode}
           name="transcode"
           options={[
             { value: TranscodePolicy.All, text: 'All videos' },
             {
               value: TranscodePolicy.Optimal,
-              text: 'Videos higher than target resolution or not in an accepted format',
+              text: $t('admin.transcoding_optimal_description'),
             },
             {
               value: TranscodePolicy.Bitrate,
-              text: 'Videos higher than max bitrate or not in an accepted format',
+              text: $t('admin.transcoding_bitrate_description'),
             },
             {
               value: TranscodePolicy.Required,
-              text: 'Only videos not in an accepted format',
+              text: $t('admin.transcoding_required_description'),
             },
             {
               value: TranscodePolicy.Disabled,
-              text: "Don't transcode any videos, may break playback on some clients",
+              text: $t('admin.transcoding_disabled_description'),
             },
           ]}
           isEdited={config.ffmpeg.transcode !== savedConfig.ffmpeg.transcode}
         />
 
         <SettingSelect
-          label="TONE-MAPPING"
+          label={$t('admin.transcoding_tone_mapping').toUpperCase()}
           {disabled}
-          desc="Attempts to preserve the appearance of HDR videos when converted to SDR. Each algorithm makes different tradeoffs for color, detail and brightness. Hable preserves detail, Mobius preserves color, and Reinhard preserves brightness."
+          desc={$t('admin.transcoding_tone_mapping_description')}
           bind:value={config.ffmpeg.tonemap}
           name="tonemap"
           options={[
@@ -234,58 +235,58 @@
         />
 
         <SettingSwitch
-          title="TWO-PASS ENCODING"
+          title={$t('admin.transcoding_two_pass_encoding').toUpperCase()}
           {disabled}
-          subtitle="Transcode in two passes to produce better encoded videos. When max bitrate is enabled (required for it to work with H.264 and HEVC), this mode uses a bitrate range based on the max bitrate and ignores CRF. For VP9, CRF can be used if max bitrate is disabled."
+          subtitle={$t('admin.transcoding_two_pass_encoding_setting_description')}
           bind:checked={config.ffmpeg.twoPass}
           isEdited={config.ffmpeg.twoPass !== savedConfig.ffmpeg.twoPass}
         />
 
         <SettingAccordion
           key="hardware-acceleration"
-          title="Hardware Acceleration"
-          subtitle="Experimental; much faster, but will have lower quality at the same bitrate"
+          title={$t('admin.transcoding_hardware_acceleration')}
+          subtitle={$t('admin.transcoding_hardware_acceleration_description')}
         >
           <div class="ml-4 mt-4 flex flex-col gap-4">
             <SettingSelect
-              label="ACCELERATION API"
+              label={$t('admin.transcoding_acceleration_api').toUpperCase()}
               {disabled}
-              desc="The API that will interact with your device to accelerate transcoding. This setting is 'best effort': it will fallback to software transcoding on failure. VP9 may or may not work depending on your hardware."
+              desc={$t('admin.transcoding_acceleration_api_description')}
               bind:value={config.ffmpeg.accel}
               name="accel"
               options={[
-                { value: TranscodeHWAccel.Nvenc, text: 'NVENC (requires NVIDIA GPU)' },
+                { value: TranscodeHWAccel.Nvenc, text: $t('admin.transcoding_acceleration_nvenc') },
                 {
                   value: TranscodeHWAccel.Qsv,
-                  text: 'Quick Sync (requires 7th gen Intel CPU or later)',
+                  text: $t('admin.transcoding_acceleration_qsv'),
                 },
                 {
                   value: TranscodeHWAccel.Vaapi,
-                  text: 'VAAPI',
+                  text: $t('admin.transcoding_acceleration_vaapi'),
                 },
                 {
                   value: TranscodeHWAccel.Rkmpp,
-                  text: 'RKMPP (only on Rockchip SOCs)',
+                  text: $t('admin.transcoding_acceleration_rkmpp'),
                 },
                 {
                   value: TranscodeHWAccel.Disabled,
-                  text: 'Disabled',
+                  text: $t('disabled'),
                 },
               ]}
               isEdited={config.ffmpeg.accel !== savedConfig.ffmpeg.accel}
             />
 
             <SettingSwitch
-              title="HARDWARE DECODING"
+              title={$t('admin.transcoding_hardware_decoding').toUpperCase()}
               {disabled}
-              subtitle="Applies only to NVENC and RKMPP. Enables end-to-end acceleration instead of only accelerating encoding. May not work on all videos."
+              subtitle={$t('admin.transcoding_hardware_decoding_setting_description')}
               bind:checked={config.ffmpeg.accelDecode}
               isEdited={config.ffmpeg.accelDecode !== savedConfig.ffmpeg.accelDecode}
             />
 
             <SettingSelect
-              label="CONSTANT QUALITY MODE"
-              desc="ICQ is better than CQP, but some hardware acceleration devices do not support this mode. Setting this option will prefer the specified mode when using quality-based encoding. Ignored by NVENC as it does not support ICQ."
+              label={$t('admin.transcoding_constant_quality_mode').toUpperCase()}
+              desc={$t('admin.transcoding_constant_quality_mode_description')}
               bind:value={config.ffmpeg.cqMode}
               options={[
                 { value: CQMode.Auto, text: 'Auto' },
@@ -297,17 +298,17 @@
             />
 
             <SettingSwitch
-              title="TEMPORAL AQ"
+              title={$t('admin.transcoding_temporal_aq').toUpperCase()}
               {disabled}
-              subtitle="Applies only to NVENC. Increases quality of high-detail, low-motion scenes. May not be compatible with older devices."
+              subtitle={$t('admin.transcoding_temporal_aq_description')}
               bind:checked={config.ffmpeg.temporalAQ}
               isEdited={config.ffmpeg.temporalAQ !== savedConfig.ffmpeg.temporalAQ}
             />
 
             <SettingInputField
               inputType={SettingInputFieldType.TEXT}
-              label="PREFERRED HARDWARE DEVICE"
-              desc="Applies only to VAAPI and QSV. Sets the dri node used for hardware transcoding."
+              label={$t('admin.transcoding_preferred_hardware_device').toUpperCase()}
+              desc={$t('admin.transcoding_preferred_hardware_device_description')}
               bind:value={config.ffmpeg.preferredHwDevice}
               isEdited={config.ffmpeg.preferredHwDevice !== savedConfig.ffmpeg.preferredHwDevice}
               {disabled}
@@ -317,14 +318,14 @@
 
         <SettingAccordion
           key="advanced-options"
-          title="Advanced"
-          subtitle="Options most users should not need to change"
+          title={$t('advanced')}
+          subtitle={$t('admin.transcoding_advanced_options_description')}
         >
           <div class="ml-4 mt-4 flex flex-col gap-4">
             <SettingInputField
               inputType={SettingInputFieldType.NUMBER}
-              label="TONE-MAPPING NPL"
-              desc="Colors will be adjusted to look normal for a display of this brightness. Counter-intuitively, lower values increase the brightness of the video and vice versa since it compensates for the brightness of the display. 0 sets this value automatically."
+              label={$t('admin.transcoding_tone_mapping_npl').toUpperCase()}
+              desc={$t('admin.transcoding_tone_mapping_npl_description')}
               bind:value={config.ffmpeg.npl}
               isEdited={config.ffmpeg.npl !== savedConfig.ffmpeg.npl}
               {disabled}
@@ -332,8 +333,8 @@
 
             <SettingInputField
               inputType={SettingInputFieldType.NUMBER}
-              label="MAX B-FRAMES"
-              desc="Higher values improve compression efficiency, but slow down encoding. May not be compatible with hardware acceleration on older devices. 0 disables B-frames, while -1 sets this value automatically."
+              label={$t('admin.transcoding_max_b_frames').toUpperCase()}
+              desc={$t('admin.transcoding_max_b_frames_description')}
               bind:value={config.ffmpeg.bframes}
               isEdited={config.ffmpeg.bframes !== savedConfig.ffmpeg.bframes}
               {disabled}
@@ -341,8 +342,8 @@
 
             <SettingInputField
               inputType={SettingInputFieldType.NUMBER}
-              label="REFERENCE FRAMES"
-              desc="The number of frames to reference when compressing a given frame. Higher values improve compression efficiency, but slow down encoding. 0 sets this value automatically."
+              label={$t('admin.transcoding_reference_frames').toUpperCase()}
+              desc={$t('admin.transcoding_reference_frames_description')}
               bind:value={config.ffmpeg.refs}
               isEdited={config.ffmpeg.refs !== savedConfig.ffmpeg.refs}
               {disabled}
@@ -350,8 +351,8 @@
 
             <SettingInputField
               inputType={SettingInputFieldType.NUMBER}
-              label="MAX KEYFRAME INTERVAL"
-              desc="Sets the maximum frame distance between keyframes. Lower values worsen compression efficiency, but improve seek times and may improve quality in scenes with fast movement. 0 sets this value automatically."
+              label={$t('admin.transcoding_max_keyframe_interval').toUpperCase()}
+              desc={$t('admin.transcoding_max_keyframe_interval_description')}
               bind:value={config.ffmpeg.gopSize}
               isEdited={config.ffmpeg.gopSize !== savedConfig.ffmpeg.gopSize}
               {disabled}
diff --git a/web/src/lib/components/admin-page/settings/image/image-settings.svelte b/web/src/lib/components/admin-page/settings/image/image-settings.svelte
index dbe21fe16a2a7..d6d4bbb2f0409 100644
--- a/web/src/lib/components/admin-page/settings/image/image-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/image/image-settings.svelte
@@ -11,6 +11,7 @@
   import SettingInputField, {
     SettingInputFieldType,
   } from '$lib/components/shared-components/settings/setting-input-field.svelte';
+  import { t } from 'svelte-i18n';
 
   export let savedConfig: SystemConfigDto;
   export let defaultConfig: SystemConfigDto;
@@ -25,8 +26,8 @@
     <form autocomplete="off" on:submit|preventDefault>
       <div class="ml-4 mt-4 flex flex-col gap-4">
         <SettingSelect
-          label="THUMBNAIL FORMAT"
-          desc="WebP produces smaller files than JPEG, but is slower to encode."
+          label={$t('admin.image_thumbnail_format').toUpperCase()}
+          desc={$t('admin.image_format_description')}
           bind:value={config.image.thumbnailFormat}
           options={[
             { value: ImageFormat.Jpeg, text: 'JPEG' },
@@ -38,8 +39,8 @@
         />
 
         <SettingSelect
-          label="THUMBNAIL RESOLUTION"
-          desc="Used when viewing groups of photos (main timeline, album view, etc.). Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness."
+          label={$t('admin.image_thumbnail_resolution').toUpperCase()}
+          desc={$t('admin.image_thumbnail_resolution_description')}
           number
           bind:value={config.image.thumbnailSize}
           options={[
@@ -55,8 +56,8 @@
         />
 
         <SettingSelect
-          label="PREVIEW FORMAT"
-          desc="WebP produces smaller files than JPEG, but is slower to encode."
+          label={$t('admin.image_preview_format').toUpperCase()}
+          desc={$t('admin.image_format_description')}
           bind:value={config.image.previewFormat}
           options={[
             { value: ImageFormat.Jpeg, text: 'JPEG' },
@@ -68,8 +69,8 @@
         />
 
         <SettingSelect
-          label="PREVIEW RESOLUTION"
-          desc="Used when viewing a single photo and for machine learning. Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness."
+          label={$t('admin.image_preview_resolution').toUpperCase()}
+          desc={$t('admin.image_preview_resolution_description')}
           number
           bind:value={config.image.previewSize}
           options={[
@@ -85,16 +86,16 @@
 
         <SettingInputField
           inputType={SettingInputFieldType.NUMBER}
-          label="QUALITY"
-          desc="Image quality from 1-100. Higher is better for quality but produces larger files."
+          label={$t('admin.image_quality').toUpperCase()}
+          desc={$t('admin.image_quality_description')}
           bind:value={config.image.quality}
           isEdited={config.image.quality !== savedConfig.image.quality}
           {disabled}
         />
 
         <SettingSwitch
-          title="PREFER WIDE GAMUT"
-          subtitle="Use Display P3 for thumbnails. This better preserves the vibrance of images with wide colorspaces, but images may appear differently on old devices with an old browser version. sRGB images are kept as sRGB to avoid color shifts."
+          title={$t('admin.image_prefer_wide_gamut').toUpperCase()}
+          subtitle={$t('admin.image_prefer_wide_gamut_setting_description')}
           checked={config.image.colorspace === Colorspace.P3}
           on:toggle={(e) => (config.image.colorspace = e.detail ? Colorspace.P3 : Colorspace.Srgb)}
           isEdited={config.image.colorspace !== savedConfig.image.colorspace}
@@ -102,8 +103,8 @@
         />
 
         <SettingSwitch
-          title="PREFER EMBEDDED PREVIEW"
-          subtitle="Use embedded previews in RAW photos as the input to image processing when available. This can produce more accurate colors for some images, but the quality of the preview is camera-dependent and the image may have more compression artifacts."
+          title={$t('admin.image_prefer_embedded_preview').toUpperCase()}
+          subtitle={$t('admin.image_prefer_embedded_preview_setting_description')}
           checked={config.image.extractEmbedded}
           on:toggle={() => (config.image.extractEmbedded = !config.image.extractEmbedded)}
           isEdited={config.image.extractEmbedded !== savedConfig.image.extractEmbedded}
diff --git a/web/src/lib/components/admin-page/settings/library-settings/library-settings.svelte b/web/src/lib/components/admin-page/settings/library-settings/library-settings.svelte
index c1076abc54739..f2f8fbada0c0a 100644
--- a/web/src/lib/components/admin-page/settings/library-settings/library-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/library-settings/library-settings.svelte
@@ -10,6 +10,7 @@
   } from '$lib/components/shared-components/settings/setting-input-field.svelte';
   import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
   import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
+  import { t } from 'svelte-i18n';
 
   export let savedConfig: SystemConfigDto;
   export let defaultConfig: SystemConfigDto;
@@ -17,10 +18,10 @@
   export let disabled = false;
 
   const cronExpressionOptions = [
-    { title: 'Every night at midnight', expression: '0 0 * * *' },
-    { title: 'Every night at 2am', expression: '0 2 * * *' },
-    { title: 'Every day at 1pm', expression: '0 13 * * *' },
-    { title: 'Every 6 hours', expression: '0 */6 * * *' },
+    { title: $t('interval.night_at_midnight'), expression: '0 0 * * *' },
+    { title: $t('interval.night_at_twoam'), expression: '0 2 * * *' },
+    { title: $t('interval.day_at_onepm'), expression: '0 13 * * *' },
+    { title: $t('interval.hours', { values: { hours: 6 } }), expression: '0 */6 * * *' },
   ];
 
   const dispatch = createEventDispatcher<SettingsEventType>();
@@ -30,16 +31,16 @@
   <div in:fade={{ duration: 500 }}>
     <SettingAccordion
       key="library-watching"
-      title="Library watching (EXPERIMENTAL)"
-      subtitle="Automatically watch for changed files"
+      title={$t('admin.library_watching_settings')}
+      subtitle={$t('admin.library_watching_settings_description')}
       isOpen
     >
       <form autocomplete="off" on:submit|preventDefault>
         <div class="ml-4 mt-4 flex flex-col gap-4">
           <SettingSwitch
-            title="Watch filesystem"
+            title={$t('enable')}
             {disabled}
-            subtitle="Watch external libraries for file changes"
+            subtitle={$t('admin.library_watching_enable_description')}
             bind:checked={config.library.watch.enabled}
           />
         </div>
@@ -57,21 +58,21 @@
 
     <SettingAccordion
       key="library-scanning"
-      title="Periodic Scanning"
-      subtitle="Configure periodic library scanning"
+      title={$t('admin.library_scanning')}
+      subtitle={$t('admin.library_scanning_description')}
       isOpen
     >
       <form autocomplete="off" on:submit|preventDefault>
         <div class="ml-4 mt-4 flex flex-col gap-4">
           <SettingSwitch
-            title="ENABLED"
+            title={$t('enabled').toUpperCase()}
             {disabled}
-            subtitle="Enable periodic library scanning"
+            subtitle={$t('admin.library_scanning_enable_description')}
             bind:checked={config.library.scan.enabled}
           />
 
           <div class="flex flex-col my-2 dark:text-immich-dark-fg">
-            <label class="text-sm" for="expression-select">Cron Expression Presets</label>
+            <label class="text-sm" for="expression-select">{$t('admin.library_cron_expression_presets')}</label>
             <select
               class="p-2 mt-2 text-sm rounded-lg bg-slate-200 hover:cursor-pointer dark:bg-gray-600"
               disabled={disabled || !config.library.scan.enabled}
@@ -89,7 +90,7 @@
             inputType={SettingInputFieldType.TEXT}
             required={true}
             disabled={disabled || !config.library.scan.enabled}
-            label="Cron Expression"
+            label={$t('admin.library_cron_expression')}
             bind:value={config.library.scan.cronExpression}
             isEdited={config.library.scan.cronExpression !== savedConfig.library.scan.cronExpression}
           >
@@ -99,7 +100,7 @@
                   href="https://crontab.guru"
                   class="underline"
                   target="_blank"
-                  rel="noreferrer">Crontab Guru</a
+                  rel="noreferrer">{$t('crontab_guru')}</a
                 >
               </p>
             </svelte:fragment>
diff --git a/web/src/lib/components/admin-page/settings/logging-settings/logging-settings.svelte b/web/src/lib/components/admin-page/settings/logging-settings/logging-settings.svelte
index 97ae0068165d3..3bf3d3709ff29 100644
--- a/web/src/lib/components/admin-page/settings/logging-settings/logging-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/logging-settings/logging-settings.svelte
@@ -7,6 +7,7 @@
   import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
   import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
   import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
+  import { t } from 'svelte-i18n';
 
   export let savedConfig: SystemConfigDto;
   export let defaultConfig: SystemConfigDto;
@@ -20,10 +21,15 @@
   <div in:fade={{ duration: 500 }}>
     <form autocomplete="off" on:submit|preventDefault>
       <div class="ml-4 mt-4 flex flex-col gap-4">
-        <SettingSwitch title="ENABLED" {disabled} subtitle="Logging" bind:checked={config.logging.enabled} />
+        <SettingSwitch
+          title={$t('enabled').toUpperCase()}
+          {disabled}
+          subtitle={$t('admin.logging_enable_description')}
+          bind:checked={config.logging.enabled}
+        />
         <SettingSelect
-          label="LEVEL"
-          desc="When enabled, what log level to use."
+          label={$t('level').toUpperCase()}
+          desc={$t('admin.logging_level_description')}
           bind:value={config.logging.level}
           options={[
             { value: LogLevel.Fatal, text: 'Fatal' },
diff --git a/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte b/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte
index c65b07f9e851e..9f4071a9df203 100644
--- a/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte
@@ -12,6 +12,7 @@
   import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
   import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
   import { featureFlags } from '$lib/stores/server-config.store';
+  import { t } from 'svelte-i18n';
 
   export let savedConfig: SystemConfigDto;
   export let defaultConfig: SystemConfigDto;
@@ -26,8 +27,8 @@
     <form autocomplete="off" on:submit|preventDefault class="mx-4 mt-4">
       <div class="flex flex-col gap-4">
         <SettingSwitch
-          title="ENABLED"
-          subtitle="If disabled, all ML features will be disabled regardless of the below settings."
+          title={$t('enabled').toUpperCase()}
+          subtitle={$t('admin.machine_learning_enabled_description')}
           {disabled}
           bind:checked={config.machineLearning.enabled}
         />
@@ -36,8 +37,8 @@
 
         <SettingInputField
           inputType={SettingInputFieldType.TEXT}
-          label="URL"
-          desc="URL of the machine learning server"
+          label={$t('url').toUpperCase()}
+          desc={$t('admin.machine_learning_url_description')}
           bind:value={config.machineLearning.url}
           required={true}
           disabled={disabled || !config.machineLearning.enabled}
@@ -47,13 +48,13 @@
 
       <SettingAccordion
         key="smart-search"
-        title="Smart Search"
-        subtitle="Search for images semantically using CLIP embeddings"
+        title={$t('admin.machine_learning_smart_search')}
+        subtitle={$t('admin.machine_learning_smart_search_description')}
       >
         <div class="ml-4 mt-4 flex flex-col gap-4">
           <SettingSwitch
-            title="ENABLED"
-            subtitle="If disabled, images will not be encoded for smart search."
+            title={$t('enabled').toUpperCase()}
+            subtitle={$t('admin.machine_learning_smart_search_enabled_description')}
             bind:checked={config.machineLearning.clip.enabled}
             disabled={disabled || !config.machineLearning.enabled}
           />
@@ -62,7 +63,7 @@
 
           <SettingInputField
             inputType={SettingInputFieldType.TEXT}
-            label="CLIP MODEL"
+            label={$t('admin.machine_learning_clip_model').toUpperCase()}
             bind:value={config.machineLearning.clip.modelName}
             required={true}
             disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.clip.enabled}
@@ -78,13 +79,13 @@
 
       <SettingAccordion
         key="duplicate-detection"
-        title="Duplicate Detection"
-        subtitle="Use CLIP embeddings to find likely duplicates"
+        title={$t('admin.machine_learning_duplicate_detection')}
+        subtitle={$t('admin.machine_learning_duplicate_detection_setting_description')}
       >
         <div class="ml-4 mt-4 flex flex-col gap-4">
           <SettingSwitch
-            title="ENABLED"
-            subtitle="If disabled, exactly identical assets will still be de-duplicated."
+            title={$t('enabled').toUpperCase()}
+            subtitle={$t('admin.machine_learning_duplicate_detection_enabled_description')}
             bind:checked={config.machineLearning.duplicateDetection.enabled}
             disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.clip.enabled}
           />
@@ -93,12 +94,12 @@
 
           <SettingInputField
             inputType={SettingInputFieldType.NUMBER}
-            label="MAX DETECTION DISTANCE"
+            label={$t('admin.machine_learning_max_detection_distance').toUpperCase()}
             bind:value={config.machineLearning.duplicateDetection.maxDistance}
             step="0.0005"
             min={0.001}
             max={0.1}
-            desc="Maximum distance between two images to consider them duplicates, ranging from 0.001-0.1. Higher values will detect more duplicates, but may result in false positives."
+            desc={$t('admin.machine_learning_max_detection_distance_description')}
             disabled={disabled || !$featureFlags.duplicateDetection}
             isEdited={config.machineLearning.duplicateDetection.maxDistance !==
               savedConfig.machineLearning.duplicateDetection.maxDistance}
@@ -108,13 +109,13 @@
 
       <SettingAccordion
         key="facial-recognition"
-        title="Facial Recognition"
-        subtitle="Detect, recognize and group faces in images"
+        title={$t('admin.machine_learning_facial_recognition')}
+        subtitle={$t('admin.machine_learning_facial_recognition_description')}
       >
         <div class="ml-4 mt-4 flex flex-col gap-4">
           <SettingSwitch
-            title="ENABLED"
-            subtitle="If disabled, images will not be encoded for facial recognition and will not populate the People section in the Explore page."
+            title={$t('enabled').toUpperCase()}
+            subtitle={$t('admin.machine_learning_facial_recognition_setting_description')}
             bind:checked={config.machineLearning.facialRecognition.enabled}
             disabled={disabled || !config.machineLearning.enabled}
           />
@@ -122,8 +123,8 @@
           <hr />
 
           <SettingSelect
-            label="FACIAL RECOGNITION MODEL"
-            desc="Models are listed in descending order of size. Larger models are slower and use more memory, but produce better results. Note that you must re-run the Face Detection job for all images upon changing a model."
+            label={$t('admin.machine_learning_facial_recognition_model').toUpperCase()}
+            desc={$t('admin.machine_learning_facial_recognition_model_description')}
             name="facial-recognition-model"
             bind:value={config.machineLearning.facialRecognition.modelName}
             options={[
@@ -139,8 +140,8 @@
 
           <SettingInputField
             inputType={SettingInputFieldType.NUMBER}
-            label="MIN DETECTION SCORE"
-            desc="Minimum confidence score for a face to be detected from 0-1. Lower values will detect more faces but may result in false positives."
+            label={$t('admin.machine_learning_min_detection_score').toUpperCase()}
+            desc={$t('admin.machine_learning_min_detection_score_description')}
             bind:value={config.machineLearning.facialRecognition.minScore}
             step="0.1"
             min={0}
@@ -152,8 +153,8 @@
 
           <SettingInputField
             inputType={SettingInputFieldType.NUMBER}
-            label="MAX RECOGNITION DISTANCE"
-            desc="Maximum distance between two faces to be considered the same person, ranging from 0-2. Lowering this can prevent labeling two people as the same person, while raising it can prevent labeling the same person as two different people. Note that it is easier to merge two people than to split one person in two, so err on the side of a lower threshold when possible."
+            label={$t('admin.machine_learning_max_recognition_distance').toUpperCase()}
+            desc={$t('admin.machine_learning_max_recognition_distance_description')}
             bind:value={config.machineLearning.facialRecognition.maxDistance}
             step="0.1"
             min={0}
@@ -165,8 +166,8 @@
 
           <SettingInputField
             inputType={SettingInputFieldType.NUMBER}
-            label="MIN RECOGNIZED FACES"
-            desc="The minimum number of recognized faces for a person to be created. Increasing this makes Facial Recognition more precise at the cost of increasing the chance that a face is not assigned to a person."
+            label={$t('admin.machine_learning_min_recognized_faces').toUpperCase()}
+            desc={$t('admin.machine_learning_min_recognized_faces_description')}
             bind:value={config.machineLearning.facialRecognition.minFaces}
             step="1"
             min={1}
diff --git a/web/src/lib/components/admin-page/settings/map-settings/map-settings.svelte b/web/src/lib/components/admin-page/settings/map-settings/map-settings.svelte
index 46dc7a6351ac2..1926eb6d0233a 100644
--- a/web/src/lib/components/admin-page/settings/map-settings/map-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/map-settings/map-settings.svelte
@@ -10,6 +10,7 @@
   import SettingInputField, {
     SettingInputFieldType,
   } from '$lib/components/shared-components/settings/setting-input-field.svelte';
+  import { t } from 'svelte-i18n';
 
   export let savedConfig: SystemConfigDto;
   export let defaultConfig: SystemConfigDto;
@@ -23,12 +24,12 @@
   <div in:fade={{ duration: 500 }}>
     <form autocomplete="off" on:submit|preventDefault>
       <div class="flex flex-col gap-4">
-        <SettingAccordion key="map" title="Map Settings" subtitle="Manage map settings">
+        <SettingAccordion key="map" title={$t('admin.map_settings')} subtitle={$t('admin.map_settings_description')}>
           <div class="ml-4 mt-4 flex flex-col gap-4">
             <SettingSwitch
-              title="ENABLED"
+              title={$t('enabled').toUpperCase()}
               {disabled}
-              subtitle="Enable map features"
+              subtitle={$t('admin.map_enable_description')}
               bind:checked={config.map.enabled}
             />
 
@@ -36,16 +37,16 @@
 
             <SettingInputField
               inputType={SettingInputFieldType.TEXT}
-              label="Light Style"
-              desc="URL to a style.json map theme"
+              label={$t('admin.map_light_style')}
+              desc={$t('admin.map_style_description')}
               bind:value={config.map.lightStyle}
               disabled={disabled || !config.map.enabled}
               isEdited={config.map.lightStyle !== savedConfig.map.lightStyle}
             />
             <SettingInputField
               inputType={SettingInputFieldType.TEXT}
-              label="Dark Style"
-              desc="URL to a style.json map theme"
+              label={$t('admin.map_dark_style')}
+              desc={$t('admin.map_style_description')}
               bind:value={config.map.darkStyle}
               disabled={disabled || !config.map.enabled}
               isEdited={config.map.darkStyle !== savedConfig.map.darkStyle}
@@ -53,22 +54,22 @@
           </div></SettingAccordion
         >
 
-        <SettingAccordion key="reverse-geocoding" title="Reverse Geocoding Settings">
+        <SettingAccordion key="reverse-geocoding" title={$t('admin.map_reverse_geocoding_settings')}>
           <svelte:fragment slot="subtitle">
             <p class="text-sm dark:text-immich-dark-fg">
               Manage <a
                 href="https://immich.app/docs/features/reverse-geocoding"
                 class="underline"
                 target="_blank"
-                rel="noreferrer">Reverse Geocoding</a
+                rel="noreferrer">{$t('admin.map_reverse_geocoding')}</a
               > settings
             </p>
           </svelte:fragment>
           <div class="ml-4 mt-4 flex flex-col gap-4">
             <SettingSwitch
-              title="ENABLED"
+              title={$t('enabled').toUpperCase()}
               {disabled}
-              subtitle="Enable reverse geocoding"
+              subtitle={$t('admin.map_reverse_geocoding_enable_description')}
               bind:checked={config.reverseGeocoding.enabled}
             />
           </div></SettingAccordion
diff --git a/web/src/lib/components/admin-page/settings/new-version-check-settings/new-version-check-settings.svelte b/web/src/lib/components/admin-page/settings/new-version-check-settings/new-version-check-settings.svelte
index ec8c9c8df589b..6838e60b4e467 100644
--- a/web/src/lib/components/admin-page/settings/new-version-check-settings/new-version-check-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/new-version-check-settings/new-version-check-settings.svelte
@@ -6,6 +6,7 @@
   import type { SettingsEventType } from '../admin-settings';
   import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
   import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
+  import { t } from 'svelte-i18n';
 
   export let savedConfig: SystemConfigDto;
   export let defaultConfig: SystemConfigDto;
@@ -20,8 +21,8 @@
     <form autocomplete="off" on:submit|preventDefault>
       <div class="ml-4 mt-4">
         <SettingSwitch
-          title="ENABLED"
-          subtitle="Enable periodic requests to GitHub to check for new releases"
+          title={$t('enabled').toUpperCase()}
+          subtitle={$t('admin.version_check_enabled_description')}
           bind:checked={config.newVersionCheck.enabled}
           {disabled}
         />
diff --git a/web/src/lib/components/admin-page/settings/notification-settings/notification-settings.svelte b/web/src/lib/components/admin-page/settings/notification-settings/notification-settings.svelte
index 8628b45ece7a0..d189262960056 100644
--- a/web/src/lib/components/admin-page/settings/notification-settings/notification-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/notification-settings/notification-settings.svelte
@@ -10,6 +10,7 @@
   import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
   import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
   import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
+  import { t } from 'svelte-i18n';
 
   export let savedConfig: SystemConfigDto;
   export let defaultConfig: SystemConfigDto;
@@ -23,11 +24,11 @@
   <div in:fade={{ duration: 500 }}>
     <form autocomplete="off" on:submit|preventDefault class="mt-4">
       <div class="flex flex-col gap-4">
-        <SettingAccordion key="email" title="Email" subtitle="Settings for sending email notifications">
+        <SettingAccordion key="email" title={$t('email')} subtitle={$t('admin.notification_email_setting_description')}>
           <div class="ml-4 mt-4 flex flex-col gap-4">
             <SettingSwitch
-              title="Enabled"
-              subtitle="Enable email notifications"
+              title={$t('enabled')}
+              subtitle={$t('admin.notification_enable_email_notifications')}
               {disabled}
               bind:checked={config.notifications.smtp.enabled}
             />
@@ -37,8 +38,8 @@
             <SettingInputField
               inputType={SettingInputFieldType.TEXT}
               required
-              label="Host"
-              desc="Host of the email server (e.g. smtp.immich.app)"
+              label={$t('host')}
+              desc={$t('admin.notification_email_host_description')}
               disabled={disabled || !config.notifications.smtp.enabled}
               bind:value={config.notifications.smtp.transport.host}
               isEdited={config.notifications.smtp.transport.host !== savedConfig.notifications.smtp.transport.host}
@@ -47,8 +48,8 @@
             <SettingInputField
               inputType={SettingInputFieldType.NUMBER}
               required
-              label="Port"
-              desc="Port of the email server (e.g 25, 465, or 587)"
+              label={$t('port')}
+              desc={$t('admin.notification_email_port_description')}
               disabled={disabled || !config.notifications.smtp.enabled}
               bind:value={config.notifications.smtp.transport.port}
               isEdited={config.notifications.smtp.transport.port !== savedConfig.notifications.smtp.transport.port}
@@ -56,8 +57,8 @@
 
             <SettingInputField
               inputType={SettingInputFieldType.TEXT}
-              label="Username"
-              desc="Username to use when authenticating with the email server"
+              label={$t('username')}
+              desc={$t('admin.notification_email_username_description')}
               disabled={disabled || !config.notifications.smtp.enabled}
               bind:value={config.notifications.smtp.transport.username}
               isEdited={config.notifications.smtp.transport.username !==
@@ -66,8 +67,8 @@
 
             <SettingInputField
               inputType={SettingInputFieldType.PASSWORD}
-              label="Password"
-              desc="Password to use when authenticating with the email server"
+              label={$t('password')}
+              desc={$t('admin.notification_email_password_description')}
               disabled={disabled || !config.notifications.smtp.enabled}
               bind:value={config.notifications.smtp.transport.password}
               isEdited={config.notifications.smtp.transport.password !==
@@ -75,8 +76,8 @@
             />
 
             <SettingSwitch
-              title="Ignore certificate errors"
-              subtitle="Ignore TLS certificate validation errors (not recommended)"
+              title={$t('admin.notification_email_ignore_certificate_errors')}
+              subtitle={$t('admin.notification_email_ignore_certificate_errors_description')}
               disabled={disabled || !config.notifications.smtp.enabled}
               bind:checked={config.notifications.smtp.transport.ignoreCert}
             />
@@ -86,8 +87,8 @@
             <SettingInputField
               inputType={SettingInputFieldType.TEXT}
               required
-              label="From address"
-              desc="Sender email address, for example: &quot;Immich Photo Server <noreply@immich.app>&quot;"
+              label={$t('admin.notification_email_from_address')}
+              desc={$t('admin.notification_email_from_address_description')}
               disabled={disabled || !config.notifications.smtp.enabled}
               bind:value={config.notifications.smtp.from}
               isEdited={config.notifications.smtp.from !== savedConfig.notifications.smtp.from}
diff --git a/web/src/lib/components/admin-page/settings/server/server-settings.svelte b/web/src/lib/components/admin-page/settings/server/server-settings.svelte
index 2b24ef5736f60..53a45d1704cab 100644
--- a/web/src/lib/components/admin-page/settings/server/server-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/server/server-settings.svelte
@@ -8,6 +8,7 @@
     SettingInputFieldType,
   } from '$lib/components/shared-components/settings/setting-input-field.svelte';
   import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
+  import { t } from 'svelte-i18n';
 
   export let savedConfig: SystemConfigDto;
   export let defaultConfig: SystemConfigDto;
@@ -23,16 +24,16 @@
       <div class="mt-4 ml-4">
         <SettingInputField
           inputType={SettingInputFieldType.TEXT}
-          label="EXTERNAL DOMAIN"
-          desc="Domain for public shared links, including http(s)://"
+          label={$t('admin.server_external_domain_settings').toUpperCase()}
+          desc={$t('admin.server_external_domain_settings_description')}
           bind:value={config.server.externalDomain}
           isEdited={config.server.externalDomain !== savedConfig.server.externalDomain}
         />
 
         <SettingInputField
           inputType={SettingInputFieldType.TEXT}
-          label="WELCOME MESSAGE"
-          desc="A message that is displayed on the login page."
+          label={$t('admin.server_welcome_message').toUpperCase()}
+          desc={$t('admin.server_welcome_message_description')}
           bind:value={config.server.loginPageMessage}
           isEdited={config.server.loginPageMessage !== savedConfig.server.loginPageMessage}
         />
diff --git a/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte b/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte
index d9a2dea9e6283..cef0f6c96c6c7 100644
--- a/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte
@@ -20,6 +20,7 @@
     SettingInputFieldType,
   } from '$lib/components/shared-components/settings/setting-input-field.svelte';
   import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
+  import { t } from 'svelte-i18n';
 
   export let savedConfig: SystemConfigDto;
   export let defaultConfig: SystemConfigDto;
@@ -54,10 +55,10 @@
     const substitutions: Record<string, string> = {
       filename: 'IMAGE_56437',
       ext: 'jpg',
-      filetype: 'IMG',
+      filetype: $t('img').toUpperCase(),
       filetypefull: 'IMAGE',
       assetId: 'a8312960-e277-447d-b4ea-56717ccba856',
-      album: 'Album Name',
+      album: $t('album_name'),
     };
 
     const dt = luxon.DateTime.fromISO(new Date('2022-02-03T04:56:05.250').toISOString());
@@ -107,18 +108,18 @@
   {#await getTemplateOptions() then}
     <div id="directory-path-builder" class="flex flex-col gap-4 {minified ? '' : 'ml-4 mt-4'}">
       <SettingSwitch
-        title="ENABLED"
+        title={$t('enabled').toUpperCase()}
         {disabled}
-        subtitle="Enable storage template engine"
+        subtitle={$t('admin.storage_template_enable_description')}
         bind:checked={config.storageTemplate.enabled}
         isEdited={!(config.storageTemplate.enabled === savedConfig.storageTemplate.enabled)}
       />
 
       {#if !minified}
         <SettingSwitch
-          title="HASH VERIFICATION ENABLED"
+          title={$t('admin.storage_template_hash_verification_enabled').toUpperCase()}
           {disabled}
-          subtitle="Enables hash verification, don't disable this unless you're certain of the implications"
+          subtitle={$t('admin.storage_template_hash_verification_enabled_description')}
           bind:checked={config.storageTemplate.hashVerificationEnabled}
           isEdited={!(
             config.storageTemplate.hashVerificationEnabled === savedConfig.storageTemplate.hashVerificationEnabled
@@ -129,7 +130,7 @@
       {#if config.storageTemplate.enabled}
         <hr />
 
-        <h3 class="text-base font-medium text-immich-primary dark:text-immich-dark-primary">Variables</h3>
+        <h3 class="text-base font-medium text-immich-primary dark:text-immich-dark-primary">{$t('variables')}</h3>
 
         <section class="support-date">
           {#await getSupportDateTimeFormat()}
@@ -146,10 +147,10 @@
         </section>
 
         <div class="flex flex-col mt-4">
-          <h3 class="text-base font-medium text-immich-primary dark:text-immich-dark-primary">Template</h3>
+          <h3 class="text-base font-medium text-immich-primary dark:text-immich-dark-primary">{$t('template')}</h3>
 
           <div class="my-2 text-sm">
-            <h4>PREVIEW</h4>
+            <h4>{$t('preview').toUpperCase()}</h4>
           </div>
 
           <p class="text-sm">
@@ -172,7 +173,7 @@
 
           <form autocomplete="off" class="flex flex-col" on:submit|preventDefault>
             <div class="flex flex-col my-2">
-              <label class="text-sm" for="preset-select">PRESET</label>
+              <label class="text-sm" for="preset-select">{$t('preset').toUpperCase()}</label>
               <select
                 class="immich-form-input p-2 mt-2 text-sm rounded-lg bg-slate-200 hover:cursor-pointer dark:bg-gray-600"
                 disabled={disabled || !config.storageTemplate.enabled}
@@ -188,7 +189,7 @@
             </div>
             <div class="flex gap-2 align-bottom">
               <SettingInputField
-                label="TEMPLATE"
+                label={$t('template').toUpperCase()}
                 disabled={disabled || !config.storageTemplate.enabled}
                 required
                 inputType={SettingInputFieldType.TEXT}
@@ -197,19 +198,24 @@
               />
 
               <div class="flex-0">
-                <SettingInputField label="EXTENSION" inputType={SettingInputFieldType.TEXT} value={'.jpg'} disabled />
+                <SettingInputField
+                  label={$t('extension')}
+                  inputType={SettingInputFieldType.TEXT}
+                  value={'.jpg'}
+                  disabled
+                />
               </div>
             </div>
 
             {#if !minified}
               <div id="migration-info" class="mt-2 text-sm">
-                <h3 class="text-base font-medium text-immich-primary dark:text-immich-dark-primary">Notes</h3>
+                <h3 class="text-base font-medium text-immich-primary dark:text-immich-dark-primary">{$t('notes')}</h3>
                 <section class="flex flex-col gap-2">
                   <p>
                     Template changes will only apply to new assets. To retroactively apply the template to previously
                     uploaded assets, run the
                     <a href={AppRoute.ADMIN_JOBS} class="text-immich-primary dark:text-immich-dark-primary"
-                      >Storage Migration Job</a
+                      >{$t('admin.storage_template_migration_job')}</a
                     >.
                   </p>
                   <p>
@@ -217,7 +223,7 @@
                     assets, so manually running the
 
                     <a href={AppRoute.ADMIN_JOBS} class="text-immich-primary dark:text-immich-dark-primary"
-                      >Storage Migration Job</a
+                      >{$t('admin.storage_template_migration_job')}</a
                     >
                     is required in order to successfully use the variable.
                   </p>
diff --git a/web/src/lib/components/admin-page/settings/storage-template/supported-datetime-panel.svelte b/web/src/lib/components/admin-page/settings/storage-template/supported-datetime-panel.svelte
index 190bf9efb1dff..e505f035de493 100644
--- a/web/src/lib/components/admin-page/settings/storage-template/supported-datetime-panel.svelte
+++ b/web/src/lib/components/admin-page/settings/storage-template/supported-datetime-panel.svelte
@@ -2,6 +2,7 @@
   import { locale } from '$lib/stores/preferences.store';
   import type { SystemConfigTemplateStorageOptionDto } from '@immich/sdk';
   import { DateTime } from 'luxon';
+  import { t } from 'svelte-i18n';
 
   export let options: SystemConfigTemplateStorageOptionDto;
 
@@ -21,7 +22,7 @@
   </div>
   <div class="flex gap-[40px]">
     <div>
-      <p class="font-medium text-immich-primary dark:text-immich-dark-primary">YEAR</p>
+      <p class="font-medium text-immich-primary dark:text-immich-dark-primary">{$t('year').toUpperCase()}</p>
       <ul>
         {#each options.yearOptions as yearFormat}
           <li>{'{{'}{yearFormat}{'}}'} - {getLuxonExample(yearFormat)}</li>
@@ -30,7 +31,7 @@
     </div>
 
     <div>
-      <p class="font-medium text-immich-primary dark:text-immich-dark-primary">MONTH</p>
+      <p class="font-medium text-immich-primary dark:text-immich-dark-primary">{$t('month').toUpperCase()}</p>
       <ul>
         {#each options.monthOptions as monthFormat}
           <li>{'{{'}{monthFormat}{'}}'} - {getLuxonExample(monthFormat)}</li>
@@ -39,7 +40,7 @@
     </div>
 
     <div>
-      <p class="font-medium text-immich-primary dark:text-immich-dark-primary">WEEK</p>
+      <p class="font-medium text-immich-primary dark:text-immich-dark-primary">{$t('week').toUpperCase()}</p>
       <ul>
         {#each options.weekOptions as weekFormat}
           <li>{'{{'}{weekFormat}{'}}'} - {getLuxonExample(weekFormat)}</li>
@@ -48,7 +49,7 @@
     </div>
 
     <div>
-      <p class="font-medium text-immich-primary dark:text-immich-dark-primary">DAY</p>
+      <p class="font-medium text-immich-primary dark:text-immich-dark-primary">{$t('day').toUpperCase()}</p>
       <ul>
         {#each options.dayOptions as dayFormat}
           <li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
@@ -57,7 +58,7 @@
     </div>
 
     <div>
-      <p class="font-medium text-immich-primary dark:text-immich-dark-primary">HOUR</p>
+      <p class="font-medium text-immich-primary dark:text-immich-dark-primary">{$t('hour').toUpperCase()}</p>
       <ul>
         {#each options.hourOptions as dayFormat}
           <li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
@@ -66,7 +67,7 @@
     </div>
 
     <div>
-      <p class="font-medium text-immich-primary dark:text-immich-dark-primary">MINUTE</p>
+      <p class="font-medium text-immich-primary dark:text-immich-dark-primary">{$t('minute').toUpperCase()}</p>
       <ul>
         {#each options.minuteOptions as dayFormat}
           <li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
@@ -75,7 +76,7 @@
     </div>
 
     <div>
-      <p class="font-medium text-immich-primary dark:text-immich-dark-primary">SECOND</p>
+      <p class="font-medium text-immich-primary dark:text-immich-dark-primary">{$t('second').toUpperCase()}</p>
       <ul>
         {#each options.secondOptions as dayFormat}
           <li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
diff --git a/web/src/lib/components/admin-page/settings/storage-template/supported-variables-panel.svelte b/web/src/lib/components/admin-page/settings/storage-template/supported-variables-panel.svelte
index 36f639815c407..515f2e48f09c5 100644
--- a/web/src/lib/components/admin-page/settings/storage-template/supported-variables-panel.svelte
+++ b/web/src/lib/components/admin-page/settings/storage-template/supported-variables-panel.svelte
@@ -1,11 +1,15 @@
+<script lang="ts">
+  import { t } from 'svelte-i18n';
+</script>
+
 <div class="mt-4 text-sm">
-  <h4>OTHER VARIABLES</h4>
+  <h4>{$t('other_variables').toUpperCase()}</h4>
 </div>
 
 <div class="p-4 mt-2 text-xs bg-gray-200 rounded-lg dark:bg-gray-700 dark:text-immich-dark-fg">
   <div class="flex gap-[50px]">
     <div>
-      <p class="font-medium text-immich-primary dark:text-immich-dark-primary">FILENAME</p>
+      <p class="font-medium text-immich-primary dark:text-immich-dark-primary">{$t('filename').toUpperCase()}</p>
       <ul>
         <li>{`{{filename}}`} - IMG_123</li>
         <li>{`{{ext}}`} - jpg</li>
@@ -13,14 +17,14 @@
     </div>
 
     <div>
-      <p class="font-medium text-immich-primary dark:text-immich-dark-primary">FILETYPE</p>
+      <p class="font-medium text-immich-primary dark:text-immich-dark-primary">{$t('filetype').toUpperCase()}</p>
       <ul>
         <li>{`{{filetype}}`} - VID or IMG</li>
         <li>{`{{filetypefull}}`} - VIDEO or IMAGE</li>
       </ul>
     </div>
     <div>
-      <p class="font-medium text-immich-primary dark:text-immich-dark-primary uppercase">OTHER</p>
+      <p class="font-medium text-immich-primary dark:text-immich-dark-primary uppercase">{$t('other').toUpperCase()}</p>
       <ul>
         <li>{`{{assetId}}`} - Asset ID</li>
         <li>{`{{album}}`} - Album Name</li>
diff --git a/web/src/lib/components/admin-page/settings/theme/theme-settings.svelte b/web/src/lib/components/admin-page/settings/theme/theme-settings.svelte
index 10c52c13610b6..e4ad32dd0a815 100644
--- a/web/src/lib/components/admin-page/settings/theme/theme-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/theme/theme-settings.svelte
@@ -6,6 +6,7 @@
   import type { SettingsEventType } from '../admin-settings';
   import SettingTextarea from '$lib/components/shared-components/settings/setting-textarea.svelte';
   import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
+  import { t } from 'svelte-i18n';
 
   export let savedConfig: SystemConfigDto;
   export let defaultConfig: SystemConfigDto;
@@ -21,8 +22,8 @@
       <div class="ml-4 mt-4 flex flex-col gap-4">
         <SettingTextarea
           {disabled}
-          label="Custom CSS"
-          desc="Cascading Style Sheets allow the design of Immich to be customized."
+          label={$t('admin.theme_custom_css_settings')}
+          desc={$t('admin.theme_custom_css_settings_description')}
           bind:value={config.theme.customCss}
           required={true}
           isEdited={config.theme.customCss !== savedConfig.theme.customCss}
diff --git a/web/src/lib/components/admin-page/settings/trash-settings/trash-settings.svelte b/web/src/lib/components/admin-page/settings/trash-settings/trash-settings.svelte
index 2b0e82ff91369..d0c52e53f49da 100644
--- a/web/src/lib/components/admin-page/settings/trash-settings/trash-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/trash-settings/trash-settings.svelte
@@ -9,6 +9,7 @@
     SettingInputFieldType,
   } from '$lib/components/shared-components/settings/setting-input-field.svelte';
   import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
+  import { t } from 'svelte-i18n';
 
   export let savedConfig: SystemConfigDto;
   export let defaultConfig: SystemConfigDto;
@@ -23,9 +24,9 @@
     <form autocomplete="off" on:submit|preventDefault>
       <div class="ml-4 mt-4 flex flex-col gap-4">
         <SettingSwitch
-          title="ENABLED"
+          title={$t('enabled').toUpperCase()}
           {disabled}
-          subtitle="Enable Trash features"
+          subtitle={$t('admin.trash_enabled_description')}
           bind:checked={config.trash.enabled}
         />
 
@@ -33,8 +34,8 @@
 
         <SettingInputField
           inputType={SettingInputFieldType.NUMBER}
-          label="Number of days"
-          desc="Number of days to keep the assets in trash before permanently removing them"
+          label={$t('admin.trash_number_of_days')}
+          desc={$t('admin.trash_number_of_days_description')}
           bind:value={config.trash.days}
           required={true}
           disabled={disabled || !config.trash.enabled}
diff --git a/web/src/lib/components/admin-page/settings/user-settings/user-settings.svelte b/web/src/lib/components/admin-page/settings/user-settings/user-settings.svelte
index 9b0b7f1950377..cf20425a59a68 100644
--- a/web/src/lib/components/admin-page/settings/user-settings/user-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/user-settings/user-settings.svelte
@@ -9,6 +9,7 @@
   import SettingInputField, {
     SettingInputFieldType,
   } from '$lib/components/shared-components/settings/setting-input-field.svelte';
+  import { t } from 'svelte-i18n';
 
   export let savedConfig: SystemConfigDto;
   export let defaultConfig: SystemConfigDto;
@@ -25,8 +26,8 @@
         <SettingInputField
           inputType={SettingInputFieldType.NUMBER}
           min={1}
-          label="DELETE DELAY"
-          desc="Number of days after removal to permanently delete a user's account and assets. The user deletion job runs at midnight to check for users that are ready for deletion. Changes to this setting will be evaluated at the next execution."
+          label={$t('admin.user_delete_delay_settings').toUpperCase()}
+          desc={$t('admin.user_delete_delay_settings_description')}
           bind:value={config.user.deleteDelay}
           isEdited={config.user.deleteDelay !== savedConfig.user.deleteDelay}
         />
diff --git a/web/src/lib/components/album-page/__tests__/album-card.spec.ts b/web/src/lib/components/album-page/__tests__/album-card.spec.ts
index 9b0d814dabc1c..93461e543dad2 100644
--- a/web/src/lib/components/album-page/__tests__/album-card.spec.ts
+++ b/web/src/lib/components/album-page/__tests__/album-card.spec.ts
@@ -2,6 +2,7 @@ import { sdkMock } from '$lib/__mocks__/sdk.mock';
 import { albumFactory } from '@test-data';
 import '@testing-library/jest-dom';
 import { fireEvent, render, waitFor, type RenderResult } from '@testing-library/svelte';
+import { init, register, waitLocale } from 'svelte-i18n';
 import AlbumCard from '../album-card.svelte';
 
 const onShowContextMenu = vi.fn();
@@ -9,6 +10,12 @@ const onShowContextMenu = vi.fn();
 describe('AlbumCard component', () => {
   let sut: RenderResult<AlbumCard>;
 
+  beforeAll(async () => {
+    await init({ fallbackLocale: 'en-US' });
+    register('en-US', () => import('$lib/i18n/en-US.json'));
+    await waitLocale('en-US');
+  });
+
   it.each([
     {
       album: albumFactory.build({ albumThumbnailAssetId: null, shared: false, assetCount: 0 }),
diff --git a/web/src/lib/components/album-page/album-card.svelte b/web/src/lib/components/album-page/album-card.svelte
index 6d64783e38ab6..dc32846642dd4 100644
--- a/web/src/lib/components/album-page/album-card.svelte
+++ b/web/src/lib/components/album-page/album-card.svelte
@@ -8,6 +8,7 @@
   import AlbumCover from '$lib/components/album-page/album-cover.svelte';
   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
   import { s } from '$lib/utils';
+  import { t } from 'svelte-i18n';
 
   export let album: AlbumResponseDto;
   export let showOwner = false;
@@ -35,7 +36,7 @@
     >
       <CircleIconButton
         color="opaque"
-        title="Show album options"
+        title={$t('show_album_options')}
         icon={mdiDotsVertical}
         size="20"
         padding="2"
@@ -76,14 +77,14 @@
 
       {#if showOwner}
         {#if $user.id === album.ownerId}
-          <p>Owned</p>
+          <p>{$t('owned')}</p>
         {:else if album.owner}
           <p>Shared by {album.owner.name}</p>
         {:else}
-          <p>Shared</p>
+          <p>{$t('shared')}</p>
         {/if}
       {:else if album.shared}
-        <p>Shared</p>
+        <p>{$t('shared')}</p>
       {/if}
     </span>
   </div>
diff --git a/web/src/lib/components/album-page/album-cover.svelte b/web/src/lib/components/album-page/album-cover.svelte
index aa8baebd044f3..f7c4ef551a4c4 100644
--- a/web/src/lib/components/album-page/album-cover.svelte
+++ b/web/src/lib/components/album-page/album-cover.svelte
@@ -1,6 +1,7 @@
 <script lang="ts">
   import { getAssetThumbnailUrl } from '$lib/utils';
   import { type AlbumResponseDto } from '@immich/sdk';
+  import { t } from 'svelte-i18n';
 
   export let album: AlbumResponseDto | undefined;
   export let preload = false;
@@ -15,7 +16,7 @@
     <img
       loading={preload ? 'eager' : 'lazy'}
       src={thumbnailUrl}
-      alt={album?.albumName ?? 'Unknown Album'}
+      alt={album?.albumName ?? $t('unknown_album')}
       class="z-0 rounded-xl object-cover {css}"
       data-testid="album-image"
       draggable="false"
@@ -25,7 +26,7 @@
       loading={preload ? 'eager' : 'lazy'}
       src="$lib/assets/no-thumbnail.png"
       sizes="min(271px,186px)"
-      alt={album?.albumName ?? 'Empty Album'}
+      alt={album?.albumName ?? $t('empty_album')}
       class="z-0 rounded-xl object-cover {css}"
       data-testid="album-image"
       draggable="false"
diff --git a/web/src/lib/components/album-page/album-description.spec.ts b/web/src/lib/components/album-page/album-description.spec.ts
index 1c069de2bc49a..61bfb70596222 100644
--- a/web/src/lib/components/album-page/album-description.spec.ts
+++ b/web/src/lib/components/album-page/album-description.spec.ts
@@ -1,9 +1,14 @@
 import AlbumDescription from '$lib/components/album-page/album-description.svelte';
 import '@testing-library/jest-dom';
 import { render, screen } from '@testing-library/svelte';
+import { init } from 'svelte-i18n';
 import { describe } from 'vitest';
 
 describe('AlbumDescription component', () => {
+  beforeAll(async () => {
+    await init({ fallbackLocale: 'en-US' });
+  });
+
   it('shows an AutogrowTextarea component when isOwned is true', () => {
     render(AlbumDescription, { isOwned: true, id: '', description: '' });
     const autogrowTextarea = screen.getByTestId('autogrow-textarea');
diff --git a/web/src/lib/components/album-page/album-description.svelte b/web/src/lib/components/album-page/album-description.svelte
index 23bcf8c95a1f7..b3ad688a30512 100644
--- a/web/src/lib/components/album-page/album-description.svelte
+++ b/web/src/lib/components/album-page/album-description.svelte
@@ -2,6 +2,7 @@
   import { updateAlbumInfo } from '@immich/sdk';
   import { handleError } from '$lib/utils/handle-error';
   import AutogrowTextarea from '$lib/components/shared-components/autogrow-textarea.svelte';
+  import { t } from 'svelte-i18n';
 
   export let id: string;
   export let description: string;
@@ -16,7 +17,7 @@
         },
       });
     } catch (error) {
-      handleError(error, 'Error updating album description');
+      handleError(error, $t('errors.unable_to_save_album'));
     }
     description = newDescription;
   };
@@ -27,7 +28,7 @@
     content={description}
     class="w-full mt-2 text-black dark:text-white border-b-2 border-transparent border-gray-500 bg-transparent text-base outline-none transition-all focus:border-b-2 focus:border-immich-primary disabled:border-none dark:focus:border-immich-dark-primary hover:border-gray-400"
     onContentUpdate={handleUpdateDescription}
-    placeholder="Add a description"
+    placeholder={$t('add_a_description')}
   />
 {:else if description}
   <p class="break-words whitespace-pre-line w-full text-black dark:text-white text-base">
diff --git a/web/src/lib/components/album-page/album-options.svelte b/web/src/lib/components/album-page/album-options.svelte
index 5815b83859388..8948073138279 100644
--- a/web/src/lib/components/album-page/album-options.svelte
+++ b/web/src/lib/components/album-page/album-options.svelte
@@ -10,6 +10,7 @@
   import type { RenderedOption } from '../elements/dropdown.svelte';
   import { handleError } from '$lib/utils/handle-error';
   import { findKey } from 'lodash-es';
+  import { t } from 'svelte-i18n';
 
   export let album: AlbumResponseDto;
   export let order: AssetOrder | undefined;
@@ -17,8 +18,8 @@
   export let onChangeOrder: (order: AssetOrder) => void;
 
   const options: Record<AssetOrder, RenderedOption> = {
-    [AssetOrder.Asc]: { icon: mdiArrowUpThin, title: 'Oldest first' },
-    [AssetOrder.Desc]: { icon: mdiArrowDownThin, title: 'Newest first' },
+    [AssetOrder.Asc]: { icon: mdiArrowUpThin, title: $t('oldest_first') },
+    [AssetOrder.Desc]: { icon: mdiArrowDownThin, title: $t('newest_first') },
   };
 
   $: selectedOption = order ? options[order] : options[AssetOrder.Desc];
@@ -45,19 +46,19 @@
       });
       onChangeOrder(order);
     } catch (error) {
-      handleError(error, 'Error updating album order');
+      handleError(error, $t('errors.unable_to_save_album'));
     }
   };
 </script>
 
-<FullScreenModal title="Options" onClose={() => dispatch('close')}>
+<FullScreenModal title={$t('options')} onClose={() => dispatch('close')}>
   <div class="items-center justify-center">
     <div class="py-2">
-      <h2 class="text-gray text-sm mb-2">SETTINGS</h2>
+      <h2 class="text-gray text-sm mb-2">{$t('settings').toUpperCase()}</h2>
       <div class="grid p-2 gap-y-2">
         {#if order}
           <SettingDropdown
-            title="Display order"
+            title={$t('display_order')}
             options={Object.values(options)}
             selectedOption={options[order]}
             onToggle={handleToggle}
@@ -65,27 +66,27 @@
         {/if}
         <SettingSwitch
           title="Comments & likes"
-          subtitle="Let others respond"
+          subtitle={$t('let_others_respond')}
           checked={album.isActivityEnabled}
           on:toggle={() => dispatch('toggleEnableActivity')}
         />
       </div>
     </div>
     <div class="py-2">
-      <div class="text-gray text-sm mb-3">PEOPLE</div>
+      <div class="text-gray text-sm mb-3">{$t('people').toUpperCase()}</div>
       <div class="p-2">
         <button type="button" class="flex items-center gap-2" on:click={() => dispatch('showSelectSharedUser')}>
           <div class="rounded-full w-10 h-10 border border-gray-500 flex items-center justify-center">
             <div><Icon path={mdiPlus} size="25" /></div>
           </div>
-          <div>Invite People</div>
+          <div>{$t('invite_people')}</div>
         </button>
         <div class="flex items-center gap-2 py-2 mt-2">
           <div>
             <UserAvatar {user} size="md" />
           </div>
           <div class="w-full">{user.name}</div>
-          <div>Owner</div>
+          <div>{$t('owner')}</div>
         </div>
         {#each album.albumUsers as { user } (user.id)}
           <div class="flex items-center gap-2 py-2">
diff --git a/web/src/lib/components/album-page/album-title.svelte b/web/src/lib/components/album-page/album-title.svelte
index 1d32f3932fee1..44b3e3d1ea6a3 100644
--- a/web/src/lib/components/album-page/album-title.svelte
+++ b/web/src/lib/components/album-page/album-title.svelte
@@ -2,6 +2,7 @@
   import { updateAlbumInfo } from '@immich/sdk';
   import { handleError } from '$lib/utils/handle-error';
   import { shortcut } from '$lib/actions/shortcut';
+  import { t } from 'svelte-i18n';
 
   export let id: string;
   export let albumName: string;
@@ -22,7 +23,7 @@
         },
       });
     } catch (error) {
-      handleError(error, 'Unable to update album name');
+      handleError(error, $t('errors.unable_to_save_album'));
       return;
     }
     albumName = newAlbumName;
@@ -38,6 +39,6 @@
   type="text"
   bind:value={newAlbumName}
   disabled={!isOwned}
-  title="Edit Title"
-  placeholder="Add a title"
+  title={$t('edit_title')}
+  placeholder={$t('add_a_title')}
 />
diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte
index 0be3d66398689..6a4c980fde180 100644
--- a/web/src/lib/components/album-page/album-viewer.svelte
+++ b/web/src/lib/components/album-page/album-viewer.svelte
@@ -18,6 +18,7 @@
   import { mdiFileImagePlusOutline, mdiFolderDownloadOutline } from '@mdi/js';
   import { handlePromiseError } from '$lib/utils';
   import AlbumSummary from './album-summary.svelte';
+  import { t } from 'svelte-i18n';
 
   export let sharedLink: SharedLinkResponseDto;
   export let user: UserResponseDto | undefined = undefined;
@@ -72,14 +73,18 @@
       <svelte:fragment slot="trailing">
         {#if sharedLink.allowUpload}
           <CircleIconButton
-            title="Add Photos"
+            title={$t('add_photos')}
             on:click={() => openFileUploadDialog({ albumId: album.id })}
             icon={mdiFileImagePlusOutline}
           />
         {/if}
 
         {#if album.assetCount > 0 && sharedLink.allowDownload}
-          <CircleIconButton title="Download" on:click={() => downloadAlbum(album)} icon={mdiFolderDownloadOutline} />
+          <CircleIconButton
+            title={$t('download')}
+            on:click={() => downloadAlbum(album)}
+            icon={mdiFolderDownloadOutline}
+          />
         {/if}
 
         <ThemeButton />
diff --git a/web/src/lib/components/album-page/albums-controls.svelte b/web/src/lib/components/album-page/albums-controls.svelte
index ae9c1474c2b00..0657221dd4e79 100644
--- a/web/src/lib/components/album-page/albums-controls.svelte
+++ b/web/src/lib/components/album-page/albums-controls.svelte
@@ -34,6 +34,7 @@
   import GroupTab from '$lib/components/elements/group-tab.svelte';
   import { createAlbumAndRedirect, collapseAllAlbumGroups, expandAllAlbumGroups } from '$lib/utils/album-utils';
   import { fly } from 'svelte/transition';
+  import { t } from 'svelte-i18n';
 
   export let albumGroups: string[];
   export let searchQuery: string;
@@ -100,20 +101,20 @@
 
 <!-- Search Albums -->
 <div class="hidden xl:block h-10 xl:w-60 2xl:w-80">
-  <SearchBar placeholder="Search albums" bind:name={searchQuery} showLoadingSpinner={false} />
+  <SearchBar placeholder={$t('search_albums')} bind:name={searchQuery} showLoadingSpinner={false} />
 </div>
 
 <!-- Create Album -->
 <LinkButton on:click={() => createAlbumAndRedirect()}>
   <div class="flex place-items-center gap-2 text-sm">
     <Icon path={mdiPlusBoxOutline} size="18" />
-    <p class="hidden md:block">Create album</p>
+    <p class="hidden md:block">{$t('create_album')}</p>
   </div>
 </LinkButton>
 
 <!-- Sort Albums -->
 <Dropdown
-  title="Sort albums by..."
+  title={$t('sort_albums_by')}
   options={Object.values(sortOptionsMetadata)}
   selectedOption={selectedSortOption}
   on:select={({ detail }) => handleChangeSortBy(detail)}
@@ -125,7 +126,7 @@
 
 <!-- Group Albums -->
 <Dropdown
-  title="Group albums by..."
+  title={$t('group_albums_by')}
   options={Object.values(groupOptionsMetadata)}
   selectedOption={selectedGroupOption}
   on:select={({ detail }) => handleChangeGroupBy(detail)}
@@ -141,7 +142,7 @@
     <!-- Expand Album Groups -->
     <div class="hidden xl:flex gap-0">
       <div class="block">
-        <LinkButton title="Expand all" on:click={() => expandAllAlbumGroups()}>
+        <LinkButton title={$t('expand_all')} on:click={() => expandAllAlbumGroups()}>
           <div class="flex place-items-center gap-2 text-sm">
             <Icon path={mdiUnfoldMoreHorizontal} size="18" />
           </div>
@@ -150,7 +151,7 @@
 
       <!-- Collapse Album Groups -->
       <div class="block">
-        <LinkButton title="Collapse all" on:click={() => collapseAllAlbumGroups(albumGroups)}>
+        <LinkButton title={$t('collapse_all')} on:click={() => collapseAllAlbumGroups(albumGroups)}>
           <div class="flex place-items-center gap-2 text-sm">
             <Icon path={mdiUnfoldLessHorizontal} size="18" />
           </div>
@@ -165,10 +166,10 @@
   <div class="flex place-items-center gap-2 text-sm">
     {#if $albumViewSettings.view === AlbumViewMode.List}
       <Icon path={mdiViewGridOutline} size="18" />
-      <p class="hidden md:block">Covers</p>
+      <p class="hidden md:block">{$t('covers')}</p>
     {:else}
       <Icon path={mdiFormatListBulletedSquare} size="18" />
-      <p class="hidden md:block">List</p>
+      <p class="hidden md:block">{$t('list')}</p>
     {/if}
   </div>
 </LinkButton>
diff --git a/web/src/lib/components/album-page/albums-list.svelte b/web/src/lib/components/album-page/albums-list.svelte
index 0a90ca525b4bd..14a2a69767b7e 100644
--- a/web/src/lib/components/album-page/albums-list.svelte
+++ b/web/src/lib/components/album-page/albums-list.svelte
@@ -33,6 +33,7 @@
   import { goto } from '$app/navigation';
   import { AppRoute } from '$lib/constants';
   import { dialogController } from '$lib/components/shared-components/dialog/dialog';
+  import { t } from 'svelte-i18n';
 
   export let ownedAlbums: AlbumResponseDto[] = [];
   export let sharedAlbums: AlbumResponseDto[] = [];
@@ -55,8 +56,8 @@
     [AlbumGroupBy.None]: (order, albums): AlbumGroup[] => {
       return [
         {
-          id: 'Albums',
-          name: 'Albums',
+          id: $t('albums'),
+          name: $t('albums'),
           albums,
         },
       ];
@@ -64,7 +65,7 @@
 
     /** Group by year */
     [AlbumGroupBy.Year]: (order, albums): AlbumGroup[] => {
-      const unknownYear = 'Unknown Year';
+      const unknownYear = $t('unknown_year');
       const useStartDate = userSettings.sortBy === AlbumSortBy.OldestPhoto;
 
       const groupedByYear = groupBy(albums, (album) => {
@@ -111,7 +112,7 @@
 
       return sortedByOwnerNames.map(([ownerId, albums]) => ({
         id: ownerId,
-        name: ownerId === currentUserId ? 'My albums' : albums[0].owner.name,
+        name: ownerId === currentUserId ? $t('my_albums') : albums[0].owner.name,
         albums,
       }));
     },
@@ -314,7 +315,7 @@
       await handleDeleteAlbum(albumToDelete);
     } catch {
       notificationController.show({
-        message: 'Error deleting album',
+        message: $t('errors.errors.unable_to_delete_album'),
         type: NotificationType.Error,
       });
     } finally {
@@ -336,7 +337,7 @@
     albumToEdit = null;
 
     notificationController.show({
-      message: 'Album info updated',
+      message: $t('album_info_updated'),
       type: NotificationType.Info,
       button: {
         text: 'View Album',
@@ -362,7 +363,7 @@
       });
       updateAlbumInfo(album);
     } catch (error) {
-      handleError(error, 'Error adding users to album');
+      handleError(error, $t('errors.unable_to_add_album_users'));
     } finally {
       albumToShare = null;
     }
diff --git a/web/src/lib/components/album-page/albums-table-row.svelte b/web/src/lib/components/album-page/albums-table-row.svelte
index f10ca8acb320b..b85b9d1229508 100644
--- a/web/src/lib/components/album-page/albums-table-row.svelte
+++ b/web/src/lib/components/album-page/albums-table-row.svelte
@@ -7,6 +7,7 @@
   import { locale } from '$lib/stores/preferences.store';
   import { mdiShareVariantOutline } from '@mdi/js';
   import Icon from '$lib/components/elements/icon.svelte';
+  import { t } from 'svelte-i18n';
 
   export let album: AlbumResponseDto;
   export let onShowContextMenu: ((position: ContextMenuPosition, album: AlbumResponseDto) => unknown) | undefined =
@@ -33,7 +34,7 @@
         path={mdiShareVariantOutline}
         size="16"
         class="inline ml-1 opacity-70"
-        title={album.ownerId === $user.id ? 'Shared by you' : `Shared by ${album.owner.name}`}
+        title={album.ownerId === $user.id ? $t('shared_by_you') : `Shared by ${album.owner.name}`}
       />
     {/if}
   </td>
diff --git a/web/src/lib/components/album-page/share-info-modal.svelte b/web/src/lib/components/album-page/share-info-modal.svelte
index be47f4e2580a9..c395053c70fc0 100644
--- a/web/src/lib/components/album-page/share-info-modal.svelte
+++ b/web/src/lib/components/album-page/share-info-modal.svelte
@@ -18,6 +18,7 @@
   import { NotificationType, notificationController } from '../shared-components/notification/notification';
   import UserAvatar from '../shared-components/user-avatar.svelte';
   import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
+  import { t } from 'svelte-i18n';
 
   export let album: AlbumResponseDto;
   export let onClose: () => void;
@@ -38,7 +39,7 @@
     try {
       currentUser = await getMyUser();
     } catch (error) {
-      handleError(error, 'Unable to refresh user');
+      handleError(error, $t('errors.unable_to_refresh_user'));
     }
   });
 
@@ -66,7 +67,7 @@
       const message = userId === 'me' ? `Left ${album.albumName}` : `Removed ${selectedRemoveUser.name}`;
       notificationController.show({ type: NotificationType.Info, message });
     } catch (error) {
-      handleError(error, 'Unable to remove user');
+      handleError(error, $t('errors.unable_to_remove_album_users'));
     } finally {
       selectedRemoveUser = null;
     }
@@ -79,7 +80,7 @@
       dispatch('refreshAlbum');
       notificationController.show({ type: NotificationType.Info, message });
     } catch (error) {
-      handleError(error, 'Unable to set user role');
+      handleError(error, $t('errors.unable_to_change_album_user_role'));
     } finally {
       selectedRemoveUser = null;
     }
@@ -87,7 +88,7 @@
 </script>
 
 {#if !selectedRemoveUser}
-  <FullScreenModal title="Options" {onClose}>
+  <FullScreenModal title={$t('options')} {onClose}>
     <section class="immich-scrollbar max-h-[400px] overflow-y-auto pb-4">
       <div class="flex w-full place-items-center justify-between gap-4 p-5">
         <div class="flex place-items-center gap-4">
@@ -96,7 +97,7 @@
         </div>
 
         <div id="icon-{album.owner.id}" class="flex place-items-center">
-          <p class="text-sm">Owner</p>
+          <p class="text-sm">{$t('owner')}</p>
         </div>
       </div>
       {#each album.albumUsers as { user, role }}
@@ -119,7 +120,7 @@
             {#if isOwned}
               <div>
                 <CircleIconButton
-                  title="Options"
+                  title={$t('options')}
                   on:click={(event) => showContextMenu(event, user)}
                   icon={mdiDotsVertical}
                   size="20"
@@ -128,14 +129,17 @@
                 {#if selectedMenuUser === user}
                   <ContextMenu {...position} onClose={() => (selectedMenuUser = null)}>
                     {#if role === AlbumUserRole.Viewer}
-                      <MenuOption on:click={() => handleSetReadonly(user, AlbumUserRole.Editor)} text="Allow edits" />
+                      <MenuOption
+                        on:click={() => handleSetReadonly(user, AlbumUserRole.Editor)}
+                        text={$t('allow_edits')}
+                      />
                     {:else}
                       <MenuOption
                         on:click={() => handleSetReadonly(user, AlbumUserRole.Viewer)}
-                        text="Disallow edits"
+                        text={$t('disallow_edits')}
                       />
                     {/if}
-                    <MenuOption on:click={handleMenuRemove} text="Remove" />
+                    <MenuOption on:click={handleMenuRemove} text={$t('remove')} />
                   </ContextMenu>
                 {/if}
               </div>
@@ -144,7 +148,7 @@
                 type="button"
                 on:click={() => (selectedRemoveUser = user)}
                 class="text-sm font-medium text-immich-primary transition-colors hover:text-immich-primary/75 dark:text-immich-dark-primary"
-                >Leave</button
+                >{$t('leave')}</button
               >
             {/if}
           </div>
@@ -158,7 +162,7 @@
   <ConfirmDialog
     title="Leave album?"
     prompt="Are you sure you want to leave {album.albumName}?"
-    confirmText="Leave"
+    confirmText={$t('leave')}
     onConfirm={handleRemoveUser}
     onCancel={() => (selectedRemoveUser = null)}
   />
@@ -168,7 +172,7 @@
   <ConfirmDialog
     title="Remove user?"
     prompt="Are you sure you want to remove {selectedRemoveUser.name}?"
-    confirmText="Remove"
+    confirmText={$t('remove')}
     onConfirm={handleRemoveUser}
     onCancel={() => (selectedRemoveUser = null)}
   />
diff --git a/web/src/lib/components/album-page/thumbnail-selection.svelte b/web/src/lib/components/album-page/thumbnail-selection.svelte
index e558592b29c89..9f964c4ada537 100644
--- a/web/src/lib/components/album-page/thumbnail-selection.svelte
+++ b/web/src/lib/components/album-page/thumbnail-selection.svelte
@@ -6,6 +6,7 @@
   import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
   import Button from '../elements/buttons/button.svelte';
   import ControlAppBar from '../shared-components/control-app-bar.svelte';
+  import { t } from 'svelte-i18n';
 
   export let album: AlbumResponseDto;
 
@@ -26,7 +27,7 @@
 >
   <ControlAppBar on:close={() => dispatch('close')}>
     <svelte:fragment slot="leading">
-      <p class="text-lg">Select album cover</p>
+      <p class="text-lg">{$t('select_album_cover')}</p>
     </svelte:fragment>
 
     <svelte:fragment slot="trailing">
diff --git a/web/src/lib/components/album-page/user-selection-modal.svelte b/web/src/lib/components/album-page/user-selection-modal.svelte
index d7d742c2390a1..1e8dd56d9cd1e 100644
--- a/web/src/lib/components/album-page/user-selection-modal.svelte
+++ b/web/src/lib/components/album-page/user-selection-modal.svelte
@@ -16,6 +16,7 @@
   import { createEventDispatcher, onMount } from 'svelte';
   import Button from '../elements/buttons/button.svelte';
   import UserAvatar from '../shared-components/user-avatar.svelte';
+  import { t } from 'svelte-i18n';
 
   export let album: AlbumResponseDto;
   export let onClose: () => void;
@@ -23,9 +24,9 @@
   let selectedUsers: Record<string, { user: UserResponseDto; role: AlbumUserRole }> = {};
 
   const roleOptions: Array<{ title: string; value: AlbumUserRole | 'none'; icon?: string }> = [
-    { title: 'Editor', value: AlbumUserRole.Editor, icon: mdiPencil },
-    { title: 'Viewer', value: AlbumUserRole.Viewer, icon: mdiEye },
-    { title: 'Remove', value: 'none' },
+    { title: $t('editor'), value: AlbumUserRole.Editor, icon: mdiPencil },
+    { title: $t('viewer'), value: AlbumUserRole.Viewer, icon: mdiEye },
+    { title: $t('remove'), value: 'none' },
   ];
 
   const dispatch = createEventDispatcher<{
@@ -70,10 +71,10 @@
   };
 </script>
 
-<FullScreenModal title="Invite to album" showLogo {onClose}>
+<FullScreenModal title={$t('invite_to_album')} showLogo {onClose}>
   {#if Object.keys(selectedUsers).length > 0}
     <div class="mb-2 py-2 sticky">
-      <p class="text-xs font-medium">SELECTED</p>
+      <p class="text-xs font-medium">{$t('selected').toUpperCase()}</p>
       <div class="my-2">
         {#each Object.values(selectedUsers) as { user }}
           {#key user.id}
@@ -95,7 +96,7 @@
               </div>
 
               <Dropdown
-                title="Role"
+                title={$t('role')}
                 options={roleOptions}
                 render={({ title, icon }) => ({ title, icon })}
                 on:select={({ detail: { value } }) => handleChangeRole(user, value)}
@@ -115,7 +116,7 @@
 
   <div class="immich-scrollbar max-h-[500px] overflow-y-auto">
     {#if users.length > 0 && users.length !== Object.keys(selectedUsers).length}
-      <p class="text-xs font-medium">SUGGESTIONS</p>
+      <p class="text-xs font-medium">{$t('suggestions').toUpperCase()}</p>
 
       <div class="my-2">
         {#each users as user}
@@ -154,7 +155,7 @@
           dispatch(
             'select',
             Object.values(selectedUsers).map(({ user, ...rest }) => ({ userId: user.id, ...rest })),
-          )}>Add</Button
+          )}>{$t('add')}</Button
       >
     </div>
   {/if}
@@ -168,7 +169,7 @@
       on:click={() => dispatch('share')}
     >
       <Icon path={mdiLink} size={24} />
-      <p class="text-sm">Create link</p>
+      <p class="text-sm">{$t('create_link')}</p>
     </button>
 
     {#if sharedLinks.length}
@@ -177,7 +178,7 @@
         class="flex flex-col place-content-center place-items-center gap-2 hover:cursor-pointer"
       >
         <Icon path={mdiShareCircle} size={24} />
-        <p class="text-sm">View links</p>
+        <p class="text-sm">{$t('view_links')}</p>
       </a>
     {/if}
   </div>
diff --git a/web/src/lib/components/asset-viewer/activity-status.svelte b/web/src/lib/components/asset-viewer/activity-status.svelte
index 099ba40a0f517..c09dbc53a44eb 100644
--- a/web/src/lib/components/asset-viewer/activity-status.svelte
+++ b/web/src/lib/components/asset-viewer/activity-status.svelte
@@ -3,6 +3,7 @@
   import { mdiCommentOutline, mdiHeart, mdiHeartOutline } from '@mdi/js';
   import { createEventDispatcher } from 'svelte';
   import Icon from '../elements/icon.svelte';
+  import { t } from 'svelte-i18n';
 
   export let isLiked: ActivityResponseDto | null;
   export let numberOfComments: number | undefined;
@@ -29,7 +30,7 @@
       {#if numberOfComments}
         <div class="text-xl">{numberOfComments}</div>
       {:else if !isShowActivity}
-        <div class="text-lg">Say something</div>
+        <div class="text-lg">{$t('say_something')}</div>
       {/if}
     </div>
   </button>
diff --git a/web/src/lib/components/asset-viewer/activity-viewer.svelte b/web/src/lib/components/asset-viewer/activity-viewer.svelte
index bb1a50c2f9ec1..9072d2b605010 100644
--- a/web/src/lib/components/asset-viewer/activity-viewer.svelte
+++ b/web/src/lib/components/asset-viewer/activity-viewer.svelte
@@ -25,6 +25,7 @@
   import UserAvatar from '../shared-components/user-avatar.svelte';
   import { locale } from '$lib/stores/preferences.store';
   import { shortcut } from '$lib/actions/shortcut';
+  import { t } from 'svelte-i18n';
 
   const units: Intl.RelativeTimeFormatUnit[] = ['year', 'month', 'week', 'day', 'hour', 'minute', 'second'];
 
@@ -91,7 +92,7 @@
     try {
       reactions = await getActivities({ assetId, albumId });
     } catch (error) {
-      handleError(error, 'Error when fetching reactions');
+      handleError(error, $t('errors.unable_to_load_asset_activity'));
     }
   };
 
@@ -120,7 +121,7 @@
         type: NotificationType.Info,
       });
     } catch (error) {
-      handleError(error, `Can't remove ${reaction.type}`);
+      handleError(error, $t('errors.unable_to_remove_reaction'));
     }
   };
 
@@ -140,7 +141,7 @@
       // Re-render the activity feed
       reactions = reactions;
     } catch (error) {
-      handleError(error, "Can't add your comment");
+      handleError(error, $t('errors.unable_to_add_comment'));
     } finally {
       clearTimeout(timeout);
     }
@@ -159,9 +160,9 @@
       bind:clientHeight={activityHeight}
     >
       <div class="flex place-items-center gap-2">
-        <CircleIconButton on:click={() => dispatch('close')} icon={mdiClose} title="Close" />
+        <CircleIconButton on:click={() => dispatch('close')} icon={mdiClose} title={$t('close')} />
 
-        <p class="text-lg text-immich-fg dark:text-immich-dark-fg">Activity</p>
+        <p class="text-lg text-immich-fg dark:text-immich-dark-fg">{$t('activity')}</p>
       </div>
     </div>
     {#if innerHeight}
@@ -190,7 +191,7 @@
                 <div class="flex items-start w-fit pt-[5px]">
                   <CircleIconButton
                     icon={mdiDotsVertical}
-                    title="Comment options"
+                    title={$t('comment_options')}
                     size="16"
                     on:click={() => (showDeleteReaction[index] ? '' : showOptionsMenu(index))}
                   />
@@ -242,7 +243,7 @@
                   <div class="flex items-start w-fit">
                     <CircleIconButton
                       icon={mdiDotsVertical}
-                      title="Reaction options"
+                      title={$t('reaction_options')}
                       size="16"
                       on:click={() => (showDeleteReaction[index] ? '' : showOptionsMenu(index))}
                     />
@@ -289,7 +290,7 @@
               bind:this={textArea}
               bind:value={message}
               use:autoGrowHeight={'5px'}
-              placeholder={disabled ? 'Comments are disabled' : 'Say something'}
+              placeholder={disabled ? $t('comments_are_disabled') : $t('say_something')}
               on:input={() => autoGrowHeight(textArea, '5px')}
               use:shortcut={{
                 shortcut: { key: 'Enter' },
@@ -308,7 +309,12 @@
             </div>
           {:else if message}
             <div class="flex items-end w-fit ml-0">
-              <CircleIconButton title="Send message" size="15" icon={mdiSend} class="dark:text-immich-dark-gray" />
+              <CircleIconButton
+                title={$t('send_message')}
+                size="15"
+                icon={mdiSend}
+                class="dark:text-immich-dark-gray"
+              />
             </div>
           {/if}
         </form>
diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte
index 4d2b397e5183a..f2e8f9d67be26 100644
--- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte
+++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte
@@ -38,6 +38,7 @@
   import { createEventDispatcher } from 'svelte';
   import ContextMenu from '../shared-components/context-menu/context-menu.svelte';
   import MenuOption from '../shared-components/context-menu/menu-option.svelte';
+  import { t } from 'svelte-i18n';
 
   export let asset: AssetResponseDto;
   export let album: AlbumResponseDto | null = null;
@@ -107,7 +108,7 @@
   class="z-[1001] flex h-16 place-items-center justify-between bg-gradient-to-b from-black/40 px-3 transition-transform duration-200"
 >
   <div class="text-white">
-    <CircleIconButton color="opaque" icon={mdiArrowLeft} title="Go back" on:click={() => dispatch('back')} />
+    <CircleIconButton color="opaque" icon={mdiArrowLeft} title={$t('go_back')} on:click={() => dispatch('back')} />
   </div>
   <div
     class="flex w-[calc(100%-3rem)] justify-end gap-2 overflow-hidden text-white"
@@ -118,7 +119,7 @@
         color="opaque"
         icon={mdiShareVariantOutline}
         on:click={() => dispatch('showShareModal')}
-        title="Share"
+        title={$t('share')}
       />
     {/if}
     {#if asset.isOffline}
@@ -126,7 +127,7 @@
         color="opaque"
         icon={mdiAlertOutline}
         on:click={() => dispatch('showDetail')}
-        title="Asset Offline"
+        title={$t('asset_offline')}
       />
     {/if}
     {#if showMotionPlayButton}
@@ -134,14 +135,14 @@
         <CircleIconButton
           color="opaque"
           icon={mdiMotionPauseOutline}
-          title="Stop Motion Photo"
+          title={$t('stop_motion_photo')}
           on:click={() => dispatch('stopMotionPhoto')}
         />
       {:else}
         <CircleIconButton
           color="opaque"
           icon={mdiPlaySpeed}
-          title="Play Motion Photo"
+          title={$t('play_motion_photo')}
           on:click={() => dispatch('playMotionPhoto')}
         />
       {/if}
@@ -151,7 +152,7 @@
         color="opaque"
         hideMobile={true}
         icon={$photoZoomState && $photoZoomState.currentZoom > 1 ? mdiMagnifyMinusOutline : mdiMagnifyPlusOutline}
-        title="Zoom Image"
+        title={$t('zoom_image')}
         on:click={() => {
           const zoomImage = new CustomEvent('zoomImage');
           window.dispatchEvent(zoomImage);
@@ -162,7 +163,7 @@
       <CircleIconButton
         color="opaque"
         icon={mdiContentCopy}
-        title="Copy Image"
+        title={$t('copy_image')}
         on:click={() => {
           const copyEvent = new CustomEvent('copyImage');
           window.dispatchEvent(copyEvent);
@@ -175,7 +176,7 @@
         color="opaque"
         icon={mdiFolderDownloadOutline}
         on:click={() => dispatch('download')}
-        title="Download"
+        title={$t('download')}
       />
     {/if}
 
@@ -184,7 +185,7 @@
         color="opaque"
         icon={mdiInformationOutline}
         on:click={() => dispatch('showDetail')}
-        title="Info"
+        title={$t('info')}
       />
     {/if}
 
@@ -193,45 +194,58 @@
         color="opaque"
         icon={asset.isFavorite ? mdiHeart : mdiHeartOutline}
         on:click={() => dispatch('favorite')}
-        title={asset.isFavorite ? 'Unfavorite' : 'Favorite'}
+        title={asset.isFavorite ? $t('unfavorite') : $t('favorite')}
       />
     {/if}
 
     {#if isOwner}
-      <CircleIconButton color="opaque" icon={mdiDeleteOutline} on:click={() => dispatch('delete')} title="Delete" />
+      <CircleIconButton
+        color="opaque"
+        icon={mdiDeleteOutline}
+        on:click={() => dispatch('delete')}
+        title={$t('delete')}
+      />
       <div
         use:clickOutside={{
           onOutclick: () => (isShowAssetOptions = false),
           onEscape: () => (isShowAssetOptions = false),
         }}
       >
-        <CircleIconButton color="opaque" icon={mdiDotsVertical} on:click={showOptionsMenu} title="More" />
+        <CircleIconButton color="opaque" icon={mdiDotsVertical} on:click={showOptionsMenu} title={$t('more')} />
         {#if isShowAssetOptions}
           <ContextMenu {...contextMenuPosition} direction="left">
             {#if showSlideshow}
-              <MenuOption icon={mdiPresentationPlay} on:click={() => onMenuClick('playSlideShow')} text="Slideshow" />
+              <MenuOption
+                icon={mdiPresentationPlay}
+                on:click={() => onMenuClick('playSlideShow')}
+                text={$t('slideshow')}
+              />
             {/if}
             {#if showDownloadButton}
-              <MenuOption icon={mdiFolderDownloadOutline} on:click={() => onMenuClick('download')} text="Download" />
+              <MenuOption
+                icon={mdiFolderDownloadOutline}
+                on:click={() => onMenuClick('download')}
+                text={$t('download')}
+              />
             {/if}
             {#if asset.isTrashed}
-              <MenuOption icon={mdiHistory} on:click={() => onMenuClick('restoreAsset')} text="Restore" />
+              <MenuOption icon={mdiHistory} on:click={() => onMenuClick('restoreAsset')} text={$t('restore')} />
             {:else}
-              <MenuOption icon={mdiImageAlbum} on:click={() => onMenuClick('addToAlbum')} text="Add to album" />
+              <MenuOption icon={mdiImageAlbum} on:click={() => onMenuClick('addToAlbum')} text={$t('add_to_album')} />
               <MenuOption
                 icon={mdiShareVariantOutline}
                 on:click={() => onMenuClick('addToSharedAlbum')}
-                text="Add to shared album"
+                text={$t('add_to_shared_album')}
               />
             {/if}
 
             {#if isOwner}
               {#if hasStackChildren}
-                <MenuOption icon={mdiImageMinusOutline} on:click={() => onMenuClick('unstack')} text="Un-stack" />
+                <MenuOption icon={mdiImageMinusOutline} on:click={() => onMenuClick('unstack')} text={$t('unstack')} />
               {/if}
               {#if album}
                 <MenuOption
-                  text="Set as album cover"
+                  text={$t('set_as_album_cover')}
                   icon={mdiImageOutline}
                   on:click={() => onMenuClick('setAsAlbumCover')}
                 />
@@ -240,18 +254,18 @@
                 <MenuOption
                   icon={mdiAccountCircleOutline}
                   on:click={() => onMenuClick('asProfileImage')}
-                  text="Set as profile picture"
+                  text={$t('set_as_profile_picture')}
                 />
               {/if}
               <MenuOption
                 on:click={() => dispatch('toggleArchive')}
                 icon={asset.isArchived ? mdiArchiveArrowUpOutline : mdiArchiveArrowDownOutline}
-                text={asset.isArchived ? 'Unarchive' : 'Archive'}
+                text={asset.isArchived ? $t('unarchive') : $t('archive')}
               />
               <MenuOption
                 icon={mdiUpload}
                 on:click={() => openFileUploadDialog({ multiple: false, assetId: asset.id })}
-                text="Replace with upload"
+                text={$t('replace_with_upload')}
               />
               <hr />
               <MenuOption
diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte
index ad474f8f8d459..e309d0dbd0214 100644
--- a/web/src/lib/components/asset-viewer/asset-viewer.svelte
+++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte
@@ -53,6 +53,7 @@
   import VideoViewer from './video-wrapper-viewer.svelte';
   import { navigate } from '$lib/utils/navigation';
   import { websocketEvents } from '$lib/stores/websocket';
+  import { t } from 'svelte-i18n';
 
   export let assetStore: AssetStore | null = null;
   export let asset: AssetResponseDto;
@@ -169,7 +170,7 @@
         });
         isLiked = data.length > 0 ? data[0] : null;
       } catch (error) {
-        handleError(error, "Can't get Favorite");
+        handleError(error, $t('errors.unable_to_load_liked_status'));
       }
     }
   };
@@ -352,11 +353,11 @@
       dispatch('action', { type: AssetAction.TRASH, asset });
 
       notificationController.show({
-        message: 'Moved to trash',
+        message: $t('moved_to_trash'),
         type: NotificationType.Info,
       });
     } catch (error) {
-      handleError(error, 'Unable to trash asset');
+      handleError(error, $t('errors.unable_to_trash_asset'));
     }
   };
 
@@ -367,11 +368,11 @@
       dispatch('action', { type: AssetAction.DELETE, asset });
 
       notificationController.show({
-        message: 'Permanently deleted asset',
+        message: $t('permanently_deleted_asset'),
         type: NotificationType.Info,
       });
     } catch (error) {
-      handleError(error, 'Unable to delete asset');
+      handleError(error, $t('errors.unable_to_delete_asset'));
     } finally {
       isShowDeleteConfirmation = false;
     }
@@ -428,7 +429,7 @@
         message: `Restored asset`,
       });
     } catch (error) {
-      handleError(error, 'Error restoring asset');
+      handleError(error, $t('errors.unable_to_restore_assets'));
     }
   };
 
@@ -483,7 +484,7 @@
     try {
       await assetViewerHtmlElement.requestFullscreen();
     } catch (error) {
-      console.error('Error entering fullscreen', error);
+      handleError(error, $t('errors.unable_to_enter_fullscreen'));
       $slideshowState = SlideshowState.StopSlideshow;
     }
   };
@@ -495,7 +496,7 @@
         await document.exitFullscreen();
       }
     } catch (error) {
-      console.error('Error exiting fullscreen', error);
+      handleError(error, $t('errors.unable_to_exit_fullscreen'));
     } finally {
       $stopSlideshowProgress = true;
       $slideshowState = SlideshowState.None;
@@ -534,7 +535,7 @@
       });
       notificationController.show({
         type: NotificationType.Info,
-        message: 'Album cover updated',
+        message: $t('album_cover_updated'),
         timeout: 1500,
       });
     } catch (error) {
@@ -606,7 +607,7 @@
 
     {#if $slideshowState === SlideshowState.None && showNavigation}
       <div class="z-[1001] my-auto column-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
-        <NavigationArea onClick={(e) => navigateAsset('previous', e)} label="View previous asset">
+        <NavigationArea onClick={(e) => navigateAsset('previous', e)} label={$t('view_previous_asset')}>
           <Icon path={mdiChevronLeft} size="36" ariaHidden />
         </NavigationArea>
       </div>
@@ -703,7 +704,7 @@
 
     {#if $slideshowState === SlideshowState.None && showNavigation}
       <div class="z-[1001] my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end">
-        <NavigationArea onClick={(e) => navigateAsset('next', e)} label="View next asset">
+        <NavigationArea onClick={(e) => navigateAsset('next', e)} label={$t('view_next_asset')}>
           <Icon path={mdiChevronRight} size="36" ariaHidden />
         </NavigationArea>
       </div>
diff --git a/web/src/lib/components/asset-viewer/detail-panel-description.svelte b/web/src/lib/components/asset-viewer/detail-panel-description.svelte
index dae49c469cab3..5a5bd61439b5f 100644
--- a/web/src/lib/components/asset-viewer/detail-panel-description.svelte
+++ b/web/src/lib/components/asset-viewer/detail-panel-description.svelte
@@ -6,6 +6,7 @@
   import { handleError } from '$lib/utils/handle-error';
   import { updateAsset, type AssetResponseDto } from '@immich/sdk';
   import AutogrowTextarea from '$lib/components/shared-components/autogrow-textarea.svelte';
+  import { t } from 'svelte-i18n';
 
   export let asset: AssetResponseDto;
   export let isOwner: boolean;
@@ -20,7 +21,7 @@
         message: 'Asset description has been updated',
       });
     } catch (error) {
-      handleError(error, 'Cannot update the description');
+      handleError(error, $t('cannot_update_the_description'));
     }
     description = newDescription;
   };
@@ -32,7 +33,7 @@
       content={description}
       class="max-h-[500px] w-full border-b border-gray-500 bg-transparent text-base text-black outline-none transition-all focus:border-b-2 focus:border-immich-primary disabled:border-none dark:text-white dark:focus:border-immich-dark-primary immich-scrollbar"
       onContentUpdate={handleFocusOut}
-      placeholder="Add a description"
+      placeholder={$t('add_a_description')}
     />
   </section>
 {:else if description}
diff --git a/web/src/lib/components/asset-viewer/detail-panel-location.svelte b/web/src/lib/components/asset-viewer/detail-panel-location.svelte
index faff48093434e..81ed06be0cc2b 100644
--- a/web/src/lib/components/asset-viewer/detail-panel-location.svelte
+++ b/web/src/lib/components/asset-viewer/detail-panel-location.svelte
@@ -5,6 +5,7 @@
   import { handleError } from '$lib/utils/handle-error';
   import { updateAsset, type AssetResponseDto } from '@immich/sdk';
   import { mdiMapMarkerOutline, mdiPencil } from '@mdi/js';
+  import { t } from 'svelte-i18n';
 
   export let isOwner: boolean;
   export let asset: AssetResponseDto;
@@ -20,7 +21,7 @@
         updateAssetDto: { latitude: gps.lat, longitude: gps.lng },
       });
     } catch (error) {
-      handleError(error, 'Unable to change location');
+      handleError(error, $t('errors.unable_to_change_location'));
     }
   }
 </script>
@@ -30,7 +31,7 @@
     type="button"
     class="flex w-full text-left justify-between place-items-start gap-4 py-4"
     on:click={() => (isOwner ? (isShowChangeLocation = true) : null)}
-    title={isOwner ? 'Edit location' : ''}
+    title={isOwner ? $t('edit_location') : ''}
     class:hover:dark:text-immich-dark-primary={isOwner}
     class:hover:text-immich-primary={isOwner}
   >
@@ -63,12 +64,12 @@
     type="button"
     class="flex w-full text-left justify-between place-items-start gap-4 py-4 rounded-lg hover:dark:text-immich-dark-primary hover:text-immich-primary"
     on:click={() => (isShowChangeLocation = true)}
-    title="Add location"
+    title={$t('add_location')}
   >
     <div class="flex gap-4">
       <div><Icon path={mdiMapMarkerOutline} size="24" /></div>
 
-      <p>Add a location</p>
+      <p>{$t('add_a_location')}</p>
     </div>
     <div class="focus:outline-none p-1">
       <Icon path={mdiPencil} size="20" />
diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte
index 9a2ad95863d6f..60ed02859b984 100644
--- a/web/src/lib/components/asset-viewer/detail-panel.svelte
+++ b/web/src/lib/components/asset-viewer/detail-panel.svelte
@@ -40,6 +40,7 @@
   import LoadingSpinner from '../shared-components/loading-spinner.svelte';
   import AlbumListItemDetails from './album-list-item-details.svelte';
   import DetailPanelDescription from '$lib/components/asset-viewer/detail-panel-description.svelte';
+  import { t } from 'svelte-i18n';
 
   export let asset: AssetResponseDto;
   export let albums: AlbumResponseDto[] = [];
@@ -130,21 +131,21 @@
     try {
       await updateAsset({ id: asset.id, updateAssetDto: { dateTimeOriginal } });
     } catch (error) {
-      handleError(error, 'Unable to change date');
+      handleError(error, $t('errors.unable_to_change_date'));
     }
   }
 </script>
 
 <section class="relative p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
   <div class="flex place-items-center gap-2">
-    <CircleIconButton icon={mdiClose} title="Close" on:click={() => dispatch('close')} />
-    <p class="text-lg text-immich-fg dark:text-immich-dark-fg">Info</p>
+    <CircleIconButton icon={mdiClose} title={$t('close')} on:click={() => dispatch('close')} />
+    <p class="text-lg text-immich-fg dark:text-immich-dark-fg">{$t('info')}</p>
   </div>
 
   {#if asset.isOffline}
     <section class="px-4 py-4">
       <div role="alert">
-        <div class="rounded-t bg-red-500 px-4 py-2 font-bold text-white">Asset offline</div>
+        <div class="rounded-t bg-red-500 px-4 py-2 font-bold text-white">{$t('asset_offline')}</div>
         <div class="rounded-b border border-t-0 border-red-400 bg-red-100 px-4 py-3 text-red-700">
           <p>
             This asset is offline. Immich can not access its file location. Please ensure the asset is available and
@@ -160,11 +161,11 @@
   {#if !isSharedLink() && people.length > 0}
     <section class="px-4 py-4 text-sm">
       <div class="flex h-10 w-full items-center justify-between">
-        <h2>PEOPLE</h2>
+        <h2>{$t('people').toUpperCase()}</h2>
         <div class="flex gap-2 items-center">
           {#if people.some((person) => person.isHidden)}
             <CircleIconButton
-              title="Show hidden people"
+              title={$t('show_hidden_people')}
               icon={showingHiddenPeople ? mdiEyeOff : mdiEye}
               padding="1"
               buttonSize="32"
@@ -172,7 +173,7 @@
             />
           {/if}
           <CircleIconButton
-            title="Edit people"
+            title={$t('edit_people')}
             icon={mdiPencil}
             padding="1"
             size="20"
@@ -247,10 +248,10 @@
   <div class="px-4 py-4">
     {#if asset.exifInfo}
       <div class="flex h-10 w-full items-center justify-between text-sm">
-        <h2>DETAILS</h2>
+        <h2>{$t('details').toUpperCase()}</h2>
       </div>
     {:else}
-      <p class="text-sm">NO EXIF INFO AVAILABLE</p>
+      <p class="text-sm">{$t('no_exif_info_available').toUpperCase()}</p>
     {/if}
 
     {#if asset.exifInfo?.dateTimeOriginal}
@@ -261,7 +262,7 @@
         type="button"
         class="flex w-full text-left justify-between place-items-start gap-4 py-4"
         on:click={() => (isOwner ? (isShowChangeDate = true) : null)}
-        title={isOwner ? 'Edit date' : ''}
+        title={isOwner ? $t('edit_date') : ''}
         class:hover:dark:text-immich-dark-primary={isOwner}
         class:hover:text-immich-primary={isOwner}
       >
@@ -340,7 +341,7 @@
             {#if isOwner}
               <CircleIconButton
                 icon={mdiInformationOutline}
-                title="Show file location"
+                title={$t('show_file_location')}
                 size="16"
                 padding="2"
                 on:click={toggleAssetPath}
@@ -448,7 +449,7 @@
 
 {#if currentAlbum && currentAlbum.albumUsers.length > 0 && asset.owner}
   <section class="px-6 dark:text-immich-dark-fg mt-4">
-    <p class="text-sm">SHARED BY</p>
+    <p class="text-sm">{$t('shared_by').toUpperCase()}</p>
     <div class="flex gap-4 pt-4">
       <div>
         <UserAvatar user={asset.owner} size="md" />
@@ -465,7 +466,7 @@
 
 {#if albums.length > 0}
   <section class="p-6 dark:text-immich-dark-fg">
-    <p class="pb-4 text-sm">APPEARS IN</p>
+    <p class="pb-4 text-sm">{$t('appears_in').toUpperCase()}</p>
     {#each albums as album}
       <a data-sveltekit-preload-data="hover" href={`/albums/${album.id}`}>
         <div class="flex gap-4 py-2 hover:cursor-pointer items-center">
diff --git a/web/src/lib/components/asset-viewer/download-panel.svelte b/web/src/lib/components/asset-viewer/download-panel.svelte
index 94eda148607cf..13f3b89219370 100644
--- a/web/src/lib/components/asset-viewer/download-panel.svelte
+++ b/web/src/lib/components/asset-viewer/download-panel.svelte
@@ -5,6 +5,7 @@
   import { asByteUnitString } from '../../utils/byte-units';
   import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
   import { mdiClose } from '@mdi/js';
+  import { t } from 'svelte-i18n';
 
   const abort = (downloadKey: string, download: DownloadProgress) => {
     download.abort?.abort();
@@ -17,7 +18,7 @@
     transition:fly={{ x: -100, duration: 350 }}
     class="absolute bottom-10 left-2 z-[10000] max-h-[270px] w-[315px] rounded-2xl border bg-immich-bg p-4 text-sm shadow-sm"
   >
-    <p class="mb-2 text-xs text-gray-500">DOWNLOADING</p>
+    <p class="mb-2 text-xs text-gray-500">{$t('downloading').toUpperCase()}</p>
     <div class="my-2 mb-2 flex max-h-[200px] flex-col overflow-y-auto text-sm">
       {#each Object.keys($downloadAssets) as downloadKey (downloadKey)}
         {@const download = $downloadAssets[downloadKey]}
@@ -40,7 +41,7 @@
           </div>
           <div class="absolute right-2">
             <CircleIconButton
-              title="Close"
+              title={$t('close')}
               on:click={() => abort(downloadKey, download)}
               size="20"
               icon={mdiClose}
diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte
index ffaca5fe73f00..f531a3ee4ce4d 100644
--- a/web/src/lib/components/asset-viewer/photo-viewer.svelte
+++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte
@@ -15,6 +15,7 @@
   import { NotificationType, notificationController } from '../shared-components/notification/notification';
   import { getAltText } from '$lib/utils/thumbnail-util';
   import { SlideshowLook, slideshowLookCssMapping, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
+  import { t } from 'svelte-i18n';
 
   const { slideshowState, slideshowLook } = slideshowStore;
 
@@ -99,7 +100,7 @@
       await copyImageToClipboard(assetData);
       notificationController.show({
         type: NotificationType.Info,
-        message: 'Copied image to clipboard.',
+        message: $t('copied_image_to_clipboard'),
         timeout: 3000,
       });
     } catch (error) {
@@ -134,7 +135,7 @@
   });
 
   const onCopyShortcut = (event: KeyboardEvent) => {
-    if (window.getSelection()?.type === 'Range') {
+    if (window.getSelection()?.type === $t('range')) {
       return;
     }
     event.preventDefault();
diff --git a/web/src/lib/components/asset-viewer/slideshow-bar.svelte b/web/src/lib/components/asset-viewer/slideshow-bar.svelte
index 887cc092c1b8e..ec3f9085f3438 100644
--- a/web/src/lib/components/asset-viewer/slideshow-bar.svelte
+++ b/web/src/lib/components/asset-viewer/slideshow-bar.svelte
@@ -6,6 +6,7 @@
   import { mdiChevronLeft, mdiChevronRight, mdiClose, mdiCog, mdiFullscreen, mdiPause, mdiPlay } from '@mdi/js';
   import { onDestroy, onMount } from 'svelte';
   import { fly } from 'svelte/transition';
+  import { t } from 'svelte-i18n';
 
   export let isFullScreen: boolean;
   export let onNext = () => {};
@@ -94,23 +95,28 @@
     transition:fly={{ duration: 150 }}
     role="navigation"
   >
-    <CircleIconButton buttonSize="50" icon={mdiClose} on:click={onClose} title="Exit Slideshow" />
+    <CircleIconButton buttonSize="50" icon={mdiClose} on:click={onClose} title={$t('exit_slideshow')} />
 
     <CircleIconButton
       buttonSize="50"
       icon={progressBarStatus === ProgressBarStatus.Paused ? mdiPlay : mdiPause}
       on:click={() => (progressBarStatus === ProgressBarStatus.Paused ? progressBar.play() : progressBar.pause())}
-      title={progressBarStatus === ProgressBarStatus.Paused ? 'Play' : 'Pause'}
+      title={progressBarStatus === ProgressBarStatus.Paused ? $t('play') : $t('pause')}
+    />
+    <CircleIconButton buttonSize="50" icon={mdiChevronLeft} on:click={onPrevious} title={$t('previous')} />
+    <CircleIconButton buttonSize="50" icon={mdiChevronRight} on:click={onNext} title={$t('next')} />
+    <CircleIconButton
+      buttonSize="50"
+      icon={mdiCog}
+      on:click={() => (showSettings = !showSettings)}
+      title={$t('next')}
     />
-    <CircleIconButton buttonSize="50" icon={mdiChevronLeft} on:click={onPrevious} title="Previous" />
-    <CircleIconButton buttonSize="50" icon={mdiChevronRight} on:click={onNext} title="Next" />
-    <CircleIconButton buttonSize="50" icon={mdiCog} on:click={() => (showSettings = !showSettings)} title="Next" />
     {#if !isFullScreen}
       <CircleIconButton
         buttonSize="50"
         icon={mdiFullscreen}
         on:click={onSetToFullScreen}
-        title="Set Slideshow to fullscreen"
+        title={$t('set_slideshow_to_fullscreen')}
       />
     {/if}
   </div>
diff --git a/web/src/lib/components/asset-viewer/video-native-viewer.svelte b/web/src/lib/components/asset-viewer/video-native-viewer.svelte
index e400773597e00..1fae59a0daf3c 100644
--- a/web/src/lib/components/asset-viewer/video-native-viewer.svelte
+++ b/web/src/lib/components/asset-viewer/video-native-viewer.svelte
@@ -6,6 +6,7 @@
   import { AssetMediaSize } from '@immich/sdk';
   import { createEventDispatcher } from 'svelte';
   import { fade } from 'svelte/transition';
+  import { t } from 'svelte-i18n';
 
   export let assetId: string;
   export let loopVideo: boolean;
@@ -31,7 +32,7 @@
       await video.play();
       dispatch('onVideoStarted');
     } catch (error) {
-      handleError(error, 'Unable to play video');
+      handleError(error, $t('errors.unable_to_play_video'));
     } finally {
       isVideoLoading = false;
     }
diff --git a/web/src/lib/components/elements/search-bar.svelte b/web/src/lib/components/elements/search-bar.svelte
index 3ac0dc8fc32db..686e5691ede5b 100644
--- a/web/src/lib/components/elements/search-bar.svelte
+++ b/web/src/lib/components/elements/search-bar.svelte
@@ -4,6 +4,7 @@
   import type { SearchOptions } from '$lib/utils/dipatch';
   import LoadingSpinner from '../shared-components/loading-spinner.svelte';
   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
+  import { t } from 'svelte-i18n';
 
   export let name: string;
   export let roundedBottom = true;
@@ -34,7 +35,7 @@
 >
   <CircleIconButton
     icon={mdiMagnify}
-    title="Search"
+    title={$t('search')}
     size="16"
     padding="2"
     on:click={() => dispatch('search', { force: true })}
@@ -54,6 +55,6 @@
     </div>
   {/if}
   {#if name}
-    <CircleIconButton icon={mdiClose} title="Clear value" size="16" padding="2" on:click={resetSearch} />
+    <CircleIconButton icon={mdiClose} title={$t('clear_value')} size="16" padding="2" on:click={resetSearch} />
   {/if}
 </div>
diff --git a/web/src/lib/components/faces-page/assign-face-side-panel.svelte b/web/src/lib/components/faces-page/assign-face-side-panel.svelte
index e6039d2bc5b8d..ec81136b95ae6 100644
--- a/web/src/lib/components/faces-page/assign-face-side-panel.svelte
+++ b/web/src/lib/components/faces-page/assign-face-side-panel.svelte
@@ -12,6 +12,7 @@
   import LoadingSpinner from '../shared-components/loading-spinner.svelte';
   import SearchPeople from '$lib/components/faces-page/people-search.svelte';
   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
+  import { t } from 'svelte-i18n';
 
   export let peopleWithFaces: AssetFaceResponseDto[];
   export let allPeople: PersonResponseDto[];
@@ -119,19 +120,19 @@
   <div class="flex place-items-center justify-between gap-2">
     {#if !searchFaces}
       <div class="flex items-center gap-2">
-        <CircleIconButton icon={mdiArrowLeftThin} title="Back" on:click={handleBackButton} />
-        <p class="flex text-lg text-immich-fg dark:text-immich-dark-fg">Select face</p>
+        <CircleIconButton icon={mdiArrowLeftThin} title={$t('back')} on:click={handleBackButton} />
+        <p class="flex text-lg text-immich-fg dark:text-immich-dark-fg">{$t('select_face')}</p>
       </div>
       <div class="flex justify-end gap-2">
         <CircleIconButton
           icon={mdiMagnify}
-          title="Search for existing person"
+          title={$t('search_for_existing_person')}
           on:click={() => {
             searchFaces = true;
           }}
         />
         {#if !isShowLoadingNewPerson}
-          <CircleIconButton icon={mdiPlus} title="Create new person" on:click={handleCreatePerson} />
+          <CircleIconButton icon={mdiPlus} title={$t('create_new_person')} on:click={handleCreatePerson} />
         {:else}
           <div class="flex place-content-center place-items-center">
             <LoadingSpinner />
@@ -139,7 +140,7 @@
         {/if}
       </div>
     {:else}
-      <CircleIconButton icon={mdiArrowLeftThin} title="Back" on:click={handleBackButton} />
+      <CircleIconButton icon={mdiArrowLeftThin} title={$t('back')} on:click={handleBackButton} />
       <div class="w-full flex">
         <SearchPeople
           type="input"
@@ -153,11 +154,11 @@
           </div>
         {/if}
       </div>
-      <CircleIconButton icon={mdiClose} title="Cancel search" on:click={() => (searchFaces = false)} />
+      <CircleIconButton icon={mdiClose} title={$t('cancel_search')} on:click={() => (searchFaces = false)} />
     {/if}
   </div>
   <div class="px-4 py-4 text-sm">
-    <h2 class="mb-8 mt-4 uppercase">All people</h2>
+    <h2 class="mb-8 mt-4 uppercase">{$t('all_people')}</h2>
     <div class="immich-scrollbar mt-4 flex flex-wrap gap-2 overflow-y-auto">
       {#each showPeople as person (person.id)}
         {#if person.id !== editedPerson.id}
diff --git a/web/src/lib/components/faces-page/edit-name-input.svelte b/web/src/lib/components/faces-page/edit-name-input.svelte
index f650e54d452be..f48a3de15a64c 100644
--- a/web/src/lib/components/faces-page/edit-name-input.svelte
+++ b/web/src/lib/components/faces-page/edit-name-input.svelte
@@ -4,6 +4,7 @@
   import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
   import Button from '../elements/buttons/button.svelte';
   import SearchPeople from '$lib/components/faces-page/people-search.svelte';
+  import { t } from 'svelte-i18n';
 
   export let person: PersonResponseDto;
   export let name: string;
@@ -35,6 +36,6 @@
       inputClass="w-full gap-2 bg-gray-100 dark:bg-gray-700 dark:text-white"
       bind:showLoadingSpinner={isSearchingPeople}
     />
-    <Button size="sm" type="submit">Done</Button>
+    <Button size="sm" type="submit">{$t('done')}</Button>
   </form>
 </div>
diff --git a/web/src/lib/components/faces-page/merge-face-selector.svelte b/web/src/lib/components/faces-page/merge-face-selector.svelte
index f2664c00c483d..752227da28e50 100644
--- a/web/src/lib/components/faces-page/merge-face-selector.svelte
+++ b/web/src/lib/components/faces-page/merge-face-selector.svelte
@@ -17,6 +17,7 @@
   import FaceThumbnail from './face-thumbnail.svelte';
   import PeopleList from './people-list.svelte';
   import { dialogController } from '$lib/components/shared-components/dialog/dialog';
+  import { t } from 'svelte-i18n';
 
   export let person: PersonResponseDto;
   let people: PersonResponseDto[] = [];
@@ -86,7 +87,7 @@
       });
       dispatch('merge', mergedPerson);
     } catch (error) {
-      handleError(error, 'Cannot merge people');
+      handleError(error, $t('cannot_merge_people'));
     }
   };
 </script>
@@ -134,7 +135,7 @@
                 {#if selectedPeople.length === 1}
                   <div class="absolute bottom-2">
                     <CircleIconButton
-                      title="Swap merge direction"
+                      title={$t('swap_merge_direction')}
                       icon={mdiSwapHorizontal}
                       size="24"
                       on:click={handleSwapPeople}
diff --git a/web/src/lib/components/faces-page/merge-suggestion-modal.svelte b/web/src/lib/components/faces-page/merge-suggestion-modal.svelte
index 6fe603eeb0c03..60d064c3a50c9 100644
--- a/web/src/lib/components/faces-page/merge-suggestion-modal.svelte
+++ b/web/src/lib/components/faces-page/merge-suggestion-modal.svelte
@@ -8,6 +8,7 @@
   import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
   import Button from '../elements/buttons/button.svelte';
   import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
+  import { t } from 'svelte-i18n';
 
   export let personMerge1: PersonResponseDto;
   export let personMerge2: PersonResponseDto;
@@ -44,7 +45,7 @@
       </div>
       <div class="mx-0.5 flex md:mx-2">
         <CircleIconButton
-          title="Swap merge direction"
+          title={$t('swap_merge_direction')}
           icon={mdiMerge}
           on:click={() => ([personMerge1, personMerge2] = [personMerge2, personMerge1])}
         />
@@ -104,7 +105,7 @@
     <p class="text-sm text-gray-500 dark:text-gray-300">They will be merged together</p>
   </div>
   <svelte:fragment slot="sticky-bottom">
-    <Button fullwidth color="gray" on:click={() => dispatch('reject')}>No</Button>
-    <Button fullwidth on:click={() => dispatch('confirm', [personMerge1, personMerge2])}>Yes</Button>
+    <Button fullwidth color="gray" on:click={() => dispatch('reject')}>{$t('no')}</Button>
+    <Button fullwidth on:click={() => dispatch('confirm', [personMerge1, personMerge2])}>{$t('yes')}</Button>
   </svelte:fragment>
 </FullScreenModal>
diff --git a/web/src/lib/components/faces-page/people-card.svelte b/web/src/lib/components/faces-page/people-card.svelte
index 1e508eca7e0f6..7e2581727e6e3 100644
--- a/web/src/lib/components/faces-page/people-card.svelte
+++ b/web/src/lib/components/faces-page/people-card.svelte
@@ -16,6 +16,7 @@
   import MenuOption from '../shared-components/context-menu/menu-option.svelte';
   import Portal from '../shared-components/portal/portal.svelte';
   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
+  import { t } from 'svelte-i18n';
 
   export let person: PersonResponseDto;
   export let preload = false;
@@ -76,7 +77,7 @@
     <CircleIconButton
       color="opaque"
       icon={mdiDotsVertical}
-      title="Show person options"
+      title={$t('show_person_options')}
       size="20"
       padding="2"
       class="icon-white-drop-shadow"
@@ -88,17 +89,17 @@
 {#if showContextMenu}
   <Portal target="body">
     <ContextMenu {...contextMenuPosition} onClose={() => onMenuExit()}>
-      <MenuOption on:click={() => onMenuClick('hide-person')} icon={mdiEyeOffOutline} text="Hide person" />
-      <MenuOption on:click={() => onMenuClick('change-name')} icon={mdiAccountEditOutline} text="Change name" />
+      <MenuOption on:click={() => onMenuClick('hide-person')} icon={mdiEyeOffOutline} text={$t('hide_person')} />
+      <MenuOption on:click={() => onMenuClick('change-name')} icon={mdiAccountEditOutline} text={$t('change_name')} />
       <MenuOption
         on:click={() => onMenuClick('set-birth-date')}
         icon={mdiCalendarEditOutline}
-        text="Set date of birth"
+        text={$t('set_date_of_birth')}
       />
       <MenuOption
         on:click={() => onMenuClick('merge-people')}
         icon={mdiAccountMultipleCheckOutline}
-        text="Merge people"
+        text={$t('merge_people')}
       />
     </ContextMenu>
   </Portal>
diff --git a/web/src/lib/components/faces-page/people-list.svelte b/web/src/lib/components/faces-page/people-list.svelte
index b1e2ba85fbf0e..5130baf30b89a 100644
--- a/web/src/lib/components/faces-page/people-list.svelte
+++ b/web/src/lib/components/faces-page/people-list.svelte
@@ -3,6 +3,7 @@
   import { createEventDispatcher } from 'svelte';
   import FaceThumbnail from './face-thumbnail.svelte';
   import SearchPeople from '$lib/components/faces-page/people-search.svelte';
+  import { t } from 'svelte-i18n';
 
   export let screenHeight: number;
   export let people: PersonResponseDto[];
@@ -25,7 +26,7 @@
 </script>
 
 <div class=" w-40 sm:w-48 md:w-96 h-14 mb-8">
-  <SearchPeople type="searchBar" placeholder="Search people" bind:searchName={name} bind:searchedPeopleLocal />
+  <SearchPeople type="searchBar" placeholder={$t('search_people')} bind:searchName={name} bind:searchedPeopleLocal />
 </div>
 
 <div
diff --git a/web/src/lib/components/faces-page/people-search.svelte b/web/src/lib/components/faces-page/people-search.svelte
index 70258685264c7..ed33ded46bd36 100644
--- a/web/src/lib/components/faces-page/people-search.svelte
+++ b/web/src/lib/components/faces-page/people-search.svelte
@@ -5,6 +5,7 @@
   import { handleError } from '$lib/utils/handle-error';
   import { searchNameLocal } from '$lib/utils/person';
   import { searchPerson, type PersonResponseDto } from '@immich/sdk';
+  import { t } from 'svelte-i18n';
 
   export let searchName: string;
   export let searchedPeopleLocal: PersonResponseDto[];
@@ -12,7 +13,7 @@
   export let numberPeopleToSearch: number = maximumLengthSearchPeople;
   export let inputClass: string = 'w-full gap-2 bg-immich-bg dark:bg-immich-dark-bg';
   export let showLoadingSpinner: boolean = false;
-  export let placeholder: string = 'Name or nickname';
+  export let placeholder: string = $t('name_or_nickname');
   export let onReset = () => {};
   export let onSearch = () => {};
 
@@ -61,7 +62,7 @@
       searchedPeople = data;
       searchWord = searchName;
     } catch (error) {
-      handleError(error, "Can't search people");
+      handleError(error, $t('cant_search_people'));
     } finally {
       clearTimeout(timeout);
       timeout = null;
diff --git a/web/src/lib/components/faces-page/person-side-panel.svelte b/web/src/lib/components/faces-page/person-side-panel.svelte
index d77a34803f987..1365f70c153a9 100644
--- a/web/src/lib/components/faces-page/person-side-panel.svelte
+++ b/web/src/lib/components/faces-page/person-side-panel.svelte
@@ -23,6 +23,7 @@
   import { NotificationType, notificationController } from '../shared-components/notification/notification';
   import AssignFaceSidePanel from './assign-face-side-panel.svelte';
   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
+  import { t } from 'svelte-i18n';
 
   export let assetId: string;
   export let assetType: AssetTypeEnum;
@@ -64,7 +65,7 @@
       allPeople = people;
       peopleWithFaces = await getFaces({ id: assetId });
     } catch (error) {
-      handleError(error, "Can't get faces");
+      handleError(error, $t('cant_get_faces'));
     } finally {
       clearTimeout(timeout);
     }
@@ -142,7 +143,7 @@
           type: NotificationType.Info,
         });
       } catch (error) {
-        handleError(error, "Can't apply changes");
+        handleError(error, $t('cant_apply_changes'));
       }
     }
 
@@ -184,8 +185,8 @@
 >
   <div class="flex place-items-center justify-between gap-2">
     <div class="flex items-center gap-2">
-      <CircleIconButton icon={mdiArrowLeftThin} title="Back" on:click={handleBackButton} />
-      <p class="flex text-lg text-immich-fg dark:text-immich-dark-fg">Edit faces</p>
+      <CircleIconButton icon={mdiArrowLeftThin} title={$t('back')} on:click={handleBackButton} />
+      <p class="flex text-lg text-immich-fg dark:text-immich-dark-fg">{$t('edit_faces')}</p>
     </div>
     {#if !isShowLoadingDone}
       <button
@@ -225,7 +226,7 @@
                       shadow
                       url={selectedPersonToCreate[face.id]}
                       altText={selectedPersonToCreate[face.id]}
-                      title={'New person'}
+                      title={$t('new_person')}
                       widthStyle={thumbnailWidth}
                       heightStyle={thumbnailWidth}
                     />
@@ -272,7 +273,7 @@
                     <CircleIconButton
                       color="primary"
                       icon={mdiRestart}
-                      title="Reset"
+                      title={$t('reset')}
                       size="18"
                       padding="1"
                       class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform"
@@ -282,7 +283,7 @@
                     <CircleIconButton
                       color="primary"
                       icon={mdiMinus}
-                      title="Select new face"
+                      title={$t('select_new_face')}
                       size="18"
                       padding="1"
                       class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform"
diff --git a/web/src/lib/components/faces-page/set-birth-date-modal.svelte b/web/src/lib/components/faces-page/set-birth-date-modal.svelte
index 1433852c90bbe..1d2e0d9a04a8c 100644
--- a/web/src/lib/components/faces-page/set-birth-date-modal.svelte
+++ b/web/src/lib/components/faces-page/set-birth-date-modal.svelte
@@ -4,6 +4,7 @@
   import FullScreenModal from '../shared-components/full-screen-modal.svelte';
   import { mdiCake } from '@mdi/js';
   import DateInput from '../elements/date-input.svelte';
+  import { t } from 'svelte-i18n';
 
   export let birthDate: string;
 
@@ -20,7 +21,7 @@
   };
 </script>
 
-<FullScreenModal title="Set date of birth" icon={mdiCake} onClose={handleCancel}>
+<FullScreenModal title={$t('set_date_of_birth')} icon={mdiCake} onClose={handleCancel}>
   <div class="text-immich-primary dark:text-immich-dark-primary">
     <p class="text-sm dark:text-immich-dark-fg">
       Date of birth is used to calculate the age of this person at the time of a photo.
@@ -40,7 +41,7 @@
     </div>
   </form>
   <svelte:fragment slot="sticky-bottom">
-    <Button color="gray" fullwidth on:click={() => handleCancel()}>Cancel</Button>
-    <Button type="submit" fullwidth form="set-birth-date-form">Set</Button>
+    <Button color="gray" fullwidth on:click={() => handleCancel()}>{$t('cancel')}</Button>
+    <Button type="submit" fullwidth form="set-birth-date-form">{$t('set')}</Button>
   </svelte:fragment>
 </FullScreenModal>
diff --git a/web/src/lib/components/faces-page/show-hide.svelte b/web/src/lib/components/faces-page/show-hide.svelte
index 82159c8ee7a81..258efe2c5d948 100644
--- a/web/src/lib/components/faces-page/show-hide.svelte
+++ b/web/src/lib/components/faces-page/show-hide.svelte
@@ -14,6 +14,7 @@
   import { mdiClose, mdiEye, mdiEyeOff, mdiEyeSettings, mdiRestart } from '@mdi/js';
   import { locale } from '$lib/stores/preferences.store';
   import Button from '$lib/components/elements/buttons/button.svelte';
+  import { t } from 'svelte-i18n';
 
   export let showLoadingSpinner: boolean;
   export let toggleVisibility: ToggleVisibilty = ToggleVisibilty.VIEW_ALL;
@@ -51,7 +52,7 @@
     class="fixed top-0 z-10 flex h-16 w-full items-center justify-between border-b bg-white p-1 dark:border-immich-dark-gray dark:bg-black dark:text-immich-dark-fg md:p-8"
   >
     <div class="flex items-center">
-      <CircleIconButton title="Close" icon={mdiClose} on:click={onClose} />
+      <CircleIconButton title={$t('close')} icon={mdiClose} on:click={onClose} />
       <div class="flex gap-2 items-center">
         <p class="ml-2">Show & hide people</p>
         <p class="text-sm text-gray-400 dark:text-gray-600">({countTotalPeople.toLocaleString($locale)})</p>
@@ -59,15 +60,15 @@
     </div>
     <div class="flex items-center justify-end">
       <div class="flex items-center md:mr-8">
-        <CircleIconButton title="Reset people visibility" icon={mdiRestart} on:click={onReset} />
+        <CircleIconButton title={$t('reset_people_visibility')} icon={mdiRestart} on:click={onReset} />
         <CircleIconButton
-          title="Toggle visibility"
+          title={$t('toggle_visibility')}
           icon={toggleIcon}
           on:click={() => onChange(getNextVisibility(toggleVisibility))}
         />
       </div>
       {#if !showLoadingSpinner}
-        <Button on:click={onDone} size="sm" rounded="lg">Done</Button>
+        <Button on:click={onDone} size="sm" rounded="lg">{$t('done')}</Button>
       {:else}
         <LoadingSpinner />
       {/if}
diff --git a/web/src/lib/components/forms/admin-registration-form.svelte b/web/src/lib/components/forms/admin-registration-form.svelte
index 0481dbf458457..5cb1e79d181b3 100644
--- a/web/src/lib/components/forms/admin-registration-form.svelte
+++ b/web/src/lib/components/forms/admin-registration-form.svelte
@@ -5,6 +5,7 @@
   import { handleError } from '../../utils/handle-error';
   import Button from '../elements/buttons/button.svelte';
   import PasswordField from '../shared-components/password-field.svelte';
+  import { t } from 'svelte-i18n';
 
   let email = '';
   let password = '';
@@ -16,7 +17,7 @@
 
   $: {
     if (password !== confirmPassword && confirmPassword.length > 0) {
-      errorMessage = 'Password does not match';
+      errorMessage = $t('password_does_not_match');
       canRegister = false;
     } else {
       errorMessage = '';
@@ -32,8 +33,8 @@
         await signUpAdmin({ signUpDto: { email, password, name } });
         await goto(AppRoute.AUTH_LOGIN);
       } catch (error) {
-        handleError(error, 'Unable to create admin account');
-        errorMessage = 'Error create admin account';
+        handleError(error, 'errors.errors.unable_to_create_admin_account');
+        errorMessage = $t('errors.errors.unable_to_create_admin_account');
       }
     }
   }
@@ -41,22 +42,22 @@
 
 <form on:submit|preventDefault={registerAdmin} method="post" class="mt-5 flex flex-col gap-5">
   <div class="flex flex-col gap-2">
-    <label class="immich-form-label" for="email">Admin Email</label>
+    <label class="immich-form-label" for="email">{$t('admin_email')}</label>
     <input class="immich-form-input" id="email" bind:value={email} type="email" autocomplete="email" required />
   </div>
 
   <div class="flex flex-col gap-2">
-    <label class="immich-form-label" for="password">Admin Password</label>
+    <label class="immich-form-label" for="password">{$t('admin_password')}</label>
     <PasswordField id="password" bind:password autocomplete="new-password" />
   </div>
 
   <div class="flex flex-col gap-2">
-    <label class="immich-form-label" for="confirmPassword">Confirm Admin Password</label>
+    <label class="immich-form-label" for="confirmPassword">{$t('confirm_admin_password')}</label>
     <PasswordField id="confirmPassword" bind:password={confirmPassword} autocomplete="new-password" />
   </div>
 
   <div class="flex flex-col gap-2">
-    <label class="immich-form-label" for="name">Name</label>
+    <label class="immich-form-label" for="name">{$t('name')}</label>
     <input class="immich-form-input" id="name" bind:value={name} type="text" autocomplete="name" required />
   </div>
 
@@ -65,6 +66,6 @@
   {/if}
 
   <div class="my-5 flex w-full">
-    <Button type="submit" size="lg" fullwidth>Sign up</Button>
+    <Button type="submit" size="lg" fullwidth>{$t('sign_up')}</Button>
   </div>
 </form>
diff --git a/web/src/lib/components/forms/api-key-form.svelte b/web/src/lib/components/forms/api-key-form.svelte
index 07fcd0ffdf906..9273faf38e245 100644
--- a/web/src/lib/components/forms/api-key-form.svelte
+++ b/web/src/lib/components/forms/api-key-form.svelte
@@ -5,11 +5,12 @@
   import Button from '../elements/buttons/button.svelte';
   import FullScreenModal from '../shared-components/full-screen-modal.svelte';
   import { NotificationType, notificationController } from '../shared-components/notification/notification';
+  import { t } from 'svelte-i18n';
 
   export let apiKey: Partial<ApiKeyResponseDto>;
   export let title: string;
-  export let cancelText = 'Cancel';
-  export let submitText = 'Save';
+  export let cancelText = $t('cancel');
+  export let submitText = $t('save');
 
   const dispatch = createEventDispatcher<{
     cancel: void;
@@ -31,7 +32,7 @@
 <FullScreenModal {title} icon={mdiKeyVariant} onClose={handleCancel}>
   <form on:submit|preventDefault={handleSubmit} autocomplete="off" id="api-key-form">
     <div class="mb-4 flex flex-col gap-2">
-      <label class="immich-form-label" for="name">Name</label>
+      <label class="immich-form-label" for="name">{$t('name')}</label>
       <input class="immich-form-input" id="name" name="name" type="text" bind:value={apiKey.name} />
     </div>
   </form>
diff --git a/web/src/lib/components/forms/api-key-secret.svelte b/web/src/lib/components/forms/api-key-secret.svelte
index 5a77630a1a4dc..1722029ae791b 100644
--- a/web/src/lib/components/forms/api-key-secret.svelte
+++ b/web/src/lib/components/forms/api-key-secret.svelte
@@ -4,6 +4,7 @@
   import { createEventDispatcher } from 'svelte';
   import Button from '../elements/buttons/button.svelte';
   import FullScreenModal from '../shared-components/full-screen-modal.svelte';
+  import { t } from 'svelte-i18n';
 
   export let secret = '';
 
@@ -13,7 +14,7 @@
   const handleDone = () => dispatch('done');
 </script>
 
-<FullScreenModal title="API key" icon={mdiKeyVariant} onClose={() => handleDone()}>
+<FullScreenModal title={$t('api_key')} icon={mdiKeyVariant} onClose={() => handleDone()}>
   <div class="text-immich-primary dark:text-immich-dark-primary">
     <p class="text-sm dark:text-immich-dark-fg">
       This value will only be shown once. Please be sure to copy it before closing the window.
@@ -21,12 +22,12 @@
   </div>
 
   <div class="my-4 flex flex-col gap-2">
-    <!-- <label class="immich-form-label" for="secret">API Key</label> -->
+    <!-- <label class="immich-form-label" for="secret">{ $t("api_key") }</label> -->
     <textarea class="immich-form-input" id="secret" name="secret" readonly={true} value={secret} />
   </div>
 
   <svelte:fragment slot="sticky-bottom">
-    <Button on:click={() => copyToClipboard(secret)} fullwidth>Copy to Clipboard</Button>
-    <Button on:click={() => handleDone()} fullwidth>Done</Button>
+    <Button on:click={() => copyToClipboard(secret)} fullwidth>{$t('copy_to_clipboard')}</Button>
+    <Button on:click={() => handleDone()} fullwidth>{$t('done')}</Button>
   </svelte:fragment>
 </FullScreenModal>
diff --git a/web/src/lib/components/forms/change-password-form.svelte b/web/src/lib/components/forms/change-password-form.svelte
index 9736ed78216ef..add87bb0ab359 100644
--- a/web/src/lib/components/forms/change-password-form.svelte
+++ b/web/src/lib/components/forms/change-password-form.svelte
@@ -3,6 +3,7 @@
   import Button from '../elements/buttons/button.svelte';
   import PasswordField from '../shared-components/password-field.svelte';
   import { updateMyUser } from '@immich/sdk';
+  import { t } from 'svelte-i18n';
 
   let errorMessage: string;
   let success: string;
@@ -14,7 +15,7 @@
 
   $: {
     if (password !== passwordConfirm && passwordConfirm.length > 0) {
-      errorMessage = 'Password does not match';
+      errorMessage = $t('password_does_not_match');
       valid = false;
     } else {
       errorMessage = '';
@@ -39,12 +40,12 @@
 
 <form on:submit|preventDefault={changePassword} method="post" class="mt-5 flex flex-col gap-5">
   <div class="flex flex-col gap-2">
-    <label class="immich-form-label" for="password">New Password</label>
+    <label class="immich-form-label" for="password">{$t('new_password')}</label>
     <PasswordField id="password" bind:password autocomplete="new-password" />
   </div>
 
   <div class="flex flex-col gap-2">
-    <label class="immich-form-label" for="confirmPassword">Confirm Password</label>
+    <label class="immich-form-label" for="confirmPassword">{$t('confirm_password')}</label>
     <PasswordField id="confirmPassword" bind:password={passwordConfirm} autocomplete="new-password" />
   </div>
 
@@ -56,6 +57,6 @@
     <p class="text-sm text-immich-primary">{success}</p>
   {/if}
   <div class="my-5 flex w-full">
-    <Button type="submit" size="lg" fullwidth>Change password</Button>
+    <Button type="submit" size="lg" fullwidth>{$t('change_password')}</Button>
   </div>
 </form>
diff --git a/web/src/lib/components/forms/create-user-form.svelte b/web/src/lib/components/forms/create-user-form.svelte
index 4dbf55d7a5f22..5f7dd08758594 100644
--- a/web/src/lib/components/forms/create-user-form.svelte
+++ b/web/src/lib/components/forms/create-user-form.svelte
@@ -9,6 +9,7 @@
   import Slider from '../elements/slider.svelte';
   import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
   import { featureFlags } from '$lib/stores/server-config.store';
+  import { t } from 'svelte-i18n';
 
   export let onClose: () => void;
 
@@ -31,7 +32,7 @@
 
   $: {
     if (password !== confirmPassword && confirmPassword.length > 0) {
-      error = 'Password does not match';
+      error = $t('password_does_not_match');
       canCreateUser = false;
     } else {
       error = '';
@@ -60,13 +61,13 @@
           },
         });
 
-        success = 'New user created';
+        success = $t('new_user_created');
 
         dispatch('submit');
 
         return;
       } catch (error) {
-        handleError(error, 'Unable to create user');
+        handleError(error, $t('errors.unable_to_create_user'));
       } finally {
         isCreatingUser = false;
       }
@@ -74,10 +75,10 @@
   }
 </script>
 
-<FullScreenModal title="Create new user" showLogo {onClose}>
+<FullScreenModal title={$t('create_new_user')} showLogo {onClose}>
   <form on:submit|preventDefault={registerUser} autocomplete="off" id="create-new-user-form">
     <div class="my-4 flex flex-col gap-2">
-      <label class="immich-form-label" for="email">Email</label>
+      <label class="immich-form-label" for="email">{$t('email')}</label>
       <input class="immich-form-input" id="email" bind:value={email} type="email" required />
     </div>
 
@@ -89,12 +90,12 @@
     {/if}
 
     <div class="my-4 flex flex-col gap-2">
-      <label class="immich-form-label" for="password">Password</label>
+      <label class="immich-form-label" for="password">{$t('password')}</label>
       <PasswordField id="password" bind:password autocomplete="new-password" />
     </div>
 
     <div class="my-4 flex flex-col gap-2">
-      <label class="immich-form-label" for="confirmPassword">Confirm Password</label>
+      <label class="immich-form-label" for="confirmPassword">{$t('confirm_password')}</label>
       <PasswordField id="confirmPassword" bind:password={confirmPassword} autocomplete="new-password" />
     </div>
 
@@ -106,7 +107,7 @@
     </div>
 
     <div class="my-4 flex flex-col gap-2">
-      <label class="immich-form-label" for="name">Name</label>
+      <label class="immich-form-label" for="name">{$t('name')}</label>
       <input class="immich-form-input" id="name" bind:value={name} type="text" required />
     </div>
 
@@ -129,7 +130,7 @@
     {/if}
   </form>
   <svelte:fragment slot="sticky-bottom">
-    <Button color="gray" fullwidth on:click={() => dispatch('cancel')}>Cancel</Button>
-    <Button type="submit" disabled={isCreatingUser} fullwidth form="create-new-user-form">Create</Button>
+    <Button color="gray" fullwidth on:click={() => dispatch('cancel')}>{$t('cancel')}</Button>
+    <Button type="submit" disabled={isCreatingUser} fullwidth form="create-new-user-form">{$t('create')}</Button>
   </svelte:fragment>
 </FullScreenModal>
diff --git a/web/src/lib/components/forms/edit-album-form.svelte b/web/src/lib/components/forms/edit-album-form.svelte
index f983beefe8c8e..32e82dab4de81 100644
--- a/web/src/lib/components/forms/edit-album-form.svelte
+++ b/web/src/lib/components/forms/edit-album-form.svelte
@@ -4,6 +4,7 @@
   import Button from '$lib/components/elements/buttons/button.svelte';
   import AlbumCover from '$lib/components/album-page/album-cover.svelte';
   import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
+  import { t } from 'svelte-i18n';
 
   export let album: AlbumResponseDto;
   export let onEditSuccess: ((album: AlbumResponseDto) => unknown) | undefined = undefined;
@@ -36,7 +37,7 @@
   };
 </script>
 
-<FullScreenModal title="Edit album" width="wide" {onClose}>
+<FullScreenModal title={$t('edit_album')} width="wide" {onClose}>
   <form on:submit|preventDefault={handleUpdateAlbumInfo} autocomplete="off" id="edit-album-form">
     <div class="flex items-center">
       <div class="hidden sm:flex">
@@ -45,19 +46,19 @@
 
       <div class="flex-grow">
         <div class="m-4 flex flex-col gap-2">
-          <label class="immich-form-label" for="name">Name</label>
+          <label class="immich-form-label" for="name">{$t('name')}</label>
           <input class="immich-form-input" id="name" type="text" bind:value={albumName} />
         </div>
 
         <div class="m-4 flex flex-col gap-2">
-          <label class="immich-form-label" for="description">Description</label>
+          <label class="immich-form-label" for="description">{$t('description')}</label>
           <textarea class="immich-form-input" id="description" bind:value={description} />
         </div>
       </div>
     </div>
   </form>
   <svelte:fragment slot="sticky-bottom">
-    <Button color="gray" fullwidth on:click={() => onCancel?.()}>Cancel</Button>
-    <Button type="submit" fullwidth disabled={isSubmitting} form="edit-album-form">OK</Button>
+    <Button color="gray" fullwidth on:click={() => onCancel?.()}>{$t('cancel')}</Button>
+    <Button type="submit" fullwidth disabled={isSubmitting} form="edit-album-form">{$t('ok')}</Button>
   </svelte:fragment>
 </FullScreenModal>
diff --git a/web/src/lib/components/forms/edit-user-form.svelte b/web/src/lib/components/forms/edit-user-form.svelte
index b7bf261442c65..f0d0c8ab78c96 100644
--- a/web/src/lib/components/forms/edit-user-form.svelte
+++ b/web/src/lib/components/forms/edit-user-form.svelte
@@ -9,6 +9,7 @@
   import { createEventDispatcher } from 'svelte';
   import Button from '../elements/buttons/button.svelte';
   import { dialogController } from '$lib/components/shared-components/dialog/dialog';
+  import { t } from 'svelte-i18n';
 
   export let user: UserAdminResponseDto;
   export let canResetPassword = true;
@@ -47,7 +48,7 @@
 
       dispatch('editSuccess');
     } catch (error) {
-      handleError(error, 'Unable to update user');
+      handleError(error, $t('errors.unable_to_update_user'));
     }
   };
 
@@ -74,7 +75,7 @@
 
       dispatch('resetPasswordSuccess');
     } catch (error) {
-      handleError(error, 'Unable to reset password');
+      handleError(error, $t('errors.unable_to_reset_password'));
     }
   };
 
@@ -96,15 +97,15 @@
   }
 </script>
 
-<FullScreenModal title="Edit user" icon={mdiAccountEditOutline} {onClose}>
+<FullScreenModal title={$t('edit_user')} icon={mdiAccountEditOutline} {onClose}>
   <form on:submit|preventDefault={editUser} autocomplete="off" id="edit-user-form">
     <div class="my-4 flex flex-col gap-2">
-      <label class="immich-form-label" for="email">Email</label>
+      <label class="immich-form-label" for="email">{$t('email')}</label>
       <input class="immich-form-input" id="email" name="email" type="email" bind:value={user.email} />
     </div>
 
     <div class="my-4 flex flex-col gap-2">
-      <label class="immich-form-label" for="name">Name</label>
+      <label class="immich-form-label" for="name">{$t('name')}</label>
       <input class="immich-form-input" id="name" name="name" type="text" required bind:value={user.name} />
     </div>
 
@@ -119,7 +120,7 @@
     </div>
 
     <div class="my-4 flex flex-col gap-2">
-      <label class="immich-form-label" for="storage-label">Storage Label</label>
+      <label class="immich-form-label" for="storage-label">{$t('storage_label')}</label>
       <input
         class="immich-form-input"
         id="storage-label"
@@ -146,8 +147,8 @@
   </form>
   <svelte:fragment slot="sticky-bottom">
     {#if canResetPassword}
-      <Button color="light-red" fullwidth on:click={resetPassword}>Reset password</Button>
+      <Button color="light-red" fullwidth on:click={resetPassword}>{$t('reset_password')}</Button>
     {/if}
-    <Button type="submit" fullwidth form="edit-user-form">Confirm</Button>
+    <Button type="submit" fullwidth form="edit-user-form">{$t('confirm')}</Button>
   </svelte:fragment>
 </FullScreenModal>
diff --git a/web/src/lib/components/forms/library-exclusion-pattern-form.svelte b/web/src/lib/components/forms/library-exclusion-pattern-form.svelte
index 782017c18fc05..da2b6f549f9e2 100644
--- a/web/src/lib/components/forms/library-exclusion-pattern-form.svelte
+++ b/web/src/lib/components/forms/library-exclusion-pattern-form.svelte
@@ -4,11 +4,12 @@
   import FullScreenModal from '../shared-components/full-screen-modal.svelte';
   import { mdiFolderRemove } from '@mdi/js';
   import { onMount } from 'svelte';
+  import { t } from 'svelte-i18n';
 
   export let exclusionPattern: string;
   export let exclusionPatterns: string[] = [];
   export let isEditing = false;
-  export let submitText = 'Submit';
+  export let submitText = $t('submit');
 
   onMount(() => {
     if (isEditing) {
@@ -28,7 +29,7 @@
   const handleSubmit = () => dispatch('submit', { excludePattern: exclusionPattern });
 </script>
 
-<FullScreenModal title="Add exclusion pattern" icon={mdiFolderRemove} onClose={handleCancel}>
+<FullScreenModal title={$t('add_exclusion_pattern')} icon={mdiFolderRemove} onClose={handleCancel}>
   <form on:submit|preventDefault={() => handleSubmit()} autocomplete="off" id="add-exclusion-pattern-form">
     <p class="py-5 text-sm">
       Exclusion patterns lets you ignore files and folders when scanning your library. This is useful if you have
@@ -38,7 +39,7 @@
       use "**/Raw/**". To ignore all files ending in ".tif", use "**/*.tif". To ignore an absolute path, use "/path/to/ignore/**".
     </p>
     <div class="my-4 flex flex-col gap-2">
-      <label class="immich-form-label" for="exclusionPattern">Pattern</label>
+      <label class="immich-form-label" for="exclusionPattern">{$t('pattern')}</label>
       <input
         class="immich-form-input"
         id="exclusionPattern"
@@ -54,9 +55,9 @@
     </div>
   </form>
   <svelte:fragment slot="sticky-bottom">
-    <Button color="gray" fullwidth on:click={() => handleCancel()}>Cancel</Button>
+    <Button color="gray" fullwidth on:click={() => handleCancel()}>{$t('cancel')}</Button>
     {#if isEditing}
-      <Button color="red" fullwidth on:click={() => dispatch('delete')}>Delete</Button>
+      <Button color="red" fullwidth on:click={() => dispatch('delete')}>{$t('delete')}</Button>
     {/if}
     <Button type="submit" disabled={!canSubmit} fullwidth form="add-exclusion-pattern-form">{submitText}</Button>
   </svelte:fragment>
diff --git a/web/src/lib/components/forms/library-import-path-form.svelte b/web/src/lib/components/forms/library-import-path-form.svelte
index 832f2f17e81ab..f1f36c704df3a 100644
--- a/web/src/lib/components/forms/library-import-path-form.svelte
+++ b/web/src/lib/components/forms/library-import-path-form.svelte
@@ -4,12 +4,13 @@
   import FullScreenModal from '../shared-components/full-screen-modal.svelte';
   import { mdiFolderSync } from '@mdi/js';
   import { onMount } from 'svelte';
+  import { t } from 'svelte-i18n';
 
   export let importPath: string | null;
   export let importPaths: string[] = [];
-  export let title = 'Import path';
-  export let cancelText = 'Cancel';
-  export let submitText = 'Save';
+  export let title = $t('import_path');
+  export let cancelText = $t('cancel');
+  export let submitText = $t('save');
   export let isEditing = false;
 
   onMount(() => {
@@ -37,7 +38,7 @@
     </p>
 
     <div class="my-4 flex flex-col gap-2">
-      <label class="immich-form-label" for="path">Path</label>
+      <label class="immich-form-label" for="path">{$t('path')}</label>
       <input class="immich-form-input" id="path" name="path" type="text" bind:value={importPath} />
     </div>
 
@@ -50,7 +51,7 @@
   <svelte:fragment slot="sticky-bottom">
     <Button color="gray" fullwidth on:click={() => handleCancel()}>{cancelText}</Button>
     {#if isEditing}
-      <Button color="red" fullwidth on:click={() => dispatch('delete')}>Delete</Button>
+      <Button color="red" fullwidth on:click={() => dispatch('delete')}>{$t('delete')}</Button>
     {/if}
     <Button type="submit" disabled={!canSubmit} fullwidth form="library-import-path-form">{submitText}</Button>
   </svelte:fragment>
diff --git a/web/src/lib/components/forms/library-import-paths-form.svelte b/web/src/lib/components/forms/library-import-paths-form.svelte
index be832a63a3a58..b78c7984ccede 100644
--- a/web/src/lib/components/forms/library-import-paths-form.svelte
+++ b/web/src/lib/components/forms/library-import-paths-form.svelte
@@ -10,6 +10,7 @@
   import { NotificationType, notificationController } from '../shared-components/notification/notification';
   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
   import { s } from '$lib/utils';
+  import { t } from 'svelte-i18n';
 
   export let library: LibraryResponseDto;
 
@@ -149,8 +150,8 @@
 
 {#if addImportPath}
   <LibraryImportPathForm
-    title="Add import path"
-    submitText="Add"
+    title={$t('add_import_path')}
+    submitText={$t('add')}
     bind:importPath={importPathToAdd}
     {importPaths}
     on:submit={handleAddImportPath}
@@ -163,8 +164,8 @@
 
 {#if editImportPath != undefined}
   <LibraryImportPathForm
-    title="Edit import path"
-    submitText="Save"
+    title={$t('edit_import_path')}
+    submitText={$t('save')}
     isEditing={true}
     bind:importPath={editedImportPath}
     {importPaths}
@@ -210,7 +211,7 @@
             <CircleIconButton
               color="primary"
               icon={mdiPencilOutline}
-              title="Edit import path"
+              title={$t('edit_import_path')}
               size="16"
               on:click={() => {
                 editImportPath = listIndex;
@@ -238,7 +239,7 @@
             size="sm"
             on:click={() => {
               addImportPath = true;
-            }}>Add path</Button
+            }}>{$t('add_path')}</Button
           ></td
         >
       </tr>
@@ -246,11 +247,13 @@
   </table>
   <div class="flex justify-between w-full">
     <div class="justify-end gap-2">
-      <Button size="sm" color="gray" on:click={() => revalidate()}><Icon path={mdiRefresh} size={20} />Validate</Button>
+      <Button size="sm" color="gray" on:click={() => revalidate()}
+        ><Icon path={mdiRefresh} size={20} />{$t('validate')}</Button
+      >
     </div>
     <div class="justify-end gap-2">
-      <Button size="sm" color="gray" on:click={() => handleCancel()}>Cancel</Button>
-      <Button size="sm" type="submit">Save</Button>
+      <Button size="sm" color="gray" on:click={() => handleCancel()}>{$t('cancel')}</Button>
+      <Button size="sm" type="submit">{$t('save')}</Button>
     </div>
   </div>
 </form>
diff --git a/web/src/lib/components/forms/library-rename-form.svelte b/web/src/lib/components/forms/library-rename-form.svelte
index 1c83c538364fe..e09e0a4f2b623 100644
--- a/web/src/lib/components/forms/library-rename-form.svelte
+++ b/web/src/lib/components/forms/library-rename-form.svelte
@@ -2,6 +2,7 @@
   import type { LibraryResponseDto } from '@immich/sdk';
   import { createEventDispatcher } from 'svelte';
   import Button from '../elements/buttons/button.svelte';
+  import { t } from 'svelte-i18n';
 
   export let library: Partial<LibraryResponseDto>;
 
@@ -20,11 +21,11 @@
 
 <form on:submit|preventDefault={() => handleSubmit()} autocomplete="off" class="m-4 flex flex-col gap-2">
   <div class="flex flex-col gap-2">
-    <label class="immich-form-label" for="path">Name</label>
+    <label class="immich-form-label" for="path">{$t('name')}</label>
     <input class="immich-form-input" id="name" name="name" type="text" bind:value={library.name} />
   </div>
   <div class="flex w-full justify-end gap-2 pt-2">
-    <Button size="sm" color="gray" on:click={() => handleCancel()}>Cancel</Button>
-    <Button size="sm" type="submit">Save</Button>
+    <Button size="sm" color="gray" on:click={() => handleCancel()}>{$t('cancel')}</Button>
+    <Button size="sm" type="submit">{$t('save')}</Button>
   </div>
 </form>
diff --git a/web/src/lib/components/forms/library-scan-settings-form.svelte b/web/src/lib/components/forms/library-scan-settings-form.svelte
index 244d99bf372f0..177c1a905e7b2 100644
--- a/web/src/lib/components/forms/library-scan-settings-form.svelte
+++ b/web/src/lib/components/forms/library-scan-settings-form.svelte
@@ -6,6 +6,7 @@
   import Button from '../elements/buttons/button.svelte';
   import LibraryExclusionPatternForm from './library-exclusion-pattern-form.svelte';
   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
+  import { t } from 'svelte-i18n';
 
   export let library: Partial<LibraryResponseDto>;
 
@@ -102,7 +103,7 @@
 
 {#if addExclusionPattern}
   <LibraryExclusionPatternForm
-    submitText="Add"
+    submitText={$t('add')}
     bind:exclusionPattern={exclusionPatternToAdd}
     {exclusionPatterns}
     on:submit={handleAddExclusionPattern}
@@ -114,7 +115,7 @@
 
 {#if editExclusionPattern != undefined}
   <LibraryExclusionPatternForm
-    submitText="Save"
+    submitText={$t('save')}
     isEditing={true}
     bind:exclusionPattern={editedExclusionPattern}
     {exclusionPatterns}
@@ -142,7 +143,7 @@
             <CircleIconButton
               color="primary"
               icon={mdiPencilOutline}
-              title="Edit exclusion pattern"
+              title={$t('edit_exclusion_pattern')}
               size="16"
               on:click={() => {
                 editExclusionPattern = listIndex;
@@ -169,7 +170,7 @@
             size="sm"
             on:click={() => {
               addExclusionPattern = true;
-            }}>Add exclusion pattern</Button
+            }}>{$t('add_exclusion_pattern')}</Button
           ></td
         ></tr
       >
@@ -177,7 +178,7 @@
   </table>
 
   <div class="flex w-full justify-end gap-4">
-    <Button size="sm" color="gray" on:click={() => handleCancel()}>Cancel</Button>
-    <Button size="sm" type="submit">Save</Button>
+    <Button size="sm" color="gray" on:click={() => handleCancel()}>{$t('cancel')}</Button>
+    <Button size="sm" type="submit">{$t('save')}</Button>
   </div>
 </form>
diff --git a/web/src/lib/components/forms/library-user-picker-form.svelte b/web/src/lib/components/forms/library-user-picker-form.svelte
index c3293c3636cd2..ae323417c0ce8 100644
--- a/web/src/lib/components/forms/library-user-picker-form.svelte
+++ b/web/src/lib/components/forms/library-user-picker-form.svelte
@@ -7,6 +7,7 @@
   import { searchUsersAdmin } from '@immich/sdk';
   import { user } from '$lib/stores/user.store';
   import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
+  import { t } from 'svelte-i18n';
 
   let ownerId: string = $user.id;
 
@@ -27,14 +28,14 @@
   const handleSubmit = () => dispatch('submit', { ownerId });
 </script>
 
-<FullScreenModal title="Select library owner" icon={mdiFolderSync} onClose={handleCancel}>
+<FullScreenModal title={$t('select_library_owner')} icon={mdiFolderSync} onClose={handleCancel}>
   <form on:submit|preventDefault={() => handleSubmit()} autocomplete="off" id="select-library-owner-form">
     <p class="p-5 text-sm">NOTE: This cannot be changed later!</p>
 
     <SettingSelect bind:value={ownerId} options={userOptions} name="user" />
   </form>
   <svelte:fragment slot="sticky-bottom">
-    <Button color="gray" fullwidth on:click={() => handleCancel()}>Cancel</Button>
-    <Button type="submit" fullwidth form="select-library-owner-form">Create</Button>
+    <Button color="gray" fullwidth on:click={() => handleCancel()}>{$t('cancel')}</Button>
+    <Button type="submit" fullwidth form="select-library-owner-form">{$t('create')}</Button>
   </svelte:fragment>
 </FullScreenModal>
diff --git a/web/src/lib/components/forms/login-form.svelte b/web/src/lib/components/forms/login-form.svelte
index 564c3aab415d2..9bfffa90bbf09 100644
--- a/web/src/lib/components/forms/login-form.svelte
+++ b/web/src/lib/components/forms/login-form.svelte
@@ -10,6 +10,7 @@
   import { fade } from 'svelte/transition';
   import Button from '../elements/buttons/button.svelte';
   import PasswordField from '../shared-components/password-field.svelte';
+  import { t } from 'svelte-i18n';
 
   export let onSuccess: () => unknown | Promise<unknown>;
   export let onFirstLogin: () => unknown | Promise<unknown>;
@@ -99,7 +100,7 @@
     {/if}
 
     <div class="flex flex-col gap-2">
-      <label class="immich-form-label" for="email">Email</label>
+      <label class="immich-form-label" for="email">{$t('email')}</label>
       <input
         class="immich-form-input"
         id="email"
@@ -112,7 +113,7 @@
     </div>
 
     <div class="flex flex-col gap-2">
-      <label class="immich-form-label" for="password">Password</label>
+      <label class="immich-form-label" for="password">{$t('password')}</label>
       <PasswordField id="password" bind:password autocomplete="current-password" />
     </div>
 
@@ -165,5 +166,5 @@
 {/if}
 
 {#if !$featureFlags.passwordLogin && !$featureFlags.oauth}
-  <p class="p-4 text-center dark:text-immich-dark-fg">Login has been disabled.</p>
+  <p class="p-4 text-center dark:text-immich-dark-fg">{$t('login_has_been_disabled')}</p>
 {/if}
diff --git a/web/src/lib/components/map-page/map-settings-modal.svelte b/web/src/lib/components/map-page/map-settings-modal.svelte
index a45ffc4bba368..ccbdc9b03c1fd 100644
--- a/web/src/lib/components/map-page/map-settings-modal.svelte
+++ b/web/src/lib/components/map-page/map-settings-modal.svelte
@@ -1,13 +1,14 @@
 <script lang="ts">
   import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
+  import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
+  import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
   import type { MapSettings } from '$lib/stores/preferences.store';
   import { Duration } from 'luxon';
   import { createEventDispatcher } from 'svelte';
+  import { t } from 'svelte-i18n';
   import { fly } from 'svelte/transition';
   import Button from '../elements/buttons/button.svelte';
   import LinkButton from '../elements/buttons/link-button.svelte';
-  import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
-  import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
   import DateInput from '../elements/date-input.svelte';
 
   export let settings: MapSettings;
@@ -21,21 +22,21 @@
   const handleClose = () => dispatch('close');
 </script>
 
-<FullScreenModal title="Map settings" onClose={handleClose}>
+<FullScreenModal title={$t('map_settings')} onClose={handleClose}>
   <form
     on:submit|preventDefault={() => dispatch('save', settings)}
     class="flex flex-col gap-4 text-immich-primary dark:text-immich-dark-primary"
     id="map-settings-form"
   >
-    <SettingSwitch title="Allow dark mode" bind:checked={settings.allowDarkMode} />
-    <SettingSwitch title="Only favorites" bind:checked={settings.onlyFavorites} />
-    <SettingSwitch title="Include archived" bind:checked={settings.includeArchived} />
-    <SettingSwitch title="Include shared partner assets" bind:checked={settings.withPartners} />
-    <SettingSwitch title="Include shared albums" bind:checked={settings.withSharedAlbums} />
+    <SettingSwitch title={$t('allow_dark_mode')} bind:checked={settings.allowDarkMode} />
+    <SettingSwitch title={$t('only_favorites')} bind:checked={settings.onlyFavorites} />
+    <SettingSwitch title={$t('include_archived')} bind:checked={settings.includeArchived} />
+    <SettingSwitch title={$t('include_shared_partner_assets')} bind:checked={settings.withPartners} />
+    <SettingSwitch title={$t('include_shared_albums')} bind:checked={settings.withSharedAlbums} />
     {#if customDateRange}
       <div in:fly={{ y: 10, duration: 200 }} class="flex flex-col gap-4">
         <div class="flex items-center justify-between gap-8">
-          <label class="immich-form-label shrink-0 text-sm" for="date-after">Date after</label>
+          <label class="immich-form-label shrink-0 text-sm" for="date-after">{$t('date_after')}</label>
           <DateInput
             class="immich-form-input w-40"
             type="date"
@@ -45,7 +46,7 @@
           />
         </div>
         <div class="flex items-center justify-between gap-8">
-          <label class="immich-form-label shrink-0 text-sm" for="date-before">Date before</label>
+          <label class="immich-form-label shrink-0 text-sm" for="date-before">{$t('date_before')}</label>
           <DateInput class="immich-form-input w-40" type="date" id="date-before" bind:value={settings.dateBefore} />
         </div>
         <div class="flex justify-center text-xs">
@@ -63,7 +64,7 @@
     {:else}
       <div in:fly={{ y: -10, duration: 200 }} class="flex flex-col gap-1">
         <SettingSelect
-          label="Date range"
+          label={$t('date_range')}
           name="date-range"
           bind:value={settings.relativeDate}
           options={[
@@ -73,23 +74,23 @@
             },
             {
               value: Duration.fromObject({ hours: 24 }).toISO() || '',
-              text: 'Past 24 hours',
+              text: $t('past_durations.hours', { values: { hours: 24 } }),
             },
             {
               value: Duration.fromObject({ days: 7 }).toISO() || '',
-              text: 'Past 7 days',
+              text: $t('past_durations.days', { values: { days: 7 } }),
             },
             {
               value: Duration.fromObject({ days: 30 }).toISO() || '',
-              text: 'Past 30 days',
+              text: $t('past_durations.days', { values: { days: 30 } }),
             },
             {
               value: Duration.fromObject({ years: 1 }).toISO() || '',
-              text: 'Past year',
+              text: $t('past_durations.years', { values: { years: 1 } }),
             },
             {
               value: Duration.fromObject({ years: 3 }).toISO() || '',
-              text: 'Past 3 years',
+              text: $t('past_durations.years', { values: { years: 3 } }),
             },
           ]}
         />
@@ -107,7 +108,7 @@
     {/if}
   </form>
   <svelte:fragment slot="sticky-bottom">
-    <Button color="gray" size="sm" fullwidth on:click={handleClose}>Cancel</Button>
-    <Button type="submit" size="sm" fullwidth form="map-settings-form">Save</Button>
+    <Button color="gray" size="sm" fullwidth on:click={handleClose}>{$t('cancel')}</Button>
+    <Button type="submit" size="sm" fullwidth form="map-settings-form">{$t('save')}</Button>
   </svelte:fragment>
 </FullScreenModal>
diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte
index dc8f63cc02469..bb0c83d1cd015 100644
--- a/web/src/lib/components/memory-page/memory-viewer.svelte
+++ b/web/src/lib/components/memory-page/memory-viewer.svelte
@@ -37,6 +37,7 @@
   import { onMount } from 'svelte';
   import { tweened } from 'svelte/motion';
   import { fade } from 'svelte/transition';
+  import { t } from 'svelte-i18n';
 
   const parseIndex = (s: string | null, max: number | null) =>
     Math.max(Math.min(Number.parseInt(s ?? '') || 0, max ?? 0), 0);
@@ -143,16 +144,16 @@
   <div class="sticky top-0 z-[90]">
     <AssetSelectControlBar assets={selectedAssets} clearSelect={() => (selectedAssets = new Set())}>
       <CreateSharedLink />
-      <CircleIconButton title="Select all" icon={mdiSelectAll} on:click={handleSelectAll} />
+      <CircleIconButton title={$t('select_all')} icon={mdiSelectAll} on:click={handleSelectAll} />
 
-      <AssetSelectContextMenu icon={mdiPlus} title="Add to...">
+      <AssetSelectContextMenu icon={mdiPlus} title={$t('add_to')}>
         <AddToAlbum />
         <AddToAlbum shared />
       </AssetSelectContextMenu>
 
       <FavoriteAction removeFavorite={isAllFavorite} onFavorite={triggerAssetUpdate} />
 
-      <AssetSelectContextMenu icon={mdiDotsVertical} title="Add">
+      <AssetSelectContextMenu icon={mdiDotsVertical} title={$t('add')}>
         <DownloadAction menuItem />
         <ChangeDate menuItem />
         <ChangeLocation menuItem />
@@ -175,7 +176,7 @@
       {#if canGoForward}
         <div class="flex place-content-center place-items-center gap-2 overflow-hidden">
           <CircleIconButton
-            title={paused ? 'Play memories' : 'Pause memories'}
+            title={paused ? $t('play_memories') : $t('pause_memories')}
             icon={paused ? mdiPlay : mdiPause}
             on:click={() => (paused = !paused)}
             class="hover:text-black"
@@ -218,7 +219,7 @@
           on:click={() => memoryWrapper.scrollIntoView({ behavior: 'smooth' })}
           disabled={!galleryInView}
         >
-          <CircleIconButton title="Hide gallery" icon={mdiChevronUp} color="light" />
+          <CircleIconButton title={$t('hide_gallery')} icon={mdiChevronUp} color="light" />
         </button>
       </div>
     {/if}
@@ -244,7 +245,7 @@
               <img
                 class="h-full w-full rounded-2xl object-cover"
                 src={getAssetThumbnailUrl({ id: previousMemory.assets[0].id, size: AssetMediaSize.Preview })}
-                alt="Previous memory"
+                alt={$t('previous_memory')}
                 draggable="false"
               />
             {:else}
@@ -252,14 +253,14 @@
                 class="h-full w-full rounded-2xl object-cover"
                 src="$lib/assets/no-thumbnail.png"
                 sizes="min(271px,186px)"
-                alt="Previous memory"
+                alt={$t('previous_memory')}
                 draggable="false"
               />
             {/if}
 
             {#if previousMemory}
               <div class="absolute bottom-4 right-4 text-left text-white">
-                <p class="text-xs font-semibold text-gray-200">PREVIOUS</p>
+                <p class="text-xs font-semibold text-gray-200">{$t('previous').toUpperCase()}</p>
                 <p class="text-xl">{memoryLaneTitle(previousMemory.yearsAgo)}</p>
               </div>
             {/if}
@@ -283,13 +284,18 @@
             <!-- CONTROL BUTTONS -->
             {#if canGoBack}
               <div class="absolute top-1/2 left-0 ml-4">
-                <CircleIconButton title="Previous memory" icon={mdiChevronLeft} color="dark" on:click={toPrevious} />
+                <CircleIconButton
+                  title={$t('previous_memory')}
+                  icon={mdiChevronLeft}
+                  color="dark"
+                  on:click={toPrevious}
+                />
               </div>
             {/if}
 
             {#if canGoForward}
               <div class="absolute top-1/2 right-0 mr-4">
-                <CircleIconButton title="Next memory" icon={mdiChevronRight} color="dark" on:click={toNext} />
+                <CircleIconButton title={$t('next_memory')} icon={mdiChevronRight} color="dark" on:click={toNext} />
               </div>
             {/if}
 
@@ -322,7 +328,7 @@
               <img
                 class="h-full w-full rounded-2xl object-cover"
                 src={getAssetThumbnailUrl({ id: nextMemory.assets[0].id, size: AssetMediaSize.Preview })}
-                alt="Next memory"
+                alt={$t('next_memory')}
                 draggable="false"
               />
             {:else}
@@ -330,14 +336,14 @@
                 class="h-full w-full rounded-2xl object-cover"
                 src="$lib/assets/no-thumbnail.png"
                 sizes="min(271px,186px)"
-                alt="Next memory"
+                alt={$t('next_memory')}
                 draggable="false"
               />
             {/if}
 
             {#if nextMemory}
               <div class="absolute bottom-4 left-4 text-left text-white">
-                <p class="text-xs font-semibold text-gray-200">UP NEXT</p>
+                <p class="text-xs font-semibold text-gray-200">{$t('up_next').toUpperCase()}</p>
                 <p class="text-xl">{memoryLaneTitle(nextMemory.yearsAgo)}</p>
               </div>
             {/if}
@@ -354,7 +360,7 @@
         class:opacity-100={!galleryInView}
       >
         <CircleIconButton
-          title="Show gallery"
+          title={$t('show_gallery')}
           icon={mdiChevronDown}
           color="light"
           on:click={() => memoryGallery.scrollIntoView({ behavior: 'smooth' })}
diff --git a/web/src/lib/components/onboarding-page/onboarding-hello.svelte b/web/src/lib/components/onboarding-page/onboarding-hello.svelte
index 00485fadfefe5..a3c7a0b93dbb7 100644
--- a/web/src/lib/components/onboarding-page/onboarding-hello.svelte
+++ b/web/src/lib/components/onboarding-page/onboarding-hello.svelte
@@ -6,6 +6,7 @@
   import { createEventDispatcher } from 'svelte';
   import ImmichLogo from '../shared-components/immich-logo.svelte';
   import { user } from '$lib/stores/user.store';
+  import { t } from 'svelte-i18n';
 
   const dispatch = createEventDispatcher<{
     done: void;
@@ -21,7 +22,7 @@
 
   <div class="w-full flex place-content-end">
     <Button class="flex gap-2 place-content-center" on:click={() => dispatch('done')}>
-      <p>Theme</p>
+      <p>{$t('theme')}</p>
       <Icon path={mdiArrowRight} size="18" />
     </Button>
   </div>
diff --git a/web/src/lib/components/onboarding-page/onboarding-storage-template.svelte b/web/src/lib/components/onboarding-page/onboarding-storage-template.svelte
index e39b473f67491..23f32459bb418 100644
--- a/web/src/lib/components/onboarding-page/onboarding-storage-template.svelte
+++ b/web/src/lib/components/onboarding-page/onboarding-storage-template.svelte
@@ -9,6 +9,7 @@
   import Button from '../elements/buttons/button.svelte';
   import Icon from '../elements/icon.svelte';
   import OnboardingCard from './onboarding-card.svelte';
+  import { t } from 'svelte-i18n';
 
   const dispatch = createEventDispatcher<{
     done: void;
@@ -23,7 +24,9 @@
 </script>
 
 <OnboardingCard>
-  <p class="text-xl text-immich-primary dark:text-immich-dark-primary">STORAGE TEMPLATE</p>
+  <p class="text-xl text-immich-primary dark:text-immich-dark-primary">
+    {$t('storage_template_settings').toUpperCase()}
+  </p>
 
   <p>
     When enabled, this feature will auto-organize files based on a user-defined template. Due to stability issues the
@@ -46,7 +49,7 @@
           <div class="w-full flex place-content-start">
             <Button class="flex gap-2 place-content-center" on:click={() => dispatch('previous')}>
               <Icon path={mdiArrowLeft} size="18" />
-              <p>Theme</p>
+              <p>{$t('theme')}</p>
             </Button>
           </div>
           <div class="flex w-full place-content-end">
diff --git a/web/src/lib/components/onboarding-page/onboarding-theme.svelte b/web/src/lib/components/onboarding-page/onboarding-theme.svelte
index 82cc75bb899ad..2f2928ab4d4cc 100644
--- a/web/src/lib/components/onboarding-page/onboarding-theme.svelte
+++ b/web/src/lib/components/onboarding-page/onboarding-theme.svelte
@@ -7,6 +7,7 @@
   import { colorTheme } from '$lib/stores/preferences.store';
   import { moonPath, moonViewBox, sunPath, sunViewBox } from '$lib/assets/svg-paths';
   import { Theme } from '$lib/constants';
+  import { t } from 'svelte-i18n';
 
   const dispatch = createEventDispatcher<{
     done: void;
@@ -15,7 +16,7 @@
 </script>
 
 <OnboardingCard>
-  <p class="text-xl text-immich-primary dark:text-immich-dark-primary">COLOR THEME</p>
+  <p class="text-xl text-immich-primary dark:text-immich-dark-primary">{$t('color_theme').toUpperCase()}</p>
 
   <div>
     <p class="pb-6 font-light">Choose a color theme for your instance. You can change this later in your settings.</p>
@@ -31,7 +32,7 @@
         class="flex flex-col place-items-center place-content-center justify-around h-full w-full text-immich-primary"
       >
         <Icon path={sunPath} viewBox={sunViewBox} size="96" />
-        <p class="font-semibold text-4xl">LIGHT</p>
+        <p class="font-semibold text-4xl">{$t('light').toUpperCase()}</p>
       </div>
     </button>
     <button
@@ -43,7 +44,7 @@
         class="flex flex-col place-items-center place-content-center justify-around h-full w-full text-immich-dark-primary"
       >
         <Icon path={moonPath} viewBox={moonViewBox} size="96" />
-        <p class="font-semibold text-4xl">DARK</p>
+        <p class="font-semibold text-4xl">{$t('dark').toUpperCase()}</p>
       </div>
     </button>
   </div>
@@ -51,7 +52,7 @@
   <div class="flex">
     <div class="w-full flex place-content-end">
       <Button class="flex gap-2 place-content-center" on:click={() => dispatch('done')}>
-        <p>Storage Template</p>
+        <p>{$t('admin.storage_template_settings')}</p>
         <Icon path={mdiArrowRight} size="18" />
       </Button>
     </div>
diff --git a/web/src/lib/components/photos-page/actions/add-to-album.svelte b/web/src/lib/components/photos-page/actions/add-to-album.svelte
index 1a0b2efd62061..7cd3e8a28a989 100644
--- a/web/src/lib/components/photos-page/actions/add-to-album.svelte
+++ b/web/src/lib/components/photos-page/actions/add-to-album.svelte
@@ -6,6 +6,7 @@
   import { getMenuContext } from '../asset-select-context-menu.svelte';
   import { getAssetControlContext } from '../asset-select-control-bar.svelte';
   import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js';
+  import { t } from 'svelte-i18n';
 
   export let shared = false;
 
@@ -37,7 +38,7 @@
 
 <MenuOption
   on:click={() => (showAlbumPicker = true)}
-  text={shared ? 'Add to shared album' : 'Add to album'}
+  text={shared ? $t('add_to_shared_album') : $t('add_to_album')}
   icon={shared ? mdiShareVariantOutline : mdiImageAlbum}
 />
 
diff --git a/web/src/lib/components/photos-page/actions/archive-action.svelte b/web/src/lib/components/photos-page/actions/archive-action.svelte
index f111db80361b3..969dbf3533f0f 100644
--- a/web/src/lib/components/photos-page/actions/archive-action.svelte
+++ b/web/src/lib/components/photos-page/actions/archive-action.svelte
@@ -10,13 +10,14 @@
   import { mdiArchiveArrowDownOutline, mdiArchiveArrowUpOutline, mdiTimerSand } from '@mdi/js';
   import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
   import { getAssetControlContext } from '../asset-select-control-bar.svelte';
+  import { t } from 'svelte-i18n';
 
   export let onArchive: OnArchive;
 
   export let menuItem = false;
   export let unarchive = false;
 
-  $: text = unarchive ? 'Unarchive' : 'Archive';
+  $: text = unarchive ? $t('unarchive') : $t('archive');
   $: icon = unarchive ? mdiArchiveArrowUpOutline : mdiArchiveArrowDownOutline;
 
   let loading = false;
@@ -42,7 +43,7 @@
       onArchive(ids, isArchived);
 
       notificationController.show({
-        message: `${isArchived ? 'Archived' : 'Unarchived'} ${ids.length}`,
+        message: `${isArchived ? $t('archived') : $t('unarchived')} ${ids.length}`,
         type: NotificationType.Info,
       });
 
@@ -61,7 +62,7 @@
 
 {#if !menuItem}
   {#if loading}
-    <CircleIconButton title="Loading" icon={mdiTimerSand} />
+    <CircleIconButton title={$t('loading')} icon={mdiTimerSand} />
   {:else}
     <CircleIconButton title={text} {icon} on:click={handleArchive} />
   {/if}
diff --git a/web/src/lib/components/photos-page/actions/asset-job-actions.svelte b/web/src/lib/components/photos-page/actions/asset-job-actions.svelte
index 0dddaf7938783..9294bd34d43ed 100644
--- a/web/src/lib/components/photos-page/actions/asset-job-actions.svelte
+++ b/web/src/lib/components/photos-page/actions/asset-job-actions.svelte
@@ -8,6 +8,7 @@
   import { handleError } from '$lib/utils/handle-error';
   import { AssetJobName, AssetTypeEnum, runAssetJobs } from '@immich/sdk';
   import { getAssetControlContext } from '../asset-select-control-bar.svelte';
+  import { t } from 'svelte-i18n';
 
   export let jobs: AssetJobName[] = [
     AssetJobName.RegenerateThumbnail,
@@ -26,7 +27,7 @@
       notificationController.show({ message: getAssetJobMessage(name), type: NotificationType.Info });
       clearSelect();
     } catch (error) {
-      handleError(error, 'Unable to submit job');
+      handleError(error, $t('errors.unable_to_submit_job'));
     }
   };
 </script>
diff --git a/web/src/lib/components/photos-page/actions/change-date-action.svelte b/web/src/lib/components/photos-page/actions/change-date-action.svelte
index 412a2f2ca7000..ded3e663cd47d 100644
--- a/web/src/lib/components/photos-page/actions/change-date-action.svelte
+++ b/web/src/lib/components/photos-page/actions/change-date-action.svelte
@@ -8,6 +8,7 @@
   import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
   import { getAssetControlContext } from '../asset-select-control-bar.svelte';
   import { mdiCalendarEditOutline } from '@mdi/js';
+  import { t } from 'svelte-i18n';
   export let menuItem = false;
   const { clearSelect, getOwnedAssets } = getAssetControlContext();
 
@@ -20,14 +21,14 @@
     try {
       await updateAssets({ assetBulkUpdateDto: { ids, dateTimeOriginal } });
     } catch (error) {
-      handleError(error, 'Unable to change date');
+      handleError(error, $t('errors.unable_to_change_date'));
     }
     clearSelect();
   };
 </script>
 
 {#if menuItem}
-  <MenuOption text="Change date" icon={mdiCalendarEditOutline} on:click={() => (isShowChangeDate = true)} />
+  <MenuOption text={$t('change_date')} icon={mdiCalendarEditOutline} on:click={() => (isShowChangeDate = true)} />
 {/if}
 {#if isShowChangeDate}
   <ChangeDate
diff --git a/web/src/lib/components/photos-page/actions/change-location-action.svelte b/web/src/lib/components/photos-page/actions/change-location-action.svelte
index 6e3a24a57559a..d0c355ce1eef6 100644
--- a/web/src/lib/components/photos-page/actions/change-location-action.svelte
+++ b/web/src/lib/components/photos-page/actions/change-location-action.svelte
@@ -7,6 +7,7 @@
   import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
   import { getAssetControlContext } from '../asset-select-control-bar.svelte';
   import { mdiMapMarkerMultipleOutline } from '@mdi/js';
+  import { t } from 'svelte-i18n';
 
   export let menuItem = false;
   const { clearSelect, getOwnedAssets } = getAssetControlContext();
@@ -20,7 +21,7 @@
     try {
       await updateAssets({ assetBulkUpdateDto: { ids, latitude: point.lat, longitude: point.lng } });
     } catch (error) {
-      handleError(error, 'Unable to update location');
+      handleError(error, $t('errors.unable_to_update_location'));
     }
     clearSelect();
   }
@@ -28,7 +29,7 @@
 
 {#if menuItem}
   <MenuOption
-    text="Change location"
+    text={$t('change_location')}
     icon={mdiMapMarkerMultipleOutline}
     on:click={() => (isShowChangeLocation = true)}
   />
diff --git a/web/src/lib/components/photos-page/actions/create-shared-link.svelte b/web/src/lib/components/photos-page/actions/create-shared-link.svelte
index 0bd00b95665a6..7436ff2177514 100644
--- a/web/src/lib/components/photos-page/actions/create-shared-link.svelte
+++ b/web/src/lib/components/photos-page/actions/create-shared-link.svelte
@@ -3,12 +3,13 @@
   import CreateSharedLinkModal from '$lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte';
   import { mdiShareVariantOutline } from '@mdi/js';
   import { getAssetControlContext } from '../asset-select-control-bar.svelte';
+  import { t } from 'svelte-i18n';
 
   let showModal = false;
   const { getAssets } = getAssetControlContext();
 </script>
 
-<CircleIconButton title="Share" icon={mdiShareVariantOutline} on:click={() => (showModal = true)} />
+<CircleIconButton title={$t('share')} icon={mdiShareVariantOutline} on:click={() => (showModal = true)} />
 
 {#if showModal}
   <CreateSharedLinkModal assetIds={[...getAssets()].map(({ id }) => id)} onClose={() => (showModal = false)} />
diff --git a/web/src/lib/components/photos-page/actions/delete-assets.svelte b/web/src/lib/components/photos-page/actions/delete-assets.svelte
index 2104203d97c20..b83fc6629f538 100644
--- a/web/src/lib/components/photos-page/actions/delete-assets.svelte
+++ b/web/src/lib/components/photos-page/actions/delete-assets.svelte
@@ -6,6 +6,7 @@
   import { mdiTimerSand, mdiDeleteOutline } from '@mdi/js';
   import { type OnDelete, deleteAssets } from '$lib/utils/actions';
   import DeleteAssetDialog from '../delete-asset-dialog.svelte';
+  import { t } from 'svelte-i18n';
 
   export let onAssetDelete: OnDelete;
   export let menuItem = false;
@@ -16,7 +17,7 @@
   let isShowConfirmation = false;
   let loading = false;
 
-  $: label = force ? 'Permanently delete' : 'Delete';
+  $: label = force ? $t('permanently_delete') : $t('delete');
 
   const handleTrash = async () => {
     if (force) {
@@ -40,7 +41,7 @@
 {#if menuItem}
   <MenuOption text={label} icon={mdiDeleteOutline} on:click={handleTrash} />
 {:else if loading}
-  <CircleIconButton title="Loading" icon={mdiTimerSand} />
+  <CircleIconButton title={$t('loading')} icon={mdiTimerSand} />
 {:else}
   <CircleIconButton title={label} icon={mdiDeleteOutline} on:click={handleTrash} />
 {/if}
diff --git a/web/src/lib/components/photos-page/actions/download-action.svelte b/web/src/lib/components/photos-page/actions/download-action.svelte
index fbdf4ae53df06..654fdcb40067b 100644
--- a/web/src/lib/components/photos-page/actions/download-action.svelte
+++ b/web/src/lib/components/photos-page/actions/download-action.svelte
@@ -4,6 +4,7 @@
   import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
   import { getAssetControlContext } from '../asset-select-control-bar.svelte';
   import { mdiCloudDownloadOutline, mdiFileDownloadOutline, mdiFolderDownloadOutline } from '@mdi/js';
+  import { t } from 'svelte-i18n';
 
   export let filename = 'immich.zip';
   export let menuItem = false;
@@ -26,7 +27,7 @@
 </script>
 
 {#if menuItem}
-  <MenuOption text="Download" icon={menuItemIcon} on:click={handleDownloadFiles} />
+  <MenuOption text={$t('download')} icon={menuItemIcon} on:click={handleDownloadFiles} />
 {:else}
-  <CircleIconButton title="Download" icon={mdiCloudDownloadOutline} on:click={handleDownloadFiles} />
+  <CircleIconButton title={$t('download')} icon={mdiCloudDownloadOutline} on:click={handleDownloadFiles} />
 {/if}
diff --git a/web/src/lib/components/photos-page/actions/favorite-action.svelte b/web/src/lib/components/photos-page/actions/favorite-action.svelte
index 520dd46564391..9f70751bf19e7 100644
--- a/web/src/lib/components/photos-page/actions/favorite-action.svelte
+++ b/web/src/lib/components/photos-page/actions/favorite-action.svelte
@@ -10,13 +10,14 @@
   import { updateAssets } from '@immich/sdk';
   import { mdiHeartMinusOutline, mdiHeartOutline, mdiTimerSand } from '@mdi/js';
   import { getAssetControlContext } from '../asset-select-control-bar.svelte';
+  import { t } from 'svelte-i18n';
 
   export let onFavorite: OnFavorite;
 
   export let menuItem = false;
   export let removeFavorite: boolean;
 
-  $: text = removeFavorite ? 'Remove from favorites' : 'Favorite';
+  $: text = removeFavorite ? $t('remove_from_favorites') : $t('favorite');
   $: icon = removeFavorite ? mdiHeartMinusOutline : mdiHeartOutline;
 
   let loading = false;
@@ -62,7 +63,7 @@
 
 {#if !menuItem}
   {#if loading}
-    <CircleIconButton title="Loading" icon={mdiTimerSand} />
+    <CircleIconButton title={$t('loading')} icon={mdiTimerSand} />
   {:else}
     <CircleIconButton title={text} {icon} on:click={handleFavorite} />
   {/if}
diff --git a/web/src/lib/components/photos-page/actions/remove-from-album.svelte b/web/src/lib/components/photos-page/actions/remove-from-album.svelte
index 848524e1e1f66..0af95a218fbe6 100644
--- a/web/src/lib/components/photos-page/actions/remove-from-album.svelte
+++ b/web/src/lib/components/photos-page/actions/remove-from-album.svelte
@@ -10,6 +10,7 @@
   import { getAssetControlContext } from '../asset-select-control-bar.svelte';
   import { s } from '$lib/utils';
   import { dialogController } from '$lib/components/shared-components/dialog/dialog';
+  import { t } from 'svelte-i18n';
 
   export let album: AlbumResponseDto;
   export let onRemove: ((assetIds: string[]) => void) | undefined;
@@ -56,7 +57,7 @@
 </script>
 
 {#if menuItem}
-  <MenuOption text="Remove from album" icon={mdiImageRemoveOutline} on:click={removeFromAlbum} />
+  <MenuOption text={$t('remove_from_album')} icon={mdiImageRemoveOutline} on:click={removeFromAlbum} />
 {:else}
-  <CircleIconButton title="Remove from album" icon={mdiDeleteOutline} on:click={removeFromAlbum} />
+  <CircleIconButton title={$t('remove_from_album')} icon={mdiDeleteOutline} on:click={removeFromAlbum} />
 {/if}
diff --git a/web/src/lib/components/photos-page/actions/remove-from-shared-link.svelte b/web/src/lib/components/photos-page/actions/remove-from-shared-link.svelte
index c9c8a3c736a77..ea8c3e3bec2ec 100644
--- a/web/src/lib/components/photos-page/actions/remove-from-shared-link.svelte
+++ b/web/src/lib/components/photos-page/actions/remove-from-shared-link.svelte
@@ -7,6 +7,7 @@
   import { NotificationType, notificationController } from '../../shared-components/notification/notification';
   import { getAssetControlContext } from '../asset-select-control-bar.svelte';
   import { dialogController } from '$lib/components/shared-components/dialog/dialog';
+  import { t } from 'svelte-i18n';
 
   export let sharedLink: SharedLinkResponseDto;
 
@@ -55,4 +56,4 @@
   };
 </script>
 
-<CircleIconButton title="Remove from shared link" on:click={handleRemove} icon={mdiDeleteOutline} />
+<CircleIconButton title={$t('remove_from_shared_link')} on:click={handleRemove} icon={mdiDeleteOutline} />
diff --git a/web/src/lib/components/photos-page/actions/restore-assets.svelte b/web/src/lib/components/photos-page/actions/restore-assets.svelte
index e6570eafe207d..36f8d214f49be 100644
--- a/web/src/lib/components/photos-page/actions/restore-assets.svelte
+++ b/web/src/lib/components/photos-page/actions/restore-assets.svelte
@@ -10,6 +10,7 @@
   import { mdiHistory } from '@mdi/js';
   import Button from '../../elements/buttons/button.svelte';
   import { getAssetControlContext } from '../asset-select-control-bar.svelte';
+  import { t } from 'svelte-i18n';
 
   export let onRestore: OnRestore | undefined;
 
@@ -32,7 +33,7 @@
 
       clearSelect();
     } catch (error) {
-      handleError(error, 'Error restoring assets');
+      handleError(error, $t('errors.unable_to_restore_assets'));
     } finally {
       loading = false;
     }
@@ -41,5 +42,5 @@
 
 <Button disabled={loading} size="sm" color="transparent-gray" shadow={false} rounded="lg" on:click={handleRestore}>
   <Icon path={mdiHistory} size="24" />
-  <span class="ml-2">Restore</span>
+  <span class="ml-2">{$t('restore')}</span>
 </Button>
diff --git a/web/src/lib/components/photos-page/actions/select-all-assets.svelte b/web/src/lib/components/photos-page/actions/select-all-assets.svelte
index 7d1f7c5f9e92c..98ee86ac6373e 100644
--- a/web/src/lib/components/photos-page/actions/select-all-assets.svelte
+++ b/web/src/lib/components/photos-page/actions/select-all-assets.svelte
@@ -4,6 +4,7 @@
   import { type AssetStore, isSelectingAllAssets } from '$lib/stores/assets.store';
   import { mdiSelectAll, mdiSelectRemove } from '@mdi/js';
   import { selectAllAssets } from '$lib/utils/asset-utils';
+  import { t } from 'svelte-i18n';
 
   export let assetStore: AssetStore;
   export let assetInteractionStore: AssetInteractionStore;
@@ -19,7 +20,7 @@
 </script>
 
 {#if $isSelectingAllAssets}
-  <CircleIconButton title="Unselect all" icon={mdiSelectRemove} on:click={handleCancel} />
+  <CircleIconButton title={$t('unselect_all')} icon={mdiSelectRemove} on:click={handleCancel} />
 {:else}
-  <CircleIconButton title="Select all" icon={mdiSelectAll} on:click={handleSelectAll} />
+  <CircleIconButton title={$t('select_all')} icon={mdiSelectAll} on:click={handleSelectAll} />
 {/if}
diff --git a/web/src/lib/components/photos-page/actions/stack-action.svelte b/web/src/lib/components/photos-page/actions/stack-action.svelte
index b6a034672be99..f2e835aecf709 100644
--- a/web/src/lib/components/photos-page/actions/stack-action.svelte
+++ b/web/src/lib/components/photos-page/actions/stack-action.svelte
@@ -4,6 +4,7 @@
   import { mdiImageMinusOutline, mdiImageMultipleOutline } from '@mdi/js';
   import { stackAssets, unstackAssets } from '$lib/utils/asset-utils';
   import type { OnStack, OnUnstack } from '$lib/utils/actions';
+  import { t } from 'svelte-i18n';
 
   export let unstack = false;
   export let onStack: OnStack | undefined;
@@ -39,7 +40,7 @@
 </script>
 
 {#if unstack}
-  <MenuOption text="Un-stack" icon={mdiImageMinusOutline} on:click={handleUnstack} />
+  <MenuOption text={$t('un-stack')} icon={mdiImageMinusOutline} on:click={handleUnstack} />
 {:else}
-  <MenuOption text="Stack" icon={mdiImageMultipleOutline} on:click={handleStack} />
+  <MenuOption text={$t('stack')} icon={mdiImageMultipleOutline} on:click={handleStack} />
 {/if}
diff --git a/web/src/lib/components/photos-page/delete-asset-dialog.svelte b/web/src/lib/components/photos-page/delete-asset-dialog.svelte
index abc778be70b0e..70870362aa219 100644
--- a/web/src/lib/components/photos-page/delete-asset-dialog.svelte
+++ b/web/src/lib/components/photos-page/delete-asset-dialog.svelte
@@ -4,6 +4,7 @@
   import { showDeleteModal } from '$lib/stores/preferences.store';
   import Checkbox from '$lib/components/elements/checkbox.svelte';
   import { s } from '$lib/utils';
+  import { t } from 'svelte-i18n';
 
   export let size: number;
 
@@ -24,7 +25,7 @@
 
 <ConfirmDialog
   title="Permanently delete asset{s(size)}"
-  confirmText="Delete"
+  confirmText={$t('delete')}
   onConfirm={handleConfirm}
   onCancel={() => dispatch('cancel')}
 >
diff --git a/web/src/lib/components/share-page/individual-shared-viewer.svelte b/web/src/lib/components/share-page/individual-shared-viewer.svelte
index 8a3b6e7cab1b3..831c98db1d27a 100644
--- a/web/src/lib/components/share-page/individual-shared-viewer.svelte
+++ b/web/src/lib/components/share-page/individual-shared-viewer.svelte
@@ -17,6 +17,7 @@
   import ImmichLogoSmallLink from '$lib/components/shared-components/immich-logo-small-link.svelte';
   import { NotificationType, notificationController } from '../shared-components/notification/notification';
   import type { Viewport } from '$lib/stores/assets.store';
+  import { t } from 'svelte-i18n';
 
   export let sharedLink: SharedLinkResponseDto;
   export let isOwned: boolean;
@@ -74,7 +75,7 @@
 <section class="bg-immich-bg dark:bg-immich-dark-bg">
   {#if isMultiSelectionMode}
     <AssetSelectControlBar assets={selectedAssets} clearSelect={() => (selectedAssets = new Set())}>
-      <CircleIconButton title="Select all" icon={mdiSelectAll} on:click={handleSelectAll} />
+      <CircleIconButton title={$t('select_all')} icon={mdiSelectAll} on:click={handleSelectAll} />
       {#if sharedLink?.allowDownload}
         <DownloadAction filename="immich-shared.zip" />
       {/if}
@@ -90,11 +91,15 @@
 
       <svelte:fragment slot="trailing">
         {#if sharedLink?.allowUpload}
-          <CircleIconButton title="Add Photos" on:click={() => handleUploadAssets()} icon={mdiFileImagePlusOutline} />
+          <CircleIconButton
+            title={$t('add_photos')}
+            on:click={() => handleUploadAssets()}
+            icon={mdiFileImagePlusOutline}
+          />
         {/if}
 
         {#if sharedLink?.allowDownload}
-          <CircleIconButton title="Download" on:click={downloadAssets} icon={mdiFolderDownloadOutline} />
+          <CircleIconButton title={$t('download')} on:click={downloadAssets} icon={mdiFolderDownloadOutline} />
         {/if}
       </svelte:fragment>
     </ControlAppBar>
diff --git a/web/src/lib/components/shared-components/album-selection-modal.svelte b/web/src/lib/components/shared-components/album-selection-modal.svelte
index 7906d5a5c5eb1..85d68b7264d19 100644
--- a/web/src/lib/components/shared-components/album-selection-modal.svelte
+++ b/web/src/lib/components/shared-components/album-selection-modal.svelte
@@ -7,6 +7,7 @@
   import { normalizeSearchString } from '$lib/utils/string-utils';
   import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
   import { initInput } from '$lib/actions/focus';
+  import { t } from 'svelte-i18n';
 
   let albums: AlbumResponseDto[] = [];
   let recentAlbums: AlbumResponseDto[] = [];
@@ -47,9 +48,9 @@
 
   const getTitle = () => {
     if (shared) {
-      return 'Add to shared album';
+      return $t('add_to_shared_album');
     }
-    return 'Add to album';
+    return $t('add_to_album');
   };
 </script>
 
@@ -71,7 +72,7 @@
     {:else}
       <input
         class="border-b-4 border-immich-bg bg-immich-bg px-6 py-2 text-2xl focus:border-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:focus:border-immich-dark-primary"
-        placeholder="Search"
+        placeholder={$t('search')}
         bind:value={search}
         use:initInput
       />
@@ -90,7 +91,7 @@
         </button>
         {#if filteredAlbums.length > 0}
           {#if !shared && search.length === 0}
-            <p class="px-5 py-3 text-xs">RECENT</p>
+            <p class="px-5 py-3 text-xs">{$t('recent').toUpperCase()}</p>
             {#each recentAlbums as album (album.id)}
               <AlbumListItem {album} on:album={() => handleSelect(album)} />
             {/each}
diff --git a/web/src/lib/components/shared-components/change-date.svelte b/web/src/lib/components/shared-components/change-date.svelte
index eb9e705f58eda..4b2f02f86d564 100644
--- a/web/src/lib/components/shared-components/change-date.svelte
+++ b/web/src/lib/components/shared-components/change-date.svelte
@@ -4,6 +4,7 @@
   import ConfirmDialog from './dialog/confirm-dialog.svelte';
   import Combobox from './combobox.svelte';
   import DateInput from '../elements/date-input.svelte';
+  import { t } from 'svelte-i18n';
 
   export let initialDate: DateTime = DateTime.now();
 
@@ -57,7 +58,7 @@
 
 <ConfirmDialog
   confirmColor="primary"
-  title="Edit date and time"
+  title={$t('edit_date_and_time')}
   prompt="Please select a new date:"
   disabled={!date.isValid}
   onConfirm={handleConfirm}
@@ -65,7 +66,7 @@
 >
   <div class="flex flex-col text-md px-4 text-center gap-2" slot="prompt">
     <div class="flex flex-col">
-      <label for="datetime">Date and Time</label>
+      <label for="datetime">{$t('date_and_time')}</label>
       <DateInput
         class="immich-form-input text-sm my-4 w-full"
         id="datetime"
@@ -74,7 +75,7 @@
       />
     </div>
     <div class="flex flex-col w-full mt-2">
-      <Combobox bind:selectedOption label="Timezone" options={timezones} placeholder="Search timezone..." />
+      <Combobox bind:selectedOption label={$t('timezone')} options={timezones} placeholder={$t('search_timezone')} />
     </div>
   </div>
 </ConfirmDialog>
diff --git a/web/src/lib/components/shared-components/change-location.svelte b/web/src/lib/components/shared-components/change-location.svelte
index dfc85837253c5..dfff248816157 100644
--- a/web/src/lib/components/shared-components/change-location.svelte
+++ b/web/src/lib/components/shared-components/change-location.svelte
@@ -11,6 +11,7 @@
   import { searchPlaces, type AssetResponseDto, type PlacesResponseDto } from '@immich/sdk';
   import SearchBar from '../elements/search-bar.svelte';
   import { listNavigation } from '$lib/actions/list-navigation';
+  import { t } from 'svelte-i18n';
 
   export let asset: AssetResponseDto | undefined = undefined;
 
@@ -88,7 +89,7 @@
           // skip error when a newer search is happening
           if (latestSearchTimeout === searchTimeout) {
             places = [];
-            handleError(error, "Can't search places");
+            handleError(error, $t('cant_search_places'));
             showLoadingSpinner = false;
           }
         });
@@ -105,7 +106,7 @@
 
 <ConfirmDialog
   confirmColor="primary"
-  title="Change location"
+  title={$t('change_location')}
   width="wide"
   onConfirm={handleConfirm}
   onCancel={handleCancel}
@@ -118,7 +119,7 @@
     >
       <button type="button" class="w-full" on:click={() => (hideSuggestion = false)}>
         <SearchBar
-          placeholder="Search places"
+          placeholder={$t('search_places')}
           bind:name={searchWord}
           {showLoadingSpinner}
           on:reset={() => {
@@ -147,7 +148,7 @@
         {/if}
       </div>
     </div>
-    <label for="datetime">Pick a location</label>
+    <label for="datetime">{$t('pick_a_location')}</label>
     <div class="h-[500px] min-h-[300px] w-full">
       {#await import('../shared-components/map/map.svelte')}
         {#await delay(timeToLoadTheMap) then}
diff --git a/web/src/lib/components/shared-components/combobox.svelte b/web/src/lib/components/shared-components/combobox.svelte
index d924df486cf53..8293eb950ac05 100644
--- a/web/src/lib/components/shared-components/combobox.svelte
+++ b/web/src/lib/components/shared-components/combobox.svelte
@@ -20,6 +20,7 @@
   import { focusOutside } from '$lib/actions/focus-outside';
   import { generateId } from '$lib/utils/generate-id';
   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
+  import { t } from 'svelte-i18n';
 
   export let label: string;
   export let hideLabel = false;
@@ -200,7 +201,7 @@
       class:pointer-events-none={!selectedOption}
     >
       {#if selectedOption}
-        <CircleIconButton on:click={onClear} title="Clear value" icon={mdiClose} size="16" padding="2" />
+        <CircleIconButton on:click={onClear} title={$t('clear_value')} icon={mdiClose} size="16" padding="2" />
       {:else if !isOpen}
         <Icon path={mdiUnfoldMoreHorizontal} ariaHidden={true} />
       {/if}
diff --git a/web/src/lib/components/shared-components/control-app-bar.svelte b/web/src/lib/components/shared-components/control-app-bar.svelte
index 2fadb696023a8..a5693f011eb9f 100644
--- a/web/src/lib/components/shared-components/control-app-bar.svelte
+++ b/web/src/lib/components/shared-components/control-app-bar.svelte
@@ -6,6 +6,7 @@
   import { fly } from 'svelte/transition';
   import { mdiClose } from '@mdi/js';
   import { isSelectingAllAssets } from '$lib/stores/assets.store';
+  import { t } from 'svelte-i18n';
 
   export let showBackButton = true;
   export let backIcon = mdiClose;
@@ -59,7 +60,7 @@
   >
     <div class="flex place-items-center gap-6 justify-self-start dark:text-immich-dark-fg">
       {#if showBackButton}
-        <CircleIconButton title="Close" on:click={handleClose} icon={backIcon} size={'24'} class={buttonClass} />
+        <CircleIconButton title={$t('close')} on:click={handleClose} icon={backIcon} size={'24'} class={buttonClass} />
       {/if}
       <slot name="leading" />
     </div>
diff --git a/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte b/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte
index 026b4e6cb426f..a87774c9e9b84 100644
--- a/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte
+++ b/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte
@@ -14,6 +14,7 @@
   import SettingInputField, { SettingInputFieldType } from '../settings/setting-input-field.svelte';
   import SettingSwitch from '../settings/setting-switch.svelte';
   import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
+  import { t } from 'svelte-i18n';
 
   export let onClose: () => void;
   export let albumId: string | undefined = undefined;
@@ -35,8 +36,18 @@
   }>();
 
   const expiredDateOption: ImmichDropDownOption = {
-    default: 'Never',
-    options: ['Never', '30 minutes', '1 hour', '6 hours', '1 day', '7 days', '30 days', '3 months', '1 year'],
+    default: $t('never'),
+    options: [
+      $t('never'),
+      $t('durations.minutes', { values: { minutes: 30 } }),
+      $t('durations.hours', { values: { hours: 1 } }),
+      $t('durations.hours', { values: { hours: 6 } }),
+      $t('durations.days', { values: { days: 1 } }),
+      $t('durations.days', { values: { days: 7 } }),
+      $t('durations.days', { values: { days: 30 } }),
+      $t('durations.months', { values: { months: 3 } }),
+      $t('durations.years', { values: { years: 1 } }),
+    ],
   };
 
   $: shareType = albumId ? SharedLinkType.Album : SharedLinkType.Individual;
@@ -142,7 +153,7 @@
 
       notificationController.show({
         type: NotificationType.Info,
-        message: 'Edited',
+        message: $t('edited'),
       });
 
       onClose();
@@ -153,9 +164,9 @@
 
   const getTitle = () => {
     if (editingLink) {
-      return 'Edit link';
+      return $t('edit_link');
     }
-    return 'Create link to share';
+    return $t('create_link_to_share');
   };
 </script>
 
@@ -186,29 +197,33 @@
     {/if}
 
     <div class="mb-2 mt-4">
-      <p class="text-xs">LINK OPTIONS</p>
+      <p class="text-xs">{$t('link_options').toUpperCase()}</p>
     </div>
     <div class="rounded-lg bg-gray-100 p-4 dark:bg-black/40 overflow-y-auto">
       <div class="flex flex-col">
         <div class="mb-2">
-          <SettingInputField inputType={SettingInputFieldType.TEXT} label="Description" bind:value={description} />
+          <SettingInputField
+            inputType={SettingInputFieldType.TEXT}
+            label={$t('description')}
+            bind:value={description}
+          />
         </div>
 
         <div class="mb-2">
           <SettingInputField
             inputType={SettingInputFieldType.TEXT}
-            label="Password"
+            label={$t('password')}
             bind:value={password}
             disabled={!enablePassword}
           />
         </div>
 
         <div class="my-3">
-          <SettingSwitch bind:checked={enablePassword} title={'Require password'} />
+          <SettingSwitch bind:checked={enablePassword} title={$t('require_password')} />
         </div>
 
         <div class="my-3">
-          <SettingSwitch bind:checked={showMetadata} title={'Show metadata'} />
+          <SettingSwitch bind:checked={showMetadata} title={$t('show_metadata')} />
         </div>
 
         <div class="my-3">
@@ -222,10 +237,10 @@
         <div class="text-sm">
           {#if editingLink}
             <p class="immich-form-label my-2">
-              <SettingSwitch bind:checked={shouldChangeExpirationTime} title={'Change expiration time'} />
+              <SettingSwitch bind:checked={shouldChangeExpirationTime} title={$t('change_expiration_time')} />
             </p>
           {:else}
-            <p class="immich-form-label my-2">Expire after</p>
+            <p class="immich-form-label my-2">{$t('expire_after')}</p>
           {/if}
 
           <DropdownButton
@@ -241,16 +256,16 @@
   <svelte:fragment slot="sticky-bottom">
     {#if !sharedLink}
       {#if editingLink}
-        <Button size="sm" fullwidth on:click={handleEditLink}>Confirm</Button>
+        <Button size="sm" fullwidth on:click={handleEditLink}>{$t('confirm')}</Button>
       {:else}
-        <Button size="sm" fullwidth on:click={handleCreateSharedLink}>Create link</Button>
+        <Button size="sm" fullwidth on:click={handleCreateSharedLink}>{$t('create_link')}</Button>
       {/if}
     {:else}
       <div class="flex w-full gap-2">
         <input class="immich-form-input w-full" bind:value={sharedLink} disabled />
         <LinkButton on:click={() => (sharedLink ? copyToClipboard(sharedLink) : '')}>
           <div class="flex place-items-center gap-2 text-sm">
-            <Icon path={mdiContentCopy} ariaLabel="Copy link to clipboard" size="18" />
+            <Icon path={mdiContentCopy} ariaLabel={$t('copy_link_to_clipboard')} size="18" />
           </div>
         </LinkButton>
       </div>
diff --git a/web/src/lib/components/shared-components/dialog/confirm-dialog.svelte b/web/src/lib/components/shared-components/dialog/confirm-dialog.svelte
index e6a2c6bd007f3..e5b35859f2195 100644
--- a/web/src/lib/components/shared-components/dialog/confirm-dialog.svelte
+++ b/web/src/lib/components/shared-components/dialog/confirm-dialog.svelte
@@ -2,12 +2,13 @@
   import FullScreenModal from '../full-screen-modal.svelte';
   import Button from '../../elements/buttons/button.svelte';
   import type { Color } from '$lib/components/elements/buttons/button.svelte';
+  import { t } from 'svelte-i18n';
 
-  export let title = 'Confirm';
+  export let title = $t('confirm');
   export let prompt = 'Are you sure you want to do this?';
-  export let confirmText = 'Confirm';
+  export let confirmText = $t('confirm');
   export let confirmColor: Color = 'red';
-  export let cancelText = 'Cancel';
+  export let cancelText = $t('cancel');
   export let cancelColor: Color = 'secondary';
   export let hideCancelButton = false;
   export let disabled = false;
diff --git a/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte b/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte
index dc115c45b45e0..59b9a210c2ee0 100644
--- a/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte
+++ b/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte
@@ -5,6 +5,7 @@
   import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
   import { fileUploadHandler } from '$lib/utils/file-uploader';
   import { isAlbumsRoute, isSharedLinkRoute } from '$lib/utils/navigation';
+  import { t } from 'svelte-i18n';
 
   $: albumId = isAlbumsRoute($page.route?.id) ? $page.params.albumId : undefined;
   $: isShare = isSharedLinkRoute($page.route?.id);
@@ -12,7 +13,7 @@
   let dragStartTarget: EventTarget | null = null;
 
   const onDragEnter = (e: DragEvent) => {
-    if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
+    if (e.dataTransfer && e.dataTransfer.types.includes($t('files'))) {
       dragStartTarget = e.target;
     }
   };
diff --git a/web/src/lib/components/shared-components/immich-logo.svelte b/web/src/lib/components/shared-components/immich-logo.svelte
index fbfc5f8453688..952960ef3fb9e 100644
--- a/web/src/lib/components/shared-components/immich-logo.svelte
+++ b/web/src/lib/components/shared-components/immich-logo.svelte
@@ -7,6 +7,7 @@
   import { colorTheme } from '$lib/stores/preferences.store';
   import { DateTime } from 'luxon';
   import type { HTMLImgAttributes } from 'svelte/elements';
+  import { t } from 'svelte-i18n';
 
   // eslint-disable-next-line @typescript-eslint/no-unused-vars
   interface $$Props extends HTMLImgAttributes {
@@ -21,11 +22,11 @@
 </script>
 
 {#if today.month === 4 && today.day === 1}
-  <img src="data:image/png;base64, {alternativeLogo}" alt="Immich Logo" class="h-20" {draggable} />
+  <img src="data:image/png;base64, {alternativeLogo}" alt={$t('immich_logo')} class="h-20" {draggable} />
 {:else}
   <img
     src={noText ? logoNoText : $colorTheme.value == Theme.LIGHT ? logoLightUrl : logoDarkUrl}
-    alt="Immich Logo"
+    alt={$t('immich_logo')}
     {draggable}
     {...$$restProps}
   />
diff --git a/web/src/lib/components/shared-components/map/map.svelte b/web/src/lib/components/shared-components/map/map.svelte
index 1d0b3dfc5904b..c43424db079f3 100644
--- a/web/src/lib/components/shared-components/map/map.svelte
+++ b/web/src/lib/components/shared-components/map/map.svelte
@@ -24,6 +24,7 @@
     ScaleControl,
     type Map,
   } from 'svelte-maplibre';
+  import { t } from 'svelte-i18n';
 
   export let mapMarkers: MapMarkerResponseDto[];
   export let showSettingsModal: boolean | undefined = undefined;
@@ -187,7 +188,7 @@
             class="rounded-full w-[60px] h-[60px] border-2 border-immich-primary shadow-lg hover:border-immich-dark-primary transition-all duration-200 hover:scale-150 object-cover bg-immich-primary"
             alt={feature.properties?.city && feature.properties.country
               ? `Map marker for images taken in ${feature.properties.city}, ${feature.properties.country}`
-              : 'Map marker with image'}
+              : $t('map_marker_with_image')}
           />
         {/if}
         {#if $$slots.popup}
diff --git a/web/src/lib/components/shared-components/modal-header.svelte b/web/src/lib/components/shared-components/modal-header.svelte
index db2961c53b7fd..59c62e0a97638 100644
--- a/web/src/lib/components/shared-components/modal-header.svelte
+++ b/web/src/lib/components/shared-components/modal-header.svelte
@@ -3,6 +3,7 @@
   import Icon from '$lib/components/elements/icon.svelte';
   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
   import { mdiClose } from '@mdi/js';
+  import { t } from 'svelte-i18n';
 
   /**
    * Unique identifier for the header text.
@@ -32,5 +33,5 @@
     </h1>
   </div>
 
-  <CircleIconButton on:click={onClose} icon={mdiClose} size={'20'} title="Close" />
+  <CircleIconButton on:click={onClose} icon={mdiClose} size={'20'} title={$t('close')} />
 </div>
diff --git a/web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte b/web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte
index 5d5a351de2414..c39d8a5c70036 100644
--- a/web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte
+++ b/web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte
@@ -13,6 +13,7 @@
   import { NotificationType, notificationController } from '../notification/notification';
   import UserAvatar from '../user-avatar.svelte';
   import AvatarSelector from './avatar-selector.svelte';
+  import { t } from 'svelte-i18n';
 
   let isShowSelectAvatar = false;
 
@@ -31,11 +32,11 @@
       isShowSelectAvatar = false;
 
       notificationController.show({
-        message: 'Saved profile',
+        message: $t('saved_profile'),
         type: NotificationType.Info,
       });
     } catch (error) {
-      handleError(error, 'Unable to save profile');
+      handleError(error, $t('errors.unable_to_save_profile'));
     }
   };
 </script>
@@ -58,7 +59,7 @@
           <CircleIconButton
             color="primary"
             icon={mdiPencil}
-            title="Edit avatar"
+            title={$t('edit_avatar')}
             class="border"
             size="12"
             padding="2"
diff --git a/web/src/lib/components/shared-components/navigation-bar/avatar-selector.svelte b/web/src/lib/components/shared-components/navigation-bar/avatar-selector.svelte
index 4e8fa1efff560..bbe2c4f142d45 100644
--- a/web/src/lib/components/shared-components/navigation-bar/avatar-selector.svelte
+++ b/web/src/lib/components/shared-components/navigation-bar/avatar-selector.svelte
@@ -3,6 +3,7 @@
   import { createEventDispatcher } from 'svelte';
   import FullScreenModal from '../full-screen-modal.svelte';
   import UserAvatar from '../user-avatar.svelte';
+  import { t } from 'svelte-i18n';
 
   export let user: UserResponseDto;
 
@@ -13,7 +14,7 @@
   const colors: UserAvatarColor[] = Object.values(UserAvatarColor);
 </script>
 
-<FullScreenModal title="Select avatar color" width="auto" onClose={() => dispatch('close')}>
+<FullScreenModal title={$t('select_avatar_color')} width="auto" onClose={() => dispatch('close')}>
   <div class="flex items-center justify-center mt-4">
     <div class="grid grid-cols-2 md:grid-cols-5 gap-4">
       {#each colors as color}
diff --git a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte
index c7c5f9eb2dd88..6926acd760e40 100644
--- a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte
+++ b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte
@@ -18,6 +18,7 @@
   import UserAvatar from '../user-avatar.svelte';
   import AccountInfoPanel from './account-info-panel.svelte';
   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
+  import { t } from 'svelte-i18n';
 
   export let showUploadButton = true;
 
@@ -42,7 +43,7 @@
 <svelte:window bind:innerWidth />
 
 <section id="dashboard-navbar" class="fixed z-[900] h-[var(--navbar-height)] w-screen text-sm">
-  <SkipLink>Skip to content</SkipLink>
+  <SkipLink>{$t('skip_to_content')}</SkipLink>
   <div
     class="grid h-full grid-cols-[theme(spacing.18)_auto] items-center border-b bg-immich-bg py-2 dark:border-b-immich-dark-gray dark:bg-immich-dark-bg md:grid-cols-[theme(spacing.64)_auto]"
   >
@@ -59,7 +60,7 @@
       <section class="flex place-items-center justify-end gap-4 max-sm:w-full">
         {#if $featureFlags.search}
           <a href={AppRoute.SEARCH} id="search-button" class="ml-4 sm:hidden">
-            <CircleIconButton title="Go to search" icon={mdiMagnify} />
+            <CircleIconButton title={$t('go_to_search')} icon={mdiMagnify} />
           </a>
         {/if}
 
@@ -70,7 +71,7 @@
             <LinkButton on:click={() => dispatch('uploadClicked')}>
               <div class="flex gap-2">
                 <Icon path={mdiTrayArrowUp} size="1.5em" />
-                <span class="hidden md:block">Upload</span>
+                <span class="hidden md:block">{$t('upload')}</span>
               </div>
             </LinkButton>
           </div>
@@ -80,7 +81,7 @@
           <a
             data-sveltekit-preload-data="hover"
             href={AppRoute.ADMIN_USER_MANAGEMENT}
-            aria-label="Administration"
+            aria-label={$t('administration')}
             aria-current={$page.url.pathname.includes('/admin') ? 'page' : null}
           >
             <div
diff --git a/web/src/lib/components/shared-components/notification/__tests__/notification-card.spec.ts b/web/src/lib/components/shared-components/notification/__tests__/notification-card.spec.ts
index 76b9c39564f6c..25d648fca416a 100644
--- a/web/src/lib/components/shared-components/notification/__tests__/notification-card.spec.ts
+++ b/web/src/lib/components/shared-components/notification/__tests__/notification-card.spec.ts
@@ -1,11 +1,16 @@
 import '@testing-library/jest-dom';
 import { cleanup, render, type RenderResult } from '@testing-library/svelte';
+import { init } from 'svelte-i18n';
 import { NotificationType } from '../notification';
 import NotificationCard from '../notification-card.svelte';
 
 describe('NotificationCard component', () => {
   let sut: RenderResult<NotificationCard>;
 
+  beforeAll(async () => {
+    await init({ fallbackLocale: 'en-US' });
+  });
+
   it('disposes timeout if already removed from the DOM', () => {
     vi.spyOn(window, 'clearTimeout');
 
diff --git a/web/src/lib/components/shared-components/notification/__tests__/notification-list.spec.ts b/web/src/lib/components/shared-components/notification/__tests__/notification-list.spec.ts
index 44634d6b20038..c84a6d45821f3 100644
--- a/web/src/lib/components/shared-components/notification/__tests__/notification-list.spec.ts
+++ b/web/src/lib/components/shared-components/notification/__tests__/notification-list.spec.ts
@@ -1,5 +1,6 @@
 import '@testing-library/jest-dom';
 import { render, waitFor, type RenderResult } from '@testing-library/svelte';
+import { init } from 'svelte-i18n';
 import { get } from 'svelte/store';
 import { NotificationType, notificationController } from '../notification';
 import NotificationList from '../notification-list.svelte';
@@ -11,7 +12,8 @@ function _getNotificationListElement(sut: RenderResult<NotificationList>): HTMLA
 describe('NotificationList component', () => {
   const sut: RenderResult<NotificationList> = render(NotificationList);
 
-  beforeAll(() => {
+  beforeAll(async () => {
+    await init({ fallbackLocale: 'en-US' });
     // https://testing-library.com/docs/svelte-testing-library/faq#why-arent-transition-events-running
     vi.stubGlobal('requestAnimationFrame', (fn: FrameRequestCallback) => {
       setTimeout(() => fn(Date.now()), 16);
diff --git a/web/src/lib/components/shared-components/notification/notification-card.svelte b/web/src/lib/components/shared-components/notification/notification-card.svelte
index 27bd1fdd2c86a..6ddeb7433ed23 100644
--- a/web/src/lib/components/shared-components/notification/notification-card.svelte
+++ b/web/src/lib/components/shared-components/notification/notification-card.svelte
@@ -9,6 +9,7 @@
   import { onMount } from 'svelte';
   import { mdiCloseCircleOutline, mdiInformationOutline, mdiWindowClose } from '@mdi/js';
   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
+  import { t } from 'svelte-i18n';
 
   export let notification: Notification;
 
@@ -81,7 +82,7 @@
     </div>
     <CircleIconButton
       icon={mdiWindowClose}
-      title="Close"
+      title={$t('close')}
       class="dark:text-immich-dark-gray"
       size="20"
       padding="2"
diff --git a/web/src/lib/components/shared-components/password-field.svelte b/web/src/lib/components/shared-components/password-field.svelte
index b03d765fccc32..d69ea9884588a 100644
--- a/web/src/lib/components/shared-components/password-field.svelte
+++ b/web/src/lib/components/shared-components/password-field.svelte
@@ -2,6 +2,7 @@
   import { mdiEyeOffOutline, mdiEyeOutline } from '@mdi/js';
   import type { HTMLInputAttributes } from 'svelte/elements';
   import Icon from '../elements/icon.svelte';
+  import { t } from 'svelte-i18n';
 
   interface $$Props extends HTMLInputAttributes {
     password: string;
@@ -36,7 +37,7 @@
       tabindex="-1"
       class="absolute inset-y-0 end-0 px-4 text-gray-700 dark:text-gray-200"
       on:click={() => (showPassword = !showPassword)}
-      title={showPassword ? 'Hide password' : 'Show password'}
+      title={showPassword ? $t('hide_password') : $t('show_password')}
     >
       <Icon path={showPassword ? mdiEyeOffOutline : mdiEyeOutline} size="1.25em" />
     </button>
diff --git a/web/src/lib/components/shared-components/profile-image-cropper.svelte b/web/src/lib/components/shared-components/profile-image-cropper.svelte
index 06961caed9b68..33fa1ee8e8d8a 100644
--- a/web/src/lib/components/shared-components/profile-image-cropper.svelte
+++ b/web/src/lib/components/shared-components/profile-image-cropper.svelte
@@ -8,6 +8,7 @@
   import Button from '../elements/buttons/button.svelte';
   import { NotificationType, notificationController } from './notification/notification';
   import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
+  import { t } from 'svelte-i18n';
 
   export let asset: AssetResponseDto;
   export let onClose: () => void;
@@ -58,18 +59,18 @@
       const { profileImagePath } = await createProfileImage({ createProfileImageDto: { file } });
       notificationController.show({
         type: NotificationType.Info,
-        message: 'Profile picture set.',
+        message: $t('profile_picture_set'),
         timeout: 3000,
       });
       $user.profileImagePath = profileImagePath;
     } catch (error) {
-      handleError(error, 'Error setting profile picture.');
+      handleError(error, $t('errors.unable_to_set_profile_picture'));
     }
     onClose();
   };
 </script>
 
-<FullScreenModal title="Set profile picture" width="auto" {onClose}>
+<FullScreenModal title={$t('set_profile_picture')} width="auto" {onClose}>
   <div class="flex place-items-center items-center justify-center">
     <div
       class="relative flex aspect-square w-[250px] overflow-hidden rounded-full border-4 border-immich-primary bg-immich-dark-primary dark:border-immich-dark-primary dark:bg-immich-primary"
@@ -78,6 +79,6 @@
     </div>
   </div>
   <svelte:fragment slot="sticky-bottom">
-    <Button fullwidth on:click={handleSetProfilePicture}>Set as profile picture</Button>
+    <Button fullwidth on:click={handleSetProfilePicture}>{$t('set_as_profile_picture')}</Button>
   </svelte:fragment>
 </FullScreenModal>
diff --git a/web/src/lib/components/shared-components/search-bar/search-bar.svelte b/web/src/lib/components/shared-components/search-bar/search-bar.svelte
index 455de6f14ed50..0e09b0e5b9acd 100644
--- a/web/src/lib/components/shared-components/search-bar/search-bar.svelte
+++ b/web/src/lib/components/shared-components/search-bar/search-bar.svelte
@@ -12,6 +12,7 @@
   import { shortcuts } from '$lib/actions/shortcut';
   import { focusOutside } from '$lib/actions/focus-outside';
   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
+  import { t } from 'svelte-i18n';
 
   export let value = '';
   export let grayTheme: boolean;
@@ -103,9 +104,9 @@
     on:submit|preventDefault={onSubmit}
   >
     <div class="absolute inset-y-0 left-0 flex items-center pl-2">
-      <CircleIconButton type="submit" title="Search" icon={mdiMagnify} size="20" />
+      <CircleIconButton type="submit" title={$t('search')} icon={mdiMagnify} size="20" />
     </div>
-    <label for="main-search-bar" class="sr-only">Search your photos</label>
+    <label for="main-search-bar" class="sr-only">{$t('search_your_photos')}</label>
     <input
       type="text"
       name="q"
@@ -117,7 +118,7 @@
       showFilter
         ? 'rounded-t-3xl border  border-gray-200 bg-white dark:border-gray-800'
         : 'rounded-3xl border border-transparent bg-gray-200'}"
-      placeholder="Search your photos"
+      placeholder={$t('search_your_photos')}
       required
       pattern="^(?!m:$).*$"
       bind:value
@@ -132,11 +133,11 @@
     />
 
     <div class="absolute inset-y-0 {showClearIcon ? 'right-14' : 'right-2'} flex items-center pl-6 transition-all">
-      <CircleIconButton title="Show search options" icon={mdiTune} on:click={onFilterClick} size="20" />
+      <CircleIconButton title={$t('show_search_options')} icon={mdiTune} on:click={onFilterClick} size="20" />
     </div>
     {#if showClearIcon}
       <div class="absolute inset-y-0 right-0 flex items-center pr-2">
-        <CircleIconButton type="reset" icon={mdiClose} title="Clear" size="20" />
+        <CircleIconButton type="reset" icon={mdiClose} title={$t('clear')} size="20" />
       </div>
     {/if}
 
diff --git a/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte b/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte
index e4238e50930f4..3d7649cf74ddb 100644
--- a/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte
+++ b/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte
@@ -9,6 +9,7 @@
   import { SearchSuggestionType, getSearchSuggestions } from '@immich/sdk';
   import Combobox, { toComboBoxOptions } from '../combobox.svelte';
   import { handlePromiseError } from '$lib/utils';
+  import { t } from 'svelte-i18n';
 
   export let filters: SearchCameraFilter;
 
@@ -36,25 +37,25 @@
 </script>
 
 <div id="camera-selection">
-  <p class="immich-form-label">CAMERA</p>
+  <p class="immich-form-label">{$t('camera').toUpperCase()}</p>
 
   <div class="grid grid-cols-[repeat(auto-fit,minmax(10rem,1fr))] gap-5 mt-1">
     <div class="w-full">
       <Combobox
-        label="Make"
+        label={$t('make')}
         on:select={({ detail }) => (filters.make = detail?.value)}
         options={toComboBoxOptions(makes)}
-        placeholder="Search camera make..."
+        placeholder={$t('search_camera_make')}
         selectedOption={makeFilter ? { label: makeFilter, value: makeFilter } : undefined}
       />
     </div>
 
     <div class="w-full">
       <Combobox
-        label="Model"
+        label={$t('model')}
         on:select={({ detail }) => (filters.model = detail?.value)}
         options={toComboBoxOptions(models)}
-        placeholder="Search camera model..."
+        placeholder={$t('search_camera_model')}
         selectedOption={modelFilter ? { label: modelFilter, value: modelFilter } : undefined}
       />
     </div>
diff --git a/web/src/lib/components/shared-components/search-bar/search-date-section.svelte b/web/src/lib/components/shared-components/search-bar/search-date-section.svelte
index f08436bd0080d..9604157529808 100644
--- a/web/src/lib/components/shared-components/search-bar/search-date-section.svelte
+++ b/web/src/lib/components/shared-components/search-bar/search-date-section.svelte
@@ -7,13 +7,14 @@
 
 <script lang="ts">
   import DateInput from '$lib/components/elements/date-input.svelte';
+  import { t } from 'svelte-i18n';
 
   export let filters: SearchDateFilter;
 </script>
 
 <div id="date-range-selection" class="grid grid-cols-[repeat(auto-fit,minmax(10rem,1fr))] gap-5">
   <label class="immich-form-label" for="start-date">
-    <span>START DATE</span>
+    <span>{$t('start_date').toUpperCase()}</span>
     <DateInput
       class="immich-form-input w-full mt-1 hover:cursor-pointer"
       type="date"
@@ -25,7 +26,7 @@
   </label>
 
   <label class="immich-form-label" for="end-date">
-    <span>END DATE</span>
+    <span>{$t('end_date').toUpperCase()}</span>
     <DateInput
       class="immich-form-input w-full mt-1 hover:cursor-pointer"
       type="date"
diff --git a/web/src/lib/components/shared-components/search-bar/search-display-section.svelte b/web/src/lib/components/shared-components/search-bar/search-display-section.svelte
index cfdc32818c0aa..f3a4fa2b0c6d0 100644
--- a/web/src/lib/components/shared-components/search-bar/search-display-section.svelte
+++ b/web/src/lib/components/shared-components/search-bar/search-display-section.svelte
@@ -8,17 +8,18 @@
 
 <script lang="ts">
   import Checkbox from '$lib/components/elements/checkbox.svelte';
+  import { t } from 'svelte-i18n';
 
   export let filters: SearchDisplayFilters;
 </script>
 
 <div id="display-options-selection">
   <fieldset>
-    <legend class="immich-form-label">DISPLAY OPTIONS</legend>
+    <legend class="immich-form-label">{$t('display_options').toUpperCase()}</legend>
     <div class="flex flex-wrap gap-x-5 gap-y-2 mt-1">
-      <Checkbox id="not-in-album-checkbox" label="Not in any album" bind:checked={filters.isNotInAlbum} />
-      <Checkbox id="archive-checkbox" label="Archive" bind:checked={filters.isArchive} />
-      <Checkbox id="favorite-checkbox" label="Favorite" bind:checked={filters.isFavorite} />
+      <Checkbox id="not-in-album-checkbox" label={$t('not_in_any_album')} bind:checked={filters.isNotInAlbum} />
+      <Checkbox id="archive-checkbox" label={$t('archive')} bind:checked={filters.isArchive} />
+      <Checkbox id="favorite-checkbox" label={$t('favorite')} bind:checked={filters.isFavorite} />
     </div>
   </fieldset>
 </div>
diff --git a/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte b/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte
index cac76ebde9155..595acf3c499dd 100644
--- a/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte
+++ b/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte
@@ -34,6 +34,7 @@
   import { parseUtcDate } from '$lib/utils/date-time';
   import SearchDisplaySection from './search-display-section.svelte';
   import SearchTextSection from './search-text-section.svelte';
+  import { t } from 'svelte-i18n';
 
   export let searchQuery: MetadataSearchDto | SmartSearchDto;
 
@@ -153,8 +154,8 @@
       id="button-row"
       class="flex justify-end gap-4 border-t dark:border-gray-800 dark:bg-immich-dark-gray px-4 sm:py-6 py-4 mt-2 rounded-b-3xl"
     >
-      <Button type="reset" color="gray">Clear all</Button>
-      <Button type="submit">Search</Button>
+      <Button type="reset" color="gray">{$t('clear_all')}</Button>
+      <Button type="submit">{$t('search')}</Button>
     </div>
   </form>
 </div>
diff --git a/web/src/lib/components/shared-components/search-bar/search-history-box.svelte b/web/src/lib/components/shared-components/search-bar/search-history-box.svelte
index 53573e56c82e0..93c4ac93f491a 100644
--- a/web/src/lib/components/shared-components/search-bar/search-history-box.svelte
+++ b/web/src/lib/components/shared-components/search-bar/search-history-box.svelte
@@ -4,6 +4,7 @@
   import { mdiMagnify, mdiClose } from '@mdi/js';
   import { createEventDispatcher } from 'svelte';
   import { fly } from 'svelte/transition';
+  import { t } from 'svelte-i18n';
 
   const dispatch = createEventDispatcher<{
     selectSearchTerm: string;
@@ -18,12 +19,12 @@
 >
   {#if $savedSearchTerms.length > 0}
     <div class="flex items-center justify-between px-5 pt-5 text-xs">
-      <p>RECENT SEARCHES</p>
+      <p>{$t('recent_searches').toUpperCase()}</p>
       <div class="flex w-18 items-center justify-center">
         <button
           type="button"
           class="rounded-lg p-2 font-semibold text-immich-primary hover:bg-immich-primary/25 dark:text-immich-dark-primary"
-          on:click={() => dispatch('clearAllSearchTerms')}>Clear all</button
+          on:click={() => dispatch('clearAllSearchTerms')}>{$t('clear_all')}</button
         >
       </div>
     </div>
diff --git a/web/src/lib/components/shared-components/search-bar/search-location-section.svelte b/web/src/lib/components/shared-components/search-bar/search-location-section.svelte
index d95ea68d1b059..075a305cef5e9 100644
--- a/web/src/lib/components/shared-components/search-bar/search-location-section.svelte
+++ b/web/src/lib/components/shared-components/search-bar/search-location-section.svelte
@@ -10,6 +10,7 @@
   import { getSearchSuggestions, SearchSuggestionType } from '@immich/sdk';
   import Combobox, { toComboBoxOptions } from '../combobox.svelte';
   import { handlePromiseError } from '$lib/utils';
+  import { t } from 'svelte-i18n';
 
   export let filters: SearchLocationFilter;
 
@@ -58,35 +59,35 @@
 </script>
 
 <div id="location-selection">
-  <p class="immich-form-label">PLACE</p>
+  <p class="immich-form-label">{$t('place').toUpperCase()}</p>
 
   <div class="grid grid-cols-[repeat(auto-fit,minmax(10rem,1fr))] gap-5 mt-1">
     <div class="w-full">
       <Combobox
-        label="Country"
+        label={$t('country')}
         on:select={({ detail }) => (filters.country = detail?.value)}
         options={toComboBoxOptions(countries)}
-        placeholder="Search country..."
+        placeholder={$t('search_country')}
         selectedOption={filters.country ? { label: filters.country, value: filters.country } : undefined}
       />
     </div>
 
     <div class="w-full">
       <Combobox
-        label="State"
+        label={$t('state')}
         on:select={({ detail }) => (filters.state = detail?.value)}
         options={toComboBoxOptions(states)}
-        placeholder="Search state..."
+        placeholder={$t('search_state')}
         selectedOption={filters.state ? { label: filters.state, value: filters.state } : undefined}
       />
     </div>
 
     <div class="w-full">
       <Combobox
-        label="City"
+        label={$t('city')}
         on:select={({ detail }) => (filters.city = detail?.value)}
         options={toComboBoxOptions(cities)}
-        placeholder="Search city..."
+        placeholder={$t('search_city')}
         selectedOption={filters.city ? { label: filters.city, value: filters.city } : undefined}
       />
     </div>
diff --git a/web/src/lib/components/shared-components/search-bar/search-media-section.svelte b/web/src/lib/components/shared-components/search-bar/search-media-section.svelte
index ad0f01b531c6e..ce43dd0141ef8 100644
--- a/web/src/lib/components/shared-components/search-bar/search-media-section.svelte
+++ b/web/src/lib/components/shared-components/search-bar/search-media-section.svelte
@@ -1,17 +1,30 @@
 <script lang="ts">
   import RadioButton from '$lib/components/elements/radio-button.svelte';
   import { MediaType } from './search-filter-box.svelte';
+  import { t } from 'svelte-i18n';
 
   export let filteredMedia: MediaType;
 </script>
 
 <div id="media-type-selection">
   <fieldset>
-    <legend class="immich-form-label">MEDIA TYPE</legend>
+    <legend class="immich-form-label">{$t('media_type').toUpperCase()}</legend>
     <div class="flex flex-wrap gap-x-5 gap-y-2 mt-1">
-      <RadioButton name="media-type" id="type-all" bind:group={filteredMedia} label="All" value={MediaType.All} />
-      <RadioButton name="media-type" id="type-image" bind:group={filteredMedia} label="Image" value={MediaType.Image} />
-      <RadioButton name="media-type" id="type-video" bind:group={filteredMedia} label="Video" value={MediaType.Video} />
+      <RadioButton name="media-type" id="type-all" bind:group={filteredMedia} label={$t('all')} value={MediaType.All} />
+      <RadioButton
+        name="media-type"
+        id="type-image"
+        bind:group={filteredMedia}
+        label={$t('image')}
+        value={MediaType.Image}
+      />
+      <RadioButton
+        name="media-type"
+        id="type-video"
+        bind:group={filteredMedia}
+        label={$t('video')}
+        value={MediaType.Video}
+      />
     </div>
   </fieldset>
 </div>
diff --git a/web/src/lib/components/shared-components/search-bar/search-people-section.svelte b/web/src/lib/components/shared-components/search-bar/search-people-section.svelte
index dc0d1f41a9677..f652f70fd899c 100644
--- a/web/src/lib/components/shared-components/search-bar/search-people-section.svelte
+++ b/web/src/lib/components/shared-components/search-bar/search-people-section.svelte
@@ -7,6 +7,7 @@
   import { getAllPeople, type PersonResponseDto } from '@immich/sdk';
   import { mdiClose, mdiArrowRight } from '@mdi/js';
   import { handleError } from '$lib/utils/handle-error';
+  import { t } from 'svelte-i18n';
 
   export let width: number;
   export let selectedPeople: Set<string>;
@@ -28,7 +29,7 @@
       const res = await getAllPeople({ withHidden: false });
       return orderBySelectedPeopleFirst(res.people);
     } catch (error) {
-      handleError(error, 'Failed to get people');
+      handleError(error, $t('failed_to_get_people'));
     }
   }
 
@@ -55,8 +56,8 @@
 
     <div id="people-selection" class="-mb-4">
       <div class="flex items-center w-full justify-between gap-6">
-        <p class="immich-form-label py-3">PEOPLE</p>
-        <SearchBar bind:name placeholder="Filter people" showLoadingSpinner={false} />
+        <p class="immich-form-label py-3">{$t('people').toUpperCase()}</p>
+        <SearchBar bind:name placeholder={$t('filter_people')} showLoadingSpinner={false} />
       </div>
 
       <div class="flex -mx-1 max-h-64 gap-1 mt-2 flex-wrap overflow-y-auto immich-scrollbar">
diff --git a/web/src/lib/components/shared-components/search-bar/search-text-section.svelte b/web/src/lib/components/shared-components/search-bar/search-text-section.svelte
index 99f8b581b99f2..6b27c9e532c0f 100644
--- a/web/src/lib/components/shared-components/search-bar/search-text-section.svelte
+++ b/web/src/lib/components/shared-components/search-bar/search-text-section.svelte
@@ -1,5 +1,6 @@
 <script lang="ts">
   import RadioButton from '$lib/components/elements/radio-button.svelte';
+  import { t } from 'svelte-i18n';
 
   export let filename: string | undefined;
   export let context: string | undefined;
@@ -21,33 +22,33 @@
 </script>
 
 <fieldset>
-  <legend class="immich-form-label">Search type</legend>
+  <legend class="immich-form-label">{$t('search_type')}</legend>
   <div class="flex flex-wrap gap-x-5 gap-y-2 mt-1 mb-2">
     <RadioButton
       name="query-type"
       id="context-radio"
       bind:group={selectedOption}
-      label="Context"
+      label={$t('context')}
       value={TextSearchOptions.Context}
     />
     <RadioButton
       name="query-type"
       id="file-name-radio"
       bind:group={selectedOption}
-      label="File name or extension"
+      label={$t('file_name_or_extension')}
       value={TextSearchOptions.Filename}
     />
   </div>
 </fieldset>
 
 {#if selectedOption === TextSearchOptions.Context}
-  <label for="context-input" class="immich-form-label">Search by context</label>
+  <label for="context-input" class="immich-form-label">{$t('search_by_context')}</label>
   <input
     class="immich-form-input hover:cursor-text w-full !mt-1"
     type="text"
     id="context-input"
     name="context"
-    placeholder="Sunrise on the beach"
+    placeholder={$t('sunrise_on_the_beach')}
     bind:value={context}
   />
 {:else}
diff --git a/web/src/lib/components/shared-components/settings/setting-buttons-row.svelte b/web/src/lib/components/shared-components/settings/setting-buttons-row.svelte
index f479112deeb7f..07cd8e1b0ec9c 100644
--- a/web/src/lib/components/shared-components/settings/setting-buttons-row.svelte
+++ b/web/src/lib/components/shared-components/settings/setting-buttons-row.svelte
@@ -2,6 +2,7 @@
   import Button from '$lib/components/elements/buttons/button.svelte';
   import type { ResetOptions } from '$lib/utils/dipatch';
   import { createEventDispatcher } from 'svelte';
+  import { t } from 'svelte-i18n';
 
   const dispatch = createEventDispatcher<{
     reset: ResetOptions;
@@ -26,7 +27,9 @@
   </div>
 
   <div class="right">
-    <Button {disabled} size="sm" color="gray" on:click={() => dispatch('reset', { default: false })}>Reset</Button>
-    <Button type="submit" {disabled} size="sm" on:click={() => dispatch('save')}>Save</Button>
+    <Button {disabled} size="sm" color="gray" on:click={() => dispatch('reset', { default: false })}
+      >{$t('reset')}</Button
+    >
+    <Button type="submit" {disabled} size="sm" on:click={() => dispatch('save')}>{$t('save')}</Button>
   </div>
 </div>
diff --git a/web/src/lib/components/shared-components/show-shortcuts.svelte b/web/src/lib/components/shared-components/show-shortcuts.svelte
index b2042517db67a..6cb2aa452462e 100644
--- a/web/src/lib/components/shared-components/show-shortcuts.svelte
+++ b/web/src/lib/components/shared-components/show-shortcuts.svelte
@@ -3,6 +3,7 @@
   import FullScreenModal from './full-screen-modal.svelte';
   import { mdiInformationOutline } from '@mdi/js';
   import Icon from '../elements/icon.svelte';
+  import { t } from 'svelte-i18n';
 
   interface Shortcuts {
     general: ExplainedShortcut[];
@@ -17,18 +18,18 @@
 
   const shortcuts: Shortcuts = {
     general: [
-      { key: ['←', '→'], action: 'Previous or next photo' },
+      { key: ['←', '→'], action: $t('previous_or_next_photo') },
       { key: ['Esc'], action: 'Back, close, or deselect' },
-      { key: ['Ctrl', 'k'], action: 'Search your photos' },
-      { key: ['Ctrl', '⇧', 'k'], action: 'Open the search filters' },
+      { key: ['Ctrl', 'k'], action: $t('search_your_photos') },
+      { key: ['Ctrl', '⇧', 'k'], action: $t('open_the_search_filters') },
     ],
     actions: [
-      { key: ['f'], action: 'Favorite or unfavorite photo' },
-      { key: ['i'], action: 'Show or hide info' },
-      { key: ['s'], action: 'Stack selected photos' },
-      { key: ['⇧', 'a'], action: 'Archive or unarchive photo' },
-      { key: ['⇧', 'd'], action: 'Download' },
-      { key: ['Space'], action: 'Play or pause video' },
+      { key: ['f'], action: $t('favorite_or_unfavorite_photo') },
+      { key: ['i'], action: $t('show_or_hide_info') },
+      { key: ['s'], action: $t('stack_selected_photos') },
+      { key: ['⇧', 'a'], action: $t('archive_or_unarchive_photo') },
+      { key: ['⇧', 'd'], action: $t('download') },
+      { key: ['Space'], action: $t('play_or_pause_video') },
       { key: ['Del'], action: 'Trash/Delete Asset', info: 'press ⇧ to permanently delete asset' },
     ],
   };
@@ -37,10 +38,10 @@
   }>();
 </script>
 
-<FullScreenModal title="Keyboard shortcuts" width="auto" onClose={() => dispatch('close')}>
+<FullScreenModal title={$t('keyboard_shortcuts')} width="auto" onClose={() => dispatch('close')}>
   <div class="grid grid-cols-1 gap-4 px-4 pb-4 md:grid-cols-2">
     <div class="p-4">
-      <h2>General</h2>
+      <h2>{$t('general')}</h2>
       <div class="text-sm">
         {#each shortcuts.general as shortcut}
           <div class="grid grid-cols-[30%_70%] items-center gap-4 pt-4 text-sm">
@@ -58,7 +59,7 @@
     </div>
 
     <div class="p-4">
-      <h2>Actions</h2>
+      <h2>{$t('actions')}</h2>
       <div class="text-sm">
         {#each shortcuts.actions as shortcut}
           <div class="grid grid-cols-[30%_70%] items-center gap-4 pt-4 text-sm">
diff --git a/web/src/lib/components/shared-components/side-bar/admin-side-bar.svelte b/web/src/lib/components/shared-components/side-bar/admin-side-bar.svelte
index 658c029fd87f8..a478cb2fdc911 100644
--- a/web/src/lib/components/shared-components/side-bar/admin-side-bar.svelte
+++ b/web/src/lib/components/shared-components/side-bar/admin-side-bar.svelte
@@ -4,16 +4,17 @@
   import StatusBox from '$lib/components/shared-components/status-box.svelte';
   import { AppRoute } from '$lib/constants';
   import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiSync, mdiTools } from '@mdi/js';
+  import { t } from 'svelte-i18n';
 </script>
 
 <SideBarSection>
-  <nav aria-label="Primary">
-    <SideBarLink title="Users" routeId={AppRoute.ADMIN_USER_MANAGEMENT} icon={mdiAccountMultipleOutline} />
-    <SideBarLink title="Jobs" routeId={AppRoute.ADMIN_JOBS} icon={mdiSync} />
-    <SideBarLink title="Settings" routeId={AppRoute.ADMIN_SETTINGS} icon={mdiCog} />
-    <SideBarLink title="External Libraries" routeId={AppRoute.ADMIN_LIBRARY_MANAGEMENT} icon={mdiBookshelf} />
-    <SideBarLink title="Server Stats" routeId={AppRoute.ADMIN_STATS} icon={mdiServer} />
-    <SideBarLink title="Repair" routeId={AppRoute.ADMIN_REPAIR} icon={mdiTools} preloadData={false} />
+  <nav aria-label={$t('primary')}>
+    <SideBarLink title={$t('users')} routeId={AppRoute.ADMIN_USER_MANAGEMENT} icon={mdiAccountMultipleOutline} />
+    <SideBarLink title={$t('jobs')} routeId={AppRoute.ADMIN_JOBS} icon={mdiSync} />
+    <SideBarLink title={$t('settings')} routeId={AppRoute.ADMIN_SETTINGS} icon={mdiCog} />
+    <SideBarLink title={$t('external_libraries')} routeId={AppRoute.ADMIN_LIBRARY_MANAGEMENT} icon={mdiBookshelf} />
+    <SideBarLink title={$t('server_stats')} routeId={AppRoute.ADMIN_STATS} icon={mdiServer} />
+    <SideBarLink title={$t('repair')} routeId={AppRoute.ADMIN_REPAIR} icon={mdiTools} preloadData={false} />
   </nav>
 
   <div class="mb-6 mt-auto">
diff --git a/web/src/lib/components/shared-components/side-bar/side-bar.svelte b/web/src/lib/components/shared-components/side-bar/side-bar.svelte
index aab968b9a7304..a0355dd407464 100644
--- a/web/src/lib/components/shared-components/side-bar/side-bar.svelte
+++ b/web/src/lib/components/shared-components/side-bar/side-bar.svelte
@@ -26,6 +26,7 @@
   import SideBarLink from './side-bar-link.svelte';
   import MoreInformationAssets from '$lib/components/shared-components/side-bar/more-information-assets.svelte';
   import MoreInformationAlbums from '$lib/components/shared-components/side-bar/more-information-albums.svelte';
+  import { t } from 'svelte-i18n';
 
   let isArchiveSelected: boolean;
   let isFavoritesSelected: boolean;
@@ -38,9 +39,9 @@
 </script>
 
 <SideBarSection>
-  <nav aria-label="Primary">
+  <nav aria-label={$t('primary')}>
     <SideBarLink
-      title="Photos"
+      title={$t('photos')}
       routeId="/(user)/photos"
       bind:isSelected={isPhotosSelected}
       icon={isPhotosSelected ? mdiImageMultiple : mdiImageMultipleOutline}
@@ -50,12 +51,12 @@
       </svelte:fragment>
     </SideBarLink>
     {#if $featureFlags.search}
-      <SideBarLink title="Explore" routeId="/(user)/explore" icon={mdiMagnify} />
+      <SideBarLink title={$t('explore')} routeId="/(user)/explore" icon={mdiMagnify} />
     {/if}
 
     {#if $featureFlags.map}
       <SideBarLink
-        title="Map"
+        title={$t('map')}
         routeId="/(user)/map"
         bind:isSelected={isMapSelected}
         icon={isMapSelected ? mdiMap : mdiMapOutline}
@@ -64,7 +65,7 @@
 
     {#if $sidebarSettings.people}
       <SideBarLink
-        title="People"
+        title={$t('people')}
         routeId="/(user)/people"
         bind:isSelected={isPeopleSelected}
         icon={isPeopleSelected ? mdiAccount : mdiAccountOutline}
@@ -72,7 +73,7 @@
     {/if}
     {#if $sidebarSettings.sharing}
       <SideBarLink
-        title="Sharing"
+        title={$t('sharing')}
         routeId="/(user)/sharing"
         icon={isSharingSelected ? mdiAccountMultiple : mdiAccountMultipleOutline}
         bind:isSelected={isSharingSelected}
@@ -84,11 +85,11 @@
     {/if}
 
     <div class="text-xs transition-all duration-200 dark:text-immich-dark-fg">
-      <p class="hidden p-6 group-hover:sm:block md:block">LIBRARY</p>
+      <p class="hidden p-6 group-hover:sm:block md:block">{$t('library').toUpperCase()}</p>
       <hr class="mx-4 mb-[31px] mt-8 block group-hover:sm:hidden md:hidden" />
     </div>
     <SideBarLink
-      title="Favorites"
+      title={$t('favorites')}
       routeId="/(user)/favorites"
       icon={isFavoritesSelected ? mdiHeart : mdiHeartOutline}
       bind:isSelected={isFavoritesSelected}
@@ -97,21 +98,21 @@
         <MoreInformationAssets assetStats={{ isFavorite: true }} />
       </svelte:fragment>
     </SideBarLink>
-    <SideBarLink title="Albums" routeId="/(user)/albums" icon={mdiImageAlbum} flippedLogo>
+    <SideBarLink title={$t('albums')} routeId="/(user)/albums" icon={mdiImageAlbum} flippedLogo>
       <svelte:fragment slot="moreInformation">
         <MoreInformationAlbums albumCountType="owned" />
       </svelte:fragment>
     </SideBarLink>
 
     <SideBarLink
-      title="Utilities"
+      title={$t('utilities')}
       routeId="/(user)/utilities"
       bind:isSelected={isUtilitiesSelected}
       icon={isUtilitiesSelected ? mdiToolbox : mdiToolboxOutline}
     ></SideBarLink>
 
     <SideBarLink
-      title="Archive"
+      title={$t('archive')}
       routeId="/(user)/archive"
       bind:isSelected={isArchiveSelected}
       icon={isArchiveSelected ? mdiArchiveArrowDown : mdiArchiveArrowDownOutline}
@@ -123,7 +124,7 @@
 
     {#if $featureFlags.trash}
       <SideBarLink
-        title="Trash"
+        title={$t('trash')}
         routeId="/(user)/trash"
         bind:isSelected={isTrashSelected}
         icon={isTrashSelected ? mdiTrashCan : mdiTrashCanOutline}
diff --git a/web/src/lib/components/shared-components/status-box.svelte b/web/src/lib/components/shared-components/status-box.svelte
index a59225d043792..8d94e9cee2c2d 100644
--- a/web/src/lib/components/shared-components/status-box.svelte
+++ b/web/src/lib/components/shared-components/status-box.svelte
@@ -9,6 +9,7 @@
   import { serverInfo } from '$lib/stores/server-info.store';
   import { user } from '$lib/stores/user.store';
   import { requestServerInfo } from '$lib/utils/auth';
+  import { t } from 'svelte-i18n';
 
   const { serverVersion, connected } = websocketStore;
 
@@ -52,7 +53,7 @@
       <Icon path={mdiChartPie} size="24" />
     </div>
     <div class="hidden group-hover:sm:block md:block">
-      <p class="text-sm font-medium text-immich-primary dark:text-immich-dark-primary">Storage</p>
+      <p class="text-sm font-medium text-immich-primary dark:text-immich-dark-primary">{$t('storage')}</p>
       {#if $serverInfo}
         <div class="my-2 h-[7px] w-full rounded-full bg-gray-200 dark:bg-gray-700">
           <div class="h-[7px] rounded-full {usageClasses}" style="width: {usedPercentage}%" />
@@ -76,20 +77,20 @@
       <Icon path={mdiDns} size="26" />
     </div>
     <div class="hidden text-xs group-hover:sm:block md:block">
-      <p class="text-sm font-medium text-immich-primary dark:text-immich-dark-primary">Server</p>
+      <p class="text-sm font-medium text-immich-primary dark:text-immich-dark-primary">{$t('server')}</p>
 
       <div class="mt-2 flex justify-between justify-items-center">
-        <p>Status</p>
+        <p>{$t('status')}</p>
 
         {#if $connected}
-          <p class="font-medium text-immich-primary dark:text-immich-dark-primary">Online</p>
+          <p class="font-medium text-immich-primary dark:text-immich-dark-primary">{$t('online')}</p>
         {:else}
-          <p class="font-medium text-red-500">Offline</p>
+          <p class="font-medium text-red-500">{$t('offline')}</p>
         {/if}
       </div>
 
       <div class="mt-2 flex justify-between justify-items-center">
-        <p>Version</p>
+        <p>{$t('version')}</p>
         {#if $connected && version}
           <a
             href="https://github.com/immich-app/immich/releases"
@@ -99,7 +100,7 @@
             {version}
           </a>
         {:else}
-          <p class="font-medium text-red-500">Unknown</p>
+          <p class="font-medium text-red-500">{$t('unknown')}</p>
         {/if}
       </div>
     </div>
diff --git a/web/src/lib/components/shared-components/theme-button.svelte b/web/src/lib/components/shared-components/theme-button.svelte
index 4b2c90b6dcf97..7fc823f8ed995 100644
--- a/web/src/lib/components/shared-components/theme-button.svelte
+++ b/web/src/lib/components/shared-components/theme-button.svelte
@@ -3,11 +3,12 @@
   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
   import { Theme } from '$lib/constants';
   import { colorTheme, handleToggleTheme } from '$lib/stores/preferences.store';
+  import { t } from 'svelte-i18n';
 
   $: icon = $colorTheme.value === Theme.LIGHT ? moonPath : sunPath;
   $: viewBox = $colorTheme.value === Theme.LIGHT ? moonViewBox : sunViewBox;
 </script>
 
 {#if !$colorTheme.system}
-  <CircleIconButton title="Toggle theme" {icon} {viewBox} on:click={handleToggleTheme} />
+  <CircleIconButton title={$t('toggle_theme')} {icon} {viewBox} on:click={handleToggleTheme} />
 {/if}
diff --git a/web/src/lib/components/shared-components/upload-asset-preview.svelte b/web/src/lib/components/shared-components/upload-asset-preview.svelte
index 025d4f03a64d7..248c814f838e5 100644
--- a/web/src/lib/components/shared-components/upload-asset-preview.svelte
+++ b/web/src/lib/components/shared-components/upload-asset-preview.svelte
@@ -10,6 +10,7 @@
   import Icon from '$lib/components/elements/icon.svelte';
   import { fileUploadHandler } from '$lib/utils/file-uploader';
   import { mdiRefresh, mdiCancel } from '@mdi/js';
+  import { t } from 'svelte-i18n';
 
   export let uploadAsset: UploadAsset;
 
@@ -59,10 +60,10 @@
           </p>
         {:else if uploadAsset.state === UploadState.PENDING}
           <div class="h-[15px] rounded-md bg-immich-dark-gray transition-all dark:bg-immich-gray" style="width: 100%" />
-          <p class="absolute top-0 h-full w-full text-center text-[10px]">Pending</p>
+          <p class="absolute top-0 h-full w-full text-center text-[10px]">{$t('pending')}</p>
         {:else if uploadAsset.state === UploadState.ERROR}
           <div class="h-[15px] rounded-md bg-immich-error transition-all" style="width: 100%" />
-          <p class="absolute top-0 h-full w-full text-center text-[10px]">Error</p>
+          <p class="absolute top-0 h-full w-full text-center text-[10px]">{$t('error')}</p>
         {:else if uploadAsset.state === UploadState.DUPLICATED}
           <div class="h-[15px] rounded-md bg-immich-warning transition-all" style="width: 100%" />
           <p class="absolute top-0 h-full w-full text-center text-[10px]">
@@ -84,13 +85,13 @@
     </div>
     {#if uploadAsset.state === UploadState.ERROR}
       <div class="flex h-full flex-col place-content-evenly place-items-center justify-items-center pr-2">
-        <button type="button" on:click={() => handleRetry(uploadAsset)} title="Retry upload" class="flex text-sm">
+        <button type="button" on:click={() => handleRetry(uploadAsset)} title={$t('retry_upload')} class="flex text-sm">
           <span class="text-immich-dark-gray dark:text-immich-dark-fg"><Icon path={mdiRefresh} size="20" /></span>
         </button>
         <button
           type="button"
           on:click={() => uploadAssetsStore.removeUploadAsset(uploadAsset.id)}
-          title="Dismiss error"
+          title={$t('dismiss_error')}
           class="flex text-sm"
         >
           <span class="text-immich-error"><Icon path={mdiCancel} size="20" /></span>
diff --git a/web/src/lib/components/shared-components/upload-panel.svelte b/web/src/lib/components/shared-components/upload-panel.svelte
index 3ae5667711a87..522251666b096 100644
--- a/web/src/lib/components/shared-components/upload-panel.svelte
+++ b/web/src/lib/components/shared-components/upload-panel.svelte
@@ -9,6 +9,7 @@
   import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
   import { mdiCog, mdiWindowMinimize, mdiCancel, mdiCloudUploadOutline } from '@mdi/js';
   import { s } from '$lib/utils';
+  import { t } from 'svelte-i18n';
 
   let showDetail = false;
   let showOptions = false;
@@ -75,14 +76,14 @@
           <div class="flex flex-col items-end">
             <div class="flex flex-row">
               <CircleIconButton
-                title="Toggle settings"
+                title={$t('toggle_settings')}
                 icon={mdiCog}
                 size="14"
                 padding="1"
                 on:click={() => (showOptions = !showOptions)}
               />
               <CircleIconButton
-                title="Minimize"
+                title={$t('minimize')}
                 icon={mdiWindowMinimize}
                 size="14"
                 padding="1"
@@ -91,7 +92,7 @@
             </div>
             {#if $hasError}
               <CircleIconButton
-                title="Dismiss all errors"
+                title={$t('dismiss_all_errors')}
                 icon={mdiCancel}
                 size="14"
                 padding="1"
@@ -103,13 +104,13 @@
         {#if showOptions}
           <div class="immich-scrollbar mb-4 max-h-[400px] overflow-y-auto rounded-lg pr-2">
             <div class="flex h-[26px] place-items-center gap-1">
-              <label class="immich-form-label" for="upload-concurrency">Upload concurrency</label>
+              <label class="immich-form-label" for="upload-concurrency">{$t('upload_concurrency')}</label>
             </div>
             <input
               class="immich-form-input w-full"
-              aria-labelledby="Upload concurrency"
+              aria-labelledby={$t('upload_concurrency')}
               id="upload-concurrency"
-              name="Upload concurrency"
+              name={$t('upload_concurrency')}
               type="number"
               min="1"
               max="50"
diff --git a/web/src/lib/components/shared-components/version-announcement-box.svelte b/web/src/lib/components/shared-components/version-announcement-box.svelte
index a5aea58a051ad..211f7ce06ef8b 100644
--- a/web/src/lib/components/shared-components/version-announcement-box.svelte
+++ b/web/src/lib/components/shared-components/version-announcement-box.svelte
@@ -3,6 +3,7 @@
   import type { ServerVersionResponseDto } from '@immich/sdk';
   import Button from '../elements/buttons/button.svelte';
   import FullScreenModal from './full-screen-modal.svelte';
+  import { t } from 'svelte-i18n';
 
   let showModal = false;
 
@@ -54,7 +55,7 @@
     </div>
 
     <svelte:fragment slot="sticky-bottom">
-      <Button fullwidth on:click={onAcknowledge}>Acknowledge</Button>
+      <Button fullwidth on:click={onAcknowledge}>{$t('acknowledge')}</Button>
     </svelte:fragment>
   </FullScreenModal>
 {/if}
diff --git a/web/src/lib/components/sharedlinks-page/shared-link-card.svelte b/web/src/lib/components/sharedlinks-page/shared-link-card.svelte
index 465f1ff62d215..5c3b908a4c038 100644
--- a/web/src/lib/components/sharedlinks-page/shared-link-card.svelte
+++ b/web/src/lib/components/sharedlinks-page/shared-link-card.svelte
@@ -8,6 +8,7 @@
   import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
   import { locale } from '$lib/stores/preferences.store';
   import AlbumCover from '$lib/components/album-page/album-cover.svelte';
+  import { t } from 'svelte-i18n';
 
   export let link: SharedLinkResponseDto;
 
@@ -59,7 +60,7 @@
       <div class="font-mono text-xs font-semibold text-gray-500 dark:text-gray-400">
         {#if link.expiresAt}
           {#if isExpired(link.expiresAt)}
-            <p class="font-bold text-red-600 dark:text-red-400">Expired</p>
+            <p class="font-bold text-red-600 dark:text-red-400">{$t('expired')}</p>
           {:else}
             <p>
               Expires {getCountDownExpirationDate()}
@@ -77,11 +78,11 @@
               {link.album?.albumName.toUpperCase()}
             </p>
           {:else if link.type === SharedLinkType.Individual}
-            <p>INDIVIDUAL SHARE</p>
+            <p>{$t('individual_share').toUpperCase()}</p>
           {/if}
 
           {#if !link.expiresAt || !isExpired(link.expiresAt)}
-            <a href="{AppRoute.SHARE}/{link.key}" title="Go to share page">
+            <a href="{AppRoute.SHARE}/{link.key}" title={$t('go_to_share_page')}>
               <Icon path={mdiOpenInNew} />
             </a>
           {/if}
@@ -128,9 +129,9 @@
 
   <div class="flex flex-auto flex-col place-content-center place-items-end text-right">
     <div class="flex">
-      <CircleIconButton title="Delete link" icon={mdiDelete} on:click={() => dispatch('delete')} />
-      <CircleIconButton title="Edit link" icon={mdiCircleEditOutline} on:click={() => dispatch('edit')} />
-      <CircleIconButton title="Copy link" icon={mdiContentCopy} on:click={() => dispatch('copy')} />
+      <CircleIconButton title={$t('delete_link')} icon={mdiDelete} on:click={() => dispatch('delete')} />
+      <CircleIconButton title={$t('edit_link')} icon={mdiCircleEditOutline} on:click={() => dispatch('edit')} />
+      <CircleIconButton title={$t('copy_link')} icon={mdiContentCopy} on:click={() => dispatch('copy')} />
     </div>
   </div>
 </div>
diff --git a/web/src/lib/components/slideshow-settings.svelte b/web/src/lib/components/slideshow-settings.svelte
index fee8a2b099c17..5998f4e9622c4 100644
--- a/web/src/lib/components/slideshow-settings.svelte
+++ b/web/src/lib/components/slideshow-settings.svelte
@@ -16,21 +16,22 @@
   import Button from './elements/buttons/button.svelte';
   import type { RenderedOption } from './elements/dropdown.svelte';
   import SettingDropdown from './shared-components/settings/setting-dropdown.svelte';
+  import { t } from 'svelte-i18n';
 
   const { slideshowDelay, showProgressBar, slideshowNavigation, slideshowLook } = slideshowStore;
 
   export let onClose = () => {};
 
   const navigationOptions: Record<SlideshowNavigation, RenderedOption> = {
-    [SlideshowNavigation.Shuffle]: { icon: mdiShuffle, title: 'Shuffle' },
-    [SlideshowNavigation.AscendingOrder]: { icon: mdiArrowUpThin, title: 'Backward' },
-    [SlideshowNavigation.DescendingOrder]: { icon: mdiArrowDownThin, title: 'Forward' },
+    [SlideshowNavigation.Shuffle]: { icon: mdiShuffle, title: $t('shuffle') },
+    [SlideshowNavigation.AscendingOrder]: { icon: mdiArrowUpThin, title: $t('backward') },
+    [SlideshowNavigation.DescendingOrder]: { icon: mdiArrowDownThin, title: $t('forward') },
   };
 
   const lookOptions: Record<SlideshowLook, RenderedOption> = {
-    [SlideshowLook.Contain]: { icon: mdiFitToScreenOutline, title: 'Contain' },
-    [SlideshowLook.Cover]: { icon: mdiFitToPageOutline, title: 'Cover' },
-    [SlideshowLook.BlurredBackground]: { icon: mdiPanorama, title: 'Blurred background' },
+    [SlideshowLook.Contain]: { icon: mdiFitToScreenOutline, title: $t('contain') },
+    [SlideshowLook.Cover]: { icon: mdiFitToPageOutline, title: $t('cover') },
+    [SlideshowLook.BlurredBackground]: { icon: mdiPanorama, title: $t('blurred_background') },
   };
 
   const handleToggle = <Type extends SlideshowNavigation | SlideshowLook>(
@@ -45,10 +46,10 @@
   };
 </script>
 
-<FullScreenModal title="Slideshow settings" {onClose}>
+<FullScreenModal title={$t('slideshow_settings')} {onClose}>
   <div class="flex flex-col gap-4 text-immich-primary dark:text-immich-dark-primary">
     <SettingDropdown
-      title="Direction"
+      title={$t('direction')}
       options={Object.values(navigationOptions)}
       selectedOption={navigationOptions[$slideshowNavigation]}
       onToggle={(option) => {
@@ -56,23 +57,23 @@
       }}
     />
     <SettingDropdown
-      title="Look"
+      title={$t('look')}
       options={Object.values(lookOptions)}
       selectedOption={lookOptions[$slideshowLook]}
       onToggle={(option) => {
         $slideshowLook = handleToggle(option, lookOptions) || $slideshowLook;
       }}
     />
-    <SettingSwitch title="Show Progress Bar" bind:checked={$showProgressBar} />
+    <SettingSwitch title={$t('show_progress_bar')} bind:checked={$showProgressBar} />
     <SettingInputField
       inputType={SettingInputFieldType.NUMBER}
-      label="Duration"
-      desc="Number of seconds to display each image"
+      label={$t('duration')}
+      desc={$t('slideshow_duration_description')}
       min={1}
       bind:value={$slideshowDelay}
     />
   </div>
   <svelte:fragment slot="sticky-bottom">
-    <Button fullwidth color="primary" on:click={onClose}>Done</Button>
+    <Button fullwidth color="primary" on:click={onClose}>{$t('done')}</Button>
   </svelte:fragment>
 </FullScreenModal>
diff --git a/web/src/lib/components/user-settings-page/app-settings.svelte b/web/src/lib/components/user-settings-page/app-settings.svelte
index ce5ffb4bd75b7..ccee1e664fcfd 100644
--- a/web/src/lib/components/user-settings-page/app-settings.svelte
+++ b/web/src/lib/components/user-settings-page/app-settings.svelte
@@ -2,10 +2,11 @@
   import type { ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
   import SettingCombobox from '$lib/components/shared-components/settings/setting-combobox.svelte';
   import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
-  import { fallbackLocale, locales } from '$lib/constants';
+  import { fallbackLang, fallbackLocale, langs, locales } from '$lib/constants';
   import {
     alwaysLoadOriginalFile,
     colorTheme,
+    lang,
     locale,
     loopVideo,
     playVideoThumbnailOnHover,
@@ -15,6 +16,8 @@
   import { findLocale } from '$lib/utils';
   import { onMount } from 'svelte';
   import { fade } from 'svelte/transition';
+  import { t, locale as i18nLocale, init } from 'svelte-i18n';
+  import { get } from 'svelte/store';
 
   let time = new Date();
 
@@ -62,6 +65,20 @@
     $locale = $locale ? undefined : fallbackLocale.code;
   };
 
+  const handleLanguageChange = async (newLang: string | undefined) => {
+    newLang = newLang || fallbackLang;
+    $lang = newLang;
+
+    const previousLang = get(i18nLocale);
+
+    if (newLang === 'dev') {
+      await init({ fallbackLocale: 'dev', initialLocale: 'dev' });
+    } else if (previousLang == 'dev' && newLang !== 'dev') {
+      await init({ fallbackLocale: 'en-US', initialLocale: newLang });
+    }
+    $i18nLocale = newLang;
+  };
+
   const handleLocaleChange = (newLocale: string | undefined) => {
     $locale = newLocale;
   };
@@ -72,17 +89,28 @@
     <div class="ml-4 mt-4 flex flex-col gap-4">
       <div class="ml-4">
         <SettingSwitch
-          title="Theme selection"
-          subtitle="Automatically set the theme to light or dark based on your browser's system preference"
+          title={$t('theme_selection')}
+          subtitle={$t('theme_selection_description')}
           bind:checked={$colorTheme.system}
           on:toggle={handleToggleColorTheme}
         />
       </div>
 
+      <div class="ml-4">
+        <SettingCombobox
+          comboboxPlaceholder={$t('language')}
+          {selectedOption}
+          options={langs.map((lang) => ({ label: lang.name, value: lang.code }))}
+          title={$t('language')}
+          subtitle={$t('language_setting_description')}
+          onSelect={(combobox) => handleLanguageChange(combobox?.value)}
+        />
+      </div>
+
       <div class="ml-4">
         <SettingSwitch
-          title="Default Locale"
-          subtitle="Format dates and numbers based on your browser locale"
+          title={$t('default_locale')}
+          subtitle={$t('default_locale_description')}
           checked={$locale == undefined}
           on:toggle={handleToggleLocaleBrowser}
         >
@@ -92,11 +120,11 @@
       {#if $locale !== undefined}
         <div class="ml-4">
           <SettingCombobox
-            comboboxPlaceholder="Searching locales..."
+            comboboxPlaceholder={$t('searching_locales')}
             {selectedOption}
             options={getAllLanguages()}
-            title="Custom Locale"
-            subtitle="Format dates and numbers based on the language and the region"
+            title={$t('custom_locale')}
+            subtitle={$t('custom_locale_description')}
             onSelect={(combobox) => handleLocaleChange(combobox?.value)}
           />
         </div>
@@ -104,8 +132,8 @@
 
       <div class="ml-4">
         <SettingSwitch
-          title="Display original photos"
-          subtitle="Prefer to display the original photo when viewing an asset rather than thumbnails when the original asset is web-compatible. This may result in slower photo display speeds."
+          title={$t('display_original_photos')}
+          subtitle={$t('display_original_photos_setting_description')}
           bind:checked={$alwaysLoadOriginalFile}
           on:toggle={() => ($alwaysLoadOriginalFile = !$alwaysLoadOriginalFile)}
         />
@@ -113,15 +141,15 @@
       <div class="ml-4">
         <SettingSwitch
           title="Play video thumbnail on hover"
-          subtitle="Play video thumbnail when mouse is hovering over item. Even when disabled, playback can be started by hovering over the play icon."
+          subtitle={$t('video_hover_setting_description')}
           bind:checked={$playVideoThumbnailOnHover}
           on:toggle={() => ($playVideoThumbnailOnHover = !$playVideoThumbnailOnHover)}
         />
       </div>
       <div class="ml-4">
         <SettingSwitch
-          title="Loop videos"
-          subtitle="Enable to automatically loop a video in the detail viewer."
+          title={$t('loop_videos')}
+          subtitle={$t('loop_videos_description')}
           bind:checked={$loopVideo}
           on:toggle={() => ($loopVideo = !$loopVideo)}
         />
@@ -129,23 +157,23 @@
 
       <div class="ml-4">
         <SettingSwitch
-          title="Permanent deletion warning"
-          subtitle="Show a warning when permanently deleting assets"
+          title={$t('permanent_deletion_warning')}
+          subtitle={$t('permanent_deletion_warning_setting_description')}
           bind:checked={$showDeleteModal}
         />
       </div>
 
       <div class="ml-4">
         <SettingSwitch
-          title="People"
-          subtitle="Display a link to People in the sidebar"
+          title={$t('people')}
+          subtitle={$t('people_sidebar_description')}
           bind:checked={$sidebarSettings.people}
         />
       </div>
       <div class="ml-4">
         <SettingSwitch
-          title="Sharing"
-          subtitle="Display a link to Sharing in the sidebar"
+          title={$t('sharing')}
+          subtitle={$t('sharing_sidebar_description')}
           bind:checked={$sidebarSettings.sharing}
         />
       </div>
diff --git a/web/src/lib/components/user-settings-page/change-password-settings.svelte b/web/src/lib/components/user-settings-page/change-password-settings.svelte
index 89a8103f59726..5c8fa2b29c6f2 100644
--- a/web/src/lib/components/user-settings-page/change-password-settings.svelte
+++ b/web/src/lib/components/user-settings-page/change-password-settings.svelte
@@ -11,6 +11,7 @@
   import SettingInputField, {
     SettingInputFieldType,
   } from '$lib/components/shared-components/settings/setting-input-field.svelte';
+  import { t } from 'svelte-i18n';
 
   let password = '';
   let newPassword = '';
@@ -21,7 +22,7 @@
       await changePassword({ changePasswordDto: { password, newPassword } });
 
       notificationController.show({
-        message: 'Updated password',
+        message: $t('updated_password'),
         type: NotificationType.Info,
       });
 
@@ -44,7 +45,7 @@
       <div class="ml-4 mt-4 flex flex-col gap-4">
         <SettingInputField
           inputType={SettingInputFieldType.PASSWORD}
-          label="PASSWORD"
+          label={$t('password').toUpperCase()}
           bind:value={password}
           required={true}
           passwordAutocomplete="current-password"
@@ -52,7 +53,7 @@
 
         <SettingInputField
           inputType={SettingInputFieldType.PASSWORD}
-          label="NEW PASSWORD"
+          label={$t('new_password').toUpperCase()}
           bind:value={newPassword}
           required={true}
           passwordAutocomplete="new-password"
@@ -60,7 +61,7 @@
 
         <SettingInputField
           inputType={SettingInputFieldType.PASSWORD}
-          label="CONFIRM PASSWORD"
+          label={$t('confirm_password').toUpperCase()}
           bind:value={confirmPassword}
           required={true}
           passwordAutocomplete="new-password"
@@ -71,7 +72,7 @@
             type="submit"
             size="sm"
             disabled={!(password && newPassword && newPassword === confirmPassword)}
-            on:click={() => handleChangePassword()}>Save</Button
+            on:click={() => handleChangePassword()}>{$t('save')}</Button
           >
         </div>
       </div>
diff --git a/web/src/lib/components/user-settings-page/device-card.svelte b/web/src/lib/components/user-settings-page/device-card.svelte
index 34304fa9131b7..deef8ca8995af 100644
--- a/web/src/lib/components/user-settings-page/device-card.svelte
+++ b/web/src/lib/components/user-settings-page/device-card.svelte
@@ -15,6 +15,7 @@
   } from '@mdi/js';
   import { DateTime, type ToRelativeCalendarOptions } from 'luxon';
   import { createEventDispatcher } from 'svelte';
+  import { t } from 'svelte-i18n';
 
   export let device: SessionResponseDto;
 
@@ -52,11 +53,11 @@
         {#if device.deviceType || device.deviceOS}
           <span>{device.deviceOS || 'Unknown'} • {device.deviceType || 'Unknown'}</span>
         {:else}
-          <span>Unknown</span>
+          <span>{$t('unknown')}</span>
         {/if}
       </span>
       <div class="text-sm">
-        <span class="">Last seen</span>
+        <span class="">{$t('last_seen')}</span>
         <span>{DateTime.fromISO(device.updatedAt, { locale: $locale }).toRelativeCalendar(options)}</span>
         <span class="text-xs text-gray-500 dark:text-gray-400"> - </span>
         <span class="text-xs text-gray-500 dark:text-gray-400">
@@ -69,7 +70,7 @@
         <CircleIconButton
           color="primary"
           icon={mdiTrashCanOutline}
-          title="Log out"
+          title={$t('log_out')}
           size="16"
           on:click={() => dispatcher('delete')}
         />
diff --git a/web/src/lib/components/user-settings-page/device-list.svelte b/web/src/lib/components/user-settings-page/device-list.svelte
index 923ea16dfff22..e6823aeed3289 100644
--- a/web/src/lib/components/user-settings-page/device-list.svelte
+++ b/web/src/lib/components/user-settings-page/device-list.svelte
@@ -5,6 +5,7 @@
   import { notificationController, NotificationType } from '../shared-components/notification/notification';
   import DeviceCard from './device-card.svelte';
   import { dialogController } from '$lib/components/shared-components/dialog/dialog';
+  import { t } from 'svelte-i18n';
 
   export let devices: SessionResponseDto[];
 
@@ -60,13 +61,17 @@
 <section class="my-4">
   {#if currentDevice}
     <div class="mb-6">
-      <h3 class="mb-2 text-xs font-medium text-immich-primary dark:text-immich-dark-primary">CURRENT DEVICE</h3>
+      <h3 class="mb-2 text-xs font-medium text-immich-primary dark:text-immich-dark-primary">
+        {$t('current_device').toUpperCase()}
+      </h3>
       <DeviceCard device={currentDevice} />
     </div>
   {/if}
   {#if otherDevices.length > 0}
     <div class="mb-6">
-      <h3 class="mb-2 text-xs font-medium text-immich-primary dark:text-immich-dark-primary">OTHER DEVICES</h3>
+      <h3 class="mb-2 text-xs font-medium text-immich-primary dark:text-immich-dark-primary">
+        {$t('other_devices').toUpperCase()}
+      </h3>
       {#each otherDevices as device, index}
         <DeviceCard {device} on:delete={() => handleDelete(device)} />
         {#if index !== otherDevices.length - 1}
@@ -74,9 +79,11 @@
         {/if}
       {/each}
     </div>
-    <h3 class="mb-2 text-xs font-medium text-immich-primary dark:text-immich-dark-primary">LOG OUT ALL DEVICES</h3>
+    <h3 class="mb-2 text-xs font-medium text-immich-primary dark:text-immich-dark-primary">
+      {$t('log_out_all_devices').toUpperCase()}
+    </h3>
     <div class="flex justify-end">
-      <Button color="red" size="sm" on:click={handleDeleteAll}>Log Out All Devices</Button>
+      <Button color="red" size="sm" on:click={handleDeleteAll}>{$t('log_out_all_devices')}</Button>
     </div>
   {/if}
 </section>
diff --git a/web/src/lib/components/user-settings-page/memories-settings.svelte b/web/src/lib/components/user-settings-page/memories-settings.svelte
index ca2a4d4e3c269..e8a58bf01651b 100644
--- a/web/src/lib/components/user-settings-page/memories-settings.svelte
+++ b/web/src/lib/components/user-settings-page/memories-settings.svelte
@@ -10,6 +10,7 @@
   import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
   import { preferences } from '$lib/stores/user.store';
   import Button from '../elements/buttons/button.svelte';
+  import { t } from 'svelte-i18n';
 
   let memoriesEnabled = $preferences?.memories?.enabled ?? false;
 
@@ -18,9 +19,9 @@
       const data = await updateMyPreferences({ userPreferencesUpdateDto: { memories: { enabled: memoriesEnabled } } });
       $preferences.memories.enabled = data.memories.enabled;
 
-      notificationController.show({ message: 'Saved settings', type: NotificationType.Info });
+      notificationController.show({ message: $t('saved_settings'), type: NotificationType.Info });
     } catch (error) {
-      handleError(error, 'Unable to update settings');
+      handleError(error, $t('errors.unable_to_update_settings'));
     }
   };
 </script>
@@ -31,13 +32,13 @@
       <div class="ml-4 mt-4 flex flex-col gap-4">
         <div class="ml-4">
           <SettingSwitch
-            title="Time-based memories"
-            subtitle="Photos from previous years"
+            title={$t('time_based_memories')}
+            subtitle={$t('photos_from_previous_years')}
             bind:checked={memoriesEnabled}
           />
         </div>
         <div class="flex justify-end">
-          <Button type="submit" size="sm" on:click={() => handleSave()}>Save</Button>
+          <Button type="submit" size="sm" on:click={() => handleSave()}>{$t('save')}</Button>
         </div>
       </div>
     </form>
diff --git a/web/src/lib/components/user-settings-page/oauth-settings.svelte b/web/src/lib/components/user-settings-page/oauth-settings.svelte
index cd10877ffdeb7..fd94fe811c6bf 100644
--- a/web/src/lib/components/user-settings-page/oauth-settings.svelte
+++ b/web/src/lib/components/user-settings-page/oauth-settings.svelte
@@ -9,6 +9,7 @@
   import Button from '../elements/buttons/button.svelte';
   import LoadingSpinner from '../shared-components/loading-spinner.svelte';
   import { notificationController, NotificationType } from '../shared-components/notification/notification';
+  import { t } from 'svelte-i18n';
 
   export let user: UserAdminResponseDto;
 
@@ -22,7 +23,7 @@
         user = await oauth.link(window.location);
 
         notificationController.show({
-          message: 'Linked OAuth account',
+          message: $t('linked_oauth_account'),
           type: NotificationType.Info,
         });
       } catch (error) {
@@ -39,11 +40,11 @@
     try {
       user = await oauth.unlink();
       notificationController.show({
-        message: 'Unlinked OAuth account',
+        message: $t('unlinked_oauth_account'),
         type: NotificationType.Info,
       });
     } catch (error) {
-      handleError(error, 'Unable to unlink account');
+      handleError(error, $t('errors.unable_to_unlink_account'));
     }
   };
 </script>
@@ -57,9 +58,9 @@
         </div>
       {:else if $featureFlags.oauth}
         {#if user.oauthId}
-          <Button size="sm" on:click={() => handleUnlink()}>Unlink Oauth</Button>
+          <Button size="sm" on:click={() => handleUnlink()}>{$t('unlink_oauth')}</Button>
         {:else}
-          <Button size="sm" on:click={() => oauth.authorize(window.location)}>Link to OAuth</Button>
+          <Button size="sm" on:click={() => oauth.authorize(window.location)}>{$t('link_to_oauth')}</Button>
         {/if}
       {/if}
     </div>
diff --git a/web/src/lib/components/user-settings-page/partner-selection-modal.svelte b/web/src/lib/components/user-settings-page/partner-selection-modal.svelte
index 37cc29a596ac1..40e517f41076c 100644
--- a/web/src/lib/components/user-settings-page/partner-selection-modal.svelte
+++ b/web/src/lib/components/user-settings-page/partner-selection-modal.svelte
@@ -4,6 +4,7 @@
   import Button from '../elements/buttons/button.svelte';
   import UserAvatar from '../shared-components/user-avatar.svelte';
   import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
+  import { t } from 'svelte-i18n';
 
   export let user: UserResponseDto;
   export let onClose: () => void;
@@ -32,7 +33,7 @@
   };
 </script>
 
-<FullScreenModal title="Add partner" showLogo {onClose}>
+<FullScreenModal title={$t('add_partner')} showLogo {onClose}>
   <div class="immich-scrollbar max-h-[300px] overflow-y-auto">
     {#if availableUsers.length > 0}
       {#each availableUsers as user}
@@ -68,7 +69,7 @@
 
     {#if selectedUsers.length > 0}
       <div class="pt-5">
-        <Button size="sm" fullwidth on:click={() => dispatch('add-users', selectedUsers)}>Add</Button>
+        <Button size="sm" fullwidth on:click={() => dispatch('add-users', selectedUsers)}>{$t('add')}</Button>
       </div>
     {/if}
   </div>
diff --git a/web/src/lib/components/user-settings-page/partner-settings.svelte b/web/src/lib/components/user-settings-page/partner-settings.svelte
index 07a08d0b3d043..53524384cdc0b 100644
--- a/web/src/lib/components/user-settings-page/partner-settings.svelte
+++ b/web/src/lib/components/user-settings-page/partner-settings.svelte
@@ -17,6 +17,7 @@
   import PartnerSelectionModal from './partner-selection-modal.svelte';
   import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
   import { dialogController } from '$lib/components/shared-components/dialog/dialog';
+  import { t } from 'svelte-i18n';
 
   interface PartnerSharing {
     user: UserResponseDto;
@@ -90,7 +91,7 @@
       await removePartner({ id: partner.id });
       await refreshPartners();
     } catch (error) {
-      handleError(error, 'Unable to remove partner');
+      handleError(error, $t('errors.unable_to_remove_partner'));
     }
   };
 
@@ -103,7 +104,7 @@
       await refreshPartners();
       createPartnerFlag = false;
     } catch (error) {
-      handleError(error, 'Unable to add partners');
+      handleError(error, $t('errors.unable_to_add_partners'));
     }
   };
 
@@ -167,8 +168,8 @@
             <hr class="my-4 border border-gray-200 dark:border-gray-700" />
             <p class="text-xs font-medium my-4">PHOTOS FROM {partner.user.name.toUpperCase()}</p>
             <SettingSwitch
-              title="Show in timeline"
-              subtitle="Show photos and videos from this user in your timeline"
+              title={$t('show_in_timeline')}
+              subtitle={$t('show_in_timeline_setting_description')}
               bind:checked={partner.inTimeline}
               on:toggle={({ detail }) => handleShowOnTimelineChanged(partner, detail)}
             />
@@ -179,7 +180,7 @@
   {/if}
 
   <div class="flex justify-end mt-5">
-    <Button size="sm" on:click={() => (createPartnerFlag = true)}>Add partner</Button>
+    <Button size="sm" on:click={() => (createPartnerFlag = true)}>{$t('add_partner')}</Button>
   </div>
 </section>
 
diff --git a/web/src/lib/components/user-settings-page/user-api-key-list.svelte b/web/src/lib/components/user-settings-page/user-api-key-list.svelte
index 11242cf95cb42..95db42365967c 100644
--- a/web/src/lib/components/user-settings-page/user-api-key-list.svelte
+++ b/web/src/lib/components/user-settings-page/user-api-key-list.svelte
@@ -10,6 +10,7 @@
   import { NotificationType, notificationController } from '../shared-components/notification/notification';
   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
   import { dialogController } from '$lib/components/shared-components/dialog/dialog';
+  import { t } from 'svelte-i18n';
 
   export let keys: ApiKeyResponseDto[];
 
@@ -84,8 +85,8 @@
 
 {#if newKey}
   <APIKeyForm
-    title="New API key"
-    submitText="Create"
+    title={$t('new_api_key')}
+    submitText={$t('create')}
     apiKey={newKey}
     on:submit={({ detail }) => handleCreate(detail)}
     on:cancel={() => (newKey = null)}
@@ -98,8 +99,8 @@
 
 {#if editKey}
   <APIKeyForm
-    title="API key"
-    submitText="Save"
+    title={$t('api_key')}
+    submitText={$t('save')}
     apiKey={editKey}
     on:submit={({ detail }) => handleUpdate(detail)}
     on:cancel={() => (editKey = null)}
@@ -109,7 +110,7 @@
 <section class="my-4">
   <div class="flex flex-col gap-2" in:fade={{ duration: 500 }}>
     <div class="mb-2 flex justify-end">
-      <Button size="sm" on:click={() => (newKey = { name: 'API Key' })}>New API Key</Button>
+      <Button size="sm" on:click={() => (newKey = { name: $t('api_key') })}>{$t('new_api_key')}</Button>
     </div>
 
     {#if keys.length > 0}
@@ -118,9 +119,9 @@
           class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
         >
           <tr class="flex w-full place-items-center">
-            <th class="w-1/3 text-center text-sm font-medium">Name</th>
-            <th class="w-1/3 text-center text-sm font-medium">Created</th>
-            <th class="w-1/3 text-center text-sm font-medium">Action</th>
+            <th class="w-1/3 text-center text-sm font-medium">{$t('name')}</th>
+            <th class="w-1/3 text-center text-sm font-medium">{$t('created')}</th>
+            <th class="w-1/3 text-center text-sm font-medium">{$t('action')}</th>
           </tr>
         </thead>
         <tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
@@ -141,14 +142,14 @@
                   <CircleIconButton
                     color="primary"
                     icon={mdiPencilOutline}
-                    title="Edit key"
+                    title={$t('edit_key')}
                     size="16"
                     on:click={() => (editKey = key)}
                   />
                   <CircleIconButton
                     color="primary"
                     icon={mdiTrashCanOutline}
-                    title="Delete key"
+                    title={$t('delete_key')}
                     size="16"
                     on:click={() => handleDelete(key)}
                   />
diff --git a/web/src/lib/components/user-settings-page/user-profile-settings.svelte b/web/src/lib/components/user-settings-page/user-profile-settings.svelte
index d239fcdf5a90d..95a2d0d5901be 100644
--- a/web/src/lib/components/user-settings-page/user-profile-settings.svelte
+++ b/web/src/lib/components/user-settings-page/user-profile-settings.svelte
@@ -12,6 +12,7 @@
   import { fade } from 'svelte/transition';
   import { handleError } from '../../utils/handle-error';
   import Button from '../elements/buttons/button.svelte';
+  import { t } from 'svelte-i18n';
 
   let editedUser = cloneDeep($user);
 
@@ -28,11 +29,11 @@
       $user = data;
 
       notificationController.show({
-        message: 'Saved profile',
+        message: $t('saved_profile'),
         type: NotificationType.Info,
       });
     } catch (error) {
-      handleError(error, 'Unable to save profile');
+      handleError(error, $t('errors.unable_to_save_profile'));
     }
   };
 </script>
@@ -43,30 +44,34 @@
       <div class="ml-4 mt-4 flex flex-col gap-4">
         <SettingInputField
           inputType={SettingInputFieldType.TEXT}
-          label="USER ID"
+          label={$t('user_id').toUpperCase()}
           bind:value={editedUser.id}
           disabled={true}
         />
 
-        <SettingInputField inputType={SettingInputFieldType.EMAIL} label="EMAIL" bind:value={editedUser.email} />
+        <SettingInputField
+          inputType={SettingInputFieldType.EMAIL}
+          label={$t('email').toUpperCase()}
+          bind:value={editedUser.email}
+        />
 
         <SettingInputField
           inputType={SettingInputFieldType.TEXT}
-          label="NAME"
+          label={$t('name').toUpperCase()}
           bind:value={editedUser.name}
           required={true}
         />
 
         <SettingInputField
           inputType={SettingInputFieldType.TEXT}
-          label="STORAGE LABEL"
+          label={$t('storage_label').toUpperCase()}
           disabled={true}
           value={editedUser.storageLabel || ''}
           required={false}
         />
 
         <div class="flex justify-end">
-          <Button type="submit" size="sm" on:click={() => handleSaveProfile()}>Save</Button>
+          <Button type="submit" size="sm" on:click={() => handleSaveProfile()}>{$t('save')}</Button>
         </div>
       </div>
     </form>
diff --git a/web/src/lib/components/user-settings-page/user-settings-list.svelte b/web/src/lib/components/user-settings-page/user-settings-list.svelte
index 95a792eb6ef57..1b27e73a3ac1b 100644
--- a/web/src/lib/components/user-settings-page/user-settings-list.svelte
+++ b/web/src/lib/components/user-settings-page/user-settings-list.svelte
@@ -16,6 +16,7 @@
   import UserAPIKeyList from './user-api-key-list.svelte';
   import UserProfileSettings from './user-profile-settings.svelte';
   import NotificationsSettings from '$lib/components/user-settings-page/notifications-settings.svelte';
+  import { t } from 'svelte-i18n';
 
   export let keys: ApiKeyResponseDto[] = [];
   export let sessions: SessionResponseDto[] = [];
@@ -26,23 +27,23 @@
 </script>
 
 <SettingAccordionState queryParam={QueryParameter.IS_OPEN}>
-  <SettingAccordion key="app-settings" title="App Settings" subtitle="Manage the app settings">
+  <SettingAccordion key="app-settings" title={$t('app_settings')} subtitle={$t('manage_the_app_settings')}>
     <AppSettings />
   </SettingAccordion>
 
-  <SettingAccordion key="account" title="Account" subtitle="Manage your account">
+  <SettingAccordion key="account" title={$t('account')} subtitle={$t('manage_your_account')}>
     <UserProfileSettings />
   </SettingAccordion>
 
-  <SettingAccordion key="api-keys" title="API Keys" subtitle="Manage your API keys">
+  <SettingAccordion key="api-keys" title={$t('api_keys')} subtitle={$t('manage_your_api_keys')}>
     <UserAPIKeyList bind:keys />
   </SettingAccordion>
 
-  <SettingAccordion key="authorized-devices" title="Authorized Devices" subtitle="Manage your logged-in devices">
+  <SettingAccordion key="authorized-devices" title={$t('authorized_devices')} subtitle={$t('manage_your_devices')}>
     <DeviceList bind:devices={sessions} />
   </SettingAccordion>
 
-  <SettingAccordion key="memories" title="Memories" subtitle="Manage what you see in your memories">
+  <SettingAccordion key="memories" title={$t('memories')} subtitle={$t('memories_setting_description')}>
     <MemoriesSettings />
   </SettingAccordion>
 
@@ -51,16 +52,21 @@
   </SettingAccordion>
 
   {#if $featureFlags.loaded && $featureFlags.oauth}
-    <SettingAccordion key="oauth" title="OAuth" subtitle="Manage your OAuth connection" isOpen={oauthOpen || undefined}>
+    <SettingAccordion
+      key="oauth"
+      title={$t('oauth')}
+      subtitle={$t('manage_your_oauth_connection')}
+      isOpen={oauthOpen || undefined}
+    >
       <OAuthSettings user={$user} />
     </SettingAccordion>
   {/if}
 
-  <SettingAccordion key="password" title="Password" subtitle="Change your password">
+  <SettingAccordion key="password" title={$t('password')} subtitle={$t('change_your_password')}>
     <ChangePasswordSettings />
   </SettingAccordion>
 
-  <SettingAccordion key="partner-sharing" title="Partner Sharing" subtitle="Manage sharing with partners">
+  <SettingAccordion key="partner-sharing" title={$t('partner_sharing')} subtitle={$t('manage_sharing_with_partners')}>
     <PartnerSettings user={$user} />
   </SettingAccordion>
 </SettingAccordionState>
diff --git a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte
index 3eafc1c068637..cea1a58f5c4b5 100644
--- a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte
+++ b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte
@@ -8,6 +8,7 @@
   import { s } from '$lib/utils';
   import { getAssetResolution, getFileSize } from '$lib/utils/asset-utils';
   import { sortBy } from 'lodash-es';
+  import { t } from 'svelte-i18n';
 
   export let duplicate: DuplicateResponseDto;
   export let onResolve: (duplicateAssetIds: string[], trashIds: string[]) => void;
@@ -67,7 +68,7 @@
           <div
             class={`absolute bottom-2 right-3 ${isSelected ? 'bg-green-400/90' : 'bg-red-300/90'} px-4 py-1 rounded-xl text-xs font-semibold`}
           >
-            {isSelected ? 'Keep' : 'Trash'}
+            {isSelected ? $t('keep') : $t('trash')}
           </div>
 
           <!-- EXTERNAL LIBRARY CHIP-->
@@ -125,7 +126,7 @@
     {:else}
       <Button size="sm" color="red" class="flex place-items-center gap-2" on:click={handleResolve}
         ><Icon path={mdiTrashCanOutline} size="20" />{trashCount === duplicate.assets.length
-          ? 'Trash All'
+          ? $t('trash_all')
           : `Trash ${trashCount}`}
       </Button>
     {/if}
diff --git a/web/src/lib/components/utilities-page/utilities-menu.svelte b/web/src/lib/components/utilities-page/utilities-menu.svelte
index e6e6346321463..4589d7aadfce7 100644
--- a/web/src/lib/components/utilities-page/utilities-menu.svelte
+++ b/web/src/lib/components/utilities-page/utilities-menu.svelte
@@ -2,15 +2,16 @@
   import { mdiContentDuplicate } from '@mdi/js';
   import Icon from '$lib/components/elements/icon.svelte';
   import { AppRoute } from '$lib/constants';
+  import { t } from 'svelte-i18n';
 </script>
 
 <div class="border border-gray-300 dark:border-immich-dark-gray rounded-3xl pt-1 pb-6 dark:text-white">
-  <p class="text-xs font-medium p-4">ORGANIZE YOUR LIBRARY</p>
+  <p class="text-xs font-medium p-4">{$t('organize_your_library').toUpperCase()}</p>
 
   <a href={AppRoute.DUPLICATES} class="w-full hover:bg-gray-100 dark:hover:bg-immich-dark-gray flex gap-4 p-4">
     <span
       ><Icon path={mdiContentDuplicate} class="text-immich-primary dark:text-immich-dark-primary" size="24" />
     </span>
-    Review duplicates
+    {$t('review_duplicates')}
   </a>
 </div>
diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts
index a9b7b8929dabb..cc118775b927c 100644
--- a/web/src/lib/constants.ts
+++ b/web/src/lib/constants.ts
@@ -1,3 +1,5 @@
+import type { register } from 'svelte-i18n';
+
 export enum AssetAction {
   ARCHIVE = 'archive',
   UNARCHIVE = 'unarchive',
@@ -155,7 +157,7 @@ export const locales = [
   { code: 'en-TT', name: 'English (Trinidad and Tobago)' },
   { code: 'en-VI', name: 'English (U.S. Virgin Islands)' },
   { code: 'en-GB', name: 'English (United Kingdom)' },
-  { code: 'en-US', name: 'English (United States)' },
+  { code: 'en-US', name: 'English (United States)', loader: () => import('$lib/i18n/en-US.json') },
   { code: 'en-ZW', name: 'English (Zimbabwe)' },
   { code: 'et-EE', name: 'Estonian (Estonia)' },
   { code: 'fo-FO', name: 'Faroese (Faroe Islands)' },
@@ -232,8 +234,6 @@ export const locales = [
   { code: 'sw-KE', name: 'Swahili (Kenya)' },
   { code: 'sv-FI', name: 'Swedish (Finland)' },
   { code: 'sv-SE', name: 'Swedish (Sweden)' },
-  { code: 'syr-SY', name: 'Syriac (Syria)' },
-  { code: 'ta-IN', name: 'Tamil (India)' },
   { code: 'te-IN', name: 'Telugu (India)' },
   { code: 'th-TH', name: 'Thai (Thailand)' },
   { code: 'tn-ZA', name: 'Tswana (South Africa)' },
@@ -245,3 +245,11 @@ export const locales = [
   { code: 'xh-ZA', name: 'Xhosa (South Africa)' },
   { code: 'zu-ZA', name: 'Zulu (South Africa)' },
 ];
+
+export const fallbackLang = 'en-US';
+
+type LanguageLoader = { code: string; name: string; loader: Parameters<typeof register>[1] };
+export const langs = [
+  ...locales.filter((item): item is LanguageLoader => !!item.loader),
+  { name: 'Development', code: 'dev', loader: () => Promise.resolve({}) },
+];
diff --git a/web/src/lib/i18n/en-US.json b/web/src/lib/i18n/en-US.json
new file mode 100644
index 0000000000000..40496e6aeb383
--- /dev/null
+++ b/web/src/lib/i18n/en-US.json
@@ -0,0 +1,780 @@
+{
+  "account": "Account",
+  "acknowledge": "Acknowledge",
+  "action": "Action",
+  "actions": "Actions",
+  "active": "Active",
+  "activity": "Activity",
+  "add": "Add",
+  "add_a_description": "Add a description",
+  "add_a_location": "Add a location",
+  "add_a_name": "Add a name",
+  "add_a_title": "Add a title",
+  "add_exclusion_pattern": "Add exclusion pattern",
+  "add_import_path": "Add import path",
+  "add_location": "Add location",
+  "add_more_users": "Add more users",
+  "add_partner": "Add partner",
+  "add_path": "Add path",
+  "add_photos": "Add photos",
+  "add_to": "Add to...",
+  "add_to_album": "Add to album",
+  "add_to_shared_album": "Add to shared album",
+  "admin": {
+    "authentication_settings": "Authentication Settings",
+    "authentication_settings_description": "Manage password, OAuth, and other authentication settings",
+    "crontab_guru": "Crontab Guru",
+    "disable_login": "Disable login",
+    "disabled": "Disabled",
+    "duplicate_detection_job_description": "Run machine learning on assets to detect similar images. Relies on Smart Search",
+    "image_format_description": "WebP produces smaller files than JPEG, but is slower to encode.",
+    "image_prefer_embedded_preview": "Prefer embedded preview",
+    "image_prefer_embedded_preview_setting_description": "Use embedded previews in RAW photos as the input to image processing when available. This can produce more accurate colors for some images, but the quality of the preview is camera-dependent and the image may have more compression artifacts.",
+    "image_prefer_wide_gamut": "Prefer wide gamut",
+    "image_prefer_wide_gamut_setting_description": "Use Display P3 for thumbnails. This better preserves the vibrance of images with wide colorspaces, but images may appear differently on old devices with an old browser version. sRGB images are kept as sRGB to avoid color shifts.",
+    "image_preview_format": "Preview format",
+    "image_preview_resolution": "Preview resolution",
+    "image_preview_resolution_description": "Used when viewing a single photo and for machine learning. Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness.",
+    "image_quality": "Quality",
+    "image_quality_description": "Image quality from 1-100. Higher is better for quality but produces larger files.",
+    "image_settings": "Image Settings",
+    "image_settings_description": "Manage the quality and resolution of generated images",
+    "image_thumbnail_format": "Thumbnail format",
+    "image_thumbnail_resolution": "Thumbnail resolution",
+    "image_thumbnail_resolution_description": "Used when viewing groups of photos (main timeline, album view, etc.). Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness.",
+    "job_settings": "Job Settings",
+    "job_settings_description": "Manage job concurrency",
+    "library_cron_expression": "Cron Expression",
+    "library_cron_expression_presets": "Cron Expression Presets",
+    "library_scanning": "Periodic Scanning",
+    "library_scanning_description": "Configure periodic library scanning",
+    "library_scanning_enable_description": "Enable periodic library scanning",
+    "library_settings": "External Library",
+    "library_settings_description": "Manage external library settings",
+    "library_watching_enable_description": "Watch external libraries for file changes",
+    "library_watching_settings": "Library watching (EXPERIMENTAL)",
+    "library_watching_settings_description": "Automatically watch for changed files",
+    "logging_enable_description": "Enable logging",
+    "logging_level_description": "When enabled, what log level to use.",
+    "logging_settings": "Logging",
+    "machine_learning_clip_model": "Clip Model",
+    "machine_learning_duplicate_detection": "Duplicate Detection",
+    "machine_learning_duplicate_detection_enabled_description": "If disabled, exactly identical assets will still be de-duplicated.",
+    "machine_learning_duplicate_detection_setting_description": "Use CLIP embeddings to find likely duplicates",
+    "machine_learning_enabled_description": "If disabled, all ML features will be disabled regardless of the below settings.",
+    "machine_learning_facial_recognition": "Facial Recognition",
+    "machine_learning_facial_recognition_description": "Detect, recognize and group faces in images",
+    "machine_learning_facial_recognition_model": "Facial recognition model",
+    "machine_learning_facial_recognition_model_description": "Models are listed in descending order of size. Larger models are slower and use more memory, but produce better results. Note that you must re-run the Face Detection job for all images upon changing a model.",
+    "machine_learning_facial_recognition_setting_description": "If disabled, images will not be encoded for facial recognition and will not populate the People section in the Explore page.",
+    "machine_learning_max_detection_distance": "Max detection distance",
+    "machine_learning_max_detection_distance_description": "Maximum distance between two images to consider them duplicates, ranging from 0.001-0.1. Higher values will detect more duplicates, but may result in false positives.",
+    "machine_learning_max_recognition_distance": "Max recognition distance",
+    "machine_learning_max_recognition_distance_description": "Maximum distance between two faces to be considered the same person, ranging from 0-2. Lowering this can prevent labeling two people as the same person, while raising it can prevent labeling the same person as two different people. Note that it is easier to merge two people than to split one person in two, so err on the side of a lower threshold when possible.",
+    "machine_learning_min_detection_score": "Min detection score",
+    "machine_learning_min_detection_score_description": "Minimum confidence score for a face to be detected from 0-1. Lower values will detect more faces but may result in false positives.",
+    "machine_learning_min_recognized_faces": "Min recognized faces",
+    "machine_learning_min_recognized_faces_description": "The minimum number of recognized faces for a person to be created. Increasing this makes Facial Recognition more precise at the cost of increasing the chance that a face is not assigned to a person.",
+    "machine_learning_settings": "Machine Learning Settings",
+    "machine_learning_settings_description": "Manage machine learning features and settings",
+    "machine_learning_smart_search": "Smart Search",
+    "machine_learning_smart_search_description": "Search for images semantically using CLIP embeddings",
+    "machine_learning_smart_search_enabled_description": "If disabled, images will not be encoded for smart search.",
+    "machine_learning_url_description": "URL of the machine learning server",
+    "manage_log_settings": "Manage log settings",
+    "map_dark_style": "Dark style",
+    "map_enable_description": "Enable map features",
+    "map_light_style": "Light style",
+    "map_reverse_geocoding": "Reverse Geocoding",
+    "map_reverse_geocoding_enable_description": "Enable reverse geocoding",
+    "map_reverse_geocoding_settings": "Reverse Geocoding Settings",
+    "map_settings": "Map & GPS Settings",
+    "map_settings_description": "Manage map settings",
+    "map_style_description": "URL to a style.json map theme",
+    "metadata_extraction_job_description": "Extract metadata information from each asset, such as GPS and resolution",
+    "migration_job_description": "Migrate thumbnails for assets and faces to the latest folder structure",
+    "notification_email_from_address": "From address",
+    "notification_email_from_address_description": "Sender email address, for example: \"Immich Photo Server <noreply@immich.app>\"",
+    "notification_email_host_description": "Host of the email server (e.g. smtp.immich.app)",
+    "notification_email_ignore_certificate_errors": "Ignore certificate errors",
+    "notification_email_ignore_certificate_errors_description": "Ignore TLS certificate validation errors (not recommended)",
+    "notification_email_password_description": "Password to use when authenticating with the email server",
+    "notification_email_port_description": "Port of the email server (e.g 25, 465, or 587)",
+    "notification_email_setting_description": "Settings for sending email notifications",
+    "notification_email_username_description": "Username to use when authenticating with the email server",
+    "notification_enable_email_notifications": "Enable email notifications",
+    "notification_settings": "Notification Settings",
+    "notification_settings_description": "Manage notification settings, including email",
+    "oauth_auto_launch": "Auto launch",
+    "oauth_auto_launch_description": "Start the OAuth login flow automatically upon navigating to the login page",
+    "oauth_auto_register": "Auto register",
+    "oauth_auto_register_description": "Automatically register new users after signing in with OAuth",
+    "oauth_button_text": "Button text",
+    "oauth_client_id": "Client ID",
+    "oauth_client_secret": "Client Secret",
+    "oauth_enable_description": "Login with OAuth",
+    "oauth_issuer_url": "Issuer URL",
+    "oauth_mobile_redirect_uri": "Mobile redirect URI",
+    "oauth_mobile_redirect_uri_override": "Mobile redirce URI override",
+    "oauth_mobile_redirect_uri_override_description": "Enable when 'app.immich:/' is an invalid redirect URI.",
+    "oauth_scope": "Scope",
+    "oauth_settings": "OAuth",
+    "oauth_settings_description": "Manage OAuth login settings",
+    "oauth_signing_algorithm": "Signing algorithm",
+    "oauth_storage_label_claim": "Storage label claim",
+    "oauth_storage_label_claim_description": "Automatically set the user's storage label to the value of this claim.",
+    "oauth_storage_quota_claim": "Storage quota claim",
+    "oauth_storage_quota_claim_description": "Automatically set the user's storage quota to the value of this claim.",
+    "oauth_storage_quota_default": "Default storage quota (GiB)",
+    "oauth_storage_quota_default_description": "Quota in GiB to be used when no claim is provided (Enter 0 for unlimited quota).",
+    "password_enable_description": "Login with email and password",
+    "password_settings": "Password Login",
+    "password_settings_description": "Manage password login settings",
+    "server_external_domain_settings": "External domain",
+    "server_external_domain_settings_description": "Domain for public shared links, including http(s)://",
+    "server_settings": "Server Settings",
+    "server_settings_description": "Manage server settings",
+    "server_welcome_message": "Welcome Message",
+    "server_welcome_message_description": "A message that is displayed on the login page.",
+    "sidecar_job_description": "Discover or synchronize sidecar metadata from the filesystem",
+    "slideshow_duration_description": "Number of seconds to display each image",
+    "smart_search_job_description": "Run machine learning on assets to support smart search",
+    "storage_template_enable_description": "Enable storage template engine",
+    "storage_template_hash_verification_enabled": "Hash verification failed",
+    "storage_template_hash_verification_enabled_description": "Enables hash verification, don't disable this unless you're certain of the implications",
+    "storage_template_migration_job": "Storage Migration Job",
+    "storage_template_settings": "Storage template",
+    "storage_template_settings_description": "Manage the folder structure and file name of the upload asset",
+    "theme_custom_css_settings": "Custom CSS",
+    "theme_custom_css_settings_description": "Cascading Style Sheets allow the design of Immich to be customized.",
+    "theme_settings": "Theme Settings",
+    "theme_settings_description": "Manage customization of the Immich web interface",
+    "thumbnail_generation_job_description": "Generate large, small and blurred thumbnails for each asset, as well as thumbnails for each person",
+    "transcode_policy_description": "Policy for when a video should be transcoded. HDR videos will always be transcoded (except if transcoding is disabled).",
+    "transcoding_acceleration_api": "Acceleration API",
+    "transcoding_acceleration_api_description": "The API that will interact with your device to accelerate transcoding. This setting is 'best effort': it will fallback to software transcoding on failure. VP9 may or may not work depending on your hardware.",
+    "transcoding_acceleration_nvenc": "NVENC (requires NVIDIA GPU)",
+    "transcoding_acceleration_qsv": "Quick Sync (requires 7th gen Intel CPU or later)",
+    "transcoding_acceleration_rkmpp": "RKMPP (only on Rockchip SOCs)",
+    "transcoding_acceleration_vaapi": "VAAPI",
+    "transcoding_accepted_audio_codecs": "Accepted audio codecs",
+    "transcoding_accepted_audio_codecs_description": "Select which audio codecs do not need to be transcoded. Only used for certain transcode policies.",
+    "transcoding_accepted_video_codecs": "Accepted video codecs",
+    "transcoding_accepted_video_codecs_description": "Select which video codecs do not need to be transcoded. Only used for certain transcode policies.",
+    "transcoding_advanced_options_description": "Options most users should not need to change",
+    "transcoding_audio_codec": "Audio codec",
+    "transcoding_audio_codec_description": "Opus is the highest quality option, but has lower compatibility with old devices or software.",
+    "transcoding_bitrate_description": "Videos higher than max bitrate or not in an accepted format",
+    "transcoding_constant_quality_mode": "Constant quality mode",
+    "transcoding_constant_quality_mode_description": "ICQ is better than CQP, but some hardware acceleration devices do not support this mode. Setting this option will prefer the specified mode when using quality-based encoding. Ignored by NVENC as it does not support ICQ.",
+    "transcoding_constant_rate_factor": "Constant rate factor (-crf)",
+    "transcoding_constant_rate_factor_description": "Video quality level. Typical values are 23 for H.264, 28 for HEVC, 31 for VP9, and 35 for AV1. Lower is better, but produces larger files.",
+    "transcoding_disabled_description": "Don't transcode any videos, may break playback on some clients",
+    "transcoding_hardware_acceleration": "Hardware Acceleration",
+    "transcoding_hardware_acceleration_description": "Experimental; much faster, but will have lower quality at the same bitrate",
+    "transcoding_hardware_decoding": "Hardware decoding",
+    "transcoding_hardware_decoding_setting_description": "Applies only to NVENC and RKMPP. Enables end-to-end acceleration instead of only accelerating encoding. May not work on all videos.",
+    "transcoding_hevc_codec": "HEVC codec",
+    "transcoding_max_b_frames": "Max B-Frames",
+    "transcoding_max_b_frames_description": "Higher values improve compression efficiency, but slow down encoding. May not be compatible with hardware acceleration on older devices. 0 disables B-frames, while -1 sets this value automatically.",
+    "transcoding_max_bitrate": "Max bitrate",
+    "transcoding_max_bitrate_description": "Setting a max bitrate can make file sizes more predictable at a minor cost to quality. At 720p, typical values are 2600k for VP9 or HEVC, or 4500k for H.264. Disabled if set to 0.",
+    "transcoding_max_keyframe_interval": "Max keyframe interval",
+    "transcoding_max_keyframe_interval_description": "Sets the maximum frame distance between keyframes. Lower values worsen compression efficiency, but improve seek times and may improve quality in scenes with fast movement. 0 sets this value automatically.",
+    "transcoding_optimal_description": "Videos higher than target resolution or not in an accepted format",
+    "transcoding_preferred_hardware_device": "Preferred hardware device",
+    "transcoding_preferred_hardware_device_description": "Applies only to VAAPI and QSV. Sets the dri node used for hardware transcoding.",
+    "transcoding_preset_preset": "PRESET (-preset)",
+    "transcoding_preset_preset_description": "Compression speed. Slower presets produce smaller files, and increase quality when targeting a certain bitrate. VP9 ignores speeds above `faster`.",
+    "transcoding_reference_frames": "Reference frames",
+    "transcoding_reference_frames_description": "The number of frames to reference when compressing a given frame. Higher values improve compression efficiency, but slow down encoding. 0 sets this value automatically.",
+    "transcoding_required_description": "Only videos not in an accepted format",
+    "transcoding_settings": "Video Transcoding Settings",
+    "transcoding_settings_description": "Manage the resolution and encoding information of the video files",
+    "transcoding_target_resolution": "Target resolution",
+    "transcoding_target_resolution_description": "Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness.",
+    "transcoding_temporal_aq": "Temporal AQ",
+    "transcoding_temporal_aq_description": "Applies only to NVENC. Increases quality of high-detail, low-motion scenes. May not be compatible with older devices.",
+    "transcoding_threads": "Threads",
+    "transcoding_threads_description": "Higher values lead to faster encoding, but leave less room for the server to process other tasks while active. This value should not be more than the number of CPU cores. Maximizes utilization if set to 0.",
+    "transcoding_tone_mapping": "TONE-MAPPING",
+    "transcoding_tone_mapping_description": "Attempts to preserve the appearance of HDR videos when converted to SDR. Each algorithm makes different tradeoffs for color, detail and brightness. Hable preserves detail, Mobius preserves color, and Reinhard preserves brightness.",
+    "transcoding_tone_mapping_npl": "TONE-MAPPING NPL",
+    "transcoding_tone_mapping_npl_description": "Colors will be adjusted to look normal for a display of this brightness. Counter-intuitively, lower values increase the brightness of the video and vice versa since it compensates for the brightness of the display. 0 sets this value automatically.",
+    "transcoding_transcode_policy": "Transcode policy",
+    "transcoding_two_pass_encoding": "TWO-PASS ENCODING",
+    "transcoding_two_pass_encoding_setting_description": "Transcode in two passes to produce better encoded videos. When max bitrate is enabled (required for it to work with H.264 and HEVC), this mode uses a bitrate range based on the max bitrate and ignores CRF. For VP9, CRF can be used if max bitrate is disabled.",
+    "transcoding_video_codec": "Video Codec",
+    "transcoding_video_codec_description": "VP9 has high efficiency and web compatibility, but takes longer to transcode. HEVC performs similarly, but has lower web compatibility. H.264 is widely compatible and quick to transcode, but produces much larger files. AV1 is the most efficient codec but lacks support on older devices.",
+    "trash_enabled_description": "Enable Trash features",
+    "trash_number_of_days": "Number of days",
+    "trash_number_of_days_description": "Number of days to keep the assets in trash before permanently removing them",
+    "trash_settings": "Trash Settings",
+    "trash_settings_description": "Manage trash settings",
+    "user_delete_delay_settings": "Delete delay",
+    "user_delete_delay_settings_description": "Number of days after removal to permanently delete a user's account and assets. The user deletion job runs at midnight to check for users that are ready for deletion. Changes to this setting will be evaluated at the next execution.",
+    "user_settings": "User Settings",
+    "user_settings_description": "Manage user settings",
+    "version_check_enabled_description": "Enable periodic requests to GitHub to check for new releases",
+    "version_check_settings": "Version Check",
+    "version_check_settings_description": "Enable/disable the new version notification",
+    "video_conversion_job_description": "Transcode videos for wider compatibility with browsers and devices"
+  },
+  "admin_email": "Admin Email",
+  "admin_password": "Admin Password",
+  "administration": "Administration",
+  "advanced": "Advanced",
+  "album_cover_updated": "Album cover updated",
+  "album_info_updated": "Album info updated",
+  "album_name": "Album Name",
+  "album_options": "Album options",
+  "albums": "Albums",
+  "all": "All",
+  "all_people": "All people",
+  "allow_dark_mode": "Allow dark mode",
+  "allow_edits": "Allow edits",
+  "api_key": "API Key",
+  "api_keys": "API Keys",
+  "app_settings": "App Settings",
+  "appears_in": "Appears in",
+  "archive": "Archive",
+  "archive_or_unarchive_photo": "Archive or unarchive photo",
+  "archived": "Archived",
+  "asset_offline": "Asset offline",
+  "assets": "Assets",
+  "authorized_devices": "Authorized Devices",
+  "back": "Back",
+  "backward": "Backward",
+  "blurred_background": "Blurred background",
+  "camera": "Camera",
+  "camera_brand": "Camera brand",
+  "camera_model": "Camera model",
+  "cancel": "Cancel",
+  "cancel_search": "Cancel search",
+  "cannot_merge_people": "Cannot merge people",
+  "cannot_update_the_description": "Cannot update the description",
+  "cant_apply_changes": "Can't apply changes",
+  "cant_get_faces": "Can't get faces",
+  "cant_search_people": "Can't search people",
+  "cant_search_places": "Can't search places",
+  "change_date": "Change date",
+  "change_expiration_time": "Change expiration time",
+  "change_location": "Change location",
+  "change_name": "Change name",
+  "change_name_successfully": "Change name successfully",
+  "change_password": "Change password",
+  "change_your_password": "Change your password",
+  "changed_visibility_successfully": "Changed visibility successfully",
+  "check_logs": "Check Logs",
+  "city": "City",
+  "clear": "Clear",
+  "clear_all": "Clear all",
+  "clear_message": "Clear message",
+  "clear_value": "Clear value",
+  "close": "Close",
+  "collapse_all": "Collapse all",
+  "color_theme": "Color theme",
+  "comment_options": "Comment options",
+  "comments_are_disabled": "Comments are disabled",
+  "confirm": "Confirm",
+  "confirm_admin_password": "Confirm Admin Password",
+  "confirm_password": "Confirm Password",
+  "contain": "Contain",
+  "context": "Context",
+  "continue": "Continue",
+  "copied_image_to_clipboard": "Copied image to clipboard.",
+  "copy_error": "Copy error",
+  "copy_file_path": "Copy file path",
+  "copy_image": "Copy Image",
+  "copy_link": "Copy link",
+  "copy_link_to_clipboard": "Copy link to clipboard",
+  "copy_password": "Copy password",
+  "copy_to_clipboard": "Copy to Clipboard",
+  "country": "Country",
+  "cover": "Cover",
+  "covers": "Covers",
+  "create": "Create",
+  "create_album": "Create album",
+  "create_library": "Create Library",
+  "create_link": "Create link",
+  "create_link_to_share": "Create link to share",
+  "create_new_person": "Create new person",
+  "create_new_user": "Create new user",
+  "create_user": "Create user",
+  "created": "Created",
+  "current_device": "Current device",
+  "custom_locale": "Custom Locale",
+  "custom_locale_description": "Format dates and numbers based on the language and the region",
+  "dark": "Dark",
+  "date_after": "Date after",
+  "date_and_time": "Date and Time",
+  "date_before": "Date before",
+  "date_range": "Date range",
+  "day": "Day",
+  "default_locale": "Default Locale",
+  "default_locale_description": "Format dates and numbers based on your browser locale",
+  "delete": "Delete",
+  "delete_album": "Delete album",
+  "delete_key": "Delete key",
+  "delete_library": "Delete library",
+  "delete_link": "Delete link",
+  "delete_shared_link": "Delete shared link",
+  "delete_user": "Delete user",
+  "deleted_shared_link": "Deleted shared link",
+  "description": "Description",
+  "details": "Details",
+  "direction": "Direction",
+  "disallow_edits": "Disallow edits",
+  "discover": "Discover",
+  "dismiss_all_errors": "Dismiss all errors",
+  "dismiss_error": "Dismiss error",
+  "display_options": "Display options",
+  "display_order": "Display order",
+  "display_original_photos": "Display original photos",
+  "display_original_photos_setting_description": "Prefer to display the original photo when viewing an asset rather than thumbnails when the original asset is web-compatible. This may result in slower photo display speeds.",
+  "done": "Done",
+  "download": "Download",
+  "downloading": "Downloading",
+  "duration": "Duration",
+  "durations": {
+    "days": "{days, plural, one {day} other {{days, number} days}}",
+    "hours": "{hours, plural, one {hour} other {{hours, number} hours}}",
+    "minutes": "{minutes, plural, one {minute} other {{minutes, number} minutes}}",
+    "months": "{months, plural, one {month} other {{months, number} months}}",
+    "years": "{years, plural, one {year} other {{years, number} years}}"
+  },
+  "edit_album": "Edit album",
+  "edit_avatar": "Edit avatar",
+  "edit_date": "Edit date",
+  "edit_date_and_time": "Edit date and time",
+  "edit_exclusion_pattern": "Edit exclusion pattern",
+  "edit_faces": "Edit faces",
+  "edit_import_path": "Edit import path",
+  "edit_import_paths": "Edit Import Paths",
+  "edit_key": "Edit key",
+  "edit_link": "Edit link",
+  "edit_location": "Edit location",
+  "edit_name": "Edit name",
+  "edit_people": "Edit people",
+  "edit_title": "Edit Title",
+  "edit_user": "Edit user",
+  "edited": "Edited",
+  "editor": "Editor",
+  "email": "Email",
+  "empty": "Empty",
+  "empty_album": "Empty Album",
+  "empty_trash": "Empty trash",
+  "enable": "Enable",
+  "enabled": "Enabled",
+  "end_date": "End date",
+  "error": "Error",
+  "error_loading_image": "Error loading image",
+  "errors": {
+    "unable_to_add_album_users": "Unable to add albums users",
+    "unable_to_add_comment": "Unable to add comment",
+    "unable_to_add_partners": "Unable to add partners",
+    "unable_to_change_album_user_role": "Unable to change the album user's role",
+    "unable_to_change_date": "Unable to change date",
+    "unable_to_change_location": "Unable to change location",
+    "unable_to_check_item": "Unable to check item",
+    "unable_to_check_items": "Unable to check items",
+    "unable_to_create_admin_account": "Unable to create admin account",
+    "unable_to_create_library": "Unable to create library",
+    "unable_to_create_user": "Unable to create user",
+    "unable_to_delete_album": "Unable to delete album",
+    "unable_to_delete_asset": "Unable to delete asset",
+    "unable_to_delete_user": "Unable to delete user",
+    "unable_to_empty_trash": "Unable to empty trash",
+    "unable_to_enter_fullscreen": "Unable to enter fullscreen",
+    "unable_to_exit_fullscreen": "Unable to exit fullscreen",
+    "unable_to_hide_person": "Unable to hide person",
+    "unable_to_load_album": "Unable to load album",
+    "unable_to_load_asset_activity": "Unable to load asset activity",
+    "unable_to_load_items": "Unable to load items",
+    "unable_to_load_liked_status": "Unable to load liked status",
+    "unable_to_play_video": "Unable to play video",
+    "unable_to_refresh_user": "Unable to refresh user",
+    "unable_to_remove_album_users": "Unable to remove albums users",
+    "unable_to_remove_comment": "Unable to remove comment",
+    "unable_to_remove_library": "Unable to remove library",
+    "unable_to_remove_partner": "Unable to remove partner",
+    "unable_to_remove_reaction": "Unable to remove reaction",
+    "unable_to_remove_user": "Unable to remove user",
+    "unable_to_repair_items": "Unable to repair items",
+    "unable_to_reset_password": "Unable to reset password",
+    "unable_to_resolve_duplicate": "Unable to resolve duplicate",
+    "unable_to_restore_assets": "Unable to restore assets",
+    "unable_to_restore_trash": "Unable to restore trash",
+    "unable_to_restore_user": "Unable to restore user",
+    "unable_to_save_album": "Unable to save album",
+    "unable_to_save_name": "Unable to save name",
+    "unable_to_save_profile": "Unable to save profile",
+    "unable_to_save_settings": "Unable to save settings",
+    "unable_to_scan_libraries": "Unable to scan libraries",
+    "unable_to_scan_library": "Unable to scan library",
+    "unable_to_set_profile_picture": "Unable to set profile picture",
+    "unable_to_submit_job": "Unable to submit job",
+    "unable_to_trash_asset": "Unable to trash asset",
+    "unable_to_unlink_account": "Unable to unlink account",
+    "unable_to_update_library": "Unable to update library",
+    "unable_to_update_location": "Unable to update location",
+    "unable_to_update_settings": "Unable to update settings",
+    "unable_to_update_user": "Unable to update user"
+  },
+  "every_day_at_onepm": "Every day at 1pm",
+  "every_night_at_midnight": "",
+  "every_night_at_twoam": "Every night at 2am",
+  "every_six_hours": "Every 6 hours",
+  "exit_slideshow": "Exit Slideshow",
+  "expand_all": "Expand all",
+  "expire_after": "Expire after",
+  "expired": "Expired",
+  "explore": "Explore",
+  "extension": "Extension",
+  "external_libraries": "External Libraries",
+  "failed_to_get_people": "Failed to get people",
+  "favorite": "Favorite",
+  "favorite_or_unfavorite_photo": "Favorite or unfavorite photo",
+  "favorites": "Favorites",
+  "feature": "Feature",
+  "feature_photo_updated": "Feature photo updated",
+  "featurecollection": "FeatureCollection",
+  "file_name": "File name",
+  "file_name_or_extension": "File name or extension",
+  "filename": "Filename",
+  "files": "Files",
+  "filetype": "Filetype",
+  "filter_people": "Filter people",
+  "fix_incorrect_match": "Fix incorrect match",
+  "force_re-scan_library_files": "Force Re-scan All Library Files",
+  "forward": "Forward",
+  "general": "General",
+  "get_help": "Get Help",
+  "getting_started": "Getting Started",
+  "go_back": "Go back",
+  "go_to_search": "Go to search",
+  "go_to_share_page": "Go to share page",
+  "group_albums_by": "Group albums by...",
+  "has_quota": "Has quota",
+  "hide_gallery": "Hide gallery",
+  "hide_password": "Hide password",
+  "hide_person": "Hide person",
+  "host": "Host",
+  "hour": "Hour",
+  "image": "Image",
+  "img": "Img",
+  "immich_logo": "Immich Logo",
+  "import_path": "Import path",
+  "in_archive": "In archive",
+  "include_archived": "Include archived",
+  "include_shared_albums": "Include shared albums",
+  "include_shared_partner_assets": "Include shared partner assets",
+  "individual_share": "Individual share",
+  "info": "Info",
+  "interval": {
+    "day_at_onepm": "Every day at 1pm",
+    "hours": "Every {hours, plural, one {hour} other {{hours, number} hours}",
+    "night_at_midnight": "Every night at midnight",
+    "night_at_twoam": "Every night at 2am"
+  },
+  "invite_people": "Invite People",
+  "invite_to_album": "Invite to album",
+  "job_settings_description": "Manage job concurrency",
+  "jobs": "Jobs",
+  "keep": "Keep",
+  "keyboard_shortcuts": "Keyboard shortcuts",
+  "last_seen": "Last seen",
+  "leave": "Leave",
+  "let_others_respond": "Let others respond",
+  "level": "Level",
+  "library": "Library",
+  "library_options": "Library options",
+  "light": "Light",
+  "link_options": "Link options",
+  "link_to_oauth": "Link to OAuth",
+  "linked_oauth_account": "Linked OAuth account",
+  "list": "List",
+  "loading": "Loading",
+  "loading_search_results_failed": "Loading search results failed",
+  "log_out": "Log out",
+  "log_out_all_devices": "Log Out All Devices",
+  "login_has_been_disabled": "Login has been disabled.",
+  "look": "Look",
+  "loop_videos": "Loop videos",
+  "loop_videos_description": "Enable to automatically loop a video in the detail viewer.",
+  "make": "Make",
+  "manage_shared_links": "Manage shared links",
+  "manage_sharing_with_partners": "Manage sharing with partners",
+  "manage_the_app_settings": "Manage the app settings",
+  "manage_your_account": "Manage your account",
+  "manage_your_api_keys": "Manage your API keys",
+  "manage_your_devices": "Manage your logged-in devices",
+  "manage_your_oauth_connection": "Manage your OAuth connection",
+  "map": "Map",
+  "map_marker_with_image": "Map marker with image",
+  "map_settings": "Map settings",
+  "media_type": "Media type",
+  "memories": "Memories",
+  "memories_setting_description": "Manage what you see in your memories",
+  "menu": "Menu",
+  "merge": "Merge",
+  "merge_people": "Merge people",
+  "merge_people_successfully": "Merge people successfully",
+  "minimize": "Minimize",
+  "minute": "Minute",
+  "missing": "Missing",
+  "model": "Model",
+  "month": "Month",
+  "more": "More",
+  "moved_to_trash": "Moved to trash",
+  "my_albums": "My albums",
+  "name": "Name",
+  "name_or_nickname": "Name or nickname",
+  "never": "Never",
+  "new_api_key": "New API Key",
+  "new_password": "New Password",
+  "new_person": "New person",
+  "new_user_created": "New user created",
+  "newest_first": "Newest first",
+  "next": "Next",
+  "next_memory": "Next memory",
+  "no": "No",
+  "no_albums_message": "Create an album to organize your photos and videos",
+  "no_archived_assets_message": "Archive photos and videos to hide them from your Photos view",
+  "no_assets_message": "CLICK TO UPLOAD YOUR FIRST PHOTO",
+  "no_exif_info_available": "No exif info available",
+  "no_favorites_message": "Add favorites to quickly find your best pictures and videos",
+  "no_libraries_message": "Create an external library to view your photos and videos",
+  "no_name": "No Name",
+  "no_places": "No places",
+  "no_results": "No results",
+  "no_shared_albums_message": "Create an album to share photos and videos with people in your network",
+  "not_in_any_album": "Not in any album",
+  "notes": "Notes",
+  "offline": "Offline",
+  "ok": "Ok",
+  "oldest_first": "Oldest first",
+  "online": "Online",
+  "only_favorites": "Only favorites",
+  "only_refreshes_modified_files": "Only refreshes modified files",
+  "open_the_search_filters": "Open the search filters",
+  "options": "Options",
+  "organize_your_library": "Organize your library",
+  "other": "Other",
+  "other_devices": "Other devices",
+  "other_variables": "Other variables",
+  "owned": "Owned",
+  "owner": "Owner",
+  "partner_sharing": "Partner Sharing",
+  "partners": "Partners",
+  "password": "Password",
+  "password_does_not_match": "Password does not match",
+  "password_required": "Password Required",
+  "password_reset_success": "Password reset success",
+  "past_durations": {
+    "days": "Past {days, plural, one {day} other {{days, number} days}}",
+    "hours": "Past {hours, plural, one {hour} other {{hours, number} hours}}",
+    "years": "Past {years, plural, one {year} other {{years, number} years}}"
+  },
+  "path": "Path",
+  "pattern": "Pattern",
+  "pause": "Pause",
+  "pause_memories": "Pause memories",
+  "paused": "Paused",
+  "pending": "Pending",
+  "people": "People",
+  "people_sidebar_description": "Display a link to People in the sidebar",
+  "perform_library_tasks": "Perform library tasks",
+  "permanent_deletion_warning": "Permanent deletion warning",
+  "permanent_deletion_warning_setting_description": "Show a warning when permanently deleting assets",
+  "permanently_delete": "Permanently delete",
+  "permanently_deleted_asset": "Permanently deleted asset",
+  "photos": "Photos",
+  "photos_from_previous_years": "Photos from previous years",
+  "pick_a_location": "Pick a location",
+  "place": "Place",
+  "places": "Places",
+  "play": "Play",
+  "play_memories": "Play memories",
+  "play_motion_photo": "Play Motion Photo",
+  "play_or_pause_video": "Play or pause video",
+  "point": "Point",
+  "port": "Port",
+  "preset": "Preset",
+  "preview": "Preview",
+  "previous": "Previous",
+  "previous_memory": "Previous memory",
+  "previous_or_next_photo": "Previous or next photo",
+  "primary": "Primary",
+  "profile_picture_set": "Profile picture set.",
+  "public_share": "Public Share",
+  "range": "Range",
+  "raw": "Raw",
+  "reaction_options": "Reaction options",
+  "read_changelog": "Read Changelog",
+  "recent": "Recent",
+  "recent_searches": "Recent searches",
+  "refresh": "Refresh",
+  "refreshed": "Refreshed",
+  "refreshes_every_file": "Refreshes every file",
+  "remove": "Remove",
+  "remove_from_album": "Remove from album",
+  "remove_from_favorites": "Remove from favorites",
+  "remove_from_shared_link": "Remove from shared link",
+  "remove_offline_files": "Remove Offline Files",
+  "repair": "Repair",
+  "repair_no_results_message": "Untracked and missing files will show up here",
+  "replace_with_upload": "Replace with upload",
+  "require_password": "Require password",
+  "reset": "Reset",
+  "reset_password": "Reset password",
+  "reset_people_visibility": "Reset people visibility",
+  "reset_settings_to_default": "Reset settings to default",
+  "restore": "Restore",
+  "restore_user": "Restore user",
+  "retry_upload": "Retry upload",
+  "review_duplicates": "Review duplicates",
+  "role": "Role",
+  "save": "Save",
+  "saved_profile": "Saved profile",
+  "saved_settings": "Saved settings",
+  "say_something": "Say something",
+  "scan_all_libraries": "Scan All Libraries",
+  "scan_all_library_files": "Re-scan All Library Files",
+  "scan_new_library_files": "Scan New Library Files",
+  "scan_settings": "Scan Settings",
+  "search": "Search",
+  "search_albums": "Search albums",
+  "search_by_context": "Search by context",
+  "search_camera_make": "Search camera make...",
+  "search_camera_model": "Search camera model...",
+  "search_city": "Search city...",
+  "search_country": "Search country...",
+  "search_for_existing_person": "Search for existing person",
+  "search_people": "Search people",
+  "search_places": "Search places",
+  "search_state": "Search state...",
+  "search_timezone": "Search timezone...",
+  "search_type": "Search type",
+  "search_your_photos": "Search your photos",
+  "searching_locales": "Searching locales...",
+  "second": "Second",
+  "select_album_cover": "Select album cover",
+  "select_all": "Select all",
+  "select_avatar_color": "Select avatar color",
+  "select_face": "Select face",
+  "select_featured_photo": "Select featured photo",
+  "select_library_owner": "Select library owner",
+  "select_new_face": "Select new face",
+  "select_photos": "Select photos",
+  "selected": "Selected",
+  "send_message": "Send message",
+  "server": "Server",
+  "server_stats": "Server Stats",
+  "set": "Set",
+  "set_as_album_cover": "Set as album cover",
+  "set_as_profile_picture": "Set as profile picture",
+  "set_date_of_birth": "Set date of birth",
+  "set_profile_picture": "Set profile picture",
+  "set_slideshow_to_fullscreen": "Set Slideshow to fullscreen",
+  "settings": "Settings",
+  "settings_saved": "Settings saved",
+  "share": "Share",
+  "shared": "Shared",
+  "shared_by": "Shared by",
+  "shared_by_you": "Shared by you",
+  "shared_links": "Shared links",
+  "sharing": "Sharing",
+  "sharing_sidebar_description": "Display a link to Sharing in the sidebar",
+  "show_album_options": "Show album options",
+  "show_file_location": "Show file location",
+  "show_gallery": "Show gallery",
+  "show_hidden_people": "Show hidden people",
+  "show_in_timeline": "Show in timeline",
+  "show_in_timeline_setting_description": "Show photos and videos from this user in your timeline",
+  "show_keyboard_shortcuts": "Show keyboard shortcuts",
+  "show_metadata": "Show metadata",
+  "show_or_hide_info": "Show or hide info",
+  "show_password": "Show password",
+  "show_person_options": "Show person options",
+  "show_progress_bar": "Show Progress Bar",
+  "show_search_options": "Show search options",
+  "shuffle": "Shuffle",
+  "sign_up": "Sign up",
+  "size": "Size",
+  "skip_to_content": "Skip to content",
+  "slideshow": "Slideshow",
+  "slideshow_settings": "Slideshow settings",
+  "sort_albums_by": "Sort albums by...",
+  "stack": "Stack",
+  "stack_selected_photos": "Stack selected photos",
+  "stacktrace": "Stacktrace",
+  "start_date": "Start date",
+  "state": "State",
+  "status": "Status",
+  "stop_motion_photo": "Stop Motion Photo",
+  "storage": "Storage",
+  "storage_label": "Storage Label",
+  "submit": "Submit",
+  "suggestions": "Suggestions",
+  "sunrise_on_the_beach": "Sunrise on the beach",
+  "swap_merge_direction": "Swap merge direction",
+  "sync": "Sync",
+  "template": "Template",
+  "theme": "Theme",
+  "theme_selection": "Theme selection",
+  "time_based_memories": "Time-based memories",
+  "timezone": "Timezone",
+  "toggle_settings": "Toggle settings",
+  "toggle_theme": "Toggle theme",
+  "toggle_visibility": "Toggle visibility",
+  "total_usage": "Total usage",
+  "trash": "Trash",
+  "trash_all": "Trash All",
+  "trash_no_results_message": "Trashed photos and videos will show up here.",
+  "type": "Type",
+  "unarchive": "Unarchive",
+  "unarchived": "Unarchived",
+  "unfavorite": "Unfavorite",
+  "unhide_person": "Unhide person",
+  "unknown": "Unknown",
+  "unknown_album": "Unknown Album",
+  "unknown_year": "Unknown Year",
+  "unlink_oauth": "Unlink Oauth",
+  "unlinked_oauth_account": "Unlinked OAuth account",
+  "unselect_all": "Unselect all",
+  "unstack": "Un-stack",
+  "up_next": "Up next",
+  "updated_password": "Updated password",
+  "upload": "Upload",
+  "upload_concurrency": "Upload concurrency",
+  "url": "URL",
+  "usage": "Usage",
+  "user": "User",
+  "user_id": "User ID",
+  "user_usage_detail": "User usage detail",
+  "username": "Username",
+  "users": "Users",
+  "utilities": "Utilities",
+  "validate": "Validate",
+  "variables": "Variables",
+  "version": "Version",
+  "video": "Video",
+  "video_hover_setting_description": "Play video thumbnail when mouse is hovering over item. Even when disabled, playback can be started by hovering over the play icon.",
+  "videos": "Videos",
+  "view_all": "View All",
+  "view_all_users": "View all users",
+  "view_links": "View links",
+  "view_next_asset": "View next asset",
+  "view_previous_asset": "View previous asset",
+  "viewer": "Viewer",
+  "waiting": "Waiting",
+  "week": "Week",
+  "welcome_to_immich": "Welcome to immich",
+  "year": "Year",
+  "yes": "Yes",
+  "theme_selection_description": "Automatically set the theme to light or dark based on your browser's system preference",
+  "language_setting_description": "Select your preferred language",
+  "language": "Language",
+  "zoom_image": "Zoom Image"
+}
diff --git a/web/src/lib/stores/preferences.store.ts b/web/src/lib/stores/preferences.store.ts
index 8e99580496600..855f2afa02bb6 100644
--- a/web/src/lib/stores/preferences.store.ts
+++ b/web/src/lib/stores/preferences.store.ts
@@ -42,6 +42,13 @@ export const locale = persisted<string | undefined>('locale', undefined, {
   },
 });
 
+export const lang = persisted<string | undefined>('lang', undefined, {
+  serializer: {
+    parse: (text) => text,
+    stringify: (object) => object ?? '',
+  },
+});
+
 export interface MapSettings {
   allowDarkMode: boolean;
   includeArchived: boolean;
diff --git a/web/src/routes/(user)/albums/+page.svelte b/web/src/routes/(user)/albums/+page.svelte
index 7369779ab3bea..b4b1f5ee1a8d5 100644
--- a/web/src/routes/(user)/albums/+page.svelte
+++ b/web/src/routes/(user)/albums/+page.svelte
@@ -8,6 +8,7 @@
   import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
   import GroupTab from '$lib/components/elements/group-tab.svelte';
   import SearchBar from '$lib/components/elements/search-bar.svelte';
+  import { t } from 'svelte-i18n';
 
   export let data: PageData;
 
@@ -29,7 +30,7 @@
       />
     </div>
     <div class="w-60">
-      <SearchBar placeholder="Search albums" bind:name={searchQuery} showLoadingSpinner={false} />
+      <SearchBar placeholder={$t('search_albums')} bind:name={searchQuery} showLoadingSpinner={false} />
     </div>
   </div>
 
@@ -41,10 +42,6 @@
     {searchQuery}
     bind:albumGroupIds={albumGroups}
   >
-    <EmptyPlaceholder
-      slot="empty"
-      text="Create an album to organize your photos and videos"
-      onClick={() => createAlbumAndRedirect()}
-    />
+    <EmptyPlaceholder slot="empty" text={$t('no_albums_message')} onClick={() => createAlbumAndRedirect()} />
   </Albums>
 </UserPageLayout>
diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte
index 0b368c37336d7..5c19f5f59a359 100644
--- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -81,6 +81,7 @@
   import { fly } from 'svelte/transition';
   import type { PageData } from './$types';
   import { dialogController } from '$lib/components/shared-components/dialog/dialog';
+  import { t } from 'svelte-i18n';
 
   export let data: PageData;
 
@@ -210,7 +211,7 @@
           isLiked = data[0];
         }
       } catch (error) {
-        handleError(error, "Can't get Favorite");
+        handleError(error, $t('errors.unable_to_load_liked_status'));
       }
     }
   };
@@ -340,7 +341,7 @@
       await refreshAlbum();
       viewMode = album.albumUsers.length > 0 ? ViewMode.VIEW_USERS : ViewMode.VIEW;
     } catch (error) {
-      handleError(error, 'Error deleting shared user');
+      handleError(error, $t('errors.unable_to_load_album'));
     }
   };
 
@@ -363,7 +364,7 @@
       await deleteAlbum({ id: album.id });
       await goto(backUrl);
     } catch (error) {
-      handleError(error, 'Unable to delete album');
+      handleError(error, $t('unable_to_delete_album'));
     } finally {
       viewMode = ViewMode.VIEW;
     }
@@ -419,21 +420,21 @@
       <AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}>
         <CreateSharedLink />
         <SelectAllAssets {assetStore} {assetInteractionStore} />
-        <AssetSelectContextMenu icon={mdiPlus} title="Add to...">
+        <AssetSelectContextMenu icon={mdiPlus} title={$t('add_to')}>
           <AddToAlbum />
           <AddToAlbum shared />
         </AssetSelectContextMenu>
         {#if isAllUserOwned}
           <FavoriteAction removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} />
         {/if}
-        <AssetSelectContextMenu icon={mdiDotsVertical} title="Menu">
+        <AssetSelectContextMenu icon={mdiDotsVertical} title={$t('menu')}>
           <DownloadAction menuItem filename="{album.albumName}.zip" />
           {#if isAllUserOwned}
             <ChangeDate menuItem />
             <ChangeLocation menuItem />
             {#if $selectedAssets.size === 1}
               <MenuOption
-                text="Set as album cover"
+                text={$t('set_as_album_cover')}
                 icon={mdiImageOutline}
                 on:click={() => updateThumbnailUsingCurrentSelection()}
               />
@@ -454,7 +455,7 @@
           <svelte:fragment slot="trailing">
             {#if isEditor}
               <CircleIconButton
-                title="Add photos"
+                title={$t('add_photos')}
                 on:click={() => (viewMode = ViewMode.SELECT_ASSETS)}
                 icon={mdiImagePlusOutline}
               />
@@ -462,28 +463,40 @@
 
             {#if isOwned}
               <CircleIconButton
-                title="Share"
+                title={$t('share')}
                 on:click={() => (viewMode = ViewMode.SELECT_USERS)}
                 icon={mdiShareVariantOutline}
               />
             {/if}
 
             {#if album.assetCount > 0}
-              <CircleIconButton title="Slideshow" on:click={handleStartSlideshow} icon={mdiPresentationPlay} />
-              <CircleIconButton title="Download" on:click={handleDownloadAlbum} icon={mdiFolderDownloadOutline} />
+              <CircleIconButton title={$t('slideshow')} on:click={handleStartSlideshow} icon={mdiPresentationPlay} />
+              <CircleIconButton title={$t('download')} on:click={handleDownloadAlbum} icon={mdiFolderDownloadOutline} />
 
               {#if isOwned}
                 <div use:clickOutside={{ onOutclick: () => (viewMode = ViewMode.VIEW) }}>
-                  <CircleIconButton title="Album options" on:click={handleOpenAlbumOptions} icon={mdiDotsVertical} />
+                  <CircleIconButton
+                    title={$t('album_options')}
+                    on:click={handleOpenAlbumOptions}
+                    icon={mdiDotsVertical}
+                  />
                   {#if viewMode === ViewMode.ALBUM_OPTIONS}
                     <ContextMenu {...contextMenuPosition}>
                       <MenuOption
                         icon={mdiImageOutline}
-                        text="Select album cover"
+                        text={$t('select_album_cover')}
                         on:click={() => (viewMode = ViewMode.SELECT_THUMBNAIL)}
                       />
-                      <MenuOption icon={mdiCogOutline} text="Options" on:click={() => (viewMode = ViewMode.OPTIONS)} />
-                      <MenuOption icon={mdiDeleteOutline} text="Delete album" on:click={() => handleRemoveAlbum()} />
+                      <MenuOption
+                        icon={mdiCogOutline}
+                        text={$t('options')}
+                        on:click={() => (viewMode = ViewMode.OPTIONS)}
+                      />
+                      <MenuOption
+                        icon={mdiDeleteOutline}
+                        text={$t('delete_album')}
+                        on:click={() => handleRemoveAlbum()}
+                      />
                     </ContextMenu>
                   {/if}
                 </div>
@@ -525,7 +538,7 @@
               Select from computer
             </button>
             <Button size="sm" rounded="lg" disabled={$timelineSelected.size === 0} on:click={handleAddAssets}
-              >Done</Button
+              >{$t('done')}</Button
             >
           </svelte:fragment>
         </ControlAppBar>
@@ -533,7 +546,7 @@
 
       {#if viewMode === ViewMode.SELECT_THUMBNAIL}
         <ControlAppBar on:close={() => (viewMode = ViewMode.VIEW)}>
-          <svelte:fragment slot="leading">Select Album Cover</svelte:fragment>
+          <svelte:fragment slot="leading">{$t('select_album_cover')}</svelte:fragment>
         </ControlAppBar>
       {/if}
     {/if}
@@ -577,7 +590,7 @@
                     <!-- link -->
                     {#if album.hasSharedLink && isOwned}
                       <CircleIconButton
-                        title="Create link to share"
+                        title={$t('create_link_to_share')}
                         color="gray"
                         size="20"
                         icon={mdiLink}
@@ -600,7 +613,7 @@
                     <!-- display ellipsis if there are readonly users too -->
                     {#if albumHasViewers}
                       <CircleIconButton
-                        title="View all users"
+                        title={$t('view_all_users')}
                         color="gray"
                         size="20"
                         icon={mdiDotsVertical}
@@ -614,7 +627,7 @@
                         size="20"
                         icon={mdiPlus}
                         on:click={() => (viewMode = ViewMode.SELECT_USERS)}
-                        title="Add more users"
+                        title={$t('add_more_users')}
                       />
                     {/if}
                   </div>
@@ -627,7 +640,7 @@
             {#if album.assetCount === 0}
               <section id="empty-album" class=" mt-[200px] flex place-content-center place-items-center">
                 <div class="w-[300px]">
-                  <p class="text-xs dark:text-immich-dark-fg">ADD PHOTOS</p>
+                  <p class="text-xs dark:text-immich-dark-fg">{$t('add_photos').toUpperCase()}</p>
                   <button
                     type="button"
                     on:click={() => (viewMode = ViewMode.SELECT_ASSETS)}
@@ -636,7 +649,7 @@
                     <span class="text-text-immich-primary dark:text-immich-dark-primary"
                       ><Icon path={mdiPlus} size="24" />
                     </span>
-                    <span class="text-lg">Select photos</span>
+                    <span class="text-lg">{$t('select_photos')}</span>
                   </button>
                 </div>
               </section>
diff --git a/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte
index 2f943a4ffd6e1..26c0c6e65cbe6 100644
--- a/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -16,6 +16,7 @@
   import { AssetStore } from '$lib/stores/assets.store';
   import type { PageData } from './$types';
   import { mdiPlus, mdiDotsVertical } from '@mdi/js';
+  import { t } from 'svelte-i18n';
 
   export let data: PageData;
 
@@ -31,12 +32,12 @@
     <ArchiveAction unarchive onArchive={(assetIds) => assetStore.removeAssets(assetIds)} />
     <CreateSharedLink />
     <SelectAllAssets {assetStore} {assetInteractionStore} />
-    <AssetSelectContextMenu icon={mdiPlus} title="Add to...">
+    <AssetSelectContextMenu icon={mdiPlus} title={$t('add_to')}>
       <AddToAlbum />
       <AddToAlbum shared />
     </AssetSelectContextMenu>
     <FavoriteAction removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} />
-    <AssetSelectContextMenu icon={mdiDotsVertical} title="Add">
+    <AssetSelectContextMenu icon={mdiDotsVertical} title={$t('add')}>
       <DownloadAction menuItem />
       <DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />
     </AssetSelectContextMenu>
@@ -45,6 +46,6 @@
 
 <UserPageLayout hideNavbar={$isMultiSelectState} title={data.meta.title} scrollbar={false}>
   <AssetGrid {assetStore} {assetInteractionStore} removeAction={AssetAction.UNARCHIVE}>
-    <EmptyPlaceholder text="Archive photos and videos to hide them from your Photos view" slot="empty" />
+    <EmptyPlaceholder text={$t('no_archived_assets_message')} slot="empty" />
   </AssetGrid>
 </UserPageLayout>
diff --git a/web/src/routes/(user)/explore/+page.svelte b/web/src/routes/(user)/explore/+page.svelte
index f54376959ff30..2a4adf06cf5d6 100644
--- a/web/src/routes/(user)/explore/+page.svelte
+++ b/web/src/routes/(user)/explore/+page.svelte
@@ -7,6 +7,7 @@
   import type { SearchExploreResponseDto } from '@immich/sdk';
   import type { PageData } from './$types';
   import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
+  import { t } from 'svelte-i18n';
 
   export let data: PageData;
 
@@ -43,11 +44,11 @@
   {#if hasPeople}
     <div class="mb-6 mt-2">
       <div class="flex justify-between">
-        <p class="mb-4 font-medium dark:text-immich-dark-fg">People</p>
+        <p class="mb-4 font-medium dark:text-immich-dark-fg">{$t('people')}</p>
         <a
           href={AppRoute.PEOPLE}
           class="pr-4 text-sm font-medium hover:text-immich-primary dark:text-immich-dark-fg dark:hover:text-immich-dark-primary"
-          draggable="false">View All</a
+          draggable="false">{$t('view_all')}</a
         >
       </div>
       <div
@@ -75,11 +76,11 @@
   {#if places.length > 0}
     <div class="mb-6 mt-2">
       <div class="flex justify-between">
-        <p class="mb-4 font-medium dark:text-immich-dark-fg">Places</p>
+        <p class="mb-4 font-medium dark:text-immich-dark-fg">{$t('places')}</p>
         <a
           href={AppRoute.PLACES}
           class="pr-4 text-sm font-medium hover:text-immich-primary dark:text-immich-dark-fg dark:hover:text-immich-dark-primary"
-          draggable="false">View All</a
+          draggable="false">{$t('view_all')}</a
         >
       </div>
       <div class="flex flex-row flex-wrap gap-4">
diff --git a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte
index 1d080d10037d0..95ed078c72998 100644
--- a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -18,6 +18,7 @@
   import { AssetStore } from '$lib/stores/assets.store';
   import type { PageData } from './$types';
   import { mdiDotsVertical, mdiPlus } from '@mdi/js';
+  import { t } from 'svelte-i18n';
 
   export let data: PageData;
 
@@ -34,11 +35,11 @@
     <FavoriteAction removeFavorite onFavorite={(assetIds) => assetStore.removeAssets(assetIds)} />
     <CreateSharedLink />
     <SelectAllAssets {assetStore} {assetInteractionStore} />
-    <AssetSelectContextMenu icon={mdiPlus} title="Add to...">
+    <AssetSelectContextMenu icon={mdiPlus} title={$t('add_to')}>
       <AddToAlbum />
       <AddToAlbum shared />
     </AssetSelectContextMenu>
-    <AssetSelectContextMenu icon={mdiDotsVertical} title="Menu">
+    <AssetSelectContextMenu icon={mdiDotsVertical} title={$t('menu')}>
       <DownloadAction menuItem />
       <ChangeDate menuItem />
       <ChangeLocation menuItem />
@@ -50,6 +51,6 @@
 
 <UserPageLayout hideNavbar={$isMultiSelectState} title={data.meta.title} scrollbar={false}>
   <AssetGrid {assetStore} {assetInteractionStore} removeAction={AssetAction.UNFAVORITE}>
-    <EmptyPlaceholder text="Add favorites to quickly find your best pictures and videos" slot="empty" />
+    <EmptyPlaceholder text={$t('no_favorites_message')} slot="empty" />
   </AssetGrid>
 </UserPageLayout>
diff --git a/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte
index b3b734c3aec01..0f8fc244d299c 100644
--- a/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -13,6 +13,7 @@
   import { onDestroy } from 'svelte';
   import type { PageData } from './$types';
   import { mdiPlus, mdiArrowLeft } from '@mdi/js';
+  import { t } from 'svelte-i18n';
 
   export let data: PageData;
 
@@ -29,7 +30,7 @@
   {#if $isMultiSelectState}
     <AssetSelectControlBar assets={$selectedAssets} clearSelect={clearMultiselect}>
       <CreateSharedLink />
-      <AssetSelectContextMenu icon={mdiPlus} title="Add">
+      <AssetSelectContextMenu icon={mdiPlus} title={$t('add')}>
         <AddToAlbum />
         <AddToAlbum shared />
       </AssetSelectContextMenu>
diff --git a/web/src/routes/(user)/people/+page.svelte b/web/src/routes/(user)/people/+page.svelte
index e16fa234b2e30..685426a1b0e2b 100644
--- a/web/src/routes/(user)/people/+page.svelte
+++ b/web/src/routes/(user)/people/+page.svelte
@@ -34,6 +34,7 @@
   import { clearQueryParam } from '$lib/utils/navigation';
   import SearchPeople from '$lib/components/faces-page/people-search.svelte';
   import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
+  import { t } from 'svelte-i18n';
 
   export let data: PageData;
 
@@ -204,11 +205,11 @@
       }
       countTotalPeople--;
       notificationController.show({
-        message: 'Merge people successfully',
+        message: $t('merge_people_successfully'),
         type: NotificationType.Info,
       });
     } catch (error) {
-      handleError(error, 'Unable to save name');
+      handleError(error, $t('unable_to_save_name'));
     }
     if (personToBeMergedIn.name !== personName && edittingPerson.id === personToBeMergedIn.id) {
       /*
@@ -227,14 +228,14 @@
           }
         }
         notificationController.show({
-          message: 'Change name successfully',
+          message: $t('change_name_successfully'),
           type: NotificationType.Info,
         });
 
         // trigger reactivity
         people = people;
       } catch (error) {
-        handleError(error, 'Unable to save name');
+        handleError(error, $t('unable_to_save_name'));
       }
     }
   };
@@ -274,11 +275,11 @@
       showChangeNameModal = false;
       countHiddenPeople++;
       notificationController.show({
-        message: 'Changed visibility successfully',
+        message: $t('changed_visibility_successfully'),
         type: NotificationType.Info,
       });
     } catch (error) {
-      handleError(error, 'Unable to hide person');
+      handleError(error, $t('unable_to_hide_person'));
     }
   };
 
@@ -349,7 +350,7 @@
         type: NotificationType.Info,
       });
     } catch (error) {
-      handleError(error, 'Unable to save name');
+      handleError(error, $t('unable_to_save_name'));
     }
   };
 
@@ -372,11 +373,11 @@
         return person;
       });
       notificationController.show({
-        message: 'Change name successfully',
+        message: $t('change_name_successfully'),
         type: NotificationType.Info,
       });
     } catch (error) {
-      handleError(error, 'Unable to save name');
+      handleError(error, $t('unable_to_save_name'));
     }
   };
 
@@ -399,7 +400,7 @@
 {/if}
 
 <UserPageLayout
-  title="People"
+  title={$t('people')}
   description={countVisiblePeople === 0 && !searchName ? undefined : `(${countVisiblePeople.toLocaleString($locale)})`}
 >
   <svelte:fragment slot="buttons">
@@ -409,7 +410,7 @@
           <div class="w-40 lg:w-80 h-10">
             <SearchPeople
               type="searchBar"
-              placeholder="Search people"
+              placeholder={$t('search_people')}
               onReset={onResetSearchBar}
               onSearch={handleSearch}
               bind:searchName
@@ -453,10 +454,10 @@
   {/if}
 
   {#if showChangeNameModal}
-    <FullScreenModal title="Change name" onClose={() => (showChangeNameModal = false)}>
+    <FullScreenModal title={$t('change_name')} onClose={() => (showChangeNameModal = false)}>
       <form on:submit|preventDefault={submitNameChange} autocomplete="off" id="change-name-form">
         <div class="flex flex-col gap-2">
-          <label class="immich-form-label" for="name">Name</label>
+          <label class="immich-form-label" for="name">{$t('name')}</label>
           <input
             class="immich-form-input"
             id="name"
@@ -473,9 +474,9 @@
           fullwidth
           on:click={() => {
             showChangeNameModal = false;
-          }}>Cancel</Button
+          }}>{$t('cancel')}</Button
         >
-        <Button type="submit" fullwidth form="change-name-form">Ok</Button>
+        <Button type="submit" fullwidth form="change-name-form">{$t('ok')}</Button>
       </svelte:fragment>
     </FullScreenModal>
   {/if}
diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte
index 0fdea0613773d..7a3f5fe634a81 100644
--- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -56,6 +56,7 @@
   import { onMount } from 'svelte';
   import type { PageData } from './$types';
   import { listNavigation } from '$lib/actions/list-navigation';
+  import { t } from 'svelte-i18n';
 
   export let data: PageData;
 
@@ -182,13 +183,13 @@
       });
 
       notificationController.show({
-        message: 'Changed visibility successfully',
+        message: $t('changed_visibility_successfully'),
         type: NotificationType.Info,
       });
 
       await goto(previousRoute, { replaceState: true });
     } catch (error) {
-      handleError(error, 'Unable to hide person');
+      handleError(error, $t('unable_to_hide_person'));
     }
   };
 
@@ -208,7 +209,7 @@
 
     await updatePerson({ id: data.person.id, personUpdateDto: { featureFaceAssetId: asset.id } });
 
-    notificationController.show({ message: 'Feature photo updated', type: NotificationType.Info });
+    notificationController.show({ message: $t('feature_photo_updated'), type: NotificationType.Info });
     assetInteractionStore.clearMultiselect();
 
     viewMode = ViewMode.VIEW_ASSETS;
@@ -224,7 +225,7 @@
         mergePersonDto: { ids: [personToMerge.id] },
       });
       notificationController.show({
-        message: 'Merge people successfully',
+        message: $t('merge_people_successfully'),
         type: NotificationType.Info,
       });
       people = people.filter((person: PersonResponseDto) => person.id !== personToMerge.id);
@@ -235,7 +236,7 @@
       }
       await goto(`${AppRoute.PEOPLE}/${personToBeMergedIn.id}`, { replaceState: true });
     } catch (error) {
-      handleError(error, 'Unable to save name');
+      handleError(error, $t('unable_to_save_name'));
     }
   };
 
@@ -257,11 +258,11 @@
       await updatePerson({ id: data.person.id, personUpdateDto: { name: personName } });
 
       notificationController.show({
-        message: 'Change name successfully',
+        message: $t('change_name_successfully'),
         type: NotificationType.Info,
       });
     } catch (error) {
-      handleError(error, 'Unable to save name');
+      handleError(error, $t('unable_to_save_name'));
     }
   };
 
@@ -379,14 +380,18 @@
     <AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}>
       <CreateSharedLink />
       <SelectAllAssets {assetStore} {assetInteractionStore} />
-      <AssetSelectContextMenu icon={mdiPlus} title="Add to...">
+      <AssetSelectContextMenu icon={mdiPlus} title={$t('add_to')}>
         <AddToAlbum />
         <AddToAlbum shared />
       </AssetSelectContextMenu>
       <FavoriteAction removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} />
-      <AssetSelectContextMenu icon={mdiDotsVertical} title="Add">
+      <AssetSelectContextMenu icon={mdiDotsVertical} title={$t('add')}>
         <DownloadAction menuItem filename="{data.person.name || 'immich'}.zip" />
-        <MenuOption icon={mdiAccountMultipleCheckOutline} text="Fix incorrect match" on:click={handleReassignAssets} />
+        <MenuOption
+          icon={mdiAccountMultipleCheckOutline}
+          text={$t('fix_incorrect_match')}
+          on:click={handleReassignAssets}
+        />
         <ChangeDate menuItem />
         <ChangeLocation menuItem />
         <ArchiveAction menuItem unarchive={isAllArchive} onArchive={(assetIds) => $assetStore.removeAssets(assetIds)} />
@@ -397,24 +402,24 @@
     {#if viewMode === ViewMode.VIEW_ASSETS || viewMode === ViewMode.SUGGEST_MERGE || viewMode === ViewMode.BIRTH_DATE}
       <ControlAppBar showBackButton backIcon={mdiArrowLeft} on:close={() => goto(previousRoute)}>
         <svelte:fragment slot="trailing">
-          <AssetSelectContextMenu icon={mdiDotsVertical} title="Menu">
+          <AssetSelectContextMenu icon={mdiDotsVertical} title={$t('menu')}>
             <MenuOption
-              text="Select featured photo"
+              text={$t('select_featured_photo')}
               icon={mdiAccountBoxOutline}
               on:click={() => (viewMode = ViewMode.SELECT_PERSON)}
             />
             <MenuOption
-              text={data.person.isHidden ? 'Unhide person' : 'Hide person'}
+              text={data.person.isHidden ? $t('unhide_person') : $t('hide_person')}
               icon={data.person.isHidden ? mdiEyeOutline : mdiEyeOffOutline}
               on:click={() => toggleHidePerson()}
             />
             <MenuOption
-              text="Set date of birth"
+              text={$t('set_date_of_birth')}
               icon={mdiCalendarEditOutline}
               on:click={() => (viewMode = ViewMode.BIRTH_DATE)}
             />
             <MenuOption
-              text="Merge people"
+              text={$t('merge_people')}
               icon={mdiAccountMultipleCheckOutline}
               on:click={() => (viewMode = ViewMode.MERGE_PEOPLE)}
             />
@@ -425,7 +430,7 @@
 
     {#if viewMode === ViewMode.SELECT_PERSON}
       <ControlAppBar on:close={() => (viewMode = ViewMode.VIEW_ASSETS)}>
-        <svelte:fragment slot="leading">Select featured photo</svelte:fragment>
+        <svelte:fragment slot="leading">{$t('select_featured_photo')}</svelte:fragment>
       </ControlAppBar>
     {/if}
   {/if}
@@ -466,7 +471,7 @@
                 <button
                   type="button"
                   class="flex items-center justify-center"
-                  title="Edit name"
+                  title={$t('edit_name')}
                   on:click={() => (isEditingName = true)}
                 >
                   <ImageThumbnail
@@ -486,7 +491,7 @@
                         {`${numberOfAssets} asset${s(numberOfAssets)}`}
                       </p>
                     {:else}
-                      <p class="font-medium">Add a name</p>
+                      <p class="font-medium">{$t('add_a_name')}</p>
                       <p class="text-sm text-gray-500 dark:text-immich-gray">Find them fast by name with search</p>
                     {/if}
                   </div>
diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte
index 2aa8f5244efb9..3fed3ba1ca681 100644
--- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte
@@ -23,6 +23,7 @@
   import { assetViewingStore } from '$lib/stores/asset-viewing.store';
   import { mdiDotsVertical, mdiPlus } from '@mdi/js';
   import { preferences, user } from '$lib/stores/user.store';
+  import { t } from 'svelte-i18n';
 
   let { isViewing: showAssetViewer } = assetViewingStore;
   const assetStore = new AssetStore({ isArchived: false, withStacked: true, withPartners: true });
@@ -57,12 +58,12 @@
   >
     <CreateSharedLink />
     <SelectAllAssets {assetStore} {assetInteractionStore} />
-    <AssetSelectContextMenu icon={mdiPlus} title="Add to...">
+    <AssetSelectContextMenu icon={mdiPlus} title={$t('add_to')}>
       <AddToAlbum />
       <AddToAlbum shared />
     </AssetSelectContextMenu>
     <FavoriteAction removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} />
-    <AssetSelectContextMenu icon={mdiDotsVertical} title="Menu">
+    <AssetSelectContextMenu icon={mdiDotsVertical} title={$t('menu')}>
       <DownloadAction menuItem />
       {#if $selectedAssets.size > 1 || isAssetStackSelected}
         <StackAction
@@ -92,6 +93,6 @@
     {#if $preferences.memories.enabled}
       <MemoryLane />
     {/if}
-    <EmptyPlaceholder text="CLICK TO UPLOAD YOUR FIRST PHOTO" onClick={() => openFileUploadDialog()} slot="empty" />
+    <EmptyPlaceholder text={$t('no_assets_message')} onClick={() => openFileUploadDialog()} slot="empty" />
   </AssetGrid>
 </UserPageLayout>
diff --git a/web/src/routes/(user)/places/+page.svelte b/web/src/routes/(user)/places/+page.svelte
index 01222ab6bcaf6..9ebf9db6aa855 100644
--- a/web/src/routes/(user)/places/+page.svelte
+++ b/web/src/routes/(user)/places/+page.svelte
@@ -7,6 +7,7 @@
   import type { PageData } from './$types';
   import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
   import type { AssetResponseDto } from '@immich/sdk';
+  import { t } from 'svelte-i18n';
 
   export let data: PageData;
 
@@ -24,7 +25,7 @@
 
 <svelte:window bind:innerHeight />
 
-<UserPageLayout title="Places">
+<UserPageLayout title={$t('places')}>
   {#if hasPlaces}
     <div class="flex flex-row flex-wrap gap-4">
       {#each places as item (item.id)}
@@ -47,7 +48,7 @@
     <div class="flex min-h-[calc(66vh_-_11rem)] w-full place-content-center items-center dark:text-white">
       <div class="flex flex-col content-center items-center text-center">
         <Icon path={mdiMapMarkerOff} size="3.5em" />
-        <p class="mt-5 text-3xl font-medium">No places</p>
+        <p class="mt-5 text-3xl font-medium">{$t('no_places')}</p>
       </div>
     </div>
   {/if}
diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte
index b39415025e16f..b947720972bc6 100644
--- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -39,6 +39,7 @@
   import { handleError } from '$lib/utils/handle-error';
   import AlbumCardGroup from '$lib/components/album-page/album-card-group.svelte';
   import { isAlbumsRoute, isPeopleRoute } from '$lib/utils/navigation';
+  import { t } from 'svelte-i18n';
 
   const MAX_ASSET_COUNT = 5000;
   let { isViewing: showAssetViewer } = assetViewingStore;
@@ -141,7 +142,7 @@
 
       nextPage = assets.nextPage ? Number(assets.nextPage) : null;
     } catch (error) {
-      handleError(error, 'Loading search results failed');
+      handleError(error, $t('loading_search_results_failed'));
     } finally {
       isLoading = false;
     }
@@ -161,20 +162,20 @@
 
   function getHumanReadableSearchKey(key: keyof SearchTerms): string {
     const keyMap: Partial<Record<keyof SearchTerms, string>> = {
-      takenAfter: 'Start date',
-      takenBefore: 'End date',
-      isArchived: 'In archive',
-      isFavorite: 'Favorite',
-      isNotInAlbum: 'Not in any album',
-      type: 'Media type',
-      query: 'Context',
-      city: 'City',
-      country: 'Country',
-      state: 'State',
-      make: 'Camera make',
-      model: 'Camera model',
-      personIds: 'People',
-      originalFileName: 'File name',
+      takenAfter: $t('start_date'),
+      takenBefore: $t('end_date'),
+      isArchived: $t('in_archive'),
+      isFavorite: $t('favorite'),
+      isNotInAlbum: $t('not_in_any_album'),
+      type: $t('media_type'),
+      query: $t('context'),
+      city: $t('city'),
+      country: $t('country'),
+      state: $t('state'),
+      make: $t('camera_brand'),
+      model: $t('camera_model'),
+      personIds: $t('people'),
+      originalFileName: $t('file_name'),
     };
     return keyMap[key] || key;
   }
@@ -185,7 +186,7 @@
         const person = await getPerson({ id: personId });
 
         if (person.name == '') {
-          return 'No Name';
+          return $t('no_name');
         }
 
         return person.name;
@@ -209,14 +210,14 @@
     <div class="fixed z-[100] top-0 left-0 w-full">
       <AssetSelectControlBar assets={selectedAssets} clearSelect={() => (selectedAssets = new Set())}>
         <CreateSharedLink />
-        <CircleIconButton title="Select all" icon={mdiSelectAll} on:click={handleSelectAll} />
-        <AssetSelectContextMenu icon={mdiPlus} title="Add to...">
+        <CircleIconButton title={$t('select_all')} icon={mdiSelectAll} on:click={handleSelectAll} />
+        <AssetSelectContextMenu icon={mdiPlus} title={$t('add_to')}>
           <AddToAlbum />
           <AddToAlbum shared />
         </AssetSelectContextMenu>
         <FavoriteAction removeFavorite={isAllFavorite} onFavorite={triggerAssetUpdate} />
 
-        <AssetSelectContextMenu icon={mdiDotsVertical} title="Add">
+        <AssetSelectContextMenu icon={mdiDotsVertical} title={$t('add')}>
           <DownloadAction menuItem />
           <ChangeDate menuItem />
           <ChangeLocation menuItem />
@@ -275,7 +276,7 @@
   <section class="immich-scrollbar relative overflow-y-auto">
     {#if searchResultAlbums.length > 0}
       <section>
-        <div class="ml-6 text-4xl font-medium text-black/70 dark:text-white/80">ALBUMS</div>
+        <div class="ml-6 text-4xl font-medium text-black/70 dark:text-white/80">{$t('albums').toUpperCase()}</div>
         <AlbumCardGroup albums={searchResultAlbums} showDateRange showItemCount />
 
         <div class="m-6 text-4xl font-medium text-black/70 dark:text-white/80">PHOTOS & VIDEOS</div>
@@ -294,7 +295,7 @@
         <div class="flex min-h-[calc(66vh_-_11rem)] w-full place-content-center items-center dark:text-white">
           <div class="flex flex-col content-center items-center text-center">
             <Icon path={mdiImageOffOutline} size="3.5em" />
-            <p class="mt-5 text-3xl font-medium">No results</p>
+            <p class="mt-5 text-3xl font-medium">{$t('no_results')}</p>
             <p class="text-base font-normal">Try a synonym or more general keyword</p>
           </div>
         </div>
diff --git a/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.svelte
index 1a975650c4a0b..d21bea9bba38d 100644
--- a/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -10,6 +10,7 @@
   import { getMySharedLink, SharedLinkType } from '@immich/sdk';
   import type { PageData } from './$types';
   import { setSharedLink } from '$lib/utils';
+  import { t } from 'svelte-i18n';
 
   export let data: PageData;
   let { sharedLink, passwordRequired, sharedLinkKey: key, meta } = data;
@@ -24,7 +25,7 @@
       setSharedLink(sharedLink);
       passwordRequired = false;
       isOwned = $user ? $user.id === sharedLink.userId : false;
-      title = (sharedLink.album ? sharedLink.album.albumName : 'Public Share') + ' - Immich';
+      title = (sharedLink.album ? sharedLink.album.albumName : $t('public_share')) + ' - Immich';
       description = sharedLink.description || `${sharedLink.assets.length} shared photos & videos.`;
     } catch (error) {
       handleError(error, 'Failed to get shared link');
@@ -54,14 +55,14 @@
     class="relative h-screen overflow-hidden bg-immich-bg px-6 pt-[var(--navbar-height)] dark:bg-immich-dark-bg sm:px-12 md:px-24 lg:px-40"
   >
     <div class="flex flex-col items-center justify-center mt-20">
-      <div class="text-2xl font-bold text-immich-primary dark:text-immich-dark-primary">Password Required</div>
+      <div class="text-2xl font-bold text-immich-primary dark:text-immich-dark-primary">{$t('password_required')}</div>
       <div class="mt-4 text-lg text-immich-primary dark:text-immich-dark-primary">
         Please enter the password to view this page.
       </div>
       <div class="mt-4">
         <form novalidate autocomplete="off" on:submit|preventDefault={handlePasswordSubmit}>
-          <input type="password" class="immich-form-input mr-2" placeholder="Password" bind:value={password} />
-          <Button type="submit">Submit</Button>
+          <input type="password" class="immich-form-input mr-2" placeholder={$t('password')} bind:value={password} />
+          <Button type="submit">{$t('submit')}</Button>
         </form>
       </div>
     </div>
diff --git a/web/src/routes/(user)/sharing/+page.svelte b/web/src/routes/(user)/sharing/+page.svelte
index dacdbf015be4a..5dbbe74f4525f 100644
--- a/web/src/routes/(user)/sharing/+page.svelte
+++ b/web/src/routes/(user)/sharing/+page.svelte
@@ -19,6 +19,7 @@
     type AlbumViewSettings,
   } from '$lib/stores/preferences.store';
   import Albums from '$lib/components/album-page/albums-list.svelte';
+  import { t } from 'svelte-i18n';
 
   export let data: PageData;
 
@@ -38,14 +39,14 @@
     <LinkButton on:click={() => createAlbumAndRedirect()}>
       <div class="flex flex-wrap place-items-center justify-center gap-x-1 text-sm">
         <Icon path={mdiPlusBoxOutline} size="18" class="shrink-0" />
-        <span class="leading-none max-sm:text-xs">Create album</span>
+        <span class="leading-none max-sm:text-xs">{$t('create_album')}</span>
       </div>
     </LinkButton>
 
     <LinkButton on:click={() => goto(AppRoute.SHARED_LINKS)}>
       <div class="flex flex-wrap place-items-center justify-center gap-x-1 text-sm">
         <Icon path={mdiLink} size="18" class="shrink-0" />
-        <span class="leading-none max-sm:text-xs">Shared links</span>
+        <span class="leading-none max-sm:text-xs">{$t('shared_links')}</span>
       </div>
     </LinkButton>
   </div>
@@ -54,7 +55,7 @@
     {#if data.partners.length > 0}
       <div class="mb-6 mt-2">
         <div>
-          <p class="mb-4 font-medium dark:text-immich-dark-fg">Partners</p>
+          <p class="mb-4 font-medium dark:text-immich-dark-fg">{$t('partners')}</p>
         </div>
 
         <div class="flex flex-row flex-wrap gap-4">
@@ -82,18 +83,14 @@
 
     <div class="mb-6 mt-2">
       <div>
-        <p class="mb-4 font-medium dark:text-immich-dark-fg">Albums</p>
+        <p class="mb-4 font-medium dark:text-immich-dark-fg">{$t('albums')}</p>
       </div>
 
       <div>
         <!-- Shared Album List -->
         <Albums sharedAlbums={data.sharedAlbums} userSettings={settings} showOwner>
           <!-- Empty List -->
-          <EmptyPlaceholder
-            slot="empty"
-            text="Create an album to share photos and videos with people in your network"
-            src={empty2Url}
-          />
+          <EmptyPlaceholder slot="empty" text={$t('no_shared_albums_message')} src={empty2Url} />
         </Albums>
       </div>
     </div>
diff --git a/web/src/routes/(user)/sharing/sharedlinks/+page.svelte b/web/src/routes/(user)/sharing/sharedlinks/+page.svelte
index 81765a1e3424e..d89ece120d776 100644
--- a/web/src/routes/(user)/sharing/sharedlinks/+page.svelte
+++ b/web/src/routes/(user)/sharing/sharedlinks/+page.svelte
@@ -16,6 +16,7 @@
   import { mdiArrowLeft } from '@mdi/js';
   import { onMount } from 'svelte';
   import { dialogController } from '$lib/components/shared-components/dialog/dialog';
+  import { t } from 'svelte-i18n';
 
   let sharedLinks: SharedLinkResponseDto[] = [];
   let editSharedLink: SharedLinkResponseDto | null = null;
@@ -42,7 +43,7 @@
 
     try {
       await removeSharedLink({ id });
-      notificationController.show({ message: 'Deleted shared link', type: NotificationType.Info });
+      notificationController.show({ message: $t('deleted_shared_link'), type: NotificationType.Info });
       await refresh();
     } catch (error) {
       handleError(error, 'Unable to delete shared link');
@@ -60,12 +61,12 @@
 </script>
 
 <ControlAppBar backIcon={mdiArrowLeft} on:close={() => goto(AppRoute.SHARING)}>
-  <svelte:fragment slot="leading">Shared links</svelte:fragment>
+  <svelte:fragment slot="leading">{$t('shared_links')}</svelte:fragment>
 </ControlAppBar>
 
 <section class="mt-[120px] flex flex-col pb-[120px]">
   <div class="m-auto mb-4 w-[50%] dark:text-immich-gray">
-    <p>Manage shared links</p>
+    <p>{$t('manage_shared_links')}</p>
   </div>
   {#if sharedLinks.length === 0}
     <div
diff --git a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte
index cf5edaae34100..53740f153b50a 100644
--- a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -24,6 +24,7 @@
   import type { PageData } from './$types';
   import { handlePromiseError } from '$lib/utils';
   import { dialogController } from '$lib/components/shared-components/dialog/dialog';
+  import { t } from 'svelte-i18n';
 
   export let data: PageData;
 
@@ -56,7 +57,7 @@
         type: NotificationType.Info,
       });
     } catch (error) {
-      handleError(error, 'Error emptying trash');
+      handleError(error, $t('errors.unable_to_empty_trash'));
     }
   };
 
@@ -81,7 +82,7 @@
         type: NotificationType.Info,
       });
     } catch (error) {
-      handleError(error, 'Error restoring trash');
+      handleError(error, $t('errors.unable_to_restore_trash'));
     }
   };
 </script>
@@ -115,7 +116,7 @@
       <p class="font-medium text-gray-500/60 dark:text-gray-300/60 p-4">
         Trashed items will be permanently deleted after {$serverConfig.trashDays} days.
       </p>
-      <EmptyPlaceholder text="Trashed photos and videos will show up here." src={empty3Url} slot="empty" />
+      <EmptyPlaceholder text={$t('trash_no_results_message')} src={empty3Url} slot="empty" />
     </AssetGrid>
   </UserPageLayout>
 {/if}
diff --git a/web/src/routes/(user)/user-settings/+page.svelte b/web/src/routes/(user)/user-settings/+page.svelte
index 76e19ff19761e..ea302454b2fbc 100644
--- a/web/src/routes/(user)/user-settings/+page.svelte
+++ b/web/src/routes/(user)/user-settings/+page.svelte
@@ -5,6 +5,7 @@
   import type { PageData } from './$types';
   import ShowShortcuts from '$lib/components/shared-components/show-shortcuts.svelte';
   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
+  import { t } from 'svelte-i18n';
 
   export let data: PageData;
   export let isShowKeyboardShortcut = false;
@@ -14,7 +15,7 @@
   <svelte:fragment slot="buttons">
     <CircleIconButton
       icon={mdiKeyboard}
-      title="Show keyboard shortcuts"
+      title={$t('show_keyboard_shortcuts')}
       on:click={() => (isShowKeyboardShortcut = !isShowKeyboardShortcut)}
     />
   </svelte:fragment>
diff --git a/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte
index fbb7ebca31f9d..f29903c7c862b 100644
--- a/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -10,6 +10,7 @@
   } from '$lib/components/shared-components/notification/notification';
   import { s } from '$lib/utils';
   import { deleteAssets, getConfig, updateAssets } from '@immich/sdk';
+  import { t } from 'svelte-i18n';
 
   export let data: PageData;
 
@@ -39,7 +40,7 @@
         type: NotificationType.Info,
       });
     } catch (error) {
-      handleError(error, 'Unable to resolve duplicate');
+      handleError(error, $t('unable_to_resolve_duplicate'));
     }
   };
 </script>
diff --git a/web/src/routes/+error.svelte b/web/src/routes/+error.svelte
index 4ec3fd1d7d563..8895be3bd76f7 100644
--- a/web/src/routes/+error.svelte
+++ b/web/src/routes/+error.svelte
@@ -5,6 +5,7 @@
   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
   import { copyToClipboard } from '$lib/utils';
   import { mdiCodeTags, mdiContentCopy, mdiMessage, mdiPartyPopper } from '@mdi/js';
+  import { t } from 'svelte-i18n';
 
   const handleCopy = async () => {
     //
@@ -40,7 +41,7 @@
               <CircleIconButton
                 color="primary"
                 icon={mdiContentCopy}
-                title="Copy error"
+                title={$t('copy_error')}
                 on:click={() => handleCopy()}
               />
             </div>
@@ -52,7 +53,7 @@
             <div class="flex w-full flex-col gap-2">
               <p class="text-red-500">{$page.error?.message} ({$page.error?.code})</p>
               {#if $page.error?.stack}
-                <label for="stacktrace">Stacktrace</label>
+                <label for="stacktrace">{$t('stacktrace')}</label>
                 <pre id="stacktrace" class="text-xs">{$page.error?.stack || 'No stack'}</pre>
               {/if}
             </div>
@@ -70,7 +71,7 @@
             >
               <div class="flex flex-col place-content-center place-items-center gap-2">
                 <Icon path={mdiMessage} size={24} />
-                <p class="text-sm">Get Help</p>
+                <p class="text-sm">{$t('get_help')}</p>
               </div>
             </a>
 
@@ -82,7 +83,7 @@
             >
               <div class="flex flex-col place-content-center place-items-center gap-2">
                 <Icon path={mdiPartyPopper} size={24} />
-                <p class="text-sm">Read Changelog</p>
+                <p class="text-sm">{$t('read_changelog')}</p>
               </div>
             </a>
 
@@ -94,7 +95,7 @@
             >
               <div class="flex flex-col place-content-center place-items-center gap-2">
                 <Icon path={mdiCodeTags} size={24} />
-                <p class="text-sm">Check Logs</p>
+                <p class="text-sm">{$t('check_logs')}</p>
               </div>
             </a>
           </div>
diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte
index 1303048645702..7fabc86e8568e 100644
--- a/web/src/routes/+layout.svelte
+++ b/web/src/routes/+layout.svelte
@@ -19,6 +19,7 @@
   import '../app.css';
   import { isAssetViewerRoute, isSharedLinkRoute } from '$lib/utils/navigation';
   import DialogWrapper from '$lib/components/shared-components/dialog/dialog-wrapper.svelte';
+  import { t } from 'svelte-i18n';
 
   let showNavigationLoadingBar = false;
 
@@ -108,7 +109,7 @@
 <noscript
   class="absolute z-[1000] flex h-screen w-screen place-content-center place-items-center bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg"
 >
-  <FullscreenContainer title="Welcome to Immich">
+  <FullscreenContainer title={$t('welcome_to_immich')}>
     To use Immich, you must enable JavaScript or use a JavaScript compatible browser.
   </FullscreenContainer>
 </noscript>
diff --git a/web/src/routes/+layout.ts b/web/src/routes/+layout.ts
index 1751ab236ab5c..b5ed291ab2ae3 100644
--- a/web/src/routes/+layout.ts
+++ b/web/src/routes/+layout.ts
@@ -1,15 +1,27 @@
+import { fallbackLang, langs } from '$lib/constants';
+import { lang } from '$lib/stores/preferences.store';
 import { defaults } from '@immich/sdk';
+import { init, register } from 'svelte-i18n';
+import { get } from 'svelte/store';
 import type { LayoutLoad } from './$types';
 
 export const ssr = false;
 export const csr = true;
 
-export const load = (({ fetch }) => {
+export const load = (async ({ fetch }) => {
   // set event.fetch on the fetch-client used by @immich/sdk
   // https://kit.svelte.dev/docs/load#making-fetch-requests
   // https://github.com/oazapfts/oazapfts/blob/main/README.md#fetch-options
   defaults.fetch = fetch;
 
+  for (const { code, loader } of langs) {
+    register(code, loader);
+  }
+
+  const preferenceLang = get(lang);
+
+  await init({ fallbackLocale: fallbackLang, initialLocale: preferenceLang || fallbackLang });
+
   return {
     meta: {
       title: 'Immich',
diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte
index 8c1a5b894012b..bb89fc2bc4f37 100644
--- a/web/src/routes/+page.svelte
+++ b/web/src/routes/+page.svelte
@@ -2,6 +2,7 @@
   import Button from '$lib/components/elements/buttons/button.svelte';
   import ImmichLogo from '$lib/components/shared-components/immich-logo.svelte';
   import { AppRoute } from '$lib/constants';
+  import { t } from 'svelte-i18n';
 </script>
 
 <section class="flex h-screen w-screen place-content-center place-items-center">
@@ -9,10 +10,10 @@
     <div class="flex place-content-center place-items-center">
       <ImmichLogo noText class="text-center" height="200" width="200" />
     </div>
-    <h1 class="text-4xl font-bold text-immich-primary dark:text-immich-dark-primary">Welcome to immich</h1>
+    <h1 class="text-4xl font-bold text-immich-primary dark:text-immich-dark-primary">{$t('welcome_to_immich')}</h1>
     <a href={AppRoute.AUTH_REGISTER}>
       <Button size="lg" rounded="lg">
-        <span class="px-2 font-bold">Getting Started</span>
+        <span class="px-2 font-bold">{$t('getting_started')}</span>
       </Button>
     </a>
   </div>
diff --git a/web/src/routes/admin/library-management/+page.svelte b/web/src/routes/admin/library-management/+page.svelte
index c997f538c0b5a..4c4e4ff806280 100644
--- a/web/src/routes/admin/library-management/+page.svelte
+++ b/web/src/routes/admin/library-management/+page.svelte
@@ -37,6 +37,7 @@
   import type { PageData } from './$types';
   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
   import { dialogController } from '$lib/components/shared-components/dialog/dialog';
+  import { t } from 'svelte-i18n';
 
   export let data: PageData;
 
@@ -124,7 +125,7 @@
         type: NotificationType.Info,
       });
     } catch (error) {
-      handleError(error, 'Unable to create library');
+      handleError(error, $t('unable_to_create_library'));
     } finally {
       toCreateLibrary = false;
       await readLibraryList();
@@ -142,7 +143,7 @@
       closeAll();
       await readLibraryList();
     } catch (error) {
-      handleError(error, 'Unable to update library');
+      handleError(error, $t('unable_to_update_library'));
     }
   };
 
@@ -162,7 +163,7 @@
         type: NotificationType.Info,
       });
     } catch (error) {
-      handleError(error, 'Unable to remove library');
+      handleError(error, $t('unable_to_remove_library'));
     } finally {
       confirmDeleteLibrary = null;
       deletedLibrary = null;
@@ -180,7 +181,7 @@
         type: NotificationType.Info,
       });
     } catch (error) {
-      handleError(error, 'Unable to scan libraries');
+      handleError(error, $t('unable_to_scan_libraries'));
     }
   };
 
@@ -192,7 +193,7 @@
         type: NotificationType.Info,
       });
     } catch (error) {
-      handleError(error, 'Unable to scan library');
+      handleError(error, $t('unable_to_scan_library'));
     }
   };
 
@@ -204,7 +205,7 @@
         type: NotificationType.Info,
       });
     } catch (error) {
-      handleError(error, 'Unable to scan library');
+      handleError(error, $t('unable_to_scan_library'));
     }
   };
 
@@ -216,7 +217,7 @@
         type: NotificationType.Info,
       });
     } catch (error) {
-      handleError(error, 'Unable to scan library');
+      handleError(error, $t('unable_to_scan_library'));
     }
   };
 
@@ -328,14 +329,14 @@
       <LinkButton on:click={() => handleScanAll()}>
         <div class="flex gap-1 text-sm">
           <Icon path={mdiSync} size="18" />
-          <span>Scan All Libraries</span>
+          <span>{$t('scan_all_libraries')}</span>
         </div>
       </LinkButton>
     {/if}
     <LinkButton on:click={() => (toCreateLibrary = true)}>
       <div class="flex gap-1 text-sm">
         <Icon path={mdiPlusBoxOutline} size="18" />
-        <span>Create Library</span>
+        <span>{$t('create_library')}</span>
       </div>
     </LinkButton>
   </div>
@@ -347,11 +348,11 @@
             class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
           >
             <tr class="grid grid-cols-6 w-full place-items-center">
-              <th class="text-center text-sm font-medium">Type</th>
-              <th class="text-center text-sm font-medium">Name</th>
-              <th class="text-center text-sm font-medium">Owner</th>
-              <th class="text-center text-sm font-medium">Assets</th>
-              <th class="text-center text-sm font-medium">Size</th>
+              <th class="text-center text-sm font-medium">{$t('type')}</th>
+              <th class="text-center text-sm font-medium">{$t('name')}</th>
+              <th class="text-center text-sm font-medium">{$t('owner')}</th>
+              <th class="text-center text-sm font-medium">{$t('assets')}</th>
+              <th class="text-center text-sm font-medium">{$t('size')}</th>
               <th class="text-center text-sm font-medium" />
             </tr>
           </thead>
@@ -390,7 +391,7 @@
                   <CircleIconButton
                     color="primary"
                     icon={mdiDotsVertical}
-                    title="Library options"
+                    title={$t('library_options')}
                     size="16"
                     on:click={(e) => showMenu(e, library, index)}
                   />
@@ -401,24 +402,27 @@
                         <MenuOption on:click={() => onRenameClicked()} text={`Rename`} />
 
                         {#if selectedLibrary}
-                          <MenuOption on:click={() => onEditImportPathClicked()} text="Edit Import Paths" />
-                          <MenuOption on:click={() => onScanSettingClicked()} text="Scan Settings" />
+                          <MenuOption on:click={() => onEditImportPathClicked()} text={$t('edit_import_paths')} />
+                          <MenuOption on:click={() => onScanSettingClicked()} text={$t('scan_settings')} />
                           <hr />
-                          <MenuOption on:click={() => onScanNewLibraryClicked()} text="Scan New Library Files" />
+                          <MenuOption on:click={() => onScanNewLibraryClicked()} text={$t('scan_new_library_files')} />
                           <MenuOption
                             on:click={() => onScanAllLibraryFilesClicked()}
-                            text="Re-scan All Library Files"
-                            subtitle={'Only refreshes modified files'}
+                            text={$t('scan_all_library_files')}
+                            subtitle={$t('only_refreshes_modified_files')}
                           />
                           <MenuOption
                             on:click={() => onForceScanAllLibraryFilesClicked()}
-                            text="Force Re-scan All Library Files"
-                            subtitle={'Refreshes every file'}
+                            text={$t('force_re-scan_library_files')}
+                            subtitle={$t('refreshes_every_file')}
                           />
                           <hr />
-                          <MenuOption on:click={() => onRemoveOfflineFilesClicked()} text="Remove Offline Files" />
+                          <MenuOption
+                            on:click={() => onRemoveOfflineFilesClicked()}
+                            text={$t('remove_offline_files')}
+                          />
                           <MenuOption on:click={() => onDeleteLibraryClicked()}>
-                            <p class="text-red-600">Delete library</p>
+                            <p class="text-red-600">{$t('delete_library')}</p>
                           </MenuOption>
                         {/if}
                       </ContextMenu>
@@ -459,10 +463,7 @@
 
         <!-- Empty message -->
       {:else}
-        <EmptyPlaceholder
-          text="Create an external library to view your photos and videos"
-          onClick={() => (toCreateLibrary = true)}
-        />
+        <EmptyPlaceholder text={$t('no_libraries_message')} onClick={() => (toCreateLibrary = true)} />
       {/if}
     </div>
   </section>
diff --git a/web/src/routes/admin/repair/+page.svelte b/web/src/routes/admin/repair/+page.svelte
index fc98bcabcda37..2b5a2a38f1084 100644
--- a/web/src/routes/admin/repair/+page.svelte
+++ b/web/src/routes/admin/repair/+page.svelte
@@ -16,6 +16,7 @@
   import { fixAuditFiles, getAuditFiles, getFileChecksums, type FileReportItemDto } from '@immich/sdk';
   import { mdiCheckAll, mdiContentCopy, mdiDownload, mdiRefresh, mdiWrench } from '@mdi/js';
   import type { PageData } from './$types';
+  import { t } from 'svelte-i18n';
 
   export let data: PageData;
 
@@ -84,7 +85,7 @@
 
       matches = [];
     } catch (error) {
-      handleError(error, 'Unable to repair items');
+      handleError(error, $t('unable_to_repair_items'));
     } finally {
       repairing = false;
     }
@@ -107,9 +108,9 @@
       orphans = report.orphans;
       extras = normalize(report.extras);
 
-      notificationController.show({ message: 'Refreshed', type: NotificationType.Info });
+      notificationController.show({ message: $t('refreshed'), type: NotificationType.Info });
     } catch (error) {
-      handleError(error, 'Unable to load items');
+      handleError(error, $t('unable_to_load_items'));
     }
   };
 
@@ -120,7 +121,7 @@
         notificationController.show({ message: `Matched 1 item`, type: NotificationType.Info });
       }
     } catch (error) {
-      handleError(error, 'Unable to check item');
+      handleError(error, $t('unable_to_check_item'));
     }
   };
 
@@ -136,7 +137,7 @@
         count += await loadAndMatch(filenames.slice(index, index + chunkSize));
       }
     } catch (error) {
-      handleError(error, 'Unable to check items');
+      handleError(error, $t('unable_to_check_items'));
     } finally {
       checking = false;
     }
@@ -203,7 +204,7 @@
     <section class="w-full pb-28 sm:w-5/6 md:w-[850px]">
       {#if matches.length + extras.length + orphans.length === 0}
         <div class="w-full">
-          <EmptyPlaceholder fullWidth text="Untracked and missing files will show up here" src={empty4Url} />
+          <EmptyPlaceholder fullWidth text={$t('repair_no_results_message')} src={empty4Url} />
         </div>
       {:else}
         <div class="gap-2">
@@ -266,7 +267,7 @@
                   title={orphan.pathValue}
                 >
                   <td on:click={() => copyToClipboard(orphan.pathValue)}>
-                    <CircleIconButton title="Copy file path" icon={mdiContentCopy} size="18" />
+                    <CircleIconButton title={$t('copy_file_path')} icon={mdiContentCopy} size="18" />
                   </td>
                   <td class="truncate text-sm font-mono text-left" title={orphan.pathValue}>
                     {orphan.pathValue}
@@ -306,7 +307,7 @@
                   title={extra.filename}
                 >
                   <td on:click={() => copyToClipboard(extra.filename)}>
-                    <CircleIconButton title="Copy file path" icon={mdiContentCopy} size="18" />
+                    <CircleIconButton title={$t('copy_file_path')} icon={mdiContentCopy} size="18" />
                   </td>
                   <td class="w-full text-md text-ellipsis flex justify-between pr-5">
                     <span class="text-ellipsis grow truncate font-mono text-sm pr-5" title={extra.filename}
diff --git a/web/src/routes/admin/system-settings/+page.svelte b/web/src/routes/admin/system-settings/+page.svelte
index 20a9557c7f0df..34c6d7e5cc425 100644
--- a/web/src/routes/admin/system-settings/+page.svelte
+++ b/web/src/routes/admin/system-settings/+page.svelte
@@ -28,6 +28,7 @@
   import type { SystemConfigDto } from '@immich/sdk';
   import { mdiAlert, mdiContentCopy, mdiDownload, mdiUpload } from '@mdi/js';
   import type { PageData } from './$types';
+  import { t } from 'svelte-i18n';
 
   export let data: PageData;
 
@@ -82,92 +83,92 @@
   }> = [
     {
       item: AuthSettings,
-      title: 'Authentication Settings',
-      subtitle: 'Manage password, OAuth, and other authentication settings',
+      title: $t('admin.authentication_settings'),
+      subtitle: $t('admin.authentication_settings_description'),
       key: 'image',
     },
     {
       item: ImageSettings,
-      title: 'Image Settings',
-      subtitle: 'Manage the quality and resolution of generated images',
+      title: $t('admin.image_settings'),
+      subtitle: $t('admin.image_settings_description'),
       key: 'image',
     },
     {
       item: JobSettings,
-      title: 'Job Settings',
-      subtitle: 'Manage job concurrency',
+      title: $t('admin.job_settings'),
+      subtitle: $t('admin.job_settings_description'),
       key: 'job',
     },
     {
       item: LibrarySettings,
-      title: 'External Library',
-      subtitle: 'Manage external library settings',
+      title: $t('admin.library_settings'),
+      subtitle: $t('admin.library_settings_description'),
       key: 'external-library',
     },
     {
       item: LoggingSettings,
-      title: 'Logging',
-      subtitle: 'Manage log settings',
+      title: $t('admin.logging_settings'),
+      subtitle: $t('admin.manage_log_settings'),
       key: 'logging',
     },
     {
       item: MachineLearningSettings,
-      title: 'Machine Learning Settings',
-      subtitle: 'Manage machine learning features and settings',
+      title: $t('admin.machine_learning_settings'),
+      subtitle: $t('admin.machine_learning_settings_description'),
       key: 'machine-learning',
     },
     {
       item: MapSettings,
-      title: 'Map & GPS Settings',
-      subtitle: 'Manage map related features and setting',
+      title: $t('admin.map_settings'),
+      subtitle: $t('admin.map_settings_description'),
       key: 'location',
     },
     {
       item: NotificationSettings,
-      title: 'Notification Settings',
-      subtitle: 'Manage notification settings, including email',
+      title: $t('admin.notification_settings'),
+      subtitle: $t('admin.notification_settings_description'),
       key: 'notifications',
     },
     {
       item: ServerSettings,
-      title: 'Server Settings',
-      subtitle: 'Manage server settings',
+      title: $t('admin.server_settings'),
+      subtitle: $t('admin.server_settings_description'),
       key: 'server',
     },
     {
       item: StorageTemplateSettings,
-      title: 'Storage Template',
-      subtitle: 'Manage the folder structure and file name of the upload asset',
+      title: $t('admin.storage_template_settings'),
+      subtitle: $t('admin.storage_template_settings_description'),
       key: 'storage-template',
     },
     {
       item: ThemeSettings,
-      title: 'Theme Settings',
-      subtitle: 'Manage customization of the Immich web interface',
+      title: $t('admin.theme_settings'),
+      subtitle: $t('admin.theme_settings_description'),
       key: 'theme',
     },
     {
       item: TrashSettings,
-      title: 'Trash Settings',
-      subtitle: 'Manage trash settings',
+      title: $t('admin.trash_settings'),
+      subtitle: $t('admin.trash_settings_description'),
       key: 'trash',
     },
     {
       item: UserSettings,
-      title: 'User Settings',
-      subtitle: 'Manage user settings',
+      title: $t('admin.user_settings'),
+      subtitle: $t('admin.user_settings_description'),
       key: 'user-settings',
     },
     {
       item: NewVersionCheckSettings,
-      title: 'Version Check',
-      subtitle: 'Enable/disable the new version notification',
+      title: $t('admin.version_check_settings'),
+      subtitle: $t('admin.version_check_settings_description'),
       key: 'version-check',
     },
     {
       item: FFmpegSettings,
-      title: 'Video Transcoding Settings',
-      subtitle: 'Manage the resolution and encoding information of the video files',
+      title: $t('admin.transcoding_settings'),
+      subtitle: $t('admin.transcoding_settings_description'),
       key: 'video-transcoding',
     },
   ];
diff --git a/web/src/routes/admin/user-management/+page.svelte b/web/src/routes/admin/user-management/+page.svelte
index 4f60d988f01d0..bc44e52f69c48 100644
--- a/web/src/routes/admin/user-management/+page.svelte
+++ b/web/src/routes/admin/user-management/+page.svelte
@@ -25,6 +25,7 @@
   import { DateTime } from 'luxon';
   import { onMount } from 'svelte';
   import type { PageData } from './$types';
+  import { t } from 'svelte-i18n';
 
   export let data: PageData;
 
@@ -154,8 +155,8 @@
 
       {#if shouldShowPasswordResetSuccess}
         <ConfirmDialog
-          title="Password reset success"
-          confirmText="Done"
+          title={$t('password_reset_success')}
+          confirmText={$t('done')}
           onConfirm={() => (shouldShowPasswordResetSuccess = false)}
           onCancel={() => (shouldShowPasswordResetSuccess = false)}
           hideCancelButton={true}
@@ -171,7 +172,7 @@
                 >
                   {newPassword}
                 </code>
-                <LinkButton on:click={() => copyToClipboard(newPassword)} title="Copy password">
+                <LinkButton on:click={() => copyToClipboard(newPassword)} title={$t('copy_password')}>
                   <div class="flex place-items-center gap-2 text-sm">
                     <Icon path={mdiContentCopy} size="18" />
                   </div>
@@ -192,10 +193,12 @@
           class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
         >
           <tr class="flex w-full place-items-center">
-            <th class="w-8/12 sm:w-5/12 lg:w-6/12 xl:w-4/12 2xl:w-5/12 text-center text-sm font-medium">Email</th>
-            <th class="hidden sm:block w-3/12 text-center text-sm font-medium">Name</th>
-            <th class="hidden xl:block w-3/12 2xl:w-2/12 text-center text-sm font-medium">Has quota</th>
-            <th class="w-4/12 lg:w-3/12 xl:w-2/12 text-center text-sm font-medium">Action</th>
+            <th class="w-8/12 sm:w-5/12 lg:w-6/12 xl:w-4/12 2xl:w-5/12 text-center text-sm font-medium"
+              >{$t('email')}</th
+            >
+            <th class="hidden sm:block w-3/12 text-center text-sm font-medium">{$t('name')}</th>
+            <th class="hidden xl:block w-3/12 2xl:w-2/12 text-center text-sm font-medium">{$t('has_quota')}</th>
+            <th class="w-4/12 lg:w-3/12 xl:w-2/12 text-center text-sm font-medium">{$t('action')}</th>
           </tr>
         </thead>
         <tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
@@ -227,7 +230,7 @@
                   {#if !immichUser.deletedAt}
                     <CircleIconButton
                       icon={mdiPencilOutline}
-                      title="Edit user"
+                      title={$t('edit_user')}
                       color="primary"
                       size="16"
                       on:click={() => editUserHandler(immichUser)}
@@ -235,7 +238,7 @@
                     {#if immichUser.id !== $user.id}
                       <CircleIconButton
                         icon={mdiTrashCanOutline}
-                        title="Delete user"
+                        title={$t('delete_user')}
                         color="primary"
                         size="16"
                         on:click={() => deleteUserHandler(immichUser)}
@@ -258,7 +261,7 @@
         </tbody>
       </table>
 
-      <Button size="sm" on:click={() => (shouldShowCreateUserForm = true)}>Create user</Button>
+      <Button size="sm" on:click={() => (shouldShowCreateUserForm = true)}>{$t('create_user')}</Button>
     </section>
   </section>
 </UserPageLayout>