diff --git a/web/astro.config.mjs b/web/astro.config.mjs
index ed7789e..80fb200 100644
--- a/web/astro.config.mjs
+++ b/web/astro.config.mjs
@@ -4,12 +4,46 @@ import react from "@astrojs/react";
import tailwind from "@astrojs/tailwind";
import node from "@astrojs/node";
import { fileURLToPath } from "url";
-import { dirname, resolve } from "path";
+import { dirname, resolve, join } from "path";
+import { readdirSync, existsSync } from "fs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const API_PORT = process.env.API_PORT || 8081;
+function i18nLanguagesPlugin() {
+ const virtualId = "virtual:i18n-languages";
+ const resolvedId = "\0" + virtualId;
+ return {
+ name: "i18n-languages",
+ resolveId(id) {
+ if (id === virtualId) return resolvedId;
+ },
+ load(id) {
+ if (id !== resolvedId) return;
+ const localesDir = join(__dirname, "public/locales");
+ const languages = readdirSync(localesDir, { withFileTypes: true })
+ .filter(
+ (d) =>
+ d.isDirectory() &&
+ existsSync(join(localesDir, d.name, "translation.json")),
+ )
+ .map((d) => {
+ const code = d.name;
+ const name =
+ new Intl.DisplayNames(["en"], { type: "language" }).of(code) ??
+ code;
+ const nativeName =
+ new Intl.DisplayNames([code], { type: "language" }).of(code) ??
+ name;
+ return { code, name, nativeName };
+ })
+ .sort((a, b) => a.name.localeCompare(b.name));
+ return `export const languages = ${JSON.stringify(languages)};`;
+ },
+ };
+}
+
// https://astro.build/config
export default defineConfig({
output: "server",
@@ -21,6 +55,7 @@ export default defineConfig({
defaultStrategy: "viewport",
},
vite: {
+ plugins: [i18nLanguagesPlugin()],
resolve: {
alias: {
"@": resolve(__dirname, "src"),
diff --git a/web/bun.lock b/web/bun.lock
index 021854e..e64b2c9 100644
--- a/web/bun.lock
+++ b/web/bun.lock
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
+ "configVersion": 0,
"workspaces": {
"": {
"name": "web",
@@ -15,11 +16,15 @@
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"emoji-picker-react": "^4.18.0",
+ "i18next": "^26.0.6",
+ "i18next-browser-languagedetector": "^8.2.1",
+ "i18next-http-backend": "^3.0.5",
"lucide-react": "^0.563.0",
"nanostores": "^1.1.0",
"postcss": "^8.5.6",
"react": "^19.2.4",
"react-dom": "^19.2.4",
+ "react-i18next": "^17.0.4",
"react-router-dom": "^7.13.2",
"satori": "^0.19.2",
"tailwind-merge": "^3.4.0",
@@ -97,6 +102,8 @@
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
+ "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
+
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
@@ -549,6 +556,8 @@
"cookie-es": ["cookie-es@1.2.2", "", {}, "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="],
+ "cross-fetch": ["cross-fetch@4.1.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw=="],
+
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="],
@@ -813,12 +822,20 @@
"html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="],
+ "html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
+
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
"http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
+ "i18next": ["i18next@26.0.6", "", { "dependencies": { "@babel/runtime": "^7.29.2" }, "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-A4U6eCXodIbrhf8EarRurB9/4ebyaurH4+fu4gig9bqxmpSt+fCAFm/GpRQDcN1Xzu/LdFCx4nYHsnM1edIIbg=="],
+
+ "i18next-browser-languagedetector": ["i18next-browser-languagedetector@8.2.1", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw=="],
+
+ "i18next-http-backend": ["i18next-http-backend@3.0.5", "", { "dependencies": { "cross-fetch": "4.1.0" } }, "sha512-QaWHnsxieEDcqKe+vo/RFqpiIFRi/KBqlOSPcUlvinBaISCeiTRCbtrazHAjtHtsLC66oDsROAH8frWkQzfMMQ=="],
+
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
@@ -1075,6 +1092,8 @@
"nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="],
+ "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
+
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
"node-mock-http": ["node-mock-http@1.0.4", "", {}, "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ=="],
@@ -1191,6 +1210,8 @@
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
+ "react-i18next": ["react-i18next@17.0.4", "", { "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.0.1", "react": ">= 16.8.0", "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-hQipmK4EF0y6RO6tt6WuqnmWpWYEXmQUUzecmMBuNsIgYd3smXcG4GtYPWhvgxn0pqMOItKlEO8H24HCs5hc3g=="],
+
"react-icons": ["react-icons@5.5.0", "", { "peerDependencies": { "react": "*" } }, "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw=="],
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
@@ -1349,6 +1370,8 @@
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
+ "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
+
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
@@ -1415,6 +1438,8 @@
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
+ "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
+
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
@@ -1427,8 +1452,14 @@
"vitefu": ["vitefu@1.1.2", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw=="],
+ "void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="],
+
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
+ "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
+
+ "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
+
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
diff --git a/web/package.json b/web/package.json
index ef54075..8d627d1 100644
--- a/web/package.json
+++ b/web/package.json
@@ -21,11 +21,15 @@
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"emoji-picker-react": "^4.18.0",
+ "i18next": "^26.0.6",
+ "i18next-browser-languagedetector": "^8.2.1",
+ "i18next-http-backend": "^3.0.5",
"lucide-react": "^0.563.0",
"nanostores": "^1.1.0",
"postcss": "^8.5.6",
"react": "^19.2.4",
"react-dom": "^19.2.4",
+ "react-i18next": "^17.0.4",
"react-router-dom": "^7.13.2",
"satori": "^0.19.2",
"tailwind-merge": "^3.4.0",
diff --git a/web/public/locales/en/translation.json b/web/public/locales/en/translation.json
new file mode 100644
index 0000000..e405f28
--- /dev/null
+++ b/web/public/locales/en/translation.json
@@ -0,0 +1,881 @@
+{
+ "appTitle": "Margin",
+
+ "nav": {
+ "feed": "Feed",
+ "discover": "Discover",
+ "annotations": "Annotations",
+ "highlights": "Highlights",
+ "bookmarks": "Bookmarks",
+ "collections": "Collections",
+ "activity": "Activity",
+ "settings": "Settings",
+ "new": "New",
+ "signIn": "Sign in",
+ "logOut": "Log out",
+ "themeLight": "Light",
+ "themeDark": "Dark",
+ "themeSystem": "System"
+ },
+
+ "pageTitles": {
+ "home": "Home — Margin",
+ "bookmarks": "Bookmarks — Margin",
+ "highlights": "Highlights — Margin",
+ "annotations": "Annotations — Margin",
+ "discover": "Discover — Margin",
+ "search": "Search — Margin",
+ "notifications": "Notifications — Margin",
+ "new": "New Annotation — Margin",
+ "settings": "Settings — Margin",
+ "collections": "Collections — Margin",
+ "admin": "Admin — Margin"
+ },
+
+ "sidebar": {
+ "getExtension": "Get the Extension",
+ "extensionTagline": "Highlight, annotate, and bookmark from any page.",
+ "downloadForFirefox": "Download for Firefox",
+ "downloadForEdge": "Download for Edge",
+ "downloadForChrome": "Download for Chrome",
+ "trending": "Trending",
+ "nothingTrending": "Nothing trending right now.",
+ "searchPlaceholder": "Search people, tags, URLs...",
+ "copyright": "© 2026 Padding Labs LLC",
+ "postCount_one": "{{count}} post",
+ "postCount_other": "{{count}} posts"
+ },
+
+ "mobileNav": {
+ "iosShortcut": "iOS Shortcut"
+ },
+
+ "feed": {
+ "welcome": "Welcome to Margin",
+ "welcomeTagline": "A quiet place to annotate, highlight, and save what you read on the web.",
+ "getStarted": "Get started",
+ "learnMore": "Learn more",
+ "tabs": {
+ "recent": "Recent",
+ "popular": "Popular",
+ "shelved": "Shelved",
+ "margin": "Margin",
+ "semble": "Semble"
+ },
+ "filters": {
+ "all": "All",
+ "annotations": "Annotations",
+ "highlights": "Highlights",
+ "bookmarks": "Bookmarks"
+ },
+ "itemsWithTag": "Items with tag:",
+ "clearFilter": "Clear filter",
+ "everyone": "Everyone",
+ "mine": "Mine",
+ "defaultEmptyMessage": "Nothing here yet — annotations from you and people you follow will show up here.",
+ "nothingHereYet": "Nothing here yet",
+ "loading": "Loading..."
+ },
+
+ "discover": {
+ "tabs": {
+ "new": "New",
+ "popular": "Popular",
+ "forYou": "For You"
+ },
+ "comingSoon": "Coming soon",
+ "forYouNotAvailable": "Personalized recommendations aren't available on this server yet.",
+ "noDocumentsYet": "No documents have been discovered yet. Check back soon!",
+ "startAnnotating": "Start annotating and highlighting to get personalized recommendations.",
+ "loadMore": "Load more"
+ },
+
+ "search": {
+ "placeholder": "Search annotations, highlights, bookmarks...",
+ "noResults": "No results found",
+ "noResultsMessage": "Nothing matched \"{{query}}\". Try different keywords.",
+ "emptyTitle": "Search your library",
+ "emptyMessage": "Find annotations, highlights, and bookmarks by keyword, URL, or tag.",
+ "filters": {
+ "all": "All",
+ "annotations": "Annotations",
+ "highlights": "Highlights",
+ "bookmarks": "Bookmarks",
+ "mine": "Mine"
+ },
+ "resultCount": "{{count}}{{hasMore}} results for \"{{query}}\"",
+ "loadMore": "Load more"
+ },
+
+ "notifications": {
+ "title": "Activity",
+ "noActivity": "No activity yet",
+ "noActivityMessage": "Interactions with your content will appear here.",
+ "likedAnnotation": "liked your annotation",
+ "likedHighlight": "liked your highlight",
+ "likedBookmark": "liked your bookmark",
+ "likedReply": "liked your reply",
+ "likedPost": "liked your post",
+ "repliedToReply": "replied to your reply",
+ "repliedToAnnotation": "replied to your annotation",
+ "mentionedInAnnotation": "mentioned you in an annotation",
+ "followedYou": "followed you",
+ "highlightedPage": "highlighted your page",
+ "inReplyTo": "in reply to",
+ "aReply": "a reply",
+ "anAnnotation": "an annotation"
+ },
+
+ "collections": {
+ "title": "Collections",
+ "subtitle": "Organize your annotations and highlights",
+ "none": "No collections yet",
+ "noneMessage": "Create a collection to organize your highlights and annotations.",
+ "createButton": "Create collection",
+ "newTitle": "New Collection",
+ "editTitle": "Edit Collection",
+ "namePlaceholder": "My Collection",
+ "namePlaceholderEdit": "Collection name",
+ "nameLabel": "Name",
+ "iconLabel": "Icon",
+ "iconsTab": "Icons",
+ "emojisTab": "Emojis",
+ "selectedIcon": "Selected:",
+ "descriptionLabel": "Description (optional)",
+ "descriptionPlaceholder": "What's this collection for?",
+ "descriptionPlaceholderEdit": "What's this collection about?",
+ "cancel": "Cancel",
+ "create": "Create Collection",
+ "creating": "Creating...",
+ "save": "Save Changes",
+ "saving": "Saving...",
+ "deleteConfirm": "Delete this collection?",
+ "failedUpdate": "Failed to update collection",
+ "errorUpdating": "An error occurred while updating",
+ "itemCount_one": "{{count}} item",
+ "itemCount_other": "{{count}} items"
+ },
+
+ "collectionDetail": {
+ "backLink": "Collections",
+ "by": "by",
+ "edit": "Edit collection",
+ "delete": "Delete collection",
+ "removeFromCollection": "Remove from collection",
+ "viewInSemble": "View in Semble",
+ "empty": "Collection is empty",
+ "notFound": "Collection not found",
+ "failedToLoad": "Failed to load collection",
+ "deleteConfirm": "Delete this collection?",
+ "removeConfirm": "Remove from collection?"
+ },
+
+ "profile": {
+ "notFound": "User not found",
+ "notFoundMessage": "This profile doesn't exist or couldn't be loaded.",
+ "tabs": {
+ "all": "All",
+ "annotations": "Annotations",
+ "highlights": "Highlights",
+ "bookmarks": "Bookmarks",
+ "collections": "Collections"
+ },
+ "loading": "Loading...",
+ "noCollections": "You haven't created any collections yet.",
+ "noCollectionsTitle": "No collections",
+ "labelDescriptions": {
+ "sexual": "Sexual Content",
+ "nudity": "Nudity",
+ "violence": "Violence",
+ "gore": "Graphic Content",
+ "spam": "Spam",
+ "misleading": "Misleading"
+ },
+ "accountLabeled": "Account labeled: {{label}}",
+ "labelAppliedBy": "This label was applied by a moderation service you subscribe to.",
+ "show": "Show",
+ "hide": "Hide",
+ "blockedBy": "@{{handle}} has blocked you. You cannot interact with their content.",
+ "youBlocked": "You have blocked @{{handle}}",
+ "blockedContent": "Their content is hidden from your feeds.",
+ "unblock": "Unblock",
+ "youMuted": "You have muted @{{handle}}",
+ "unmute": "Unmute",
+ "viewInBluesky": "View profile in Bluesky",
+ "blockUser": "Block @{{handle}}",
+ "unblockUser": "Unblock @{{handle}}",
+ "muteUser": "Mute @{{handle}}",
+ "unmuteUser": "Unmute @{{handle}}",
+ "report": "Report",
+ "emptyOwn": "Your {{tab}} will show up here.",
+ "emptyOther": "Nothing to see here yet."
+ },
+
+ "login": {
+ "signInWith": "Sign in with your",
+ "handleSuffix": "handle",
+ "handlePlaceholder": "handle.margin.cafe",
+ "connecting": "Connecting...",
+ "continue": "Continue",
+ "createAccount": "Create New Account",
+ "termsPrefix": "By signing in, you agree to our",
+ "termsLink": "Terms of Service",
+ "termsAnd": "and",
+ "privacyLink": "Privacy Policy"
+ },
+
+ "signUp": {
+ "title": "Create your account",
+ "subtitle": "Margin adheres to the",
+ "atProtocol": "AT Protocol",
+ "subtitleSuffix": ". Choose a provider to host your account.",
+ "customPdsTitle": "Use a custom PDS",
+ "customPdsSubtitle": "Enter the address of the PDS hosting your account.",
+ "pdsAddressLabel": "PDS address",
+ "pdsAddressPlaceholder": "pds.example.com",
+ "connecting": "Connecting...",
+ "back": "Back",
+ "continue": "Continue",
+ "invite": "Invite",
+ "providerError": "Could not connect to this provider. Please try again.",
+ "customPdsError": "Couldn't connect to that PDS. Double-check the address.",
+ "providers": {
+ "margin": {
+ "name": "Margin",
+ "description": "The easiest way to get started"
+ },
+ "bluesky": {
+ "name": "Bluesky",
+ "description": "The largest and most popular community"
+ },
+ "blacksky": {
+ "name": "Blacksky",
+ "description": "For the Culture — a safe space for users and allies"
+ },
+ "eurosky": {
+ "name": "Eurosky",
+ "description": "Eurosky is your European home on the Atmosphere"
+ },
+ "selfhostedSocial": {
+ "name": "selfhosted.social",
+ "description": "A home for builders, tinkerers, and the curious"
+ },
+ "northsky": {
+ "name": "Northsky",
+ "description": "A Canadian worker-owned cooperative"
+ },
+ "tophhie": {
+ "name": "Tophhie",
+ "description": "A welcoming and friendly community"
+ },
+ "customPds": {
+ "name": "Use a custom PDS",
+ "description": "Already have a PDS? Enter its address."
+ }
+ }
+ },
+
+ "composer": {
+ "newHighlight": "New highlight",
+ "newAnnotation": "New annotation",
+ "newNote": "New note",
+ "saveHighlight": "Save highlight",
+ "postAnnotation": "Post annotation",
+ "postNote": "Post note",
+ "highlightHint": "Saving a passage without a comment. Add text below to turn it into an annotation.",
+ "addQuote": "+ Add a quote from the page",
+ "quotePlaceholder": "Paste or type the text you're annotating...",
+ "removeQuote": "Remove Quote",
+ "thoughtsPlaceholder": "Add your thoughts on this passage...",
+ "mindPlaceholder": "What's on your mind?",
+ "tagsPlaceholder": "Add tags...",
+ "contentWarning": "Content Warning",
+ "contentWarningCount": "Content Warning ({{count}})",
+ "cancel": "Cancel",
+ "failedToPost": "Failed to post",
+ "labels": {
+ "sexual": "Sexual",
+ "nudity": "Nudity",
+ "violence": "Violence",
+ "gore": "Gore",
+ "spam": "Spam",
+ "misleading": "Misleading"
+ }
+ },
+
+ "card": {
+ "addedTo": "Added to",
+ "addedToLower": "added to",
+ "and": "and",
+ "communityBookmark": "Community bookmark",
+ "openInSemble": "Open in Semble",
+ "deleteConfirm": "Delete this item?",
+ "hideContent": "Hide Content",
+ "show": "Show",
+ "edited": "(edited)",
+ "annotate": "Annotate",
+ "untitledBookmark": "Untitled Bookmark",
+ "addNotePlaceholder": "Add your note to convert this highlight into an annotation...",
+ "addToCollectionTitle": "Add to Collection",
+ "annotateTitle": "Annotate this highlight",
+ "editTitle": "Edit",
+ "deleteTitle": "Delete",
+ "report": "Report",
+ "muteUser": "Mute @{{handle}}",
+ "blockUser": "Block @{{handle}}",
+ "convertToAnnotation": "Convert to annotation",
+ "justNow": "just now",
+ "labelDescriptions": {
+ "sexual": "Sexual Content",
+ "nudity": "Nudity",
+ "violence": "Violence",
+ "gore": "Graphic Content",
+ "spam": "Spam",
+ "misleading": "Misleading"
+ }
+ },
+
+ "profileHoverCard": {
+ "viewProfile": "View Profile",
+ "notFound": "Profile not found"
+ },
+
+ "replyList": {
+ "noReplies": "No replies yet"
+ },
+
+ "shareMenu": {
+ "sembleIntegration": "Semble Integration",
+ "openOnSemble": "Open on Semble",
+ "copySembleLink": "Copy Semble Link",
+ "copyLink": "Copy Link",
+ "shareViaApp": "Share via App",
+ "copyUniversalLink": "Copy Universal Link",
+ "moreOptions": "More Options...",
+ "copied": "Copied!"
+ },
+
+ "addToCollection": {
+ "title": "Add to Collection",
+ "loading": "Loading collections...",
+ "collectionNameLabel": "Collection name",
+ "namePlaceholder": "My Collection",
+ "descriptionLabel": "Description (optional)",
+ "descriptionPlaceholder": "What's this collection about?",
+ "iconLabel": "Icon",
+ "iconsTab": "Icons",
+ "emojisTab": "Emojis",
+ "selected": "Selected:",
+ "back": "Back",
+ "create": "Create",
+ "creating": "Creating...",
+ "newCollectionButton": "New Collection",
+ "createNewDescription": "Create a new collection",
+ "none": "No collections yet",
+ "done": "Done",
+ "failedLoad": "Failed to load collections",
+ "failedAdd": "Failed to add to collection",
+ "failedCreate": "Failed to create collection"
+ },
+
+ "editItem": {
+ "editAnnotation": "Edit Annotation",
+ "editHighlight": "Edit Highlight",
+ "editBookmark": "Edit Bookmark",
+ "textLabel": "Text",
+ "textPlaceholder": "Write your annotation...",
+ "colorLabel": "Color",
+ "tagsLabel": "Tags",
+ "tagPlaceholder": "Add a tag...",
+ "contentWarning": "Content Warning",
+ "cancel": "Cancel",
+ "save": "Save",
+ "saving": "Saving...",
+ "failedSave": "Failed to save changes. Please try again.",
+ "titleLabel": "Title",
+ "titlePlaceholder": "Bookmark title",
+ "descriptionLabel": "Description",
+ "descriptionPlaceholder": "Optional description..."
+ },
+
+ "editCollection": {
+ "title": "Edit Collection",
+ "nameLabel": "Collection name",
+ "namePlaceholder": "My Collection",
+ "descriptionLabel": "Description (optional)",
+ "descriptionPlaceholder": "What's this collection about?",
+ "iconLabel": "Icon",
+ "iconsTab": "Icons",
+ "emojisTab": "Emojis",
+ "selected": "Selected:",
+ "cancel": "Cancel",
+ "save": "Save Changes",
+ "saving": "Saving...",
+ "failedUpdate": "Failed to update collection",
+ "errorUpdating": "An error occurred while updating"
+ },
+
+ "externalLink": {
+ "title": "Leaving Margin",
+ "message": "You're about to visit an external site.",
+ "alwaysAllow": "Always allow links to {{hostname}}",
+ "cancel": "Cancel",
+ "open": "Open Link"
+ },
+
+ "report": {
+ "submitted": "Report submitted",
+ "submittedMessage": "Thank you. We'll review this shortly.",
+ "titleUser": "Report @{{handle}}",
+ "titleGeneric": "Report user",
+ "reportingContent": "Reporting specific content",
+ "issueLabel": "What's the issue?",
+ "cancel": "Cancel",
+ "submit": "Submit Report",
+ "submitting": "Submitting…",
+ "detailsPlaceholder": "Additional details (optional)",
+ "reasons": {
+ "spam": "Spam",
+ "ruleViolation": "Rule violation",
+ "misleading": "Misleading",
+ "rudeOrHarassing": "Rude or harassing",
+ "inappropriateContent": "Inappropriate content",
+ "other": "Other"
+ }
+ },
+
+ "editProfile": {
+ "title": "Edit Profile",
+ "avatarLabel": "Avatar",
+ "uploadButton": "Upload",
+ "uploading": "Uploading...",
+ "displayNameLabel": "Display Name",
+ "bioLabel": "Bio",
+ "websiteLabel": "Website",
+ "linksLabel": "Links",
+ "addLinkPlaceholder": "Add a link...",
+ "cancel": "Cancel",
+ "save": "Save",
+ "saving": "Saving...",
+ "avatarTypeError": "Please select a JPEG or PNG image",
+ "avatarSizeError": "Image must be under 2MB",
+ "avatarUploadError": "Failed to upload: {{message}}"
+ },
+
+ "iosShortcut": {
+ "title": "Save from iOS Safari",
+ "howTo": "How to use the shortcut",
+ "step1Title": "Install the shortcut",
+ "step1Link": "Get iOS Shortcut",
+ "step2Title": "Generate an API Key",
+ "step2Description": "Create a new key on this settings page and copy it.",
+ "step3Title": "Configure the shortcut",
+ "step3Description": "In the Shortcuts app, click the menu on the Save to Margin shortcut, and paste your API key in the Text action right below the setup comment.",
+ "step4Title": "To Bookmark a page",
+ "step4Description": "Don't select any text. Click the menu in Safari, press Share, and select Save to Margin.",
+ "step5Title": "To Highlight text",
+ "step5Description": "Select text on the page, click the menu, press Share, and select Save to Margin. Leave the Note field empty.",
+ "step6Title": "To Add an Annotation",
+ "step6Description": "Select text, share to Save to Margin (via the menu), enter your custom note in the Note field, and press Done!",
+ "gotIt": "Got it"
+ },
+
+ "editHistory": {
+ "title": "Edit History",
+ "noHistory": "No edit history found.",
+ "currentVersion": "Current Version",
+ "previousVersion": "Previous Version",
+ "editedAgo": "Edited {{time}} ago",
+ "postedAgo": "Posted {{time}} ago",
+ "timeAgo": "{{time}} ago",
+ "close": "Close",
+ "failedLoad": "Failed to load edit history"
+ },
+
+ "settings": {
+ "sections": {
+ "profile": "Profile",
+ "appearance": "Appearance",
+ "batchImport": "Batch Import Highlights",
+ "apiKeys": "API Keys",
+ "moderation": "Moderation",
+ "contentFiltering": "Content Filtering",
+ "iosShortcut": "iOS Shortcut"
+ },
+ "apiKeys": {
+ "noKeys": "No API keys yet. Create one to use with the browser extension.",
+ "forIos": "For the iOS shortcut and other apps",
+ "namePlaceholder": "Key name, e.g. iOS Shortcut",
+ "created": "Created {{date}}",
+ "revokeConfirm": "Revoke this key? Apps using it will stop working.",
+ "copyNow": "Copy now - you won't see this again!"
+ },
+ "moderation": {
+ "blockedAccounts": "Blocked accounts ({{count}})",
+ "mutedAccounts": "Muted accounts ({{count}})",
+ "noBlocked": "No blocked accounts",
+ "noMuted": "No muted accounts",
+ "unblock": "Unblock",
+ "unmute": "Unmute",
+ "remove": "Remove",
+ "add": "Add",
+ "noLabelers": "No labelers subscribed",
+ "labelerPlaceholder": "did:plc:... (labeler DID)"
+ },
+ "contentFiltering": {
+ "warn": "Warn",
+ "hide": "Hide",
+ "ignore": "Ignore"
+ },
+ "ios": {
+ "description": "Save pages to Margin from Safari on iPhone and iPad",
+ "setupButton": "Setup iOS Shortcut"
+ },
+ "logOut": "Log out"
+ },
+
+ "new": {
+ "signInRequired": "Sign in to create",
+ "needsAccount": "You need a Bluesky account",
+ "signInButton": "Sign in with Bluesky",
+ "composeTitle": "Compose",
+ "composeTagline": "Highlight a passage, leave a note, or annotate a page — all from here.",
+ "urlLabel": "URL to annotate",
+ "urlPlaceholder": "https://example.com/article"
+ },
+
+ "annotationDetail": {
+ "back": "Back",
+ "replies": "Replies ({{count}})",
+ "replyingTo": "Replying to",
+ "replyPlaceholder": "Write a reply...",
+ "reply": "Reply",
+ "signInToReply": "Sign in to reply",
+ "logIn": "Log in",
+ "notFound": "Not found",
+ "mayBeDeleted": "This may have been deleted.",
+ "backToFeed": "Back to Feed",
+ "deleteReplyConfirm": "Delete this reply?",
+ "failedReply": "Failed to post reply: {{message}}",
+ "failedDelete": "Failed to delete: {{message}}",
+ "failedResolve": "Failed to resolve handle: {{message}}"
+ },
+
+ "urlPage": {
+ "title": "URL Annotations",
+ "description": "Enter a URL to see all public annotations and highlights from the Margin community.",
+ "urlPlaceholder": "https://example.com/article",
+ "view": "View",
+ "myAnnotations": "My Annotations",
+ "share": "Share",
+ "copied": "Copied!",
+ "contributor_one": "{{count}} contributor",
+ "contributor_other": "{{count}} contributors",
+ "loadingAnnotations": "Loading annotations...",
+ "blankCanvas": "This page is a blank canvas",
+ "blankCanvasMessage": "No one's left notes here yet. Want to be the first? Grab the Margin extension and share what you're thinking.",
+ "tabs": {
+ "all": "All",
+ "annotations": "Annotations",
+ "highlights": "Highlights",
+ "bookmarks": "Bookmarks",
+ "collections": "Collections"
+ },
+ "noAnnotationsYet": "No annotations yet",
+ "noAnnotationsMessage": "Nobody has left a written note on this page.",
+ "noHighlightsYet": "No highlights yet",
+ "noHighlightsMessage": "Nobody has highlighted a passage from this page.",
+ "loadMore": "Load more",
+ "loading": "Loading...",
+ "failedLoadMore": "Failed to load more: {{message}}"
+ },
+
+ "userUrlPage": {
+ "on": "on",
+ "loadingAnnotations": "Loading annotations...",
+ "noUrl": "No URL specified",
+ "noUrlMessage": "Please provide a URL to view annotations.",
+ "noItems": "No items found",
+ "noItemsMessage": "{{name}} hasn't annotated this page yet.",
+ "noAnnotations": "No annotations",
+ "noHighlights": "No highlights",
+ "loadMore": "Load more",
+ "loading": "Loading...",
+ "failedLoadMore": "Failed to load more: {{message}}"
+ },
+
+ "settings": {
+ "title": "Settings",
+ "sections": {
+ "profile": "Profile",
+ "appearance": "Appearance",
+ "language": "Language",
+ "batchImport": "Batch Import Highlights",
+ "apiKeys": "API Keys",
+ "moderation": "Moderation",
+ "contentFiltering": "Content Filtering",
+ "iosShortcut": "iOS Shortcut"
+ },
+ "language": {
+ "label": "Interface Language",
+ "description": "Choose the language for the Margin interface."
+ },
+ "appearance": {
+ "disableExternalLinkWarning": "Disable external link warning",
+ "disableExternalLinkWarningDesc": "Don't ask for confirmation when opening external links",
+ "communityBookmarks": "Share bookmarks to community feed",
+ "communityBookmarksDesc": "Your saved bookmarks will appear in the community bookmarks feed"
+ },
+ "batchImport": {
+ "description": "Upload highlights from CSV. Required: url, text. Optional: title, tags, color, created_at"
+ },
+ "apiKeys": {
+ "description": "For the iOS shortcut and other apps",
+ "keyNamePlaceholder": "Key name, e.g. iOS Shortcut",
+ "generate": "Generate",
+ "copyNow": "Copy now - you won't see this again!",
+ "empty": "No API keys yet. Create one to use with the browser extension.",
+ "created": "Created {{date}}",
+ "revokeConfirm": "Revoke this key? Apps using it will stop working."
+ },
+ "moderation": {
+ "description": "Manage blocked and muted accounts",
+ "blockedAccounts": "Blocked accounts ({{count}})",
+ "noBlocked": "No blocked accounts",
+ "unblock": "Unblock",
+ "mutedAccounts": "Muted accounts ({{count}})",
+ "noMuted": "No muted accounts",
+ "unmute": "Unmute"
+ },
+ "contentFiltering": {
+ "description": "Subscribe to labelers and configure how labeled content appears",
+ "subscribedLabelers": "Subscribed Labelers",
+ "noLabelers": "No labelers subscribed",
+ "labelerDidPlaceholder": "did:plc:... (labeler DID)",
+ "remove": "Remove",
+ "add": "Add",
+ "labelVisibility": "Label Visibility",
+ "labelVisibilityDesc": "Choose how to handle each label type: Warn shows a blur overlay, Hide removes content entirely, Ignore shows content normally.",
+ "warn": "Warn",
+ "hide": "Hide",
+ "ignore": "Ignore"
+ },
+ "iosShortcut": {
+ "description": "Save pages to Margin from Safari on iPhone and iPad",
+ "setupButton": "Setup iOS Shortcut"
+ },
+ "logout": "Log out"
+ },
+
+ "adminModeration": {
+ "accessDenied": "Access Denied",
+ "accessDeniedMessage": "You don't have permission to access the moderation dashboard.",
+ "title": "Moderation",
+ "stats": "{{pending}} pending · {{total}} total reports",
+ "tabs": {
+ "reports": "Reports",
+ "actions": "Actions",
+ "labels": "Labels"
+ },
+ "filters": {
+ "all": "All",
+ "pending": "Pending",
+ "resolved": "Resolved",
+ "dismissed": "Dismissed",
+ "escalated": "Escalated"
+ },
+ "reports": {
+ "empty": "No reports",
+ "emptyPending": "No pending reports to review.",
+ "emptyFiltered": "No {{status}} reports found.",
+ "reportedUser": "Reported User",
+ "reporter": "Reporter",
+ "details": "Details",
+ "contentUri": "Content URI",
+ "acknowledge": "Acknowledge",
+ "dismiss": "Dismiss",
+ "takedown": "Takedown"
+ },
+ "reasons": {
+ "spam": "Spam",
+ "violation": "Rule Violation",
+ "misleading": "Misleading",
+ "sexual": "Inappropriate",
+ "rude": "Rude / Harassing",
+ "other": "Other"
+ },
+ "actions": {
+ "applyWarning": "Apply Content Warning",
+ "applyWarningDesc": "Add a content warning label to a specific post or account. Users will see a blur overlay with the option to reveal.",
+ "accountDid": "Account DID",
+ "contentUri": "Content URI",
+ "contentUriOptional": "optional — leave empty for account-level label",
+ "labelType": "Label Type",
+ "applyLabel": "Apply Label",
+ "labelApplied": "Label applied"
+ },
+ "labels": {
+ "empty": "No labels",
+ "emptyMessage": "No content labels have been applied yet.",
+ "accountLevel": "Account-level label",
+ "removeConfirm": "Remove this label?",
+ "removeTitle": "Remove label"
+ }
+ },
+
+ "highlightImporter": {
+ "clickToUpload": "Click to upload CSV",
+ "processing": "Processing...",
+ "requiredColumns": "Required columns: url, text | Optional: title, tags, color, created_at",
+ "downloadTemplate": "Download Template",
+ "importProgress": "Import Progress",
+ "complete": "{{rate}}% complete",
+ "failed_one": "{{count}} failed",
+ "failed_other": "{{count}} failed",
+ "importing": "Importing highlights...",
+ "success": "Successfully imported {{count}} highlights!",
+ "errorsTitle": "{{count}} errors during import",
+ "row": "Row {{row}}: {{error}}",
+ "moreErrors": "+{{count}} more errors",
+ "importAnother": "Import Another File",
+ "noHighlights": "No valid highlights found in CSV",
+ "csvMustHaveUrl": "CSV must have a 'url' column",
+ "csvMustHaveText": "CSV must have a 'text' column (also matches: highlight, excerpt)",
+ "errorParsing": "Error parsing CSV: {{message}}"
+ },
+
+ "profile": {
+ "notFound": "User not found",
+ "notFoundMessage": "This profile doesn't exist or couldn't be loaded.",
+ "edit": "Edit",
+ "viewInBluesky": "View profile in Bluesky",
+ "unblock": "Unblock @{{handle}}",
+ "block": "Block @{{handle}}",
+ "unmute": "Unmute @{{handle}}",
+ "mute": "Mute @{{handle}}",
+ "report": "Report",
+ "accountLabeled": "Account labeled: {{description}}",
+ "labelApplied": "This label was applied by a moderation service you subscribe to.",
+ "show": "Show",
+ "hide": "Hide",
+ "blockedBanner": "You have blocked @{{handle}}",
+ "blockedMessage": "Their content is hidden from your feeds.",
+ "mutedBanner": "You have muted @{{handle}}",
+ "mutedMessage": "Their content is hidden from your feeds.",
+ "blockedByBanner": "@{{handle}} has blocked you. You cannot interact with their content.",
+ "unblock_action": "Unblock",
+ "unmute_action": "Unmute",
+ "emptyCollectionsOwn": "You haven't created any collections yet.",
+ "emptyCollectionsOther": "No collections",
+ "itemCount_one": "{{count}} item",
+ "itemCount_other": "{{count}} items",
+ "emptyTabOwn": "Your {{tab}} will show up here.",
+ "emptyTabOther": "Nothing to see here yet."
+ },
+
+ "common": {
+ "loading": "Loading...",
+ "cancel": "Cancel",
+ "save": "Save",
+ "close": "Close",
+ "back": "Back",
+ "continue": "Continue",
+ "error": "Error",
+ "retry": "Retry",
+ "loadMore": "Load more",
+ "new": "New"
+ },
+
+ "about": {
+ "nav": {
+ "getExtension": "Get Extension",
+ "install": "Install"
+ },
+ "hero": {
+ "openSource": "Fully open source",
+ "headline": "Write on the margins",
+ "headlineAccent": "of the internet.",
+ "descriptionPre": "Margin is an open annotation layer for the internet. Highlight text, leave notes, and bookmark pages, all stored on your decentralized identity with the",
+ "atProtocol": "AT Protocol",
+ "descriptionPost": ". Not locked in a silo.",
+ "openApp": "Open App",
+ "getStarted": "Get Started",
+ "installFor": "Install for {{browser}}"
+ },
+ "features": {
+ "title": "Everything you need to engage with the web",
+ "subtitle": "More than bookmarks. A full toolkit for reading, thinking, and sharing on the open web.",
+ "annotations": {
+ "title": "Annotations",
+ "description": "Leave notes on any web page. Start discussions, share insights, or just jot down your thoughts for later."
+ },
+ "highlights": {
+ "title": "Highlights",
+ "description": "Select and highlight text on any page with customizable colors. Your highlights are rendered inline with the CSS Highlights API."
+ },
+ "bookmarks": {
+ "title": "Bookmarks",
+ "description": "Save pages with one click or a keyboard shortcut. All your bookmarks are synced to your AT Protocol identity."
+ },
+ "collections": {
+ "title": "Collections",
+ "description": "Organize your annotations, highlights, and bookmarks into themed collections. Share them publicly or keep them private."
+ },
+ "socialDiscovery": {
+ "title": "Social Discovery",
+ "description": "See what others are saying about the pages you visit. Discover annotations, trending tags, and connect with other readers."
+ },
+ "tagsSearch": {
+ "title": "Tags & Search",
+ "description": "Tag your annotations for easy retrieval. Search by URL, tag, or content to find exactly what you're looking for."
+ }
+ },
+ "extension": {
+ "badge": "Browser Extension",
+ "title": "Your annotation toolkit,",
+ "titleLine2": "right in the browser",
+ "description": "The Margin extension brings the full annotation experience directly into every page you visit. Just select, annotate, and go.",
+ "iosShortcut": "iOS Shortcut",
+ "features": {
+ "inlineOverlay": {
+ "title": "Inline Overlay",
+ "description": "See annotations and highlights rendered directly on the page. Uses the CSS Highlights API for beautiful, native-feeling text underlines."
+ },
+ "contextMenu": {
+ "title": "Context Menu & Selection",
+ "description": "Right-click any selected text to annotate, highlight, or quote it. Or just right-click the page to bookmark it instantly."
+ },
+ "keyboard": {
+ "title": "Keyboard Shortcuts",
+ "description": "Toggle the overlay, bookmark the current page, or annotate selected text without reaching for the mouse."
+ },
+ "sidePanel": {
+ "title": "Side Panel",
+ "description": "Open the Margin side panel to browse annotations, bookmarks, and collections without leaving the page you're reading."
+ }
+ }
+ },
+ "protocol": {
+ "badge": "Decentralized",
+ "title": "Your data, your identity",
+ "descriptionPre": "Margin is built on the",
+ "descriptionPost": ", the open protocol that powers apps like Bluesky. Your annotations, highlights, and bookmarks are stored in your personal data repository, not locked in a silo.",
+ "point0": "Sign in with your AT Protocol handle, no new account needed",
+ "point1": "Your data lives in your PDS, portable and under your control",
+ "point2": "Custom Lexicon schemas for annotations, highlights, collections & more",
+ "point3": "Fully open source, check out the code and contribute"
+ },
+ "cta": {
+ "title": "Start writing on the margins",
+ "description": "Join the open annotation layer. Sign in with your AT Protocol identity and install the extension to get started.",
+ "signIn": "Sign in",
+ "viewGitHub": "View on GitHub",
+ "viewTangled": "View on Tangled"
+ },
+ "footer": {
+ "privacy": "Privacy",
+ "terms": "Terms",
+ "contact": "Contact"
+ }
+ }
+}
diff --git a/web/src/components/common/Card.tsx b/web/src/components/common/Card.tsx
index ca90f45..ffee1fc 100644
--- a/web/src/components/common/Card.tsx
+++ b/web/src/components/common/Card.tsx
@@ -1,5 +1,6 @@
import React, { useState } from "react";
import { formatDistanceToNow } from "date-fns";
+import { useTranslation } from "react-i18next";
import RichText from "./RichText";
import MoreMenu from "./MoreMenu";
import type { MoreMenuItem } from "./MoreMenu";
@@ -119,6 +120,7 @@ export default function Card({
hideCollection = false,
layout = "list",
}: CardProps) {
+ const { t } = useTranslation();
const [item, setItem] = useState(initialItem);
const user = useStore($user);
const preferences = useStore($preferences);
@@ -229,7 +231,7 @@ export default function Card({
};
const handleDelete = async () => {
- if (window.confirm("Delete this item?")) {
+ if (window.confirm(t("card.deleteConfirm"))) {
const success = await deleteItem(item.uri, type);
if (success && onDelete) {
analytics.capture("item_deleted", { type });
@@ -297,7 +299,7 @@ export default function Card({
const timestamp = item.createdAt
? formatDistanceToNow(new Date(item.createdAt), { addSuffix: false })
- .replace("less than a minute", "just now")
+ .replace("less than a minute", t("card.justNow"))
.replace("about ", "")
.replace(" hours", "h")
.replace(" hour", "h")
@@ -347,7 +349,7 @@ export default function Card({
};
const displayTitle = decodeHTMLEntities(
- item.title || ogData?.title || pageTitle || "Untitled Bookmark",
+ item.title || ogData?.title || pageTitle || t("card.untitledBookmark"),
);
const displayDescription =
item.description || ogData?.description
@@ -377,10 +379,10 @@ export default function Card({
- added to
+ {t("card.addedToLower")}
>
) : (
- Added to
+ {t("card.addedTo")}
)}
{item.context && item.context.length > 0 ? (
@@ -392,7 +394,7 @@ export default function Card({
)}
{index > 0 && index === item.context!.length - 1 && (
- and
+ {t("card.and")}
)}
- (edited)
+ {t("card.edited")}
)}
@@ -495,7 +497,7 @@ export default function Card({
className="h-3.5"
/>
- Open in Semble
+ {t("card.openInSemble")}
@@ -514,7 +516,7 @@ export default function Card({
className="text-surface-400 dark:text-surface-500 fill-current"
/>
- Community bookmark
+ {t("card.communityBookmark")}
@@ -548,7 +550,9 @@ export default function Card({
- {contentWarning.description}
+ {t(`card.labelDescriptions.${contentWarning.label}`, {
+ defaultValue: contentWarning.description,
+ })}
- Show
+ {t("card.show")}
)}
@@ -566,7 +570,7 @@ export default function Card({
className="flex items-center gap-1.5 mb-2 px-2.5 py-1 text-xs font-medium rounded-lg bg-surface-100 dark:bg-surface-800 text-surface-500 dark:text-surface-400 hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors"
>
- Hide Content
+ {t("card.hideContent")}
)}
{!(contentWarning && !contentRevealed) && isBookmark && (
@@ -741,7 +745,7 @@ export default function Card({
setShowCollectionModal(true)}
className="flex items-center px-2.5 py-1.5 rounded-lg text-surface-400 dark:text-surface-500 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-all"
- title="Add to Collection"
+ title={t("card.addToCollectionTitle")}
>
@@ -764,23 +768,23 @@ export default function Card({
setShowConvertInput(true)}
className="flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-surface-400 dark:text-surface-500 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-all text-xs font-medium"
- title="Annotate this highlight"
+ title={t("card.annotateTitle")}
>
- Annotate
+ {t("card.annotate")}
)}
setShowEditModal(true)}
className="flex items-center px-2.5 py-1.5 rounded-lg text-surface-400 dark:text-surface-500 hover:text-surface-600 dark:hover:text-surface-300 hover:bg-surface-50 dark:hover:bg-surface-800 transition-all"
- title="Edit"
+ title={t("card.editTitle")}
>
@@ -794,13 +798,15 @@ export default function Card({
items={(() => {
const menuItems: MoreMenuItem[] = [
{
- label: "Report",
+ label: t("card.report"),
icon: ,
onClick: () => setShowReportModal(true),
variant: "danger",
},
{
- label: `Mute @${item.author?.handle || "user"}`,
+ label: t("card.muteUser", {
+ handle: item.author?.handle || "user",
+ }),
icon: ,
onClick: async () => {
if (item.author?.did) {
@@ -810,7 +816,9 @@ export default function Card({
},
},
{
- label: `Block @${item.author?.handle || "user"}`,
+ label: t("card.blockUser", {
+ handle: item.author?.handle || "user",
+ }),
icon: ,
onClick: async () => {
if (item.author?.did) {
@@ -839,7 +847,7 @@ export default function Card({
) : (
- Profile not found
+ {t("profileHoverCard.notFound")}
)}
diff --git a/web/src/components/feed/Composer.tsx b/web/src/components/feed/Composer.tsx
index 73e568d..8cda17a 100644
--- a/web/src/components/feed/Composer.tsx
+++ b/web/src/components/feed/Composer.tsx
@@ -1,4 +1,5 @@
import React, { useState, useEffect } from "react";
+import { useTranslation } from "react-i18next";
import {
createAnnotation,
createHighlight,
@@ -11,13 +12,13 @@ import { X, ShieldAlert, Highlighter, PenLine } from "lucide-react";
import TagInput from "../ui/TagInput";
import { analytics } from "../../lib/analytics";
-const SELF_LABEL_OPTIONS: { value: ContentLabelValue; label: string }[] = [
- { value: "sexual", label: "Sexual" },
- { value: "nudity", label: "Nudity" },
- { value: "violence", label: "Violence" },
- { value: "gore", label: "Gore" },
- { value: "spam", label: "Spam" },
- { value: "misleading", label: "Misleading" },
+const SELF_LABEL_VALUES: ContentLabelValue[] = [
+ "sexual",
+ "nudity",
+ "violence",
+ "gore",
+ "spam",
+ "misleading",
];
interface ComposerProps {
@@ -33,6 +34,7 @@ export default function Composer({
onSuccess,
onCancel,
}: ComposerProps) {
+ const { t } = useTranslation();
const [text, setText] = useState("");
const [quoteText, setQuoteText] = useState("");
const [tags, setTags] = useState([]);
@@ -76,21 +78,21 @@ export default function Composer({
const modeCopy = {
highlight: {
- title: "New highlight",
+ title: t("composer.newHighlight"),
icon: Highlighter,
- submit: "Save highlight",
- hint: "Saving a passage without a comment. Add text below to turn it into an annotation.",
+ submit: t("composer.saveHighlight"),
+ hint: t("composer.highlightHint"),
},
annotation: {
- title: "New annotation",
+ title: t("composer.newAnnotation"),
icon: PenLine,
- submit: "Post annotation",
+ submit: t("composer.postAnnotation"),
hint: null,
},
note: {
- title: "New note",
+ title: t("composer.newNote"),
icon: PenLine,
- submit: "Post note",
+ submit: t("composer.postNote"),
hint: null,
},
}[mode];
@@ -221,14 +223,14 @@ export default function Composer({
className="text-left text-sm text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-medium py-1"
onClick={() => setShowQuoteInput(true)}
>
- + Add a quote from the page
+ {t("composer.addQuote")}
) : (
@@ -251,8 +253,8 @@ export default function Composer({
onChange={(e) => setText(e.target.value)}
placeholder={
hasQuote
- ? "Add your thoughts on this passage..."
- : "What's on your mind?"
+ ? t("composer.thoughtsPlaceholder")
+ : t("composer.mindPlaceholder")
}
className="w-full p-3 bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-lg text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 dark:focus:border-primary-400 outline-none min-h-[100px] resize-none"
maxLength={3000}
@@ -263,7 +265,7 @@ export default function Composer({
tags={tags}
onChange={setTags}
suggestions={tagSuggestions}
- placeholder="Add tags..."
+ placeholder={t("composer.tagsPlaceholder")}
disabled={loading}
/>
@@ -275,31 +277,32 @@ export default function Composer({
>
- Content Warning
- {selfLabels.length > 0 ? ` (${selfLabels.length})` : ""}
+ {selfLabels.length > 0
+ ? t("composer.contentWarningCount", { count: selfLabels.length })
+ : t("composer.contentWarning")}
{showLabelPicker && (
- {SELF_LABEL_OPTIONS.map((opt) => (
+ {SELF_LABEL_VALUES.map((value) => (
setSelfLabels((prev) =>
- prev.includes(opt.value)
- ? prev.filter((v) => v !== opt.value)
- : [...prev, opt.value],
+ prev.includes(value)
+ ? prev.filter((v) => v !== value)
+ : [...prev, value],
)
}
className={`px-2.5 py-1 text-xs font-medium rounded-lg transition-all ${
- selfLabels.includes(opt.value)
+ selfLabels.includes(value)
? "bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 ring-1 ring-amber-300 dark:ring-amber-700"
: "bg-surface-100 dark:bg-surface-800 text-surface-500 dark:text-surface-400 hover:bg-surface-200 dark:hover:bg-surface-700"
}`}
>
- {opt.label}
+ {t(`composer.labels.${value}`)}
))}
@@ -324,7 +327,7 @@ export default function Composer({
onClick={onCancel}
disabled={loading}
>
- Cancel
+ {t("composer.cancel")}
)}
- {loading ? "..." : modeCopy.submit}
+ {loading ? "…" : modeCopy.submit}
diff --git a/web/src/components/feed/FeedItems.tsx b/web/src/components/feed/FeedItems.tsx
index ea6be4e..14ef191 100644
--- a/web/src/components/feed/FeedItems.tsx
+++ b/web/src/components/feed/FeedItems.tsx
@@ -1,5 +1,6 @@
import { Clock, Loader2 } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
import { type GetFeedParams, getFeed } from "../../api/client";
import Card from "../../components/common/Card";
import { EmptyState } from "../../components/ui";
@@ -38,6 +39,7 @@ export default function FeedItems({
initialItems,
initialHasMore,
}: FeedItemsProps) {
+ const { t } = useTranslation();
const [items, setItems] = useState(initialItems || []);
const [loading, setLoading] = useState(!initialItems);
const [loadingMore, setLoadingMore] = useState(false);
@@ -169,7 +171,7 @@ export default function FeedItems({
size={32}
/>
- Loading...
+ {t("feed.loading")}
);
@@ -179,7 +181,7 @@ export default function FeedItems({
return (
}
- title="Nothing here yet"
+ title={t("feed.nothingHereYet")}
message={emptyMessage}
/>
);
@@ -196,10 +198,10 @@ export default function FeedItems({
{loadingMore ? (
<>
- Loading...
+ {t("common.loading")}
>
) : (
- "Load more"
+ t("common.loadMore")
)}
diff --git a/web/src/components/feed/ReplyList.tsx b/web/src/components/feed/ReplyList.tsx
index 9b68d51..3afb4b7 100644
--- a/web/src/components/feed/ReplyList.tsx
+++ b/web/src/components/feed/ReplyList.tsx
@@ -1,5 +1,6 @@
import React from "react";
import { formatDistanceToNow } from "date-fns";
+import { useTranslation } from "react-i18next";
import { MessageSquare, Trash2, Reply } from "lucide-react";
import type { AnnotationItem, UserProfile } from "../../types";
import { getAvatarUrl } from "../../api/client";
@@ -196,11 +197,12 @@ export default function ReplyList({
onDelete,
isInline = false,
}: ReplyListProps) {
+ const { t } = useTranslation();
if (!replies || replies.length === 0) {
return (
- No replies yet
+ {t("replyList.noReplies")}
);
diff --git a/web/src/components/modals/AddToCollectionModal.tsx b/web/src/components/modals/AddToCollectionModal.tsx
index 11da726..e4e2874 100644
--- a/web/src/components/modals/AddToCollectionModal.tsx
+++ b/web/src/components/modals/AddToCollectionModal.tsx
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useCallback } from "react";
+import { useTranslation } from "react-i18next";
import {
X,
Plus,
@@ -34,6 +35,7 @@ export default function AddToCollectionModal({
onClose,
annotationUri,
}: AddToCollectionModalProps) {
+ const { t } = useTranslation();
const user = useStore($user);
const theme = useStore($theme);
const [collections, setCollections] = useState([]);
@@ -66,11 +68,11 @@ export default function AddToCollectionModal({
setCollections(data);
} catch (err) {
console.error(err);
- setError("Failed to load collections");
+ setError(t("addToCollection.failedLoad"));
} finally {
setLoading(false);
}
- }, [user]);
+ }, [user, t]);
useEffect(() => {
if (isOpen && user) {
@@ -92,7 +94,7 @@ export default function AddToCollectionModal({
analytics.capture("item_added_to_collection");
} catch (err) {
console.error(err);
- setError("Failed to add to collection");
+ setError(t("addToCollection.failedAdd"));
} finally {
setAddingTo(null);
}
@@ -122,7 +124,7 @@ export default function AddToCollectionModal({
}
} catch (err) {
console.error(err);
- setError("Failed to create collection");
+ setError(t("addToCollection.failedCreate"));
} finally {
setCreating(false);
}
@@ -141,7 +143,7 @@ export default function AddToCollectionModal({
>
- Add to Collection
+ {t("addToCollection.title")}
- Loading collections...
+ {t("addToCollection.loading")}
) : showNewForm ? (
@@ -337,10 +341,10 @@ export default function AddToCollectionModal({
- New Collection
+ {t("addToCollection.newCollectionButton")}
- Create a new collection
+ {t("addToCollection.createNewDescription")}
- No collections yet
+ {t("addToCollection.none")}
) : (
@@ -404,7 +408,7 @@ export default function AddToCollectionModal({
onClick={onClose}
className="w-full mt-4 py-3 bg-surface-900 dark:bg-white text-white dark:text-surface-900 font-semibold rounded-xl hover:bg-surface-800 dark:hover:bg-surface-100 transition-colors"
>
- Done
+ {t("addToCollection.done")}
)}
diff --git a/web/src/components/modals/EditCollectionModal.tsx b/web/src/components/modals/EditCollectionModal.tsx
index 989fd7c..bb68059 100644
--- a/web/src/components/modals/EditCollectionModal.tsx
+++ b/web/src/components/modals/EditCollectionModal.tsx
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from "react";
import { X, Loader2 } from "lucide-react";
+import { useTranslation } from "react-i18next";
import CollectionIcon from "../common/CollectionIcon";
import { ICON_MAP } from "../common/iconMap";
import { Theme } from "emoji-picker-react";
@@ -30,6 +31,7 @@ export default function EditCollectionModal({
initialIsIcon || !collection.icon ? "icon" : "emoji",
);
const [icon, setIcon] = useState(initialIconValue);
+ const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const theme = useStore($theme);
@@ -74,11 +76,11 @@ export default function EditCollectionModal({
onUpdate(updated);
onClose();
} else {
- setError("Failed to update collection");
+ setError(t("editCollection.failedUpdate"));
}
} catch (err) {
console.error(err);
- setError("An error occurred while updating");
+ setError(t("editCollection.errorUpdating"));
} finally {
setLoading(false);
}
@@ -97,7 +99,7 @@ export default function EditCollectionModal({
>
- Edit Collection
+ {t("editCollection.title")}
- Collection name
+ {t("editCollection.nameLabel")}
setName(e.target.value)}
- placeholder="My Collection"
+ placeholder={t("editCollection.namePlaceholder")}
autoFocus
/>
- Description (optional)
+ {t("editCollection.descriptionLabel")}
setDescription(e.target.value)}
- placeholder="What's this collection about?"
+ placeholder={t("editCollection.descriptionPlaceholder")}
rows={3}
/>
- Icon
+ {t("editCollection.iconLabel")}
@@ -151,7 +153,7 @@ export default function EditCollectionModal({
: "text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-200"
}`}
>
- Icons
+ {t("editCollection.iconsTab")}
- Emojis
+ {t("editCollection.emojisTab")}
@@ -223,7 +225,7 @@ export default function EditCollectionModal({
{icon && (
- Selected:
+ {t("editCollection.selected")}
- Cancel
+ {t("editCollection.cancel")}
{loading && }
- {loading ? "Saving..." : "Save Changes"}
+ {loading
+ ? t("editCollection.saving")
+ : t("editCollection.save")}
diff --git a/web/src/components/modals/EditHistoryModal.tsx b/web/src/components/modals/EditHistoryModal.tsx
index 26eec19..955fa65 100644
--- a/web/src/components/modals/EditHistoryModal.tsx
+++ b/web/src/components/modals/EditHistoryModal.tsx
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from "react";
import { X, Loader2, History } from "lucide-react";
+import { useTranslation } from "react-i18next";
import { formatDistanceToNow } from "date-fns";
import type { AnnotationItem, EditHistoryItem } from "../../types";
@@ -14,6 +15,7 @@ export default function EditHistoryModal({
onClose,
item,
}: EditHistoryModalProps) {
+ const { t } = useTranslation();
const [history, setHistory] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
@@ -33,7 +35,7 @@ export default function EditHistoryModal({
setHistory(data);
} catch (err) {
console.error(err);
- setError("Failed to load edit history");
+ setError(t("editHistory.failedLoad"));
} finally {
setLoading(false);
}
@@ -46,7 +48,7 @@ export default function EditHistoryModal({
return () => {
document.body.style.overflow = "unset";
};
- }, [isOpen, item.uri]);
+ }, [isOpen, item.uri, t]);
if (!isOpen) return null;
@@ -63,7 +65,7 @@ export default function EditHistoryModal({
- Edit History
+ {t("editHistory.title")}
{error}
) : history.length === 0 ? (
- No edit history found.
+ {t("editHistory.noHistory")}
) : (
- Current Version
+ {t("editHistory.currentVersion")}
{item.editedAt
- ? `Edited ${formatDistanceToNow(new Date(item.editedAt))} ago`
- : `Posted ${formatDistanceToNow(new Date(item.createdAt))} ago`}
+ ? t("editHistory.editedAgo", {
+ time: formatDistanceToNow(new Date(item.editedAt)),
+ })
+ : t("editHistory.postedAgo", {
+ time: formatDistanceToNow(new Date(item.createdAt)),
+ })}
@@ -110,13 +116,15 @@ export default function EditHistoryModal({
>
- Previous Version
+ {t("editHistory.previousVersion")}
- {formatDistanceToNow(new Date(edit.editedAt))} ago
+ {t("editHistory.timeAgo", {
+ time: formatDistanceToNow(new Date(edit.editedAt)),
+ })}
@@ -133,7 +141,7 @@ export default function EditHistoryModal({
onClick={onClose}
className="w-full py-2.5 bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 text-surface-700 dark:text-surface-200 font-medium rounded-xl hover:bg-surface-50 dark:hover:bg-surface-700 transition-colors"
>
- Close
+ {t("editHistory.close")}
diff --git a/web/src/components/modals/EditItemModal.tsx b/web/src/components/modals/EditItemModal.tsx
index f509dc6..0f7c487 100644
--- a/web/src/components/modals/EditItemModal.tsx
+++ b/web/src/components/modals/EditItemModal.tsx
@@ -1,4 +1,5 @@
import React, { useState, useEffect } from "react";
+import { useTranslation } from "react-i18next";
import { X, ShieldAlert } from "lucide-react";
import {
updateAnnotation,
@@ -11,13 +12,13 @@ import {
import type { AnnotationItem, ContentLabelValue } from "../../types";
import TagInput from "../ui/TagInput";
-const SELF_LABEL_OPTIONS: { value: ContentLabelValue; label: string }[] = [
- { value: "sexual", label: "Sexual" },
- { value: "nudity", label: "Nudity" },
- { value: "violence", label: "Violence" },
- { value: "gore", label: "Gore" },
- { value: "spam", label: "Spam" },
- { value: "misleading", label: "Misleading" },
+const SELF_LABEL_VALUES: ContentLabelValue[] = [
+ "sexual",
+ "nudity",
+ "violence",
+ "gore",
+ "spam",
+ "misleading",
];
const HIGHLIGHT_COLORS = [
@@ -60,6 +61,7 @@ function EditItemModalContent({
onClose,
onSaved,
}: Omit
) {
+ const { t } = useTranslation();
const [text, setText] = useState(item.body?.value || "");
const [tags, setTags] = useState(item.tags || []);
const [tagSuggestions, setTagSuggestions] = useState([]);
@@ -137,14 +139,14 @@ function EditItemModalContent({
}
} catch (e) {
console.error("Edit save error:", e);
- setError(e instanceof Error ? e.message : "Failed to save");
+ setError(e instanceof Error ? e.message : t("editItem.failedSave"));
setSaving(false);
return;
}
setSaving(false);
if (!success) {
- setError("Failed to save changes. Please try again.");
+ setError(t("editItem.failedSave"));
return;
}
const updated = { ...item };
@@ -181,12 +183,11 @@ function EditItemModalContent({
>
- Edit{" "}
{type === "annotation"
- ? "Annotation"
+ ? t("editItem.editAnnotation")
: type === "highlight"
- ? "Highlight"
- : "Bookmark"}
+ ? t("editItem.editHighlight")
+ : t("editItem.editBookmark")}
- Text
+ {t("editItem.textLabel")}
{text.length}/3000
@@ -219,7 +220,7 @@ function EditItemModalContent({
{type === "highlight" && (
- Color
+ {t("editItem.colorLabel")}
{HIGHLIGHT_COLORS.map((c) => (
@@ -247,26 +248,26 @@ function EditItemModalContent({
<>
- Title
+ {t("editItem.titleLabel")}
setTitle(e.target.value)}
className="w-full px-3 py-2 rounded-xl border border-surface-200 dark:border-surface-700 bg-surface-50 dark:bg-surface-800 text-surface-900 dark:text-surface-100 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
- placeholder="Bookmark title"
+ placeholder={t("editItem.titlePlaceholder")}
/>
- Description
+ {t("editItem.descriptionLabel")}
setDescription(e.target.value)}
rows={3}
className="w-full px-3 py-2 rounded-xl border border-surface-200 dark:border-surface-700 bg-surface-50 dark:bg-surface-800 text-surface-900 dark:text-surface-100 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 resize-none"
- placeholder="Optional description..."
+ placeholder={t("editItem.descriptionPlaceholder")}
/>
>
@@ -274,13 +275,13 @@ function EditItemModalContent({
- Tags
+ {t("editItem.tagsLabel")}
@@ -294,7 +295,7 @@ function EditItemModalContent({
}`}
>
- Content Warning
+ {t("editItem.contentWarning")}
{selfLabels.length > 0 && (
{selfLabels.length}
@@ -303,17 +304,17 @@ function EditItemModalContent({
{showLabelPicker && (
- {SELF_LABEL_OPTIONS.map((opt) => (
+ {SELF_LABEL_VALUES.map((val) => (
toggleLabel(opt.value)}
+ key={val}
+ onClick={() => toggleLabel(val)}
className={`px-3 py-1 rounded-full text-xs font-medium border transition-all ${
- selfLabels.includes(opt.value)
+ selfLabels.includes(val)
? "bg-amber-100 dark:bg-amber-900/40 border-amber-300 dark:border-amber-700 text-amber-800 dark:text-amber-200"
: "bg-surface-50 dark:bg-surface-800 border-surface-200 dark:border-surface-700 text-surface-600 dark:text-surface-400 hover:border-amber-300 dark:hover:border-amber-700"
}`}
>
- {opt.label}
+ {t(`composer.labels.${val}`)}
))}
@@ -328,14 +329,14 @@ function EditItemModalContent({
onClick={onClose}
className="px-4 py-2 rounded-xl text-sm font-medium text-surface-600 dark:text-surface-400 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors"
>
- Cancel
+ {t("editItem.cancel")}
- {saving ? "Saving..." : "Save"}
+ {saving ? t("editItem.saving") : t("editItem.save")}
diff --git a/web/src/components/modals/EditProfileModal.tsx b/web/src/components/modals/EditProfileModal.tsx
index 9c9496a..31f5f86 100644
--- a/web/src/components/modals/EditProfileModal.tsx
+++ b/web/src/components/modals/EditProfileModal.tsx
@@ -2,6 +2,7 @@ import React, { useState, useRef } from "react";
import { updateProfile, uploadAvatar, getAvatarUrl } from "../../api/client";
import type { UserProfile } from "../../types";
import { Loader2, X, Plus, User as UserIcon } from "lucide-react";
+import { useTranslation } from "react-i18next";
interface EditProfileModalProps {
profile: UserProfile;
@@ -14,6 +15,7 @@ export default function EditProfileModal({
onClose,
onUpdate,
}: EditProfileModalProps) {
+ const { t } = useTranslation();
const [displayName, setDisplayName] = useState(profile.displayName || "");
const [description, setDescription] = useState(profile.description || "");
const [website, setWebsite] = useState(profile.website || "");
@@ -33,12 +35,12 @@ export default function EditProfileModal({
if (!file) return;
if (!["image/jpeg", "image/png"].includes(file.type)) {
- setError("Please select a JPEG or PNG image");
+ setError(t("editProfile.avatarTypeError"));
return;
}
if (file.size > 1024 * 1024 * 2) {
- setError("Image must be under 2MB");
+ setError(t("editProfile.avatarSizeError"));
return;
}
@@ -52,8 +54,9 @@ export default function EditProfileModal({
setAvatarBlob(result.blob);
} catch (err) {
setError(
- "Failed to upload: " +
- (err instanceof Error ? err.message : "Unknown error"),
+ t("editProfile.avatarUploadError", {
+ message: err instanceof Error ? err.message : "Unknown error",
+ }),
);
setAvatarPreview(null);
} finally {
@@ -117,7 +120,7 @@ export default function EditProfileModal({
>
- Edit Profile
+ {t("editProfile.title")}
- Avatar
+ {t("editProfile.avatarLabel")}
- {uploading ? "Uploading..." : "Upload"}
+ {uploading
+ ? t("editProfile.uploading")
+ : t("editProfile.uploadButton")}
- Display Name
+ {t("editProfile.displayNameLabel")}
- Bio
+ {t("editProfile.bioLabel")}
- Website
+ {t("editProfile.websiteLabel")}
- Links
+ {t("editProfile.linksLabel")}
{links.map((link, i) => (
@@ -244,7 +249,7 @@ export default function EditProfileModal({
type="url"
value={newLink}
onChange={(e) => setNewLink(e.target.value)}
- placeholder="Add a link..."
+ placeholder={t("editProfile.addLinkPlaceholder")}
className="flex-1 px-3 py-2 rounded-lg bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 dark:focus:border-primary-400 text-sm"
onKeyDown={(e) =>
e.key === "Enter" && (e.preventDefault(), handleAddLink())
@@ -268,7 +273,7 @@ export default function EditProfileModal({
className="px-4 py-2 rounded-lg text-surface-600 dark:text-surface-300 font-medium hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors"
disabled={saving}
>
- Cancel
+ {t("editProfile.cancel")}
{saving && }
- {saving ? "Saving..." : "Save"}
+ {saving ? t("editProfile.saving") : t("editProfile.save")}
diff --git a/web/src/components/modals/ExternalLinkModal.tsx b/web/src/components/modals/ExternalLinkModal.tsx
index ce357f0..ba429c5 100644
--- a/web/src/components/modals/ExternalLinkModal.tsx
+++ b/web/src/components/modals/ExternalLinkModal.tsx
@@ -2,6 +2,7 @@ import React, { useState } from "react";
import { Button } from "../ui";
import { ExternalLink, Shield } from "lucide-react";
import { addSkippedHostname } from "../../store/preferences";
+import { useTranslation } from "react-i18next";
interface ExternalLinkModalProps {
isOpen: boolean;
@@ -14,6 +15,7 @@ export default function ExternalLinkModal({
onClose,
url,
}: ExternalLinkModalProps) {
+ const { t } = useTranslation();
const [dontAskAgain, setDontAskAgain] = useState(false);
if (!isOpen || !url) return null;
@@ -57,10 +59,10 @@ export default function ExternalLinkModal({
- Leaving Margin
+ {t("externalLink.title")}
- You're about to visit an external site.
+ {t("externalLink.message")}
@@ -85,10 +87,7 @@ export default function ExternalLinkModal({
className="rounded border-surface-300 dark:border-surface-600 text-primary-600 focus:ring-primary-500 w-3.5 h-3.5 cursor-pointer"
/>
- Always allow links to{" "}
-
- {hostname}
-
+ {t("externalLink.alwaysAllow", { hostname })}
@@ -98,7 +97,7 @@ export default function ExternalLinkModal({
variant="ghost"
className="flex-1 justify-center"
>
- Cancel
+ {t("externalLink.cancel")}
}
>
- Open Link
+ {t("externalLink.open")}
diff --git a/web/src/components/modals/IOSShortcutModal.tsx b/web/src/components/modals/IOSShortcutModal.tsx
index e6ee472..efe74c6 100644
--- a/web/src/components/modals/IOSShortcutModal.tsx
+++ b/web/src/components/modals/IOSShortcutModal.tsx
@@ -1,15 +1,8 @@
import React from "react";
import { Button } from "../ui";
-import {
- X,
- ExternalLink,
- Key,
- Share,
- Bookmark,
- PenTool,
- MoreHorizontal,
-} from "lucide-react";
+import { X, ExternalLink, Key, Bookmark, PenTool } from "lucide-react";
import { AppleIcon } from "../common/Icons";
+import { useTranslation } from "react-i18next";
interface IOSShortcutModalProps {
isOpen: boolean;
@@ -20,6 +13,7 @@ export default function IOSShortcutModal({
isOpen,
onClose,
}: IOSShortcutModalProps) {
+ const { t } = useTranslation();
if (!isOpen) return null;
return (
@@ -37,7 +31,7 @@ export default function IOSShortcutModal({
- Save from iOS Safari
+ {t("iosShortcut.title")}
- How to use the shortcut
+ {t("iosShortcut.howTo")}
@@ -73,7 +67,7 @@ export default function IOSShortcutModal({
@@ -92,11 +86,11 @@ export default function IOSShortcutModal({
- Generate an API Key{" "}
+ {t("iosShortcut.step2Title")}{" "}
- Create a new key on this settings page and copy it.
+ {t("iosShortcut.step2Description")}
@@ -107,14 +101,10 @@ export default function IOSShortcutModal({
- Configure the shortcut
+ {t("iosShortcut.step3Title")}
- In the Shortcuts app, click the{" "}
- menu
- on the Save to Margin shortcut, and paste
- your API key in the Text action right below the setup
- comment.
+ {t("iosShortcut.step3Description")}
@@ -125,16 +115,11 @@ export default function IOSShortcutModal({
- To Bookmark a page{" "}
+ {t("iosShortcut.step4Title")}{" "}
- Don't select any text. Click the{" "}
- menu
- in Safari, press{" "}
- {" "}
- Share , and select{" "}
- Save to Margin .
+ {t("iosShortcut.step4Description")}
@@ -145,14 +130,11 @@ export default function IOSShortcutModal({
- To Highlight text{" "}
+ {t("iosShortcut.step5Title")}{" "}
- Select text on the page, click the{" "}
- menu,
- press Share , and select{" "}
- Save to Margin . Leave the Note field empty.
+ {t("iosShortcut.step5Description")}
@@ -163,13 +145,10 @@ export default function IOSShortcutModal({
- To Add an Annotation
+ {t("iosShortcut.step6Title")}
- Select text, share to Save to Margin (via
- the {" "}
- menu), enter your custom note in the Note field, and press
- Done!
+ {t("iosShortcut.step6Description")}
@@ -183,7 +162,7 @@ export default function IOSShortcutModal({
variant="primary"
className="w-full justify-center"
>
- Got it
+ {t("iosShortcut.gotIt")}
diff --git a/web/src/components/modals/ReportModal.tsx b/web/src/components/modals/ReportModal.tsx
index e9d71ef..75c0220 100644
--- a/web/src/components/modals/ReportModal.tsx
+++ b/web/src/components/modals/ReportModal.tsx
@@ -1,5 +1,6 @@
import React, { useState } from "react";
import { Flag, X } from "lucide-react";
+import { useTranslation } from "react-i18next";
import { reportUser } from "../../api/client";
import type { ReportReasonType } from "../../types";
@@ -11,37 +12,13 @@ interface ReportModalProps {
subjectHandle?: string;
}
-const REASONS: {
- value: ReportReasonType;
- label: string;
- description: string;
-}[] = [
- { value: "spam", label: "Spam", description: "Unwanted repetitive content" },
- {
- value: "violation",
- label: "Rule violation",
- description: "Violates community guidelines",
- },
- {
- value: "misleading",
- label: "Misleading",
- description: "False or misleading information",
- },
- {
- value: "rude",
- label: "Rude or harassing",
- description: "Targeting or harassing a user",
- },
- {
- value: "sexual",
- label: "Inappropriate content",
- description: "Sexual or explicit material",
- },
- {
- value: "other",
- label: "Other",
- description: "Something else not listed above",
- },
+const REASON_VALUES: { value: ReportReasonType; descKey: string }[] = [
+ { value: "spam", descKey: "spam" },
+ { value: "violation", descKey: "ruleViolation" },
+ { value: "misleading", descKey: "misleading" },
+ { value: "rude", descKey: "rudeOrHarassing" },
+ { value: "sexual", descKey: "inappropriateContent" },
+ { value: "other", descKey: "other" },
];
export default function ReportModal({
@@ -51,6 +28,7 @@ export default function ReportModal({
subjectUri,
subjectHandle,
}: ReportModalProps) {
+ const { t } = useTranslation();
const [selectedReason, setSelectedReason] = useState(
null,
);
@@ -105,10 +83,10 @@ export default function ReportModal({
- Report submitted
+ {t("report.submitted")}
- Thank you. We'll review this shortly.
+ {t("report.submittedMessage")}
) : (
@@ -120,11 +98,13 @@ export default function ReportModal({
- Report {subjectHandle ? `@${subjectHandle}` : "user"}
+ {subjectHandle
+ ? t("report.titleUser", { handle: subjectHandle })
+ : t("report.titleGeneric")}
{subjectUri && (
- Reporting specific content
+ {t("report.reportingContent")}
)}
@@ -139,35 +119,26 @@ export default function ReportModal({
- What's the issue?
+ {t("report.issueLabel")}
- {REASONS.map((reason) => (
+ {REASON_VALUES.map((r) => (
setSelectedReason(reason.value)}
+ key={r.value}
+ onClick={() => setSelectedReason(r.value)}
className={`w-full text-left px-3.5 py-2.5 rounded-xl border transition-all ${
- selectedReason === reason.value
+ selectedReason === r.value
? "border-primary-500 bg-primary-50 dark:bg-primary-900/20"
: "border-surface-200 dark:border-surface-700 hover:border-surface-300 dark:hover:border-surface-600"
}`}
>
- {reason.label}
-
-
- {reason.description}
+ {t(`report.reasons.${r.descKey}`)}
))}
@@ -178,7 +149,7 @@ export default function ReportModal({
setAdditionalText(e.target.value)}
- placeholder="Additional details (optional)"
+ placeholder={t("report.detailsPlaceholder")}
rows={2}
className="w-full px-3.5 py-2.5 text-sm rounded-xl border border-surface-200 dark:border-surface-700 bg-white dark:bg-surface-800 text-surface-800 dark:text-surface-200 placeholder:text-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500/30 focus:border-primary-500 resize-none"
/>
@@ -190,14 +161,14 @@ export default function ReportModal({
onClick={handleClose}
className="px-4 py-2 text-sm font-medium text-surface-600 dark:text-surface-400 hover:text-surface-800 dark:hover:text-surface-200 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors"
>
- Cancel
+ {t("report.cancel")}
- {submitting ? "Submitting…" : "Submit Report"}
+ {submitting ? t("report.submitting") : t("report.submit")}
>
diff --git a/web/src/components/modals/ShareMenu.tsx b/web/src/components/modals/ShareMenu.tsx
index 935ff9e..832b884 100644
--- a/web/src/components/modals/ShareMenu.tsx
+++ b/web/src/components/modals/ShareMenu.tsx
@@ -15,6 +15,7 @@ import {
DeerIcon,
} from "../common/Icons";
import { analytics } from "../../lib/analytics";
+import { useTranslation } from "react-i18next";
const SembleLogo = () => (
@@ -39,6 +40,7 @@ export default function ShareMenu({
type,
url,
}: ShareMenuProps) {
+ const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const [copied, setCopied] = useState(null);
const menuRef = useRef(null);
@@ -199,7 +201,9 @@ export default function ShareMenu({
icon
)}
- {isCopied ? "Copied!" : label}
+
+ {isCopied ? t("shareMenu.copied") : label}
+
);
@@ -249,17 +253,17 @@ export default function ShareMenu({
<>
- Semble Integration
+ {t("shareMenu.sembleIntegration")}
{renderMenuItem(
- "Open on Semble",
+ t("shareMenu.openOnSemble"),
,
() => window.open(sembleUrl, "_blank"),
false,
true,
)}
{renderMenuItem(
- "Copy Semble Link",
+ t("shareMenu.copySembleLink"),
,
() => handleCopy(sembleUrl, "semble"),
copied === "semble",
@@ -269,14 +273,14 @@ export default function ShareMenu({
) : null}
{renderMenuItem(
- "Copy Link",
+ t("shareMenu.copyLink"),
,
() => handleCopy(shareUrl, "link"),
copied === "link",
)}
- Share via App
+ {t("shareMenu.shareViaApp")}
@@ -295,7 +299,7 @@ export default function ShareMenu({
{renderMenuItem(
- "Copy Universal Link",
+ t("shareMenu.copyUniversalLink"),
,
() =>
handleCopy(uri.replace("at://", "https://aturi.to/"), "aturi"),
@@ -305,7 +309,7 @@ export default function ShareMenu({
{typeof navigator !== "undefined" &&
navigator.share &&
renderMenuItem(
- "More Options...",
+ t("shareMenu.moreOptions"),
,
() => {
navigator
diff --git a/web/src/components/modals/SignUpModal.tsx b/web/src/components/modals/SignUpModal.tsx
index 190678b..9ee7fc6 100644
--- a/web/src/components/modals/SignUpModal.tsx
+++ b/web/src/components/modals/SignUpModal.tsx
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useMemo } from "react";
import { X, ChevronRight, Loader2, AlertCircle } from "lucide-react";
+import { useTranslation } from "react-i18next";
import {
BlackskyIcon,
NorthskyIcon,
@@ -20,65 +21,27 @@ interface Provider {
wide?: boolean;
}
-const MARGIN_PROVIDER: Provider = {
+type ProviderBase = {
+ id: string;
+ service: string;
+ Icon: React.ComponentType<{ size?: number }> | null;
+ custom?: boolean;
+};
+
+const MARGIN_PROVIDER_BASE: ProviderBase = {
id: "margin",
- name: "Margin",
service: "https://margin.cafe",
Icon: MarginIcon,
- description: "The easiest way to get started",
};
-const OTHER_PROVIDERS: Provider[] = [
- {
- id: "bluesky",
- name: "Bluesky",
- service: "https://bsky.social",
- Icon: BlueskyIcon,
- description: "The largest and most popular community",
- },
- {
- id: "blacksky",
- name: "Blacksky",
- service: "https://blacksky.app",
- Icon: BlackskyIcon,
- description: "For the Culture — a safe space for users and allies",
- },
- {
- id: "eurosky",
- name: "Eurosky",
- service: "https://eurosky.social",
- Icon: null,
- description: "Eurosky is your European home on the Atmosphere",
- },
- {
- id: "selfhosted.social",
- name: "selfhosted.social",
- service: "https://selfhosted.social",
- Icon: null,
- description: "A home for builders, tinkerers, and the curious",
- },
- {
- id: "northsky",
- name: "Northsky",
- service: "https://northsky.social",
- Icon: NorthskyIcon,
- description: "A Canadian worker-owned cooperative",
- },
- {
- id: "tophhie",
- name: "Tophhie",
- service: "https://tophhie.social",
- Icon: TophhieIcon,
- description: "A welcoming and friendly community",
- },
- {
- id: "custom",
- name: "Use a custom PDS",
- service: "",
- custom: true,
- Icon: null,
- description: "Already have a PDS? Enter its address.",
- },
+const OTHER_PROVIDERS_BASE: ProviderBase[] = [
+ { id: "bluesky", service: "https://bsky.social", Icon: BlueskyIcon },
+ { id: "blacksky", service: "https://blacksky.app", Icon: BlackskyIcon },
+ { id: "eurosky", service: "https://eurosky.social", Icon: null },
+ { id: "selfhosted.social", service: "https://selfhosted.social", Icon: null },
+ { id: "northsky", service: "https://northsky.social", Icon: NorthskyIcon },
+ { id: "tophhie", service: "https://tophhie.social", Icon: TophhieIcon },
+ { id: "custom", service: "", custom: true, Icon: null },
];
function shuffleArray
(arr: T[]): T[] {
@@ -93,7 +56,7 @@ function shuffleArray(arr: T[]): T[] {
const inviteStatusPromise: Promise> = (async () => {
const results: Record = {};
await Promise.allSettled(
- [MARGIN_PROVIDER, ...OTHER_PROVIDERS]
+ [MARGIN_PROVIDER_BASE, ...OTHER_PROVIDERS_BASE]
.filter((p) => p.service && !p.custom)
.map(async (p) => {
try {
@@ -117,6 +80,7 @@ interface SignUpModalProps {
}
export default function SignUpModal({ onClose }: SignUpModalProps) {
+ const { t } = useTranslation();
const [showCustomInput, setShowCustomInput] = useState(false);
const [customService, setCustomService] = useState("");
const [loading, setLoading] = useState(false);
@@ -124,6 +88,30 @@ export default function SignUpModal({ onClose }: SignUpModalProps) {
const [inviteStatus, setInviteStatus] = useState>({});
const [statusLoaded, setStatusLoaded] = useState(false);
+ const MARGIN_PROVIDER: Provider = {
+ ...MARGIN_PROVIDER_BASE,
+ name: t("signUp.providers.margin.name"),
+ description: t("signUp.providers.margin.description"),
+ };
+
+ const providerI18nKey: Record = {
+ bluesky: "bluesky",
+ blacksky: "blacksky",
+ eurosky: "eurosky",
+ "selfhosted.social": "selfhostedSocial",
+ northsky: "northsky",
+ tophhie: "tophhie",
+ custom: "customPds",
+ };
+ const OTHER_PROVIDERS: Provider[] = OTHER_PROVIDERS_BASE.map((p) => {
+ const k = providerI18nKey[p.id] || p.id;
+ return {
+ ...p,
+ name: t(`signUp.providers.${k}.name`),
+ description: t(`signUp.providers.${k}.description`),
+ };
+ });
+
useEffect(() => {
inviteStatusPromise.then((status) => {
setInviteStatus(status);
@@ -151,6 +139,7 @@ export default function SignUpModal({ onClose }: SignUpModalProps) {
...shuffleArray(inviteOnly),
...(custom ? [custom] : []),
];
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [statusLoaded, inviteStatus]);
useEffect(() => {
@@ -178,7 +167,7 @@ export default function SignUpModal({ onClose }: SignUpModalProps) {
} catch (err) {
console.error(err);
analytics.captureException(err);
- setError("Could not connect to this provider. Please try again.");
+ setError(t("signUp.providerError"));
setLoading(false);
}
};
@@ -207,7 +196,7 @@ export default function SignUpModal({ onClose }: SignUpModalProps) {
} catch (err) {
console.error(err);
analytics.captureException(err);
- setError("Couldn't connect to that PDS. Double-check the address.");
+ setError(t("signUp.customPdsError"));
setLoading(false);
}
};
@@ -232,28 +221,29 @@ export default function SignUpModal({ onClose }: SignUpModalProps) {
className="animate-spin text-primary-600 dark:text-primary-400 mx-auto mb-4"
/>
- Connecting...
+ {t("signUp.connecting")}
) : showCustomInput ? (
- Use a custom PDS
+ {t("signUp.customPdsTitle")}
+
- Enter the address of the PDS hosting your account.
+ {t("signUp.customPdsSubtitle")}
- PDS address
+ {t("signUp.pdsAddressLabel")}
setCustomService(e.target.value)}
- placeholder="pds.example.com"
+ placeholder={t("signUp.pdsAddressPlaceholder")}
autoFocus
/>
@@ -274,14 +264,14 @@ export default function SignUpModal({ onClose }: SignUpModalProps) {
setError(null);
}}
>
- Back
+ {t("signUp.back")}
- Continue
+ {t("signUp.continue")}
@@ -289,19 +279,19 @@ export default function SignUpModal({ onClose }: SignUpModalProps) {
) : (
- Create your account
+ {t("signUp.title")}
- Margin adheres to the{" "}
+ {t("signUp.subtitle")}{" "}
- AT Protocol
+ {t("signUp.atProtocol")}
- . Choose a provider to host your account.
+ {t("signUp.subtitleSuffix")}
{error && (
@@ -345,7 +335,7 @@ export default function SignUpModal({ onClose }: SignUpModalProps) {
{inviteStatus[p.id] && (
- Invite
+ {t("signUp.invite")}
)}
(
- iOS Shortcut
+ {t("mobileNav.iosShortcut")}
@@ -153,7 +167,7 @@ export default function MobileNav({
}}
>
- Log Out
+ {t("nav.logOut")}
>
) : (
@@ -164,11 +178,19 @@ export default function MobileNav({
onClick={closeMenu}
>
- Sign In
+ {t("nav.signIn")}
{[
- { href: "/collections", icon: Folder, label: "Collections" },
- { href: "/settings", icon: Settings, label: "Settings" },
+ {
+ href: "/collections",
+ icon: Folder,
+ label: t("nav.collections"),
+ },
+ {
+ href: "/settings",
+ icon: Settings,
+ label: t("nav.settings"),
+ },
].map(({ href, icon: Icon, label }) => (
- iOS Shortcut
+ {t("mobileNav.iosShortcut")}
>
)}
diff --git a/web/src/components/navigation/RightSidebar.tsx b/web/src/components/navigation/RightSidebar.tsx
index 106179a..d3e7f90 100644
--- a/web/src/components/navigation/RightSidebar.tsx
+++ b/web/src/components/navigation/RightSidebar.tsx
@@ -7,6 +7,7 @@ import {
type Tag,
} from "../../api/client";
import { Avatar } from "../ui";
+import { useTranslation } from "react-i18next";
function looksLikeUrl(query: string): boolean {
const q = query.trim().toLowerCase();
@@ -22,6 +23,7 @@ interface RightSidebarProps {
}
export default function RightSidebar({ onNavigate }: RightSidebarProps) {
+ const { t } = useTranslation();
const navigate = (path: string) => {
if (onNavigate) onNavigate(path);
else window.location.href = path;
@@ -169,7 +171,7 @@ export default function RightSidebar({ onNavigate }: RightSidebarProps) {
suggestions.length > 0 &&
setShowSuggestions(true)
}
- placeholder="Search people, tags, URLs..."
+ placeholder={t("sidebar.searchPlaceholder")}
className="w-full bg-surface-100 dark:bg-surface-800/80 rounded-lg pl-9 pr-4 py-2 text-sm text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:bg-white dark:focus:bg-surface-800 transition-all border border-transparent focus:border-surface-200 dark:focus:border-surface-700"
/>
@@ -202,10 +204,10 @@ export default function RightSidebar({ onNavigate }: RightSidebarProps) {
- Trending
+ {t("sidebar.trending")}
{tags.length > 0 ? (
diff --git a/web/src/components/navigation/Sidebar.tsx b/web/src/components/navigation/Sidebar.tsx
index b432104..504770c 100644
--- a/web/src/components/navigation/Sidebar.tsx
+++ b/web/src/components/navigation/Sidebar.tsx
@@ -20,6 +20,7 @@ import { $user, logout } from "../../store/auth";
import { $theme, cycleTheme } from "../../store/theme";
import { getUnreadNotificationCount } from "../../api/client";
import { Avatar, CountBadge } from "../ui";
+import { useTranslation } from "react-i18next";
interface SidebarProps {
currentPath?: string;
@@ -30,6 +31,7 @@ export default function Sidebar({
currentPath: initialPath,
onNavigate,
}: SidebarProps) {
+ const { t } = useTranslation();
const user = useStore($user);
const theme = useStore($theme);
const currentPath = initialPath || "/";
@@ -49,45 +51,61 @@ export default function Sidebar({
}, [user]);
const publicNavItems = [
- { icon: Home, label: "Feed", href: "/home", badge: undefined },
- { icon: Compass, label: "Discover", href: "/discover", badge: undefined },
+ { icon: Home, label: t("nav.feed"), href: "/home", badge: undefined },
+ {
+ icon: Compass,
+ label: t("nav.discover"),
+ href: "/discover",
+ badge: undefined,
+ },
{
icon: MessageSquareText,
- label: "Annotations",
+ label: t("nav.annotations"),
href: "/annotations",
badge: undefined,
},
{
icon: Highlighter,
- label: "Highlights",
+ label: t("nav.highlights"),
href: "/highlights",
badge: undefined,
},
{
icon: Bookmark,
- label: "Bookmarks",
+ label: t("nav.bookmarks"),
href: "/bookmarks",
badge: undefined,
},
];
const authNavItems = [
- { icon: Home, label: "Feed", href: "/home" },
- { icon: Compass, label: "Discover", href: "/discover" },
+ { icon: Home, label: t("nav.feed"), href: "/home" },
+ { icon: Compass, label: t("nav.discover"), href: "/discover" },
{
icon: Bell,
- label: "Activity",
+ label: t("nav.activity"),
href: "/notifications",
badge: unreadCount,
},
- { icon: MessageSquareText, label: "Annotations", href: "/annotations" },
- { icon: Highlighter, label: "Highlights", href: "/highlights" },
- { icon: Bookmark, label: "Bookmarks", href: "/bookmarks" },
- { icon: Folder, label: "Collections", href: "/collections" },
+ {
+ icon: MessageSquareText,
+ label: t("nav.annotations"),
+ href: "/annotations",
+ },
+ { icon: Highlighter, label: t("nav.highlights"), href: "/highlights" },
+ { icon: Bookmark, label: t("nav.bookmarks"), href: "/bookmarks" },
+ { icon: Folder, label: t("nav.collections"), href: "/collections" },
];
const navItems = user ? authNavItems : publicNavItems;
+ const themeLabel =
+ theme === "light"
+ ? t("nav.themeLight")
+ : theme === "dark"
+ ? t("nav.themeDark")
+ : t("nav.themeSystem");
+
return (
@@ -146,7 +164,7 @@ export default function Sidebar({
{user && (
{
@@ -158,7 +176,7 @@ export default function Sidebar({
className="flex items-center justify-center lg:justify-start gap-3 px-0 lg:px-3 py-2.5 mt-2 rounded-lg bg-primary-600 dark:bg-primary-500 text-white hover:bg-primary-700 dark:hover:bg-primary-400 transition-colors text-[14px] font-semibold"
>
- New
+ {t("nav.new")}
)}
@@ -167,9 +185,7 @@ export default function Sidebar({
{theme === "light" ? (
@@ -179,16 +195,14 @@ export default function Sidebar({
) : (
)}
-
- {theme === "light" ? "Light" : theme === "dark" ? "Dark" : "System"}
-
+ {themeLabel}
{user ? (
<>
{
@@ -200,7 +214,7 @@ export default function Sidebar({
className="flex items-center justify-center lg:justify-start gap-3 px-0 lg:px-3 py-2.5 rounded-lg hover:bg-surface-100 dark:hover:bg-surface-800 text-[13px] font-medium text-surface-500 dark:text-surface-400 transition-colors"
>
- Settings
+ {t("nav.settings")}
@@ -231,11 +245,11 @@ export default function Sidebar({
- Log out
+ {t("nav.logOut")}
>
) : (
@@ -244,11 +258,11 @@ export default function Sidebar({
- Sign in
+ {t("nav.signIn")}
>
)}
diff --git a/web/src/env.d.ts b/web/src/env.d.ts
index 9dd9afd..666fd8b 100644
--- a/web/src/env.d.ts
+++ b/web/src/env.d.ts
@@ -1,5 +1,9 @@
///
+declare module "virtual:i18n-languages" {
+ export const languages: { code: string; name: string; nativeName: string }[];
+}
+
declare namespace App {
interface Locals {
user: import("./types").UserProfile | null;
diff --git a/web/src/i18n.ts b/web/src/i18n.ts
new file mode 100644
index 0000000..39c1083
--- /dev/null
+++ b/web/src/i18n.ts
@@ -0,0 +1,26 @@
+import i18n from "i18next";
+import { initReactI18next } from "react-i18next";
+import HttpBackend from "i18next-http-backend";
+import LanguageDetector from "i18next-browser-languagedetector";
+
+i18n
+ .use(HttpBackend)
+ .use(LanguageDetector)
+ .use(initReactI18next)
+ .init({
+ fallbackLng: "en",
+ ns: ["translation"],
+ defaultNS: "translation",
+ backend: {
+ loadPath: "/locales/{{lng}}/{{ns}}.json",
+ },
+ detection: {
+ order: ["localStorage", "navigator", "htmlTag"],
+ caches: ["localStorage"],
+ },
+ interpolation: {
+ escapeValue: false,
+ },
+ });
+
+export default i18n;
diff --git a/web/src/pages/about.astro b/web/src/pages/about.astro
index a0d71b8..bcbd7ab 100644
--- a/web/src/pages/about.astro
+++ b/web/src/pages/about.astro
@@ -4,5 +4,5 @@ import About from '../views/About';
---
-
+
diff --git a/web/src/pages/index.astro b/web/src/pages/index.astro
index 4632195..ebc8ce0 100644
--- a/web/src/pages/index.astro
+++ b/web/src/pages/index.astro
@@ -10,5 +10,5 @@ if (user) {
---
-
+
diff --git a/web/src/views/About.tsx b/web/src/views/About.tsx
index 522fee0..82e8330 100644
--- a/web/src/views/About.tsx
+++ b/web/src/views/About.tsx
@@ -1,5 +1,7 @@
import React from "react";
import { useStore } from "@nanostores/react";
+import { useTranslation } from "react-i18next";
+import "../i18n";
import { $theme, cycleTheme } from "../store/theme";
import { $user } from "../store/auth";
@@ -93,6 +95,7 @@ function ExtensionFeature({
}
export default function About() {
+ const { t } = useTranslation();
const theme = useStore($theme); // ensure theme is applied on this page
const user = useStore($user);
@@ -166,13 +169,13 @@ export default function About() {
href="/home"
className="text-[13px] font-medium text-surface-500 dark:text-surface-400 hover:text-surface-900 dark:hover:text-white transition-colors px-3 py-1.5 rounded-lg hover:bg-surface-100 dark:hover:bg-surface-800"
>
- Feed
+ {t("nav.feed")}
- Discover
+ {t("nav.discover")}
@@ -182,7 +185,7 @@ export default function About() {
href="/login"
className="text-[13px] font-medium text-surface-600 dark:text-surface-300 hover:text-surface-900 dark:hover:text-white transition-colors px-3 py-1.5 rounded-lg hover:bg-surface-100 dark:hover:bg-surface-800"
>
- Sign in
+ {t("nav.signIn")}
)}
@@ -193,8 +196,10 @@ export default function About() {
className="inline-flex items-center gap-1.5 text-[13px] font-semibold px-3.5 py-1.5 bg-surface-900 dark:bg-white text-white dark:text-surface-900 rounded-lg hover:bg-surface-800 dark:hover:bg-surface-100 transition-colors"
>
- Get Extension
- Install
+
+ {t("about.nav.getExtension")}
+
+ {t("about.nav.install")}
@@ -230,32 +235,30 @@ export default function About() {
- Fully open source{" "}
+ {t("about.hero.openSource")}{" "}
✨
- Write on the margins
+ {t("about.hero.headline")}
- of the internet.
+ {t("about.hero.headlineAccent")}
- Margin is an open annotation layer for the internet. Highlight text,
- leave notes, and bookmark pages, all stored on your decentralized
- identity with the{" "}
+ {t("about.hero.descriptionPre")}{" "}
- AT Protocol
+ {t("about.hero.atProtocol")}
- . Not locked in a silo.
+ {t("about.hero.descriptionPost")}
@@ -263,7 +266,7 @@ export default function About() {
href={user ? "/home" : "/login"}
className="group inline-flex items-center justify-center gap-2 px-8 py-3.5 bg-surface-900 dark:bg-white text-white dark:text-surface-900 rounded-[14px] font-semibold hover:bg-surface-800 dark:hover:bg-surface-200 hover:scale-[1.02] shadow-sm transition-all duration-200 w-full sm:w-auto"
>
- {user ? "Open App" : "Get Started"}
+ {user ? t("about.hero.openApp") : t("about.hero.getStarted")}
- Install for {extensionLabel}
+ {t("about.hero.installFor", { browser: extensionLabel })}
@@ -285,45 +288,44 @@ export default function About() {
- Everything you need to engage with the web
+ {t("about.features.title")}
- More than bookmarks. A full toolkit for reading, thinking, and
- sharing on the open web.
+ {t("about.features.subtitle")}
@@ -334,39 +336,45 @@ export default function About() {
- Browser Extension
+ {t("about.extension.badge")}
- Your annotation toolkit,
+ {t("about.extension.title")}
- right in the browser
+ {t("about.extension.titleLine2")}
- The Margin extension brings the full annotation experience
- directly into every page you visit. Just select, annotate, and
- go.
+ {t("about.extension.description")}
@@ -378,7 +386,7 @@ export default function About() {
className="inline-flex items-center justify-center gap-2 px-5 py-2.5 bg-surface-900 dark:bg-white text-white dark:text-surface-900 rounded-lg font-medium text-sm transition-all hover:opacity-90"
>
- Install for {extensionLabel}
+ {t("about.hero.installFor", { browser: extensionLabel })}
{browser !== "chrome" && (
@@ -424,7 +432,7 @@ export default function About() {
className="inline-flex items-center justify-center gap-2 px-5 py-2.5 bg-surface-100 dark:bg-surface-800 text-surface-700 dark:text-surface-200 rounded-lg font-medium text-sm transition-all hover:bg-surface-200 dark:hover:bg-surface-700 border border-surface-200/80 dark:border-surface-700/80"
>
- iOS Shortcut
+ {t("about.extension.iosShortcut")}
@@ -499,51 +507,32 @@ export default function About() {
- Decentralized
+ {t("about.protocol.badge")}
- Your data, your identity
+ {t("about.protocol.title")}
- Margin is built on the{" "}
+ {t("about.protocol.descriptionPre")}{" "}
- AT Protocol
+ {t("about.hero.atProtocol")}
- , the open protocol that powers apps like Bluesky. Your
- annotations, highlights, and bookmarks are stored in your personal
- data repository, not locked in a silo.
+ {t("about.protocol.descriptionPost")}
-
-
- Sign in with your AT Protocol handle, no new account needed
-
-
-
- Your data lives in your PDS, portable and under your control
-
-
-
- Custom Lexicon schemas for annotations, highlights, collections
- & more
-
-
-
- Fully open source, check out the code and contribute
-
+ {([0, 1, 2, 3] as const).map((i) => (
+
+
+ {t(`about.protocol.point${i}`)}
+
+ ))}
@@ -609,18 +598,17 @@ export default function About() {
- Start writing on the margins
+ {t("about.cta.title")}
- Join the open annotation layer. Sign in with your AT Protocol
- identity and install the extension to get started.
+ {t("about.cta.description")}
@@ -666,7 +654,7 @@ export default function About() {
className="w-5 h-5 opacity-60"
/>
- © 2026 Padding Labs LLC
+ {t("sidebar.copyright")}
@@ -675,20 +663,20 @@ export default function About() {
href="/home"
className="hover:text-surface-600 dark:hover:text-surface-300 transition-colors"
>
- Feed
+ {t("nav.feed")}
)}
- Privacy
+ {t("about.footer.privacy")}
- Terms
+ {t("about.footer.terms")}
- Contact
+ {t("about.footer.contact")}
diff --git a/web/src/views/AppShell.tsx b/web/src/views/AppShell.tsx
index 96221dc..708fa6f 100644
--- a/web/src/views/AppShell.tsx
+++ b/web/src/views/AppShell.tsx
@@ -1,5 +1,7 @@
import { useStore } from "@nanostores/react";
-import { useEffect, useState } from "react";
+import { Suspense, useEffect, useState } from "react";
+import { I18nextProvider } from "react-i18next";
+import i18n from "../i18n";
import type { UserProfile } from "../types";
declare global {
@@ -344,8 +346,12 @@ export default function AppShell() {
}, []);
return (
-
-
-
+
+
+
+
+
+
+
);
}
diff --git a/web/src/views/auth/Login.tsx b/web/src/views/auth/Login.tsx
index 3478c7e..2c7296c 100644
--- a/web/src/views/auth/Login.tsx
+++ b/web/src/views/auth/Login.tsx
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useRef } from "react";
import { AtSign } from "lucide-react";
+import { useTranslation } from "react-i18next";
import SignUpModal from "../../components/modals/SignUpModal";
import {
searchActors,
@@ -16,6 +17,7 @@ interface LoginProps {
}
export default function Login({ initialError }: LoginProps) {
+ const { t } = useTranslation();
useStore($theme); // ensure theme is applied on this page
const [handle, setHandle] = useState("");
const [suggestions, setSuggestions] = useState([]);
@@ -32,7 +34,7 @@ export default function Login({ initialError }: LoginProps) {
const [providerIndex, setProviderIndex] = useState(0);
const [morphClass, setMorphClass] = useState(
"opacity-100 translate-y-0 blur-0",
- );
+ );
const providers = [
"AT Protocol",
"Margin",
@@ -162,13 +164,13 @@ export default function Login({ initialError }: LoginProps) {
- Sign in with your
+ {t("login.signInWith")}
{providers[providerIndex]}
{" "}
- handle
+ {t("login.handleSuffix")}
@@ -208,7 +210,7 @@ export default function Login({ initialError }: LoginProps) {
!handle.includes(".") &&
setShowSuggestions(true)
}
- placeholder="handle.margin.cafe"
+ placeholder={t("login.handlePlaceholder")}
className="w-full pl-12 pr-4 py-3.5 bg-surface-50 dark:bg-surface-950 border border-surface-200 dark:border-surface-700 rounded-xl focus:border-[#027bff] dark:focus:border-[#027bff] outline-none focus:ring-4 focus:ring-[#027bff]/10 transition-all font-medium text-lg text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500"
autoCapitalize="none"
autoCorrect="off"
@@ -255,23 +257,23 @@ export default function Login({ initialError }: LoginProps) {
disabled={loading || !handle}
className="w-full py-3.5 bg-[#027bff] hover:bg-[#0269d9] active:scale-[0.98] text-white rounded-xl font-bold text-lg shadow-md shadow-[#027bff]/20 focus:outline-none focus:ring-4 focus:ring-[#027bff]/20 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center justify-center gap-2 mt-2"
>
- {loading ? "Connecting..." : "Continue"}
+ {loading ? t("login.connecting") : t("login.continue")}
- By signing in, you agree to our{" "}
+ {t("login.termsPrefix")}{" "}
- Terms of Service
+ {t("login.termsLink")}
{" "}
- and{" "}
+ {t("login.termsAnd")}{" "}
- Privacy Policy
+ {t("login.privacyLink")}
@@ -288,7 +290,7 @@ export default function Login({ initialError }: LoginProps) {
onClick={() => setShowSignUp(true)}
className="w-full py-3.5 bg-transparent border border-surface-200 dark:border-surface-700 hover:bg-surface-50 dark:hover:bg-surface-800 text-surface-600 dark:text-surface-300 hover:text-surface-900 dark:hover:text-white rounded-xl font-bold transition-all text-sm"
>
- Create New Account
+ {t("login.createAccount")}
diff --git a/web/src/views/collections/CollectionDetail.tsx b/web/src/views/collections/CollectionDetail.tsx
index 0e9d85a..60f3363 100644
--- a/web/src/views/collections/CollectionDetail.tsx
+++ b/web/src/views/collections/CollectionDetail.tsx
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
+import { useTranslation } from "react-i18next";
import {
getCollection,
getCollectionItems,
@@ -34,6 +35,7 @@ export default function CollectionDetail({
initialItems,
resolvedUri,
}: CollectionDetailProps) {
+ const { t } = useTranslation();
const user = useStore($user);
const navigate = useNavigate();
const [collection, setCollection] = useState(
@@ -59,7 +61,7 @@ export default function CollectionDetail({
if (did) {
targetUri = `at://${did}/at.margin.collection/${rkey}`;
} else {
- setError("Collection not found");
+ setError(t("collectionDetail.notFound"));
setLoading(false);
return;
}
@@ -73,22 +75,22 @@ export default function CollectionDetail({
const colItems = await getCollectionItems(col.uri);
setItems(colItems.filter((i) => i && i.uri));
} else {
- setError("Collection not found");
+ setError(t("collectionDetail.notFound"));
}
}
} catch {
- setError("Failed to load collection");
+ setError(t("collectionDetail.failedToLoad"));
} finally {
setLoading(false);
}
};
loadData();
- }, [handle, rkey, uri, initialCollection, resolvedUri]);
+ }, [handle, rkey, uri, initialCollection, resolvedUri, t]);
const handleDelete = async () => {
if (!collection) return;
- if (window.confirm("Delete this collection?")) {
+ if (window.confirm(t("collectionDetail.deleteConfirm"))) {
await deleteCollection(collection.id);
navigate("/collections");
}
@@ -96,7 +98,7 @@ export default function CollectionDetail({
const handleRemoveItem = async (item: AnnotationItem) => {
if (!item.collectionItemUri) return;
- if (!window.confirm("Remove from collection?")) return;
+ if (!window.confirm(t("collectionDetail.removeConfirm"))) return;
const success = await removeCollectionItem(item.collectionItemUri);
if (success) {
setItems((prev) =>
@@ -119,7 +121,7 @@ export default function CollectionDetail({
if (error || !collection) {
return (
- {error || "Collection not found"}
+ {error || t("collectionDetail.notFound")}
);
}
@@ -146,7 +148,7 @@ export default function CollectionDetail({
className="inline-flex items-center gap-1.5 text-sm font-medium text-surface-500 dark:text-surface-400 hover:text-surface-900 dark:hover:text-white mb-4 transition-colors"
>
- Collections
+ {t("collectionDetail.backLink")}
@@ -165,10 +167,10 @@ export default function CollectionDetail({
)}
) : (
items.map((item) => (
@@ -249,7 +251,7 @@ export default function CollectionDetail({
handleRemoveItem(item)}
- title="Remove from collection"
+ title={t("collectionDetail.removeFromCollection")}
>
diff --git a/web/src/views/collections/Collections.tsx b/web/src/views/collections/Collections.tsx
index 9817eee..a287d69 100644
--- a/web/src/views/collections/Collections.tsx
+++ b/web/src/views/collections/Collections.tsx
@@ -1,4 +1,5 @@
import React, { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
import {
getCollections,
createCollection,
@@ -28,6 +29,7 @@ interface CollectionsProps {
}
export default function Collections({ initialCollections }: CollectionsProps) {
+ const { t } = useTranslation();
const user = useStore($user);
const theme = useStore($theme);
const [collections, setCollections] = useState
(
@@ -107,7 +109,7 @@ export default function Collections({ initialCollections }: CollectionsProps) {
const handleDelete = async (id: string, e: React.MouseEvent) => {
e.preventDefault();
- if (window.confirm("Delete this collection?")) {
+ if (window.confirm(t("collections.deleteConfirm"))) {
const success = await deleteCollection(id);
if (success) {
setCollections((prev) => {
@@ -151,27 +153,27 @@ export default function Collections({ initialCollections }: CollectionsProps) {
- Collections
+ {t("collections.title")}
- Organize your annotations and highlights
+ {t("collections.subtitle")}
setShowCreateModal(true)}
icon={ }
>
- New
+ {t("common.new")}
{collections.length === 0 ? (
}
- title="No collections yet"
- message="Create a collection to organize your highlights and annotations."
+ title={t("collections.none")}
+ message={t("collections.noneMessage")}
action={{
- label: "Create collection",
+ label: t("collections.createButton"),
onClick: () => setShowCreateModal(true),
}}
/>
@@ -193,8 +195,9 @@ export default function Collections({ initialCollections }: CollectionsProps) {
{collection.name}
- {collection.itemCount}{" "}
- {collection.itemCount === 1 ? "item" : "items"}
+ {t("collections.itemCount", {
+ count: collection.itemCount,
+ })}
{collection.createdAt &&
` · ${formatDistanceToNow(new Date(collection.createdAt), { addSuffix: true })}`}
@@ -217,7 +220,7 @@ export default function Collections({ initialCollections }: CollectionsProps) {
- New Collection
+ {t("collections.newTitle")}
setShowCreateModal(false)}
@@ -229,7 +232,7 @@ export default function Collections({ initialCollections }: CollectionsProps) {
setNewItemName(e.target.value)}
placeholder="e.g. Design Inspiration"
@@ -239,7 +242,7 @@ export default function Collections({ initialCollections }: CollectionsProps) {
- Icon
+ {t("collections.iconLabel")}
@@ -252,7 +255,7 @@ export default function Collections({ initialCollections }: CollectionsProps) {
: "text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-200"
}`}
>
- Icons
+ {t("collections.iconsTab")}
- Emojis
+ {t("collections.emojisTab")}
@@ -326,13 +329,13 @@ export default function Collections({ initialCollections }: CollectionsProps) {
- Description
+ {t("collections.descriptionLabel")}
setNewItemDesc(e.target.value)}
className="w-full px-3 py-2.5 bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-xl text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 dark:focus:border-primary-400 min-h-[80px] resize-none"
- placeholder="What's this collection for?"
+ placeholder={t("collections.descriptionPlaceholder")}
/>
@@ -341,10 +344,10 @@ export default function Collections({ initialCollections }: CollectionsProps) {
variant="ghost"
onClick={() => setShowCreateModal(false)}
>
- Cancel
+ {t("collections.cancel")}
- Create Collection
+ {t("collections.create")}
diff --git a/web/src/views/content/AnnotationDetail.tsx b/web/src/views/content/AnnotationDetail.tsx
index b68028a..21f733e 100644
--- a/web/src/views/content/AnnotationDetail.tsx
+++ b/web/src/views/content/AnnotationDetail.tsx
@@ -1,6 +1,7 @@
import React, { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useStore } from "@nanostores/react";
+import { useTranslation } from "react-i18next";
import { $user } from "../../store/auth";
import {
getAnnotation,
@@ -43,6 +44,7 @@ export default function AnnotationDetail({
initialReplies,
resolvedUri,
}: AnnotationDetailProps) {
+ const { t } = useTranslation();
const user = useStore($user);
const navigate = useNavigate();
@@ -88,8 +90,9 @@ export default function AnnotationDetail({
}
} catch (e) {
setError(
- "Failed to resolve handle: " +
- (e instanceof Error ? e.message : "Unknown error"),
+ t("annotationDetail.failedResolve", {
+ message: e instanceof Error ? e.message : "Unknown error",
+ }),
);
setLoading(false);
}
@@ -98,7 +101,7 @@ export default function AnnotationDetail({
}
}
resolve();
- }, [uri, did, rkey, handle, type, resolvedUri]);
+ }, [uri, did, rkey, handle, type, resolvedUri, t]);
const refreshReplies = async () => {
if (!targetUri) return;
@@ -125,7 +128,7 @@ export default function AnnotationDetail({
]);
if (!annData) {
- setError("Annotation not found");
+ setError(t("annotationDetail.notFound"));
} else {
setAnnotation(annData);
setReplies(repliesData.items || []);
@@ -137,7 +140,7 @@ export default function AnnotationDetail({
}
}
fetchData();
- }, [targetUri]);
+ }, [targetUri, t]);
const handleReply = async (e?: React.FormEvent) => {
if (e) e.preventDefault();
@@ -170,8 +173,9 @@ export default function AnnotationDetail({
await refreshReplies();
} catch (err) {
alert(
- "Failed to post reply: " +
- (err instanceof Error ? err.message : "Unknown error"),
+ t("annotationDetail.failedReply", {
+ message: err instanceof Error ? err.message : "Unknown error",
+ }),
);
} finally {
setPosting(false);
@@ -179,14 +183,15 @@ export default function AnnotationDetail({
};
const handleDeleteReply = async (reply: AnnotationItem) => {
- if (!window.confirm("Delete this reply?")) return;
+ if (!window.confirm(t("annotationDetail.deleteReplyConfirm"))) return;
try {
await deleteReply(reply.uri || reply.id!);
await refreshReplies();
} catch (err) {
alert(
- "Failed to delete: " +
- (err instanceof Error ? err.message : "Unknown error"),
+ t("annotationDetail.failedDelete", {
+ message: err instanceof Error ? err.message : "Unknown error",
+ }),
);
}
};
@@ -209,10 +214,10 @@ export default function AnnotationDetail({
- Not found
+ {t("annotationDetail.notFound")}
- {error || "This may have been deleted."}
+ {error || t("annotationDetail.mayBeDeleted")}
- Back to Feed
+ {t("annotationDetail.backToFeed")}
);
@@ -240,7 +245,7 @@ export default function AnnotationDetail({
className="inline-flex items-center gap-1.5 text-sm font-medium text-surface-500 dark:text-surface-400 hover:text-surface-900 dark:hover:text-white transition-colors"
>
- Back
+ {t("annotationDetail.back")}
@@ -258,7 +263,7 @@ export default function AnnotationDetail({
- Replies ({replies.length})
+ {t("annotationDetail.replies", { count: replies.length })}
{user ? (
@@ -266,7 +271,7 @@ export default function AnnotationDetail({
{replyingTo && (
- Replying to{" "}
+ {t("annotationDetail.replyingTo")}{" "}
@
{(replyingTo.author || replyingTo.creator)?.handle ||
@@ -297,7 +302,7 @@ export default function AnnotationDetail({
setReplyText(e.target.value)}
- placeholder="Write a reply..."
+ placeholder={t("annotationDetail.replyPlaceholder")}
className="w-full p-3 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-lg text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 dark:focus:border-primary-400 outline-none resize-none min-h-[80px]"
rows={2}
disabled={posting}
@@ -308,7 +313,7 @@ export default function AnnotationDetail({
disabled={posting || !replyText.trim()}
onClick={() => handleReply()}
>
- {posting ? "..." : "Reply"}
+ {posting ? "..." : t("annotationDetail.reply")}
@@ -317,13 +322,13 @@ export default function AnnotationDetail({
) : (
)}
diff --git a/web/src/views/content/UrlPage.tsx b/web/src/views/content/UrlPage.tsx
index f33cb5c..b68a6e2 100644
--- a/web/src/views/content/UrlPage.tsx
+++ b/web/src/views/content/UrlPage.tsx
@@ -1,4 +1,5 @@
import { useStore } from "@nanostores/react";
+import { useTranslation } from "react-i18next";
import {
AlertTriangle,
Check,
@@ -38,6 +39,7 @@ export default function UrlPage({ urlPath }: UrlPageProps) {
"all" | "annotations" | "highlights"
>("all");
const [copied, setCopied] = useState(false);
+ const { t } = useTranslation();
const user = useStore($user);
const LIMIT = 50;
@@ -153,11 +155,10 @@ export default function UrlPage({ urlPath }: UrlPageProps) {
/>
- URL Annotations
+ {t("urlPage.title")}
- Enter a URL to see all public annotations and highlights from the
- Margin community.
+ {t("urlPage.description")}
- View
+ {t("urlPage.view")}
@@ -233,18 +234,18 @@ export default function UrlPage({ urlPath }: UrlPageProps) {
- My Annotations
+ {t("urlPage.myAnnotations")}
)}
{copied ? : }
- {copied ? "Copied!" : "Share"}
+ {copied ? t("urlPage.copied") : t("urlPage.share")}
@@ -253,7 +254,7 @@ export default function UrlPage({ urlPath }: UrlPageProps) {
- {authorCount} contributor{authorCount !== 1 ? "s" : ""}
+ {t("urlPage.contributor", { count: authorCount })}
)}
@@ -266,7 +267,7 @@ export default function UrlPage({ urlPath }: UrlPageProps) {
size={32}
/>
- Loading annotations...
+ {t("urlPage.loadingAnnotations")}
)}
@@ -281,8 +282,8 @@ export default function UrlPage({ urlPath }: UrlPageProps) {
{!loading && !error && totalItems === 0 && (
}
- title="This page is a blank canvas"
- message="No one's left notes here yet. Want to be the first? Grab the Margin extension and share what you're thinking."
+ title={t("urlPage.blankCanvas")}
+ message={t("urlPage.blankCanvasMessage")}
/>
)}
@@ -291,9 +292,9 @@ export default function UrlPage({ urlPath }: UrlPageProps) {
@@ -306,15 +307,15 @@ export default function UrlPage({ urlPath }: UrlPageProps) {
{activeTab === "annotations" && annotations.length === 0 && (
}
- title="No annotations yet"
- message="Nobody has left a written note on this page."
+ title={t("urlPage.noAnnotationsYet")}
+ message={t("urlPage.noAnnotationsMessage")}
/>
)}
{activeTab === "highlights" && highlights.length === 0 && (
}
- title="No highlights yet"
- message="Nobody has highlighted a passage from this page."
+ title={t("urlPage.noHighlightsYet")}
+ message={t("urlPage.noHighlightsMessage")}
/>
)}
@@ -327,7 +328,7 @@ export default function UrlPage({ urlPath }: UrlPageProps) {
{loadMoreError && (
- Failed to load more: {loadMoreError}
+ {t("urlPage.failedLoadMore", { message: loadMoreError })}
)}
- Loading...
+ {t("urlPage.loading")}
>
) : (
- "Load more"
+ t("urlPage.loadMore")
)}
diff --git a/web/src/views/content/UserUrlPage.tsx b/web/src/views/content/UserUrlPage.tsx
index 398660e..e3d88ba 100644
--- a/web/src/views/content/UserUrlPage.tsx
+++ b/web/src/views/content/UserUrlPage.tsx
@@ -7,6 +7,7 @@ import {
Search,
} from "lucide-react";
import React, { useCallback, useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
import { getUserTargetItems } from "../../api/client";
import Card from "../../components/common/Card";
import Avatar from "../../components/ui/Avatar";
@@ -19,6 +20,7 @@ interface UserUrlPageProps {
}
export default function UserUrlPage({ handle, urlPath }: UserUrlPageProps) {
+ const { t } = useTranslation();
const targetUrl = urlPath || "";
const [profile, setProfile] = useState(null);
@@ -133,8 +135,8 @@ export default function UserUrlPage({ handle, urlPath }: UserUrlPageProps) {
return (
}
- title="No URL specified"
- message="Please provide a URL to view annotations."
+ title={t("userUrlPage.noUrl")}
+ message={t("userUrlPage.noUrlMessage")}
/>
);
}
@@ -174,7 +176,7 @@ export default function UserUrlPage({ handle, urlPath }: UserUrlPageProps) {
- on
+ {t("userUrlPage.on")}
- Loading annotations...
+ {t("userUrlPage.loadingAnnotations")}
)}
@@ -211,8 +213,8 @@ export default function UserUrlPage({ handle, urlPath }: UserUrlPageProps) {
{!loading && !error && totalItems === 0 && (
}
- title="No items found"
- message={`${displayName} hasn't annotated this page yet.`}
+ title={t("userUrlPage.noItems")}
+ message={t("userUrlPage.noItemsMessage", { name: displayName })}
/>
)}
@@ -221,9 +223,9 @@ export default function UserUrlPage({ handle, urlPath }: UserUrlPageProps) {
@@ -236,15 +238,15 @@ export default function UserUrlPage({ handle, urlPath }: UserUrlPageProps) {
{activeTab === "annotations" && annotations.length === 0 && (
}
- title="No annotations"
- message={`${displayName} hasn't annotated this page yet.`}
+ title={t("userUrlPage.noAnnotations")}
+ message={t("userUrlPage.noItemsMessage", { name: displayName })}
/>
)}
{activeTab === "highlights" && highlights.length === 0 && (
}
- title="No highlights"
- message={`${displayName} hasn't highlighted this page yet.`}
+ title={t("userUrlPage.noHighlights")}
+ message={t("userUrlPage.noItemsMessage", { name: displayName })}
/>
)}
@@ -257,7 +259,7 @@ export default function UserUrlPage({ handle, urlPath }: UserUrlPageProps) {
{loadMoreError && (
- Failed to load more: {loadMoreError}
+ {t("userUrlPage.failedLoadMore", { message: loadMoreError })}
)}
- Loading...
+ {t("userUrlPage.loading")}
>
) : (
- "Load more"
+ t("userUrlPage.loadMore")
)}
diff --git a/web/src/views/core/AdminModeration.tsx b/web/src/views/core/AdminModeration.tsx
index 60d3c40..3817e03 100644
--- a/web/src/views/core/AdminModeration.tsx
+++ b/web/src/views/core/AdminModeration.tsx
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from "react";
import { useStore } from "@nanostores/react";
+import { useTranslation } from "react-i18next";
import { $user } from "../../store/auth";
import {
checkAdminAccess,
@@ -38,27 +39,28 @@ const STATUS_COLORS: Record = {
"bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300",
};
-const REASON_LABELS: Record = {
- spam: "Spam",
- violation: "Rule Violation",
- misleading: "Misleading",
- sexual: "Inappropriate",
- rude: "Rude / Harassing",
- other: "Other",
+const REASON_LABEL_KEYS: Record = {
+ spam: "adminModeration.reasons.spam",
+ violation: "adminModeration.reasons.violation",
+ misleading: "adminModeration.reasons.misleading",
+ sexual: "adminModeration.reasons.sexual",
+ rude: "adminModeration.reasons.rude",
+ other: "adminModeration.reasons.other",
};
-const LABEL_OPTIONS = [
- { val: "sexual", label: "Sexual Content" },
- { val: "nudity", label: "Nudity" },
- { val: "violence", label: "Violence" },
- { val: "gore", label: "Graphic Content" },
- { val: "spam", label: "Spam" },
- { val: "misleading", label: "Misleading" },
+const LABEL_VALS = [
+ "sexual",
+ "nudity",
+ "violence",
+ "gore",
+ "spam",
+ "misleading",
];
type Tab = "reports" | "labels" | "actions";
export default function AdminModeration() {
+ const { t } = useTranslation();
const user = useStore($user);
const [isAdmin, setIsAdmin] = useState(false);
const [loading, setLoading] = useState(true);
@@ -141,7 +143,7 @@ export default function AdminModeration() {
};
const handleDeleteLabel = async (id: number) => {
- if (!window.confirm("Remove this label?")) return;
+ if (!window.confirm(t("adminModeration.labels.removeConfirm"))) return;
const success = await adminDeleteLabel(id);
if (success) setLabels((prev) => prev.filter((l) => l.id !== id));
};
@@ -163,8 +165,8 @@ export default function AdminModeration() {
return (
}
- title="Access Denied"
- message="You don't have permission to access the moderation dashboard."
+ title={t("adminModeration.accessDenied")}
+ message={t("adminModeration.accessDeniedMessage")}
/>
);
}
@@ -178,10 +180,13 @@ export default function AdminModeration() {
size={24}
className="text-primary-600 dark:text-primary-400"
/>
- Moderation
+ {t("adminModeration.title")}
- {pendingCount} pending · {totalCount} total reports
+ {t("adminModeration.stats", {
+ pending: pendingCount,
+ total: totalCount,
+ })}
@@ -190,15 +195,19 @@ export default function AdminModeration() {
{[
{
id: "reports" as Tab,
- label: "Reports",
+ label: t("adminModeration.tabs.reports"),
icon: ,
},
{
id: "actions" as Tab,
- label: "Actions",
+ label: t("adminModeration.tabs.actions"),
icon: ,
},
- { id: "labels" as Tab, label: "Labels", icon: },
+ {
+ id: "labels" as Tab,
+ label: t("adminModeration.tabs.labels"),
+ icon: ,
+ },
].map((tab) => (
{status
- ? status.charAt(0).toUpperCase() + status.slice(1)
- : "All"}
+ ? t(`adminModeration.filters.${status}`, {
+ defaultValue:
+ status.charAt(0).toUpperCase() + status.slice(1),
+ })
+ : t("adminModeration.filters.all")}
),
)}
@@ -240,11 +252,13 @@ export default function AdminModeration() {
{reports.length === 0 ? (
}
- title="No reports"
+ title={t("adminModeration.reports.empty")}
message={
statusFilter === "pending"
- ? "No pending reports to review."
- : `No ${statusFilter || ""} reports found.`
+ ? t("adminModeration.reports.emptyPending")
+ : t("adminModeration.reports.emptyFiltered", {
+ status: statusFilter || "",
+ })
}
/>
) : (
@@ -281,7 +295,9 @@ export default function AdminModeration() {
- {REASON_LABELS[report.reasonType] || report.reasonType}{" "}
+ {REASON_LABEL_KEYS[report.reasonType]
+ ? t(REASON_LABEL_KEYS[report.reasonType])
+ : report.reasonType}{" "}
· reported by @
{report.reporter.handle || report.reporter.did} ·{" "}
{new Date(report.createdAt).toLocaleDateString()}
@@ -299,7 +315,7 @@ export default function AdminModeration() {