(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
98
fork

Configure Feed

Select the types of activity you want to include in your feed.

localize margin's web app

+1946 -801
+36 -1
web/astro.config.mjs
··· 4 4 import tailwind from "@astrojs/tailwind"; 5 5 import node from "@astrojs/node"; 6 6 import { fileURLToPath } from "url"; 7 - import { dirname, resolve } from "path"; 7 + import { dirname, resolve, join } from "path"; 8 + import { readdirSync, existsSync } from "fs"; 8 9 9 10 const __dirname = dirname(fileURLToPath(import.meta.url)); 10 11 11 12 const API_PORT = process.env.API_PORT || 8081; 12 13 14 + function i18nLanguagesPlugin() { 15 + const virtualId = "virtual:i18n-languages"; 16 + const resolvedId = "\0" + virtualId; 17 + return { 18 + name: "i18n-languages", 19 + resolveId(id) { 20 + if (id === virtualId) return resolvedId; 21 + }, 22 + load(id) { 23 + if (id !== resolvedId) return; 24 + const localesDir = join(__dirname, "public/locales"); 25 + const languages = readdirSync(localesDir, { withFileTypes: true }) 26 + .filter( 27 + (d) => 28 + d.isDirectory() && 29 + existsSync(join(localesDir, d.name, "translation.json")), 30 + ) 31 + .map((d) => { 32 + const code = d.name; 33 + const name = 34 + new Intl.DisplayNames(["en"], { type: "language" }).of(code) ?? 35 + code; 36 + const nativeName = 37 + new Intl.DisplayNames([code], { type: "language" }).of(code) ?? 38 + name; 39 + return { code, name, nativeName }; 40 + }) 41 + .sort((a, b) => a.name.localeCompare(b.name)); 42 + return `export const languages = ${JSON.stringify(languages)};`; 43 + }, 44 + }; 45 + } 46 + 13 47 // https://astro.build/config 14 48 export default defineConfig({ 15 49 output: "server", ··· 21 55 defaultStrategy: "viewport", 22 56 }, 23 57 vite: { 58 + plugins: [i18nLanguagesPlugin()], 24 59 resolve: { 25 60 alias: { 26 61 "@": resolve(__dirname, "src"),
+31
web/bun.lock
··· 1 1 { 2 2 "lockfileVersion": 1, 3 + "configVersion": 0, 3 4 "workspaces": { 4 5 "": { 5 6 "name": "web", ··· 15 16 "clsx": "^2.1.1", 16 17 "date-fns": "^4.1.0", 17 18 "emoji-picker-react": "^4.18.0", 19 + "i18next": "^26.0.6", 20 + "i18next-browser-languagedetector": "^8.2.1", 21 + "i18next-http-backend": "^3.0.5", 18 22 "lucide-react": "^0.563.0", 19 23 "nanostores": "^1.1.0", 20 24 "postcss": "^8.5.6", 21 25 "react": "^19.2.4", 22 26 "react-dom": "^19.2.4", 27 + "react-i18next": "^17.0.4", 23 28 "react-router-dom": "^7.13.2", 24 29 "satori": "^0.19.2", 25 30 "tailwind-merge": "^3.4.0", ··· 96 101 "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], 97 102 98 103 "@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=="], 104 + 105 + "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], 99 106 100 107 "@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=="], 101 108 ··· 549 556 550 557 "cookie-es": ["cookie-es@1.2.2", "", {}, "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="], 551 558 559 + "cross-fetch": ["cross-fetch@4.1.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw=="], 560 + 552 561 "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], 553 562 554 563 "crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="], ··· 813 822 814 823 "html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="], 815 824 825 + "html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="], 826 + 816 827 "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], 817 828 818 829 "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], 819 830 820 831 "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=="], 821 832 833 + "i18next": ["i18next@26.0.6", "", { "dependencies": { "@babel/runtime": "^7.29.2" }, "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-A4U6eCXodIbrhf8EarRurB9/4ebyaurH4+fu4gig9bqxmpSt+fCAFm/GpRQDcN1Xzu/LdFCx4nYHsnM1edIIbg=="], 834 + 835 + "i18next-browser-languagedetector": ["i18next-browser-languagedetector@8.2.1", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw=="], 836 + 837 + "i18next-http-backend": ["i18next-http-backend@3.0.5", "", { "dependencies": { "cross-fetch": "4.1.0" } }, "sha512-QaWHnsxieEDcqKe+vo/RFqpiIFRi/KBqlOSPcUlvinBaISCeiTRCbtrazHAjtHtsLC66oDsROAH8frWkQzfMMQ=="], 838 + 822 839 "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], 823 840 824 841 "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], ··· 1075 1092 1076 1093 "nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="], 1077 1094 1095 + "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=="], 1096 + 1078 1097 "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], 1079 1098 1080 1099 "node-mock-http": ["node-mock-http@1.0.4", "", {}, "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ=="], ··· 1191 1210 1192 1211 "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], 1193 1212 1213 + "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=="], 1214 + 1194 1215 "react-icons": ["react-icons@5.5.0", "", { "peerDependencies": { "react": "*" } }, "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw=="], 1195 1216 1196 1217 "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], ··· 1349 1370 1350 1371 "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], 1351 1372 1373 + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], 1374 + 1352 1375 "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], 1353 1376 1354 1377 "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], ··· 1415 1438 1416 1439 "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], 1417 1440 1441 + "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=="], 1442 + 1418 1443 "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], 1419 1444 1420 1445 "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], ··· 1427 1452 1428 1453 "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=="], 1429 1454 1455 + "void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="], 1456 + 1430 1457 "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], 1458 + 1459 + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], 1460 + 1461 + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], 1431 1462 1432 1463 "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], 1433 1464
+4
web/package.json
··· 21 21 "clsx": "^2.1.1", 22 22 "date-fns": "^4.1.0", 23 23 "emoji-picker-react": "^4.18.0", 24 + "i18next": "^26.0.6", 25 + "i18next-browser-languagedetector": "^8.2.1", 26 + "i18next-http-backend": "^3.0.5", 24 27 "lucide-react": "^0.563.0", 25 28 "nanostores": "^1.1.0", 26 29 "postcss": "^8.5.6", 27 30 "react": "^19.2.4", 28 31 "react-dom": "^19.2.4", 32 + "react-i18next": "^17.0.4", 29 33 "react-router-dom": "^7.13.2", 30 34 "satori": "^0.19.2", 31 35 "tailwind-merge": "^3.4.0",
+881
web/public/locales/en/translation.json
··· 1 + { 2 + "appTitle": "Margin", 3 + 4 + "nav": { 5 + "feed": "Feed", 6 + "discover": "Discover", 7 + "annotations": "Annotations", 8 + "highlights": "Highlights", 9 + "bookmarks": "Bookmarks", 10 + "collections": "Collections", 11 + "activity": "Activity", 12 + "settings": "Settings", 13 + "new": "New", 14 + "signIn": "Sign in", 15 + "logOut": "Log out", 16 + "themeLight": "Light", 17 + "themeDark": "Dark", 18 + "themeSystem": "System" 19 + }, 20 + 21 + "pageTitles": { 22 + "home": "Home — Margin", 23 + "bookmarks": "Bookmarks — Margin", 24 + "highlights": "Highlights — Margin", 25 + "annotations": "Annotations — Margin", 26 + "discover": "Discover — Margin", 27 + "search": "Search — Margin", 28 + "notifications": "Notifications — Margin", 29 + "new": "New Annotation — Margin", 30 + "settings": "Settings — Margin", 31 + "collections": "Collections — Margin", 32 + "admin": "Admin — Margin" 33 + }, 34 + 35 + "sidebar": { 36 + "getExtension": "Get the Extension", 37 + "extensionTagline": "Highlight, annotate, and bookmark from any page.", 38 + "downloadForFirefox": "Download for Firefox", 39 + "downloadForEdge": "Download for Edge", 40 + "downloadForChrome": "Download for Chrome", 41 + "trending": "Trending", 42 + "nothingTrending": "Nothing trending right now.", 43 + "searchPlaceholder": "Search people, tags, URLs...", 44 + "copyright": "© 2026 Padding Labs LLC", 45 + "postCount_one": "{{count}} post", 46 + "postCount_other": "{{count}} posts" 47 + }, 48 + 49 + "mobileNav": { 50 + "iosShortcut": "iOS Shortcut" 51 + }, 52 + 53 + "feed": { 54 + "welcome": "Welcome to Margin", 55 + "welcomeTagline": "A quiet place to annotate, highlight, and save what you read on the web.", 56 + "getStarted": "Get started", 57 + "learnMore": "Learn more", 58 + "tabs": { 59 + "recent": "Recent", 60 + "popular": "Popular", 61 + "shelved": "Shelved", 62 + "margin": "Margin", 63 + "semble": "Semble" 64 + }, 65 + "filters": { 66 + "all": "All", 67 + "annotations": "Annotations", 68 + "highlights": "Highlights", 69 + "bookmarks": "Bookmarks" 70 + }, 71 + "itemsWithTag": "Items with tag:", 72 + "clearFilter": "Clear filter", 73 + "everyone": "Everyone", 74 + "mine": "Mine", 75 + "defaultEmptyMessage": "Nothing here yet — annotations from you and people you follow will show up here.", 76 + "nothingHereYet": "Nothing here yet", 77 + "loading": "Loading..." 78 + }, 79 + 80 + "discover": { 81 + "tabs": { 82 + "new": "New", 83 + "popular": "Popular", 84 + "forYou": "For You" 85 + }, 86 + "comingSoon": "Coming soon", 87 + "forYouNotAvailable": "Personalized recommendations aren't available on this server yet.", 88 + "noDocumentsYet": "No documents have been discovered yet. Check back soon!", 89 + "startAnnotating": "Start annotating and highlighting to get personalized recommendations.", 90 + "loadMore": "Load more" 91 + }, 92 + 93 + "search": { 94 + "placeholder": "Search annotations, highlights, bookmarks...", 95 + "noResults": "No results found", 96 + "noResultsMessage": "Nothing matched \"{{query}}\". Try different keywords.", 97 + "emptyTitle": "Search your library", 98 + "emptyMessage": "Find annotations, highlights, and bookmarks by keyword, URL, or tag.", 99 + "filters": { 100 + "all": "All", 101 + "annotations": "Annotations", 102 + "highlights": "Highlights", 103 + "bookmarks": "Bookmarks", 104 + "mine": "Mine" 105 + }, 106 + "resultCount": "{{count}}{{hasMore}} results for \"{{query}}\"", 107 + "loadMore": "Load more" 108 + }, 109 + 110 + "notifications": { 111 + "title": "Activity", 112 + "noActivity": "No activity yet", 113 + "noActivityMessage": "Interactions with your content will appear here.", 114 + "likedAnnotation": "liked your annotation", 115 + "likedHighlight": "liked your highlight", 116 + "likedBookmark": "liked your bookmark", 117 + "likedReply": "liked your reply", 118 + "likedPost": "liked your post", 119 + "repliedToReply": "replied to your reply", 120 + "repliedToAnnotation": "replied to your annotation", 121 + "mentionedInAnnotation": "mentioned you in an annotation", 122 + "followedYou": "followed you", 123 + "highlightedPage": "highlighted your page", 124 + "inReplyTo": "in reply to", 125 + "aReply": "a reply", 126 + "anAnnotation": "an annotation" 127 + }, 128 + 129 + "collections": { 130 + "title": "Collections", 131 + "subtitle": "Organize your annotations and highlights", 132 + "none": "No collections yet", 133 + "noneMessage": "Create a collection to organize your highlights and annotations.", 134 + "createButton": "Create collection", 135 + "newTitle": "New Collection", 136 + "editTitle": "Edit Collection", 137 + "namePlaceholder": "My Collection", 138 + "namePlaceholderEdit": "Collection name", 139 + "nameLabel": "Name", 140 + "iconLabel": "Icon", 141 + "iconsTab": "Icons", 142 + "emojisTab": "Emojis", 143 + "selectedIcon": "Selected:", 144 + "descriptionLabel": "Description (optional)", 145 + "descriptionPlaceholder": "What's this collection for?", 146 + "descriptionPlaceholderEdit": "What's this collection about?", 147 + "cancel": "Cancel", 148 + "create": "Create Collection", 149 + "creating": "Creating...", 150 + "save": "Save Changes", 151 + "saving": "Saving...", 152 + "deleteConfirm": "Delete this collection?", 153 + "failedUpdate": "Failed to update collection", 154 + "errorUpdating": "An error occurred while updating", 155 + "itemCount_one": "{{count}} item", 156 + "itemCount_other": "{{count}} items" 157 + }, 158 + 159 + "collectionDetail": { 160 + "backLink": "Collections", 161 + "by": "by", 162 + "edit": "Edit collection", 163 + "delete": "Delete collection", 164 + "removeFromCollection": "Remove from collection", 165 + "viewInSemble": "View in Semble", 166 + "empty": "Collection is empty", 167 + "notFound": "Collection not found", 168 + "failedToLoad": "Failed to load collection", 169 + "deleteConfirm": "Delete this collection?", 170 + "removeConfirm": "Remove from collection?" 171 + }, 172 + 173 + "profile": { 174 + "notFound": "User not found", 175 + "notFoundMessage": "This profile doesn't exist or couldn't be loaded.", 176 + "tabs": { 177 + "all": "All", 178 + "annotations": "Annotations", 179 + "highlights": "Highlights", 180 + "bookmarks": "Bookmarks", 181 + "collections": "Collections" 182 + }, 183 + "loading": "Loading...", 184 + "noCollections": "You haven't created any collections yet.", 185 + "noCollectionsTitle": "No collections", 186 + "labelDescriptions": { 187 + "sexual": "Sexual Content", 188 + "nudity": "Nudity", 189 + "violence": "Violence", 190 + "gore": "Graphic Content", 191 + "spam": "Spam", 192 + "misleading": "Misleading" 193 + }, 194 + "accountLabeled": "Account labeled: {{label}}", 195 + "labelAppliedBy": "This label was applied by a moderation service you subscribe to.", 196 + "show": "Show", 197 + "hide": "Hide", 198 + "blockedBy": "@{{handle}} has blocked you. You cannot interact with their content.", 199 + "youBlocked": "You have blocked @{{handle}}", 200 + "blockedContent": "Their content is hidden from your feeds.", 201 + "unblock": "Unblock", 202 + "youMuted": "You have muted @{{handle}}", 203 + "unmute": "Unmute", 204 + "viewInBluesky": "View profile in Bluesky", 205 + "blockUser": "Block @{{handle}}", 206 + "unblockUser": "Unblock @{{handle}}", 207 + "muteUser": "Mute @{{handle}}", 208 + "unmuteUser": "Unmute @{{handle}}", 209 + "report": "Report", 210 + "emptyOwn": "Your {{tab}} will show up here.", 211 + "emptyOther": "Nothing to see here yet." 212 + }, 213 + 214 + "login": { 215 + "signInWith": "Sign in with your", 216 + "handleSuffix": "handle", 217 + "handlePlaceholder": "handle.margin.cafe", 218 + "connecting": "Connecting...", 219 + "continue": "Continue", 220 + "createAccount": "Create New Account", 221 + "termsPrefix": "By signing in, you agree to our", 222 + "termsLink": "Terms of Service", 223 + "termsAnd": "and", 224 + "privacyLink": "Privacy Policy" 225 + }, 226 + 227 + "signUp": { 228 + "title": "Create your account", 229 + "subtitle": "Margin adheres to the", 230 + "atProtocol": "AT Protocol", 231 + "subtitleSuffix": ". Choose a provider to host your account.", 232 + "customPdsTitle": "Use a custom PDS", 233 + "customPdsSubtitle": "Enter the address of the PDS hosting your account.", 234 + "pdsAddressLabel": "PDS address", 235 + "pdsAddressPlaceholder": "pds.example.com", 236 + "connecting": "Connecting...", 237 + "back": "Back", 238 + "continue": "Continue", 239 + "invite": "Invite", 240 + "providerError": "Could not connect to this provider. Please try again.", 241 + "customPdsError": "Couldn't connect to that PDS. Double-check the address.", 242 + "providers": { 243 + "margin": { 244 + "name": "Margin", 245 + "description": "The easiest way to get started" 246 + }, 247 + "bluesky": { 248 + "name": "Bluesky", 249 + "description": "The largest and most popular community" 250 + }, 251 + "blacksky": { 252 + "name": "Blacksky", 253 + "description": "For the Culture — a safe space for users and allies" 254 + }, 255 + "eurosky": { 256 + "name": "Eurosky", 257 + "description": "Eurosky is your European home on the Atmosphere" 258 + }, 259 + "selfhostedSocial": { 260 + "name": "selfhosted.social", 261 + "description": "A home for builders, tinkerers, and the curious" 262 + }, 263 + "northsky": { 264 + "name": "Northsky", 265 + "description": "A Canadian worker-owned cooperative" 266 + }, 267 + "tophhie": { 268 + "name": "Tophhie", 269 + "description": "A welcoming and friendly community" 270 + }, 271 + "customPds": { 272 + "name": "Use a custom PDS", 273 + "description": "Already have a PDS? Enter its address." 274 + } 275 + } 276 + }, 277 + 278 + "composer": { 279 + "newHighlight": "New highlight", 280 + "newAnnotation": "New annotation", 281 + "newNote": "New note", 282 + "saveHighlight": "Save highlight", 283 + "postAnnotation": "Post annotation", 284 + "postNote": "Post note", 285 + "highlightHint": "Saving a passage without a comment. Add text below to turn it into an annotation.", 286 + "addQuote": "+ Add a quote from the page", 287 + "quotePlaceholder": "Paste or type the text you're annotating...", 288 + "removeQuote": "Remove Quote", 289 + "thoughtsPlaceholder": "Add your thoughts on this passage...", 290 + "mindPlaceholder": "What's on your mind?", 291 + "tagsPlaceholder": "Add tags...", 292 + "contentWarning": "Content Warning", 293 + "contentWarningCount": "Content Warning ({{count}})", 294 + "cancel": "Cancel", 295 + "failedToPost": "Failed to post", 296 + "labels": { 297 + "sexual": "Sexual", 298 + "nudity": "Nudity", 299 + "violence": "Violence", 300 + "gore": "Gore", 301 + "spam": "Spam", 302 + "misleading": "Misleading" 303 + } 304 + }, 305 + 306 + "card": { 307 + "addedTo": "Added to", 308 + "addedToLower": "added to", 309 + "and": "and", 310 + "communityBookmark": "Community bookmark", 311 + "openInSemble": "Open in Semble", 312 + "deleteConfirm": "Delete this item?", 313 + "hideContent": "Hide Content", 314 + "show": "Show", 315 + "edited": "(edited)", 316 + "annotate": "Annotate", 317 + "untitledBookmark": "Untitled Bookmark", 318 + "addNotePlaceholder": "Add your note to convert this highlight into an annotation...", 319 + "addToCollectionTitle": "Add to Collection", 320 + "annotateTitle": "Annotate this highlight", 321 + "editTitle": "Edit", 322 + "deleteTitle": "Delete", 323 + "report": "Report", 324 + "muteUser": "Mute @{{handle}}", 325 + "blockUser": "Block @{{handle}}", 326 + "convertToAnnotation": "Convert to annotation", 327 + "justNow": "just now", 328 + "labelDescriptions": { 329 + "sexual": "Sexual Content", 330 + "nudity": "Nudity", 331 + "violence": "Violence", 332 + "gore": "Graphic Content", 333 + "spam": "Spam", 334 + "misleading": "Misleading" 335 + } 336 + }, 337 + 338 + "profileHoverCard": { 339 + "viewProfile": "View Profile", 340 + "notFound": "Profile not found" 341 + }, 342 + 343 + "replyList": { 344 + "noReplies": "No replies yet" 345 + }, 346 + 347 + "shareMenu": { 348 + "sembleIntegration": "Semble Integration", 349 + "openOnSemble": "Open on Semble", 350 + "copySembleLink": "Copy Semble Link", 351 + "copyLink": "Copy Link", 352 + "shareViaApp": "Share via App", 353 + "copyUniversalLink": "Copy Universal Link", 354 + "moreOptions": "More Options...", 355 + "copied": "Copied!" 356 + }, 357 + 358 + "addToCollection": { 359 + "title": "Add to Collection", 360 + "loading": "Loading collections...", 361 + "collectionNameLabel": "Collection name", 362 + "namePlaceholder": "My Collection", 363 + "descriptionLabel": "Description (optional)", 364 + "descriptionPlaceholder": "What's this collection about?", 365 + "iconLabel": "Icon", 366 + "iconsTab": "Icons", 367 + "emojisTab": "Emojis", 368 + "selected": "Selected:", 369 + "back": "Back", 370 + "create": "Create", 371 + "creating": "Creating...", 372 + "newCollectionButton": "New Collection", 373 + "createNewDescription": "Create a new collection", 374 + "none": "No collections yet", 375 + "done": "Done", 376 + "failedLoad": "Failed to load collections", 377 + "failedAdd": "Failed to add to collection", 378 + "failedCreate": "Failed to create collection" 379 + }, 380 + 381 + "editItem": { 382 + "editAnnotation": "Edit Annotation", 383 + "editHighlight": "Edit Highlight", 384 + "editBookmark": "Edit Bookmark", 385 + "textLabel": "Text", 386 + "textPlaceholder": "Write your annotation...", 387 + "colorLabel": "Color", 388 + "tagsLabel": "Tags", 389 + "tagPlaceholder": "Add a tag...", 390 + "contentWarning": "Content Warning", 391 + "cancel": "Cancel", 392 + "save": "Save", 393 + "saving": "Saving...", 394 + "failedSave": "Failed to save changes. Please try again.", 395 + "titleLabel": "Title", 396 + "titlePlaceholder": "Bookmark title", 397 + "descriptionLabel": "Description", 398 + "descriptionPlaceholder": "Optional description..." 399 + }, 400 + 401 + "editCollection": { 402 + "title": "Edit Collection", 403 + "nameLabel": "Collection name", 404 + "namePlaceholder": "My Collection", 405 + "descriptionLabel": "Description (optional)", 406 + "descriptionPlaceholder": "What's this collection about?", 407 + "iconLabel": "Icon", 408 + "iconsTab": "Icons", 409 + "emojisTab": "Emojis", 410 + "selected": "Selected:", 411 + "cancel": "Cancel", 412 + "save": "Save Changes", 413 + "saving": "Saving...", 414 + "failedUpdate": "Failed to update collection", 415 + "errorUpdating": "An error occurred while updating" 416 + }, 417 + 418 + "externalLink": { 419 + "title": "Leaving Margin", 420 + "message": "You're about to visit an external site.", 421 + "alwaysAllow": "Always allow links to {{hostname}}", 422 + "cancel": "Cancel", 423 + "open": "Open Link" 424 + }, 425 + 426 + "report": { 427 + "submitted": "Report submitted", 428 + "submittedMessage": "Thank you. We'll review this shortly.", 429 + "titleUser": "Report @{{handle}}", 430 + "titleGeneric": "Report user", 431 + "reportingContent": "Reporting specific content", 432 + "issueLabel": "What's the issue?", 433 + "cancel": "Cancel", 434 + "submit": "Submit Report", 435 + "submitting": "Submitting…", 436 + "detailsPlaceholder": "Additional details (optional)", 437 + "reasons": { 438 + "spam": "Spam", 439 + "ruleViolation": "Rule violation", 440 + "misleading": "Misleading", 441 + "rudeOrHarassing": "Rude or harassing", 442 + "inappropriateContent": "Inappropriate content", 443 + "other": "Other" 444 + } 445 + }, 446 + 447 + "editProfile": { 448 + "title": "Edit Profile", 449 + "avatarLabel": "Avatar", 450 + "uploadButton": "Upload", 451 + "uploading": "Uploading...", 452 + "displayNameLabel": "Display Name", 453 + "bioLabel": "Bio", 454 + "websiteLabel": "Website", 455 + "linksLabel": "Links", 456 + "addLinkPlaceholder": "Add a link...", 457 + "cancel": "Cancel", 458 + "save": "Save", 459 + "saving": "Saving...", 460 + "avatarTypeError": "Please select a JPEG or PNG image", 461 + "avatarSizeError": "Image must be under 2MB", 462 + "avatarUploadError": "Failed to upload: {{message}}" 463 + }, 464 + 465 + "iosShortcut": { 466 + "title": "Save from iOS Safari", 467 + "howTo": "How to use the shortcut", 468 + "step1Title": "Install the shortcut", 469 + "step1Link": "Get iOS Shortcut", 470 + "step2Title": "Generate an API Key", 471 + "step2Description": "Create a new key on this settings page and copy it.", 472 + "step3Title": "Configure the shortcut", 473 + "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.", 474 + "step4Title": "To Bookmark a page", 475 + "step4Description": "Don't select any text. Click the menu in Safari, press Share, and select Save to Margin.", 476 + "step5Title": "To Highlight text", 477 + "step5Description": "Select text on the page, click the menu, press Share, and select Save to Margin. Leave the Note field empty.", 478 + "step6Title": "To Add an Annotation", 479 + "step6Description": "Select text, share to Save to Margin (via the menu), enter your custom note in the Note field, and press Done!", 480 + "gotIt": "Got it" 481 + }, 482 + 483 + "editHistory": { 484 + "title": "Edit History", 485 + "noHistory": "No edit history found.", 486 + "currentVersion": "Current Version", 487 + "previousVersion": "Previous Version", 488 + "editedAgo": "Edited {{time}} ago", 489 + "postedAgo": "Posted {{time}} ago", 490 + "timeAgo": "{{time}} ago", 491 + "close": "Close", 492 + "failedLoad": "Failed to load edit history" 493 + }, 494 + 495 + "settings": { 496 + "sections": { 497 + "profile": "Profile", 498 + "appearance": "Appearance", 499 + "batchImport": "Batch Import Highlights", 500 + "apiKeys": "API Keys", 501 + "moderation": "Moderation", 502 + "contentFiltering": "Content Filtering", 503 + "iosShortcut": "iOS Shortcut" 504 + }, 505 + "apiKeys": { 506 + "noKeys": "No API keys yet. Create one to use with the browser extension.", 507 + "forIos": "For the iOS shortcut and other apps", 508 + "namePlaceholder": "Key name, e.g. iOS Shortcut", 509 + "created": "Created {{date}}", 510 + "revokeConfirm": "Revoke this key? Apps using it will stop working.", 511 + "copyNow": "Copy now - you won't see this again!" 512 + }, 513 + "moderation": { 514 + "blockedAccounts": "Blocked accounts ({{count}})", 515 + "mutedAccounts": "Muted accounts ({{count}})", 516 + "noBlocked": "No blocked accounts", 517 + "noMuted": "No muted accounts", 518 + "unblock": "Unblock", 519 + "unmute": "Unmute", 520 + "remove": "Remove", 521 + "add": "Add", 522 + "noLabelers": "No labelers subscribed", 523 + "labelerPlaceholder": "did:plc:... (labeler DID)" 524 + }, 525 + "contentFiltering": { 526 + "warn": "Warn", 527 + "hide": "Hide", 528 + "ignore": "Ignore" 529 + }, 530 + "ios": { 531 + "description": "Save pages to Margin from Safari on iPhone and iPad", 532 + "setupButton": "Setup iOS Shortcut" 533 + }, 534 + "logOut": "Log out" 535 + }, 536 + 537 + "new": { 538 + "signInRequired": "Sign in to create", 539 + "needsAccount": "You need a Bluesky account", 540 + "signInButton": "Sign in with Bluesky", 541 + "composeTitle": "Compose", 542 + "composeTagline": "Highlight a passage, leave a note, or annotate a page — all from here.", 543 + "urlLabel": "URL to annotate", 544 + "urlPlaceholder": "https://example.com/article" 545 + }, 546 + 547 + "annotationDetail": { 548 + "back": "Back", 549 + "replies": "Replies ({{count}})", 550 + "replyingTo": "Replying to", 551 + "replyPlaceholder": "Write a reply...", 552 + "reply": "Reply", 553 + "signInToReply": "Sign in to reply", 554 + "logIn": "Log in", 555 + "notFound": "Not found", 556 + "mayBeDeleted": "This may have been deleted.", 557 + "backToFeed": "Back to Feed", 558 + "deleteReplyConfirm": "Delete this reply?", 559 + "failedReply": "Failed to post reply: {{message}}", 560 + "failedDelete": "Failed to delete: {{message}}", 561 + "failedResolve": "Failed to resolve handle: {{message}}" 562 + }, 563 + 564 + "urlPage": { 565 + "title": "URL Annotations", 566 + "description": "Enter a URL to see all public annotations and highlights from the Margin community.", 567 + "urlPlaceholder": "https://example.com/article", 568 + "view": "View", 569 + "myAnnotations": "My Annotations", 570 + "share": "Share", 571 + "copied": "Copied!", 572 + "contributor_one": "{{count}} contributor", 573 + "contributor_other": "{{count}} contributors", 574 + "loadingAnnotations": "Loading annotations...", 575 + "blankCanvas": "This page is a blank canvas", 576 + "blankCanvasMessage": "No one's left notes here yet. Want to be the first? Grab the Margin extension and share what you're thinking.", 577 + "tabs": { 578 + "all": "All", 579 + "annotations": "Annotations", 580 + "highlights": "Highlights", 581 + "bookmarks": "Bookmarks", 582 + "collections": "Collections" 583 + }, 584 + "noAnnotationsYet": "No annotations yet", 585 + "noAnnotationsMessage": "Nobody has left a written note on this page.", 586 + "noHighlightsYet": "No highlights yet", 587 + "noHighlightsMessage": "Nobody has highlighted a passage from this page.", 588 + "loadMore": "Load more", 589 + "loading": "Loading...", 590 + "failedLoadMore": "Failed to load more: {{message}}" 591 + }, 592 + 593 + "userUrlPage": { 594 + "on": "on", 595 + "loadingAnnotations": "Loading annotations...", 596 + "noUrl": "No URL specified", 597 + "noUrlMessage": "Please provide a URL to view annotations.", 598 + "noItems": "No items found", 599 + "noItemsMessage": "{{name}} hasn't annotated this page yet.", 600 + "noAnnotations": "No annotations", 601 + "noHighlights": "No highlights", 602 + "loadMore": "Load more", 603 + "loading": "Loading...", 604 + "failedLoadMore": "Failed to load more: {{message}}" 605 + }, 606 + 607 + "settings": { 608 + "title": "Settings", 609 + "sections": { 610 + "profile": "Profile", 611 + "appearance": "Appearance", 612 + "language": "Language", 613 + "batchImport": "Batch Import Highlights", 614 + "apiKeys": "API Keys", 615 + "moderation": "Moderation", 616 + "contentFiltering": "Content Filtering", 617 + "iosShortcut": "iOS Shortcut" 618 + }, 619 + "language": { 620 + "label": "Interface Language", 621 + "description": "Choose the language for the Margin interface." 622 + }, 623 + "appearance": { 624 + "disableExternalLinkWarning": "Disable external link warning", 625 + "disableExternalLinkWarningDesc": "Don't ask for confirmation when opening external links", 626 + "communityBookmarks": "Share bookmarks to community feed", 627 + "communityBookmarksDesc": "Your saved bookmarks will appear in the community bookmarks feed" 628 + }, 629 + "batchImport": { 630 + "description": "Upload highlights from CSV. Required: url, text. Optional: title, tags, color, created_at" 631 + }, 632 + "apiKeys": { 633 + "description": "For the iOS shortcut and other apps", 634 + "keyNamePlaceholder": "Key name, e.g. iOS Shortcut", 635 + "generate": "Generate", 636 + "copyNow": "Copy now - you won't see this again!", 637 + "empty": "No API keys yet. Create one to use with the browser extension.", 638 + "created": "Created {{date}}", 639 + "revokeConfirm": "Revoke this key? Apps using it will stop working." 640 + }, 641 + "moderation": { 642 + "description": "Manage blocked and muted accounts", 643 + "blockedAccounts": "Blocked accounts ({{count}})", 644 + "noBlocked": "No blocked accounts", 645 + "unblock": "Unblock", 646 + "mutedAccounts": "Muted accounts ({{count}})", 647 + "noMuted": "No muted accounts", 648 + "unmute": "Unmute" 649 + }, 650 + "contentFiltering": { 651 + "description": "Subscribe to labelers and configure how labeled content appears", 652 + "subscribedLabelers": "Subscribed Labelers", 653 + "noLabelers": "No labelers subscribed", 654 + "labelerDidPlaceholder": "did:plc:... (labeler DID)", 655 + "remove": "Remove", 656 + "add": "Add", 657 + "labelVisibility": "Label Visibility", 658 + "labelVisibilityDesc": "Choose how to handle each label type: Warn shows a blur overlay, Hide removes content entirely, Ignore shows content normally.", 659 + "warn": "Warn", 660 + "hide": "Hide", 661 + "ignore": "Ignore" 662 + }, 663 + "iosShortcut": { 664 + "description": "Save pages to Margin from Safari on iPhone and iPad", 665 + "setupButton": "Setup iOS Shortcut" 666 + }, 667 + "logout": "Log out" 668 + }, 669 + 670 + "adminModeration": { 671 + "accessDenied": "Access Denied", 672 + "accessDeniedMessage": "You don't have permission to access the moderation dashboard.", 673 + "title": "Moderation", 674 + "stats": "{{pending}} pending · {{total}} total reports", 675 + "tabs": { 676 + "reports": "Reports", 677 + "actions": "Actions", 678 + "labels": "Labels" 679 + }, 680 + "filters": { 681 + "all": "All", 682 + "pending": "Pending", 683 + "resolved": "Resolved", 684 + "dismissed": "Dismissed", 685 + "escalated": "Escalated" 686 + }, 687 + "reports": { 688 + "empty": "No reports", 689 + "emptyPending": "No pending reports to review.", 690 + "emptyFiltered": "No {{status}} reports found.", 691 + "reportedUser": "Reported User", 692 + "reporter": "Reporter", 693 + "details": "Details", 694 + "contentUri": "Content URI", 695 + "acknowledge": "Acknowledge", 696 + "dismiss": "Dismiss", 697 + "takedown": "Takedown" 698 + }, 699 + "reasons": { 700 + "spam": "Spam", 701 + "violation": "Rule Violation", 702 + "misleading": "Misleading", 703 + "sexual": "Inappropriate", 704 + "rude": "Rude / Harassing", 705 + "other": "Other" 706 + }, 707 + "actions": { 708 + "applyWarning": "Apply Content Warning", 709 + "applyWarningDesc": "Add a content warning label to a specific post or account. Users will see a blur overlay with the option to reveal.", 710 + "accountDid": "Account DID", 711 + "contentUri": "Content URI", 712 + "contentUriOptional": "optional — leave empty for account-level label", 713 + "labelType": "Label Type", 714 + "applyLabel": "Apply Label", 715 + "labelApplied": "Label applied" 716 + }, 717 + "labels": { 718 + "empty": "No labels", 719 + "emptyMessage": "No content labels have been applied yet.", 720 + "accountLevel": "Account-level label", 721 + "removeConfirm": "Remove this label?", 722 + "removeTitle": "Remove label" 723 + } 724 + }, 725 + 726 + "highlightImporter": { 727 + "clickToUpload": "Click to upload CSV", 728 + "processing": "Processing...", 729 + "requiredColumns": "Required columns: url, text | Optional: title, tags, color, created_at", 730 + "downloadTemplate": "Download Template", 731 + "importProgress": "Import Progress", 732 + "complete": "{{rate}}% complete", 733 + "failed_one": "{{count}} failed", 734 + "failed_other": "{{count}} failed", 735 + "importing": "Importing highlights...", 736 + "success": "Successfully imported {{count}} highlights!", 737 + "errorsTitle": "{{count}} errors during import", 738 + "row": "Row {{row}}: {{error}}", 739 + "moreErrors": "+{{count}} more errors", 740 + "importAnother": "Import Another File", 741 + "noHighlights": "No valid highlights found in CSV", 742 + "csvMustHaveUrl": "CSV must have a 'url' column", 743 + "csvMustHaveText": "CSV must have a 'text' column (also matches: highlight, excerpt)", 744 + "errorParsing": "Error parsing CSV: {{message}}" 745 + }, 746 + 747 + "profile": { 748 + "notFound": "User not found", 749 + "notFoundMessage": "This profile doesn't exist or couldn't be loaded.", 750 + "edit": "Edit", 751 + "viewInBluesky": "View profile in Bluesky", 752 + "unblock": "Unblock @{{handle}}", 753 + "block": "Block @{{handle}}", 754 + "unmute": "Unmute @{{handle}}", 755 + "mute": "Mute @{{handle}}", 756 + "report": "Report", 757 + "accountLabeled": "Account labeled: {{description}}", 758 + "labelApplied": "This label was applied by a moderation service you subscribe to.", 759 + "show": "Show", 760 + "hide": "Hide", 761 + "blockedBanner": "You have blocked @{{handle}}", 762 + "blockedMessage": "Their content is hidden from your feeds.", 763 + "mutedBanner": "You have muted @{{handle}}", 764 + "mutedMessage": "Their content is hidden from your feeds.", 765 + "blockedByBanner": "@{{handle}} has blocked you. You cannot interact with their content.", 766 + "unblock_action": "Unblock", 767 + "unmute_action": "Unmute", 768 + "emptyCollectionsOwn": "You haven't created any collections yet.", 769 + "emptyCollectionsOther": "No collections", 770 + "itemCount_one": "{{count}} item", 771 + "itemCount_other": "{{count}} items", 772 + "emptyTabOwn": "Your {{tab}} will show up here.", 773 + "emptyTabOther": "Nothing to see here yet." 774 + }, 775 + 776 + "common": { 777 + "loading": "Loading...", 778 + "cancel": "Cancel", 779 + "save": "Save", 780 + "close": "Close", 781 + "back": "Back", 782 + "continue": "Continue", 783 + "error": "Error", 784 + "retry": "Retry", 785 + "loadMore": "Load more", 786 + "new": "New" 787 + }, 788 + 789 + "about": { 790 + "nav": { 791 + "getExtension": "Get Extension", 792 + "install": "Install" 793 + }, 794 + "hero": { 795 + "openSource": "Fully open source", 796 + "headline": "Write on the margins", 797 + "headlineAccent": "of the internet.", 798 + "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", 799 + "atProtocol": "AT Protocol", 800 + "descriptionPost": ". Not locked in a silo.", 801 + "openApp": "Open App", 802 + "getStarted": "Get Started", 803 + "installFor": "Install for {{browser}}" 804 + }, 805 + "features": { 806 + "title": "Everything you need to engage with the web", 807 + "subtitle": "More than bookmarks. A full toolkit for reading, thinking, and sharing on the open web.", 808 + "annotations": { 809 + "title": "Annotations", 810 + "description": "Leave notes on any web page. Start discussions, share insights, or just jot down your thoughts for later." 811 + }, 812 + "highlights": { 813 + "title": "Highlights", 814 + "description": "Select and highlight text on any page with customizable colors. Your highlights are rendered inline with the CSS Highlights API." 815 + }, 816 + "bookmarks": { 817 + "title": "Bookmarks", 818 + "description": "Save pages with one click or a keyboard shortcut. All your bookmarks are synced to your AT Protocol identity." 819 + }, 820 + "collections": { 821 + "title": "Collections", 822 + "description": "Organize your annotations, highlights, and bookmarks into themed collections. Share them publicly or keep them private." 823 + }, 824 + "socialDiscovery": { 825 + "title": "Social Discovery", 826 + "description": "See what others are saying about the pages you visit. Discover annotations, trending tags, and connect with other readers." 827 + }, 828 + "tagsSearch": { 829 + "title": "Tags & Search", 830 + "description": "Tag your annotations for easy retrieval. Search by URL, tag, or content to find exactly what you're looking for." 831 + } 832 + }, 833 + "extension": { 834 + "badge": "Browser Extension", 835 + "title": "Your annotation toolkit,", 836 + "titleLine2": "right in the browser", 837 + "description": "The Margin extension brings the full annotation experience directly into every page you visit. Just select, annotate, and go.", 838 + "iosShortcut": "iOS Shortcut", 839 + "features": { 840 + "inlineOverlay": { 841 + "title": "Inline Overlay", 842 + "description": "See annotations and highlights rendered directly on the page. Uses the CSS Highlights API for beautiful, native-feeling text underlines." 843 + }, 844 + "contextMenu": { 845 + "title": "Context Menu & Selection", 846 + "description": "Right-click any selected text to annotate, highlight, or quote it. Or just right-click the page to bookmark it instantly." 847 + }, 848 + "keyboard": { 849 + "title": "Keyboard Shortcuts", 850 + "description": "Toggle the overlay, bookmark the current page, or annotate selected text without reaching for the mouse." 851 + }, 852 + "sidePanel": { 853 + "title": "Side Panel", 854 + "description": "Open the Margin side panel to browse annotations, bookmarks, and collections without leaving the page you're reading." 855 + } 856 + } 857 + }, 858 + "protocol": { 859 + "badge": "Decentralized", 860 + "title": "Your data, your identity", 861 + "descriptionPre": "Margin is built on the", 862 + "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.", 863 + "point0": "Sign in with your AT Protocol handle, no new account needed", 864 + "point1": "Your data lives in your PDS, portable and under your control", 865 + "point2": "Custom Lexicon schemas for annotations, highlights, collections & more", 866 + "point3": "Fully open source, check out the code and contribute" 867 + }, 868 + "cta": { 869 + "title": "Start writing on the margins", 870 + "description": "Join the open annotation layer. Sign in with your AT Protocol identity and install the extension to get started.", 871 + "signIn": "Sign in", 872 + "viewGitHub": "View on GitHub", 873 + "viewTangled": "View on Tangled" 874 + }, 875 + "footer": { 876 + "privacy": "Privacy", 877 + "terms": "Terms", 878 + "contact": "Contact" 879 + } 880 + } 881 + }
+31 -23
web/src/components/common/Card.tsx
··· 1 1 import React, { useState } from "react"; 2 2 import { formatDistanceToNow } from "date-fns"; 3 + import { useTranslation } from "react-i18next"; 3 4 import RichText from "./RichText"; 4 5 import MoreMenu from "./MoreMenu"; 5 6 import type { MoreMenuItem } from "./MoreMenu"; ··· 119 120 hideCollection = false, 120 121 layout = "list", 121 122 }: CardProps) { 123 + const { t } = useTranslation(); 122 124 const [item, setItem] = useState(initialItem); 123 125 const user = useStore($user); 124 126 const preferences = useStore($preferences); ··· 229 231 }; 230 232 231 233 const handleDelete = async () => { 232 - if (window.confirm("Delete this item?")) { 234 + if (window.confirm(t("card.deleteConfirm"))) { 233 235 const success = await deleteItem(item.uri, type); 234 236 if (success && onDelete) { 235 237 analytics.capture("item_deleted", { type }); ··· 297 299 298 300 const timestamp = item.createdAt 299 301 ? formatDistanceToNow(new Date(item.createdAt), { addSuffix: false }) 300 - .replace("less than a minute", "just now") 302 + .replace("less than a minute", t("card.justNow")) 301 303 .replace("about ", "") 302 304 .replace(" hours", "h") 303 305 .replace(" hour", "h") ··· 347 349 }; 348 350 349 351 const displayTitle = decodeHTMLEntities( 350 - item.title || ogData?.title || pageTitle || "Untitled Bookmark", 352 + item.title || ogData?.title || pageTitle || t("card.untitledBookmark"), 351 353 ); 352 354 const displayDescription = 353 355 item.description || ogData?.description ··· 377 379 </span> 378 380 </a> 379 381 </ProfileHoverCard> 380 - <span>added to</span> 382 + <span>{t("card.addedToLower")}</span> 381 383 </> 382 384 ) : ( 383 - <span>Added to</span> 385 + <span>{t("card.addedTo")}</span> 384 386 )} 385 387 386 388 {item.context && item.context.length > 0 ? ( ··· 392 394 </span> 393 395 )} 394 396 {index > 0 && index === item.context!.length - 1 && ( 395 - <span>and</span> 397 + <span>{t("card.and")}</span> 396 398 )} 397 399 <a 398 400 href={`/${item.addedBy?.handle || ""}/collection/${(col.uri || "").split("/").pop()}`} ··· 463 465 className="ml-1 text-surface-400 dark:text-surface-500 hover:text-surface-600 dark:hover:text-surface-400 hover:underline cursor-pointer" 464 466 title={`Edited ${new Date(item.editedAt).toLocaleString()}`} 465 467 > 466 - (edited) 468 + {t("card.edited")} 467 469 </button> 468 470 )} 469 471 </span> ··· 495 497 className="h-3.5" 496 498 /> 497 499 <span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2.5 py-1 rounded-lg bg-surface-800 dark:bg-surface-700 text-white text-[11px] font-medium whitespace-nowrap opacity-0 group-hover/semble:opacity-100 transition-opacity shadow-lg"> 498 - Open in Semble 500 + {t("card.openInSemble")} 499 501 <span className="absolute top-full left-1/2 -translate-x-1/2 -mt-px border-4 border-transparent border-t-surface-800 dark:border-t-surface-700" /> 500 502 </span> 501 503 </button> ··· 514 516 className="text-surface-400 dark:text-surface-500 fill-current" 515 517 /> 516 518 <span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2.5 py-1 rounded-lg bg-surface-800 dark:bg-surface-700 text-white text-[11px] font-medium whitespace-nowrap opacity-0 group-hover/cb:opacity-100 transition-opacity shadow-lg"> 517 - Community bookmark 519 + {t("card.communityBookmark")} 518 520 <span className="absolute top-full left-1/2 -translate-x-1/2 -mt-px border-4 border-transparent border-t-surface-800 dark:border-t-surface-700" /> 519 521 </span> 520 522 </span> ··· 548 550 <div className="flex items-center gap-2 text-surface-500 dark:text-surface-400"> 549 551 <EyeOff size={16} /> 550 552 <span className="text-sm font-medium"> 551 - {contentWarning.description} 553 + {t(`card.labelDescriptions.${contentWarning.label}`, { 554 + defaultValue: contentWarning.description, 555 + })} 552 556 </span> 553 557 </div> 554 558 <button ··· 556 560 className="flex items-center gap-1.5 px-3 py-1 text-xs font-medium rounded-lg bg-surface-200 dark:bg-surface-700 text-surface-600 dark:text-surface-300 hover:bg-surface-300 dark:hover:bg-surface-600 transition-colors" 557 561 > 558 562 <Eye size={12} /> 559 - Show 563 + {t("card.show")} 560 564 </button> 561 565 </div> 562 566 )} ··· 566 570 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" 567 571 > 568 572 <EyeOff size={12} /> 569 - Hide Content 573 + {t("card.hideContent")} 570 574 </button> 571 575 )} 572 576 {!(contentWarning && !contentRevealed) && isBookmark && ( ··· 741 745 <button 742 746 onClick={() => setShowCollectionModal(true)} 743 747 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" 744 - title="Add to Collection" 748 + title={t("card.addToCollectionTitle")} 745 749 > 746 750 <FolderPlus size={16} /> 747 751 </button> ··· 764 768 <button 765 769 onClick={() => setShowConvertInput(true)} 766 770 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" 767 - title="Annotate this highlight" 771 + title={t("card.annotateTitle")} 768 772 > 769 773 <MessageSquare size={14} /> 770 - <span className="hidden sm:inline">Annotate</span> 774 + <span className="hidden sm:inline">{t("card.annotate")}</span> 771 775 </button> 772 776 )} 773 777 <button 774 778 onClick={() => setShowEditModal(true)} 775 779 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" 776 - title="Edit" 780 + title={t("card.editTitle")} 777 781 > 778 782 <Edit3 size={14} /> 779 783 </button> 780 784 <button 781 785 onClick={handleDelete} 782 786 className="flex items-center px-2.5 py-1.5 rounded-lg text-surface-400 dark:text-surface-500 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all" 783 - title="Delete" 787 + title={t("card.deleteTitle")} 784 788 > 785 789 <Trash2 size={14} /> 786 790 </button> ··· 794 798 items={(() => { 795 799 const menuItems: MoreMenuItem[] = [ 796 800 { 797 - label: "Report", 801 + label: t("card.report"), 798 802 icon: <Flag size={14} />, 799 803 onClick: () => setShowReportModal(true), 800 804 variant: "danger", 801 805 }, 802 806 { 803 - label: `Mute @${item.author?.handle || "user"}`, 807 + label: t("card.muteUser", { 808 + handle: item.author?.handle || "user", 809 + }), 804 810 icon: <VolumeX size={14} />, 805 811 onClick: async () => { 806 812 if (item.author?.did) { ··· 810 816 }, 811 817 }, 812 818 { 813 - label: `Block @${item.author?.handle || "user"}`, 819 + label: t("card.blockUser", { 820 + handle: item.author?.handle || "user", 821 + }), 814 822 icon: <ShieldBan size={14} />, 815 823 onClick: async () => { 816 824 if (item.author?.did) { ··· 839 847 <textarea 840 848 value={convertText} 841 849 onChange={(e) => setConvertText(e.target.value)} 842 - placeholder="Add your note to convert this highlight into an annotation..." 850 + placeholder={t("card.addNotePlaceholder")} 843 851 autoFocus 844 852 onKeyDown={(e) => { 845 853 if (e.key === "Enter" && !e.shiftKey) { ··· 858 866 onClick={handleConvert} 859 867 disabled={converting || !convertText.trim()} 860 868 className="p-2.5 bg-primary-600 text-white rounded-xl hover:bg-primary-700 disabled:opacity-40 disabled:cursor-not-allowed transition-all" 861 - title="Convert to annotation" 869 + title={t("card.convertToAnnotation")} 862 870 > 863 871 {converting ? ( 864 872 <div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent" /> ··· 872 880 setConvertText(""); 873 881 }} 874 882 className="p-2.5 text-surface-400 hover:text-surface-600 dark:hover:text-surface-200 hover:bg-surface-100 dark:hover:bg-surface-800 rounded-xl transition-all" 875 - title="Cancel" 883 + title={t("common.cancel")} 876 884 > 877 885 <X size={16} /> 878 886 </button>
+4 -2
web/src/components/common/ProfileHoverCard.tsx
··· 4 4 import { getProfile } from "../../api/client"; 5 5 import type { UserProfile } from "../../types"; 6 6 import { Loader2 } from "lucide-react"; 7 + import { useTranslation } from "react-i18next"; 7 8 8 9 interface ProfileHoverCardProps { 9 10 did?: string; ··· 18 19 children, 19 20 className, 20 21 }: ProfileHoverCardProps) { 22 + const { t } = useTranslation(); 21 23 const [isOpen, setIsOpen] = useState(false); 22 24 const [profile, setProfile] = useState<UserProfile | null>(null); 23 25 const [loading, setLoading] = useState(false); ··· 143 145 href={`/profile/${profile.did}`} 144 146 className="block w-full text-center py-2 bg-primary-600 hover:bg-primary-700 text-white text-sm font-medium rounded-lg transition-colors" 145 147 > 146 - View Profile 148 + {t("profileHoverCard.viewProfile")} 147 149 </a> 148 150 </div> 149 151 ) : ( 150 152 <p className="text-sm text-surface-500 text-center py-2"> 151 - Profile not found 153 + {t("profileHoverCard.notFound")} 152 154 </p> 153 155 )} 154 156 </div>
+34 -31
web/src/components/feed/Composer.tsx
··· 1 1 import React, { useState, useEffect } from "react"; 2 + import { useTranslation } from "react-i18next"; 2 3 import { 3 4 createAnnotation, 4 5 createHighlight, ··· 11 12 import TagInput from "../ui/TagInput"; 12 13 import { analytics } from "../../lib/analytics"; 13 14 14 - const SELF_LABEL_OPTIONS: { value: ContentLabelValue; label: string }[] = [ 15 - { value: "sexual", label: "Sexual" }, 16 - { value: "nudity", label: "Nudity" }, 17 - { value: "violence", label: "Violence" }, 18 - { value: "gore", label: "Gore" }, 19 - { value: "spam", label: "Spam" }, 20 - { value: "misleading", label: "Misleading" }, 15 + const SELF_LABEL_VALUES: ContentLabelValue[] = [ 16 + "sexual", 17 + "nudity", 18 + "violence", 19 + "gore", 20 + "spam", 21 + "misleading", 21 22 ]; 22 23 23 24 interface ComposerProps { ··· 33 34 onSuccess, 34 35 onCancel, 35 36 }: ComposerProps) { 37 + const { t } = useTranslation(); 36 38 const [text, setText] = useState(""); 37 39 const [quoteText, setQuoteText] = useState(""); 38 40 const [tags, setTags] = useState<string[]>([]); ··· 76 78 77 79 const modeCopy = { 78 80 highlight: { 79 - title: "New highlight", 81 + title: t("composer.newHighlight"), 80 82 icon: Highlighter, 81 - submit: "Save highlight", 82 - hint: "Saving a passage without a comment. Add text below to turn it into an annotation.", 83 + submit: t("composer.saveHighlight"), 84 + hint: t("composer.highlightHint"), 83 85 }, 84 86 annotation: { 85 - title: "New annotation", 87 + title: t("composer.newAnnotation"), 86 88 icon: PenLine, 87 - submit: "Post annotation", 89 + submit: t("composer.postAnnotation"), 88 90 hint: null, 89 91 }, 90 92 note: { 91 - title: "New note", 93 + title: t("composer.newNote"), 92 94 icon: PenLine, 93 - submit: "Post note", 95 + submit: t("composer.postNote"), 94 96 hint: null, 95 97 }, 96 98 }[mode]; ··· 221 223 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" 222 224 onClick={() => setShowQuoteInput(true)} 223 225 > 224 - + Add a quote from the page 226 + {t("composer.addQuote")} 225 227 </button> 226 228 ) : ( 227 229 <div className="flex flex-col gap-2"> 228 230 <textarea 229 231 value={quoteText} 230 232 onChange={(e) => setQuoteText(e.target.value)} 231 - placeholder="Paste or type the text you're annotating..." 233 + placeholder={t("composer.quotePlaceholder")} 232 234 className="w-full text-sm 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" 233 235 rows={2} 234 236 /> ··· 238 240 className="text-xs text-red-500 dark:text-red-400 font-medium" 239 241 onClick={handleRemoveSelector} 240 242 > 241 - Remove Quote 243 + {t("composer.removeQuote")} 242 244 </button> 243 245 </div> 244 246 </div> ··· 251 253 onChange={(e) => setText(e.target.value)} 252 254 placeholder={ 253 255 hasQuote 254 - ? "Add your thoughts on this passage..." 255 - : "What's on your mind?" 256 + ? t("composer.thoughtsPlaceholder") 257 + : t("composer.mindPlaceholder") 256 258 } 257 259 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" 258 260 maxLength={3000} ··· 263 265 tags={tags} 264 266 onChange={setTags} 265 267 suggestions={tagSuggestions} 266 - placeholder="Add tags..." 268 + placeholder={t("composer.tagsPlaceholder")} 267 269 disabled={loading} 268 270 /> 269 271 ··· 275 277 > 276 278 <ShieldAlert size={14} /> 277 279 <span> 278 - Content Warning 279 - {selfLabels.length > 0 ? ` (${selfLabels.length})` : ""} 280 + {selfLabels.length > 0 281 + ? t("composer.contentWarningCount", { count: selfLabels.length }) 282 + : t("composer.contentWarning")} 280 283 </span> 281 284 </button> 282 285 283 286 {showLabelPicker && ( 284 287 <div className="mt-2 flex flex-wrap gap-1.5"> 285 - {SELF_LABEL_OPTIONS.map((opt) => ( 288 + {SELF_LABEL_VALUES.map((value) => ( 286 289 <button 287 - key={opt.value} 290 + key={value} 288 291 type="button" 289 292 onClick={() => 290 293 setSelfLabels((prev) => 291 - prev.includes(opt.value) 292 - ? prev.filter((v) => v !== opt.value) 293 - : [...prev, opt.value], 294 + prev.includes(value) 295 + ? prev.filter((v) => v !== value) 296 + : [...prev, value], 294 297 ) 295 298 } 296 299 className={`px-2.5 py-1 text-xs font-medium rounded-lg transition-all ${ 297 - selfLabels.includes(opt.value) 300 + selfLabels.includes(value) 298 301 ? "bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 ring-1 ring-amber-300 dark:ring-amber-700" 299 302 : "bg-surface-100 dark:bg-surface-800 text-surface-500 dark:text-surface-400 hover:bg-surface-200 dark:hover:bg-surface-700" 300 303 }`} 301 304 > 302 - {opt.label} 305 + {t(`composer.labels.${value}`)} 303 306 </button> 304 307 ))} 305 308 </div> ··· 324 327 onClick={onCancel} 325 328 disabled={loading} 326 329 > 327 - Cancel 330 + {t("composer.cancel")} 328 331 </button> 329 332 )} 330 333 <button ··· 334 337 loading || (!text.trim() && !highlightedText && !quoteText.trim()) 335 338 } 336 339 > 337 - {loading ? "..." : modeCopy.submit} 340 + {loading ? "…" : modeCopy.submit} 338 341 </button> 339 342 </div> 340 343 </div>
+6 -4
web/src/components/feed/FeedItems.tsx
··· 1 1 import { Clock, Loader2 } from "lucide-react"; 2 2 import { useCallback, useEffect, useRef, useState } from "react"; 3 + import { useTranslation } from "react-i18next"; 3 4 import { type GetFeedParams, getFeed } from "../../api/client"; 4 5 import Card from "../../components/common/Card"; 5 6 import { EmptyState } from "../../components/ui"; ··· 38 39 initialItems, 39 40 initialHasMore, 40 41 }: FeedItemsProps) { 42 + const { t } = useTranslation(); 41 43 const [items, setItems] = useState<AnnotationItem[]>(initialItems || []); 42 44 const [loading, setLoading] = useState(!initialItems); 43 45 const [loadingMore, setLoadingMore] = useState(false); ··· 169 171 size={32} 170 172 /> 171 173 <p className="text-sm text-surface-400 dark:text-surface-500"> 172 - Loading... 174 + {t("feed.loading")} 173 175 </p> 174 176 </div> 175 177 ); ··· 179 181 return ( 180 182 <EmptyState 181 183 icon={<Clock size={48} />} 182 - title="Nothing here yet" 184 + title={t("feed.nothingHereYet")} 183 185 message={emptyMessage} 184 186 /> 185 187 ); ··· 196 198 {loadingMore ? ( 197 199 <> 198 200 <Loader2 size={16} className="animate-spin" /> 199 - Loading... 201 + {t("common.loading")} 200 202 </> 201 203 ) : ( 202 - "Load more" 204 + t("common.loadMore") 203 205 )} 204 206 </button> 205 207 </div>
+3 -1
web/src/components/feed/ReplyList.tsx
··· 1 1 import React from "react"; 2 2 import { formatDistanceToNow } from "date-fns"; 3 + import { useTranslation } from "react-i18next"; 3 4 import { MessageSquare, Trash2, Reply } from "lucide-react"; 4 5 import type { AnnotationItem, UserProfile } from "../../types"; 5 6 import { getAvatarUrl } from "../../api/client"; ··· 196 197 onDelete, 197 198 isInline = false, 198 199 }: ReplyListProps) { 200 + const { t } = useTranslation(); 199 201 if (!replies || replies.length === 0) { 200 202 return ( 201 203 <div className="py-8 text-center"> 202 204 <p className="text-surface-500 dark:text-surface-400 text-sm"> 203 - No replies yet 205 + {t("replyList.noReplies")} 204 206 </p> 205 207 </div> 206 208 );
+24 -20
web/src/components/modals/AddToCollectionModal.tsx
··· 1 1 import React, { useState, useEffect, useCallback } from "react"; 2 + import { useTranslation } from "react-i18next"; 2 3 import { 3 4 X, 4 5 Plus, ··· 34 35 onClose, 35 36 annotationUri, 36 37 }: AddToCollectionModalProps) { 38 + const { t } = useTranslation(); 37 39 const user = useStore($user); 38 40 const theme = useStore($theme); 39 41 const [collections, setCollections] = useState<Collection[]>([]); ··· 66 68 setCollections(data); 67 69 } catch (err) { 68 70 console.error(err); 69 - setError("Failed to load collections"); 71 + setError(t("addToCollection.failedLoad")); 70 72 } finally { 71 73 setLoading(false); 72 74 } 73 - }, [user]); 75 + }, [user, t]); 74 76 75 77 useEffect(() => { 76 78 if (isOpen && user) { ··· 92 94 analytics.capture("item_added_to_collection"); 93 95 } catch (err) { 94 96 console.error(err); 95 - setError("Failed to add to collection"); 97 + setError(t("addToCollection.failedAdd")); 96 98 } finally { 97 99 setAddingTo(null); 98 100 } ··· 122 124 } 123 125 } catch (err) { 124 126 console.error(err); 125 - setError("Failed to create collection"); 127 + setError(t("addToCollection.failedCreate")); 126 128 } finally { 127 129 setCreating(false); 128 130 } ··· 141 143 > 142 144 <div className="p-4 flex justify-between items-center border-b border-surface-100 dark:border-surface-800"> 143 145 <h2 className="text-xl font-display font-bold text-surface-900 dark:text-white"> 144 - Add to Collection 146 + {t("addToCollection.title")} 145 147 </h2> 146 148 <button 147 149 onClick={onClose} ··· 159 161 className="animate-spin text-primary-600 dark:text-primary-400 mx-auto mb-3" 160 162 /> 161 163 <p className="text-surface-500 dark:text-surface-400 font-medium"> 162 - Loading collections... 164 + {t("addToCollection.loading")} 163 165 </p> 164 166 </div> 165 167 ) : showNewForm ? ( 166 168 <form onSubmit={handleCreate} className="space-y-4"> 167 169 <div> 168 170 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1"> 169 - Collection name 171 + {t("addToCollection.collectionNameLabel")} 170 172 </label> 171 173 <input 172 174 type="text" 173 175 className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-xl focus:border-primary-500 dark:focus:border-primary-400 focus:ring-4 focus:ring-primary-500/10 outline-none transition-all text-surface-900 dark:text-white placeholder-surface-400 dark:placeholder-surface-500" 174 176 value={newName} 175 177 onChange={(e) => setNewName(e.target.value)} 176 - placeholder="My Collection" 178 + placeholder={t("addToCollection.namePlaceholder")} 177 179 autoFocus 178 180 /> 179 181 </div> 180 182 181 183 <div> 182 184 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1"> 183 - Description (optional) 185 + {t("addToCollection.descriptionLabel")} 184 186 </label> 185 187 <textarea 186 188 className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-xl focus:border-primary-500 dark:focus:border-primary-400 focus:ring-4 focus:ring-primary-500/10 outline-none transition-all text-surface-900 dark:text-white placeholder-surface-400 dark:placeholder-surface-500 resize-none" 187 189 value={newDescription} 188 190 onChange={(e) => setNewDescription(e.target.value)} 189 - placeholder="What's this collection about?" 191 + placeholder={t("addToCollection.descriptionPlaceholder")} 190 192 rows={2} 191 193 /> 192 194 </div> 193 195 194 196 <div> 195 197 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2"> 196 - Icon 198 + {t("addToCollection.iconLabel")} 197 199 </label> 198 200 199 201 <div className="flex gap-2 mb-3 bg-surface-100 dark:bg-surface-800 p-1 rounded-xl"> ··· 206 208 : "text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-200" 207 209 }`} 208 210 > 209 - Icons 211 + {t("addToCollection.iconsTab")} 210 212 </button> 211 213 <button 212 214 type="button" ··· 217 219 : "text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-200" 218 220 }`} 219 221 > 220 - Emojis 222 + {t("addToCollection.emojisTab")} 221 223 </button> 222 224 </div> 223 225 ··· 280 282 281 283 {newIcon && ( 282 284 <p className="mt-2 text-sm text-surface-600 dark:text-surface-300 flex items-center gap-2"> 283 - Selected: 285 + {t("addToCollection.selected")} 284 286 <span className="inline-flex items-center justify-center w-8 h-8 bg-surface-100 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700"> 285 287 <CollectionIcon 286 288 icon={ICON_MAP[newIcon] ? `icon:${newIcon}` : newIcon} ··· 308 310 setError(null); 309 311 }} 310 312 > 311 - Back 313 + {t("addToCollection.back")} 312 314 </button> 313 315 <button 314 316 type="submit" ··· 316 318 disabled={!newName.trim() || creating} 317 319 > 318 320 {creating && <Loader2 size={16} className="animate-spin" />} 319 - {creating ? "Creating..." : "Create"} 321 + {creating 322 + ? t("addToCollection.creating") 323 + : t("addToCollection.create")} 320 324 </button> 321 325 </div> 322 326 </form> ··· 337 341 </div> 338 342 <div className="flex-1 min-w-0"> 339 343 <h3 className="font-bold text-surface-900 dark:text-white group-hover:text-primary-700 dark:group-hover:text-primary-400 transition-colors"> 340 - New Collection 344 + {t("addToCollection.newCollectionButton")} 341 345 </h3> 342 346 <span className="text-sm text-surface-500 dark:text-surface-400"> 343 - Create a new collection 347 + {t("addToCollection.createNewDescription")} 344 348 </span> 345 349 </div> 346 350 <ChevronRight ··· 352 356 {collections.length === 0 ? ( 353 357 <div className="text-center py-6"> 354 358 <p className="text-surface-500 dark:text-surface-400"> 355 - No collections yet 359 + {t("addToCollection.none")} 356 360 </p> 357 361 </div> 358 362 ) : ( ··· 404 408 onClick={onClose} 405 409 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" 406 410 > 407 - Done 411 + {t("addToCollection.done")} 408 412 </button> 409 413 </div> 410 414 )}
+17 -13
web/src/components/modals/EditCollectionModal.tsx
··· 1 1 import React, { useState, useEffect } from "react"; 2 2 import { X, Loader2 } from "lucide-react"; 3 + import { useTranslation } from "react-i18next"; 3 4 import CollectionIcon from "../common/CollectionIcon"; 4 5 import { ICON_MAP } from "../common/iconMap"; 5 6 import { Theme } from "emoji-picker-react"; ··· 30 31 initialIsIcon || !collection.icon ? "icon" : "emoji", 31 32 ); 32 33 const [icon, setIcon] = useState(initialIconValue); 34 + const { t } = useTranslation(); 33 35 const [loading, setLoading] = useState(false); 34 36 const [error, setError] = useState<string | null>(null); 35 37 const theme = useStore($theme); ··· 74 76 onUpdate(updated); 75 77 onClose(); 76 78 } else { 77 - setError("Failed to update collection"); 79 + setError(t("editCollection.failedUpdate")); 78 80 } 79 81 } catch (err) { 80 82 console.error(err); 81 - setError("An error occurred while updating"); 83 + setError(t("editCollection.errorUpdating")); 82 84 } finally { 83 85 setLoading(false); 84 86 } ··· 97 99 > 98 100 <div className="p-4 flex justify-between items-center border-b border-surface-100 dark:border-surface-800"> 99 101 <h2 className="text-xl font-display font-bold text-surface-900 dark:text-white"> 100 - Edit Collection 102 + {t("editCollection.title")} 101 103 </h2> 102 104 <button 103 105 onClick={onClose} ··· 111 113 <form onSubmit={handleSubmit} className="space-y-4"> 112 114 <div> 113 115 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1"> 114 - Collection name 116 + {t("editCollection.nameLabel")} 115 117 </label> 116 118 <input 117 119 type="text" 118 120 className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-xl focus:border-primary-500 dark:focus:border-primary-400 focus:ring-4 focus:ring-primary-500/10 outline-none transition-all text-surface-900 dark:text-white placeholder-surface-400 dark:placeholder-surface-500" 119 121 value={name} 120 122 onChange={(e) => setName(e.target.value)} 121 - placeholder="My Collection" 123 + placeholder={t("editCollection.namePlaceholder")} 122 124 autoFocus 123 125 /> 124 126 </div> 125 127 126 128 <div> 127 129 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1"> 128 - Description (optional) 130 + {t("editCollection.descriptionLabel")} 129 131 </label> 130 132 <textarea 131 133 className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-xl focus:border-primary-500 dark:focus:border-primary-400 focus:ring-4 focus:ring-primary-500/10 outline-none transition-all text-surface-900 dark:text-white placeholder-surface-400 dark:placeholder-surface-500 resize-none" 132 134 value={description} 133 135 onChange={(e) => setDescription(e.target.value)} 134 - placeholder="What's this collection about?" 136 + placeholder={t("editCollection.descriptionPlaceholder")} 135 137 rows={3} 136 138 /> 137 139 </div> 138 140 139 141 <div> 140 142 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2"> 141 - Icon 143 + {t("editCollection.iconLabel")} 142 144 </label> 143 145 144 146 <div className="flex gap-2 mb-3 bg-surface-100 dark:bg-surface-800 p-1 rounded-xl"> ··· 151 153 : "text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-200" 152 154 }`} 153 155 > 154 - Icons 156 + {t("editCollection.iconsTab")} 155 157 </button> 156 158 <button 157 159 type="button" ··· 162 164 : "text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-200" 163 165 }`} 164 166 > 165 - Emojis 167 + {t("editCollection.emojisTab")} 166 168 </button> 167 169 </div> 168 170 ··· 223 225 224 226 {icon && ( 225 227 <p className="mt-2 text-sm text-surface-600 dark:text-surface-300 flex items-center gap-2"> 226 - Selected: 228 + {t("editCollection.selected")} 227 229 <span className="inline-flex items-center justify-center w-8 h-8 bg-surface-100 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700"> 228 230 <CollectionIcon 229 231 icon={ICON_MAP[icon] ? `icon:${icon}` : icon} ··· 246 248 className="flex-1 py-3 bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 text-surface-700 dark:text-surface-200 font-semibold rounded-xl hover:bg-surface-50 dark:hover:bg-surface-700 transition-colors" 247 249 onClick={onClose} 248 250 > 249 - Cancel 251 + {t("editCollection.cancel")} 250 252 </button> 251 253 <button 252 254 type="submit" ··· 254 256 disabled={!name.trim() || loading} 255 257 > 256 258 {loading && <Loader2 size={16} className="animate-spin" />} 257 - {loading ? "Saving..." : "Save Changes"} 259 + {loading 260 + ? t("editCollection.saving") 261 + : t("editCollection.save")} 258 262 </button> 259 263 </div> 260 264 </form>
+18 -10
web/src/components/modals/EditHistoryModal.tsx
··· 1 1 import React, { useState, useEffect } from "react"; 2 2 import { X, Loader2, History } from "lucide-react"; 3 + import { useTranslation } from "react-i18next"; 3 4 import { formatDistanceToNow } from "date-fns"; 4 5 import type { AnnotationItem, EditHistoryItem } from "../../types"; 5 6 ··· 14 15 onClose, 15 16 item, 16 17 }: EditHistoryModalProps) { 18 + const { t } = useTranslation(); 17 19 const [history, setHistory] = useState<EditHistoryItem[]>([]); 18 20 const [loading, setLoading] = useState(false); 19 21 const [error, setError] = useState<string | null>(null); ··· 33 35 setHistory(data); 34 36 } catch (err) { 35 37 console.error(err); 36 - setError("Failed to load edit history"); 38 + setError(t("editHistory.failedLoad")); 37 39 } finally { 38 40 setLoading(false); 39 41 } ··· 46 48 return () => { 47 49 document.body.style.overflow = "unset"; 48 50 }; 49 - }, [isOpen, item.uri]); 51 + }, [isOpen, item.uri, t]); 50 52 51 53 if (!isOpen) return null; 52 54 ··· 63 65 <div className="flex items-center gap-2"> 64 66 <History className="text-surface-500" size={20} /> 65 67 <h2 className="text-xl font-display font-bold text-surface-900 dark:text-white"> 66 - Edit History 68 + {t("editHistory.title")} 67 69 </h2> 68 70 </div> 69 71 <button ··· 83 85 <div className="p-8 text-center text-red-500">{error}</div> 84 86 ) : history.length === 0 ? ( 85 87 <div className="p-8 text-center text-surface-500"> 86 - No edit history found. 88 + {t("editHistory.noHistory")} 87 89 </div> 88 90 ) : ( 89 91 <div className="divide-y divide-surface-100 dark:divide-surface-800"> 90 92 <div className="p-4 bg-primary-50/50 dark:bg-primary-900/10"> 91 93 <div className="flex justify-between items-start mb-2"> 92 94 <span className="text-xs font-bold uppercase tracking-wider text-primary-600 dark:text-primary-400"> 93 - Current Version 95 + {t("editHistory.currentVersion")} 94 96 </span> 95 97 <span className="text-xs text-surface-400"> 96 98 {item.editedAt 97 - ? `Edited ${formatDistanceToNow(new Date(item.editedAt))} ago` 98 - : `Posted ${formatDistanceToNow(new Date(item.createdAt))} ago`} 99 + ? t("editHistory.editedAgo", { 100 + time: formatDistanceToNow(new Date(item.editedAt)), 101 + }) 102 + : t("editHistory.postedAgo", { 103 + time: formatDistanceToNow(new Date(item.createdAt)), 104 + })} 99 105 </span> 100 106 </div> 101 107 <div className="text-surface-900 dark:text-white whitespace-pre-wrap text-sm"> ··· 110 116 > 111 117 <div className="flex justify-between items-start mb-2"> 112 118 <span className="text-xs font-medium text-surface-500"> 113 - Previous Version 119 + {t("editHistory.previousVersion")} 114 120 </span> 115 121 <span 116 122 className="text-xs text-surface-400" 117 123 title={new Date(edit.editedAt).toLocaleString()} 118 124 > 119 - {formatDistanceToNow(new Date(edit.editedAt))} ago 125 + {t("editHistory.timeAgo", { 126 + time: formatDistanceToNow(new Date(edit.editedAt)), 127 + })} 120 128 </span> 121 129 </div> 122 130 <div className="text-surface-600 dark:text-surface-300 whitespace-pre-wrap text-sm"> ··· 133 141 onClick={onClose} 134 142 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" 135 143 > 136 - Close 144 + {t("editHistory.close")} 137 145 </button> 138 146 </div> 139 147 </div>
+31 -30
web/src/components/modals/EditItemModal.tsx
··· 1 1 import React, { useState, useEffect } from "react"; 2 + import { useTranslation } from "react-i18next"; 2 3 import { X, ShieldAlert } from "lucide-react"; 3 4 import { 4 5 updateAnnotation, ··· 11 12 import type { AnnotationItem, ContentLabelValue } from "../../types"; 12 13 import TagInput from "../ui/TagInput"; 13 14 14 - const SELF_LABEL_OPTIONS: { value: ContentLabelValue; label: string }[] = [ 15 - { value: "sexual", label: "Sexual" }, 16 - { value: "nudity", label: "Nudity" }, 17 - { value: "violence", label: "Violence" }, 18 - { value: "gore", label: "Gore" }, 19 - { value: "spam", label: "Spam" }, 20 - { value: "misleading", label: "Misleading" }, 15 + const SELF_LABEL_VALUES: ContentLabelValue[] = [ 16 + "sexual", 17 + "nudity", 18 + "violence", 19 + "gore", 20 + "spam", 21 + "misleading", 21 22 ]; 22 23 23 24 const HIGHLIGHT_COLORS = [ ··· 60 61 onClose, 61 62 onSaved, 62 63 }: Omit<EditItemModalProps, "isOpen">) { 64 + const { t } = useTranslation(); 63 65 const [text, setText] = useState(item.body?.value || ""); 64 66 const [tags, setTags] = useState<string[]>(item.tags || []); 65 67 const [tagSuggestions, setTagSuggestions] = useState<string[]>([]); ··· 137 139 } 138 140 } catch (e) { 139 141 console.error("Edit save error:", e); 140 - setError(e instanceof Error ? e.message : "Failed to save"); 142 + setError(e instanceof Error ? e.message : t("editItem.failedSave")); 141 143 setSaving(false); 142 144 return; 143 145 } 144 146 145 147 setSaving(false); 146 148 if (!success) { 147 - setError("Failed to save changes. Please try again."); 149 + setError(t("editItem.failedSave")); 148 150 return; 149 151 } 150 152 const updated = { ...item }; ··· 181 183 > 182 184 <div className="flex items-center justify-between px-5 py-4 border-b border-surface-200 dark:border-surface-700"> 183 185 <h3 className="text-lg font-semibold text-surface-900 dark:text-surface-100"> 184 - Edit{" "} 185 186 {type === "annotation" 186 - ? "Annotation" 187 + ? t("editItem.editAnnotation") 187 188 : type === "highlight" 188 - ? "Highlight" 189 - : "Bookmark"} 189 + ? t("editItem.editHighlight") 190 + : t("editItem.editBookmark")} 190 191 </h3> 191 192 <button 192 193 onClick={onClose} ··· 200 201 {type === "annotation" && ( 201 202 <div> 202 203 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5"> 203 - Text 204 + {t("editItem.textLabel")} 204 205 </label> 205 206 <textarea 206 207 value={text} ··· 208 209 rows={4} 209 210 maxLength={3000} 210 211 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" 211 - placeholder="Write your annotation..." 212 + placeholder={t("editItem.textPlaceholder")} 212 213 /> 213 214 <p className="text-xs text-surface-400 mt-1"> 214 215 {text.length}/3000 ··· 219 220 {type === "highlight" && ( 220 221 <div> 221 222 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2"> 222 - Color 223 + {t("editItem.colorLabel")} 223 224 </label> 224 225 <div className="flex gap-2"> 225 226 {HIGHLIGHT_COLORS.map((c) => ( ··· 247 248 <> 248 249 <div> 249 250 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5"> 250 - Title 251 + {t("editItem.titleLabel")} 251 252 </label> 252 253 <input 253 254 type="text" 254 255 value={title} 255 256 onChange={(e) => setTitle(e.target.value)} 256 257 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" 257 - placeholder="Bookmark title" 258 + placeholder={t("editItem.titlePlaceholder")} 258 259 /> 259 260 </div> 260 261 <div> 261 262 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5"> 262 - Description 263 + {t("editItem.descriptionLabel")} 263 264 </label> 264 265 <textarea 265 266 value={description} 266 267 onChange={(e) => setDescription(e.target.value)} 267 268 rows={3} 268 269 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" 269 - placeholder="Optional description..." 270 + placeholder={t("editItem.descriptionPlaceholder")} 270 271 /> 271 272 </div> 272 273 </> ··· 274 275 275 276 <div> 276 277 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5"> 277 - Tags 278 + {t("editItem.tagsLabel")} 278 279 </label> 279 280 <TagInput 280 281 tags={tags} 281 282 onChange={setTags} 282 283 suggestions={tagSuggestions} 283 - placeholder="Add a tag..." 284 + placeholder={t("editItem.tagPlaceholder")} 284 285 /> 285 286 </div> 286 287 ··· 294 295 }`} 295 296 > 296 297 <ShieldAlert size={16} /> 297 - Content Warning 298 + {t("editItem.contentWarning")} 298 299 {selfLabels.length > 0 && ( 299 300 <span className="text-xs bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300 px-1.5 py-0.5 rounded-full"> 300 301 {selfLabels.length} ··· 303 304 </button> 304 305 {showLabelPicker && ( 305 306 <div className="flex flex-wrap gap-1.5 mt-2"> 306 - {SELF_LABEL_OPTIONS.map((opt) => ( 307 + {SELF_LABEL_VALUES.map((val) => ( 307 308 <button 308 - key={opt.value} 309 - onClick={() => toggleLabel(opt.value)} 309 + key={val} 310 + onClick={() => toggleLabel(val)} 310 311 className={`px-3 py-1 rounded-full text-xs font-medium border transition-all ${ 311 - selfLabels.includes(opt.value) 312 + selfLabels.includes(val) 312 313 ? "bg-amber-100 dark:bg-amber-900/40 border-amber-300 dark:border-amber-700 text-amber-800 dark:text-amber-200" 313 314 : "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" 314 315 }`} 315 316 > 316 - {opt.label} 317 + {t(`composer.labels.${val}`)} 317 318 </button> 318 319 ))} 319 320 </div> ··· 328 329 onClick={onClose} 329 330 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" 330 331 > 331 - Cancel 332 + {t("editItem.cancel")} 332 333 </button> 333 334 <button 334 335 onClick={handleSave} 335 336 disabled={saving || (type === "annotation" && !text.trim())} 336 337 className="px-4 py-2 rounded-xl bg-primary-500 text-white text-sm font-medium hover:bg-primary-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors" 337 338 > 338 - {saving ? "Saving..." : "Save"} 339 + {saving ? t("editItem.saving") : t("editItem.save")} 339 340 </button> 340 341 </div> 341 342 </div>
+19 -14
web/src/components/modals/EditProfileModal.tsx
··· 2 2 import { updateProfile, uploadAvatar, getAvatarUrl } from "../../api/client"; 3 3 import type { UserProfile } from "../../types"; 4 4 import { Loader2, X, Plus, User as UserIcon } from "lucide-react"; 5 + import { useTranslation } from "react-i18next"; 5 6 6 7 interface EditProfileModalProps { 7 8 profile: UserProfile; ··· 14 15 onClose, 15 16 onUpdate, 16 17 }: EditProfileModalProps) { 18 + const { t } = useTranslation(); 17 19 const [displayName, setDisplayName] = useState(profile.displayName || ""); 18 20 const [description, setDescription] = useState(profile.description || ""); 19 21 const [website, setWebsite] = useState(profile.website || ""); ··· 33 35 if (!file) return; 34 36 35 37 if (!["image/jpeg", "image/png"].includes(file.type)) { 36 - setError("Please select a JPEG or PNG image"); 38 + setError(t("editProfile.avatarTypeError")); 37 39 return; 38 40 } 39 41 40 42 if (file.size > 1024 * 1024 * 2) { 41 - setError("Image must be under 2MB"); 43 + setError(t("editProfile.avatarSizeError")); 42 44 return; 43 45 } 44 46 ··· 52 54 setAvatarBlob(result.blob); 53 55 } catch (err) { 54 56 setError( 55 - "Failed to upload: " + 56 - (err instanceof Error ? err.message : "Unknown error"), 57 + t("editProfile.avatarUploadError", { 58 + message: err instanceof Error ? err.message : "Unknown error", 59 + }), 57 60 ); 58 61 setAvatarPreview(null); 59 62 } finally { ··· 117 120 > 118 121 <div className="flex items-center justify-between p-4 border-b border-surface-100 dark:border-surface-800"> 119 122 <h2 className="text-lg font-bold text-surface-900 dark:text-white"> 120 - Edit Profile 123 + {t("editProfile.title")} 121 124 </h2> 122 125 <button 123 126 onClick={onClose} ··· 139 142 140 143 <div className="mb-5"> 141 144 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2"> 142 - Avatar 145 + {t("editProfile.avatarLabel")} 143 146 </label> 144 147 <div className="flex items-center gap-3"> 145 148 <div ··· 174 177 className="px-3 py-1.5 rounded-lg bg-surface-100 dark:bg-surface-800 hover:bg-surface-200 dark:hover:bg-surface-700 text-surface-900 dark:text-white font-medium text-sm transition-colors" 175 178 disabled={uploading} 176 179 > 177 - {uploading ? "Uploading..." : "Upload"} 180 + {uploading 181 + ? t("editProfile.uploading") 182 + : t("editProfile.uploadButton")} 178 183 </button> 179 184 </div> 180 185 </div> 181 186 182 187 <div className="mb-4"> 183 188 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1"> 184 - Display Name 189 + {t("editProfile.displayNameLabel")} 185 190 </label> 186 191 <input 187 192 type="text" ··· 194 199 195 200 <div className="mb-4"> 196 201 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1"> 197 - Bio 202 + {t("editProfile.bioLabel")} 198 203 </label> 199 204 <textarea 200 205 value={description} ··· 206 211 207 212 <div className="mb-4"> 208 213 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1"> 209 - Website 214 + {t("editProfile.websiteLabel")} 210 215 </label> 211 216 <input 212 217 type="url" ··· 219 224 220 225 <div className="mb-5"> 221 226 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1"> 222 - Links 227 + {t("editProfile.linksLabel")} 223 228 </label> 224 229 <div className="space-y-2"> 225 230 {links.map((link, i) => ( ··· 244 249 type="url" 245 250 value={newLink} 246 251 onChange={(e) => setNewLink(e.target.value)} 247 - placeholder="Add a link..." 252 + placeholder={t("editProfile.addLinkPlaceholder")} 248 253 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" 249 254 onKeyDown={(e) => 250 255 e.key === "Enter" && (e.preventDefault(), handleAddLink()) ··· 268 273 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" 269 274 disabled={saving} 270 275 > 271 - Cancel 276 + {t("editProfile.cancel")} 272 277 </button> 273 278 <button 274 279 type="submit" ··· 276 281 disabled={saving} 277 282 > 278 283 {saving && <Loader2 size={14} className="animate-spin" />} 279 - {saving ? "Saving..." : "Save"} 284 + {saving ? t("editProfile.saving") : t("editProfile.save")} 280 285 </button> 281 286 </div> 282 287 </form>
+7 -8
web/src/components/modals/ExternalLinkModal.tsx
··· 2 2 import { Button } from "../ui"; 3 3 import { ExternalLink, Shield } from "lucide-react"; 4 4 import { addSkippedHostname } from "../../store/preferences"; 5 + import { useTranslation } from "react-i18next"; 5 6 6 7 interface ExternalLinkModalProps { 7 8 isOpen: boolean; ··· 14 15 onClose, 15 16 url, 16 17 }: ExternalLinkModalProps) { 18 + const { t } = useTranslation(); 17 19 const [dontAskAgain, setDontAskAgain] = useState(false); 18 20 19 21 if (!isOpen || !url) return null; ··· 57 59 </div> 58 60 <div className="min-w-0"> 59 61 <h2 className="text-base font-semibold text-surface-900 dark:text-white"> 60 - Leaving Margin 62 + {t("externalLink.title")} 61 63 </h2> 62 64 <p className="text-sm text-surface-500 dark:text-surface-400 mt-1"> 63 - You're about to visit an external site. 65 + {t("externalLink.message")} 64 66 </p> 65 67 </div> 66 68 </div> ··· 85 87 className="rounded border-surface-300 dark:border-surface-600 text-primary-600 focus:ring-primary-500 w-3.5 h-3.5 cursor-pointer" 86 88 /> 87 89 <span className="text-xs text-surface-500 dark:text-surface-400 group-hover:text-surface-600 dark:group-hover:text-surface-300 transition-colors"> 88 - Always allow links to{" "} 89 - <span className="font-medium text-surface-700 dark:text-surface-200"> 90 - {hostname} 91 - </span> 90 + {t("externalLink.alwaysAllow", { hostname })} 92 91 </span> 93 92 </label> 94 93 ··· 98 97 variant="ghost" 99 98 className="flex-1 justify-center" 100 99 > 101 - Cancel 100 + {t("externalLink.cancel")} 102 101 </Button> 103 102 <Button 104 103 onClick={handleContinue} ··· 106 105 className="flex-1 justify-center" 107 106 icon={<ExternalLink size={14} />} 108 107 > 109 - Open Link 108 + {t("externalLink.open")} 110 109 </Button> 111 110 </div> 112 111 </div>
+18 -39
web/src/components/modals/IOSShortcutModal.tsx
··· 1 1 import React from "react"; 2 2 import { Button } from "../ui"; 3 - import { 4 - X, 5 - ExternalLink, 6 - Key, 7 - Share, 8 - Bookmark, 9 - PenTool, 10 - MoreHorizontal, 11 - } from "lucide-react"; 3 + import { X, ExternalLink, Key, Bookmark, PenTool } from "lucide-react"; 12 4 import { AppleIcon } from "../common/Icons"; 5 + import { useTranslation } from "react-i18next"; 13 6 14 7 interface IOSShortcutModalProps { 15 8 isOpen: boolean; ··· 20 13 isOpen, 21 14 onClose, 22 15 }: IOSShortcutModalProps) { 16 + const { t } = useTranslation(); 23 17 if (!isOpen) return null; 24 18 25 19 return ( ··· 37 31 <AppleIcon size={16} /> 38 32 </div> 39 33 <h2 className="text-lg font-semibold text-surface-900 dark:text-white"> 40 - Save from iOS Safari 34 + {t("iosShortcut.title")} 41 35 </h2> 42 36 </div> 43 37 <button ··· 63 57 64 58 <div className="space-y-4"> 65 59 <h3 className="text-sm font-semibold text-surface-900 dark:text-white uppercase tracking-wider"> 66 - How to use the shortcut 60 + {t("iosShortcut.howTo")} 67 61 </h3> 68 62 69 63 <div className="space-y-3"> ··· 73 67 </div> 74 68 <div> 75 69 <p className="text-surface-900 dark:text-white font-medium"> 76 - Install the shortcut 70 + {t("iosShortcut.step1Title")} 77 71 </p> 78 72 <a 79 73 href="https://www.icloud.com/shortcuts/1e33ebf52f55431fae1e187cfe9738c3" ··· 81 75 rel="noopener noreferrer" 82 76 className="inline-flex items-center gap-1.5 mt-1.5 px-3 py-1.5 bg-surface-100 dark:bg-surface-800 hover:bg-surface-200 dark:hover:bg-surface-700 text-surface-900 dark:text-white rounded-lg text-xs font-medium transition-colors" 83 77 > 84 - <ExternalLink size={14} /> Get iOS Shortcut 78 + <ExternalLink size={14} /> {t("iosShortcut.step1Link")} 85 79 </a> 86 80 </div> 87 81 </div> ··· 92 86 </div> 93 87 <div> 94 88 <p className="text-surface-900 dark:text-white font-medium flex items-center gap-1.5"> 95 - Generate an API Key{" "} 89 + {t("iosShortcut.step2Title")}{" "} 96 90 <Key size={14} className="text-surface-400" /> 97 91 </p> 98 92 <p className="text-surface-600 dark:text-surface-400 mt-0.5"> 99 - Create a new key on this settings page and copy it. 93 + {t("iosShortcut.step2Description")} 100 94 </p> 101 95 </div> 102 96 </div> ··· 107 101 </div> 108 102 <div> 109 103 <p className="text-surface-900 dark:text-white font-medium"> 110 - Configure the shortcut 104 + {t("iosShortcut.step3Title")} 111 105 </p> 112 106 <p className="text-surface-600 dark:text-surface-400 mt-0.5"> 113 - In the Shortcuts app, click the{" "} 114 - <MoreHorizontal size={14} className="inline mx-0.5" /> menu 115 - on the <strong>Save to Margin</strong> shortcut, and paste 116 - your API key in the Text action right below the setup 117 - comment. 107 + {t("iosShortcut.step3Description")} 118 108 </p> 119 109 </div> 120 110 </div> ··· 125 115 </div> 126 116 <div> 127 117 <p className="text-surface-900 dark:text-white font-medium flex items-center gap-1.5"> 128 - To Bookmark a page{" "} 118 + {t("iosShortcut.step4Title")}{" "} 129 119 <Bookmark size={14} className="text-surface-400" /> 130 120 </p> 131 121 <p className="text-surface-600 dark:text-surface-400 mt-0.5"> 132 - Don't select any text. Click the{" "} 133 - <MoreHorizontal size={14} className="inline mx-0.5" /> menu 134 - in Safari, press{" "} 135 - <Share size={12} className="inline mx-0.5" />{" "} 136 - <strong>Share</strong>, and select{" "} 137 - <strong>Save to Margin</strong>. 122 + {t("iosShortcut.step4Description")} 138 123 </p> 139 124 </div> 140 125 </div> ··· 145 130 </div> 146 131 <div> 147 132 <p className="text-surface-900 dark:text-white font-medium flex items-center gap-1.5"> 148 - To Highlight text{" "} 133 + {t("iosShortcut.step5Title")}{" "} 149 134 <PenTool size={14} className="text-surface-400" /> 150 135 </p> 151 136 <p className="text-surface-600 dark:text-surface-400 mt-0.5"> 152 - Select text on the page, click the{" "} 153 - <MoreHorizontal size={14} className="inline mx-0.5" /> menu, 154 - press <strong>Share</strong>, and select{" "} 155 - <strong>Save to Margin</strong>. Leave the Note field empty. 137 + {t("iosShortcut.step5Description")} 156 138 </p> 157 139 </div> 158 140 </div> ··· 163 145 </div> 164 146 <div> 165 147 <p className="text-surface-900 dark:text-white font-medium"> 166 - To Add an Annotation 148 + {t("iosShortcut.step6Title")} 167 149 </p> 168 150 <p className="text-surface-600 dark:text-surface-400 mt-0.5"> 169 - Select text, share to <strong>Save to Margin</strong> (via 170 - the <MoreHorizontal size={14} className="inline mx-0.5" />{" "} 171 - menu), enter your custom note in the Note field, and press 172 - Done! 151 + {t("iosShortcut.step6Description")} 173 152 </p> 174 153 </div> 175 154 </div> ··· 183 162 variant="primary" 184 163 className="w-full justify-center" 185 164 > 186 - Got it 165 + {t("iosShortcut.gotIt")} 187 166 </Button> 188 167 </div> 189 168 </div>
+25 -54
web/src/components/modals/ReportModal.tsx
··· 1 1 import React, { useState } from "react"; 2 2 import { Flag, X } from "lucide-react"; 3 + import { useTranslation } from "react-i18next"; 3 4 import { reportUser } from "../../api/client"; 4 5 import type { ReportReasonType } from "../../types"; 5 6 ··· 11 12 subjectHandle?: string; 12 13 } 13 14 14 - const REASONS: { 15 - value: ReportReasonType; 16 - label: string; 17 - description: string; 18 - }[] = [ 19 - { value: "spam", label: "Spam", description: "Unwanted repetitive content" }, 20 - { 21 - value: "violation", 22 - label: "Rule violation", 23 - description: "Violates community guidelines", 24 - }, 25 - { 26 - value: "misleading", 27 - label: "Misleading", 28 - description: "False or misleading information", 29 - }, 30 - { 31 - value: "rude", 32 - label: "Rude or harassing", 33 - description: "Targeting or harassing a user", 34 - }, 35 - { 36 - value: "sexual", 37 - label: "Inappropriate content", 38 - description: "Sexual or explicit material", 39 - }, 40 - { 41 - value: "other", 42 - label: "Other", 43 - description: "Something else not listed above", 44 - }, 15 + const REASON_VALUES: { value: ReportReasonType; descKey: string }[] = [ 16 + { value: "spam", descKey: "spam" }, 17 + { value: "violation", descKey: "ruleViolation" }, 18 + { value: "misleading", descKey: "misleading" }, 19 + { value: "rude", descKey: "rudeOrHarassing" }, 20 + { value: "sexual", descKey: "inappropriateContent" }, 21 + { value: "other", descKey: "other" }, 45 22 ]; 46 23 47 24 export default function ReportModal({ ··· 51 28 subjectUri, 52 29 subjectHandle, 53 30 }: ReportModalProps) { 31 + const { t } = useTranslation(); 54 32 const [selectedReason, setSelectedReason] = useState<ReportReasonType | null>( 55 33 null, 56 34 ); ··· 105 83 <Flag size={20} className="text-green-600 dark:text-green-400" /> 106 84 </div> 107 85 <h3 className="text-lg font-semibold text-surface-900 dark:text-white"> 108 - Report submitted 86 + {t("report.submitted")} 109 87 </h3> 110 88 <p className="text-surface-500 dark:text-surface-400 text-sm mt-1"> 111 - Thank you. We'll review this shortly. 89 + {t("report.submittedMessage")} 112 90 </p> 113 91 </div> 114 92 ) : ( ··· 120 98 </div> 121 99 <div> 122 100 <h3 className="text-base font-semibold text-surface-900 dark:text-white"> 123 - Report {subjectHandle ? `@${subjectHandle}` : "user"} 101 + {subjectHandle 102 + ? t("report.titleUser", { handle: subjectHandle }) 103 + : t("report.titleGeneric")} 124 104 </h3> 125 105 {subjectUri && ( 126 106 <p className="text-xs text-surface-400 dark:text-surface-500"> 127 - Reporting specific content 107 + {t("report.reportingContent")} 128 108 </p> 129 109 )} 130 110 </div> ··· 139 119 140 120 <div className="p-4 space-y-2"> 141 121 <p className="text-sm font-medium text-surface-700 dark:text-surface-300 mb-3"> 142 - What's the issue? 122 + {t("report.issueLabel")} 143 123 </p> 144 - {REASONS.map((reason) => ( 124 + {REASON_VALUES.map((r) => ( 145 125 <button 146 - key={reason.value} 147 - onClick={() => setSelectedReason(reason.value)} 126 + key={r.value} 127 + onClick={() => setSelectedReason(r.value)} 148 128 className={`w-full text-left px-3.5 py-2.5 rounded-xl border transition-all ${ 149 - selectedReason === reason.value 129 + selectedReason === r.value 150 130 ? "border-primary-500 bg-primary-50 dark:bg-primary-900/20" 151 131 : "border-surface-200 dark:border-surface-700 hover:border-surface-300 dark:hover:border-surface-600" 152 132 }`} 153 133 > 154 134 <span 155 135 className={`text-sm font-medium ${ 156 - selectedReason === reason.value 136 + selectedReason === r.value 157 137 ? "text-primary-700 dark:text-primary-300" 158 138 : "text-surface-800 dark:text-surface-200" 159 139 }`} 160 140 > 161 - {reason.label} 162 - </span> 163 - <span 164 - className={`block text-xs mt-0.5 ${ 165 - selectedReason === reason.value 166 - ? "text-primary-600/70 dark:text-primary-400/70" 167 - : "text-surface-400 dark:text-surface-500" 168 - }`} 169 - > 170 - {reason.description} 141 + {t(`report.reasons.${r.descKey}`)} 171 142 </span> 172 143 </button> 173 144 ))} ··· 178 149 <textarea 179 150 value={additionalText} 180 151 onChange={(e) => setAdditionalText(e.target.value)} 181 - placeholder="Additional details (optional)" 152 + placeholder={t("report.detailsPlaceholder")} 182 153 rows={2} 183 154 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" 184 155 /> ··· 190 161 onClick={handleClose} 191 162 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" 192 163 > 193 - Cancel 164 + {t("report.cancel")} 194 165 </button> 195 166 <button 196 167 onClick={handleSubmit} 197 168 disabled={!selectedReason || submitting} 198 169 className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-xl transition-colors disabled:opacity-50 disabled:cursor-not-allowed" 199 170 > 200 - {submitting ? "Submitting…" : "Submit Report"} 171 + {submitting ? t("report.submitting") : t("report.submit")} 201 172 </button> 202 173 </div> 203 174 </>
+12 -8
web/src/components/modals/ShareMenu.tsx
··· 15 15 DeerIcon, 16 16 } from "../common/Icons"; 17 17 import { analytics } from "../../lib/analytics"; 18 + import { useTranslation } from "react-i18next"; 18 19 19 20 const SembleLogo = () => ( 20 21 <img src="/semble-logo.svg" alt="Semble" className="w-4 h-4 opacity-90" /> ··· 39 40 type, 40 41 url, 41 42 }: ShareMenuProps) { 43 + const { t } = useTranslation(); 42 44 const [isOpen, setIsOpen] = useState(false); 43 45 const [copied, setCopied] = useState<string | null>(null); 44 46 const menuRef = useRef<HTMLDivElement>(null); ··· 199 201 icon 200 202 )} 201 203 </span> 202 - <span className="flex-1 text-left">{isCopied ? "Copied!" : label}</span> 204 + <span className="flex-1 text-left"> 205 + {isCopied ? t("shareMenu.copied") : label} 206 + </span> 203 207 </button> 204 208 ); 205 209 ··· 249 253 <> 250 254 <div className="px-3 py-2 text-[11px] font-bold text-surface-400 dark:text-surface-500 uppercase tracking-wider flex items-center gap-1.5 select-none"> 251 255 <SembleLogo /> 252 - Semble Integration 256 + {t("shareMenu.sembleIntegration")} 253 257 </div> 254 258 {renderMenuItem( 255 - "Open on Semble", 259 + t("shareMenu.openOnSemble"), 256 260 <ExternalLink size={16} />, 257 261 () => window.open(sembleUrl, "_blank"), 258 262 false, 259 263 true, 260 264 )} 261 265 {renderMenuItem( 262 - "Copy Semble Link", 266 + t("shareMenu.copySembleLink"), 263 267 <Copy size={16} />, 264 268 () => handleCopy(sembleUrl, "semble"), 265 269 copied === "semble", ··· 269 273 ) : null} 270 274 271 275 {renderMenuItem( 272 - "Copy Link", 276 + t("shareMenu.copyLink"), 273 277 <Copy size={16} />, 274 278 () => handleCopy(shareUrl, "link"), 275 279 copied === "link", 276 280 )} 277 281 278 282 <div className="px-3 pt-3 pb-1 text-[11px] font-bold text-surface-400 dark:text-surface-500 uppercase tracking-wider select-none"> 279 - Share via App 283 + {t("shareMenu.shareViaApp")} 280 284 </div> 281 285 282 286 <div className="grid grid-cols-5 gap-1 px-1 mb-1"> ··· 295 299 <div className="h-px bg-surface-100 dark:bg-surface-800 my-1 mx-2" /> 296 300 297 301 {renderMenuItem( 298 - "Copy Universal Link", 302 + t("shareMenu.copyUniversalLink"), 299 303 <AturiIcon size={16} />, 300 304 () => 301 305 handleCopy(uri.replace("at://", "https://aturi.to/"), "aturi"), ··· 305 309 {typeof navigator !== "undefined" && 306 310 navigator.share && 307 311 renderMenuItem( 308 - "More Options...", 312 + t("shareMenu.moreOptions"), 309 313 <MoreHorizontal size={16} />, 310 314 () => { 311 315 navigator
+59 -69
web/src/components/modals/SignUpModal.tsx
··· 1 1 import React, { useState, useEffect, useMemo } from "react"; 2 2 import { X, ChevronRight, Loader2, AlertCircle } from "lucide-react"; 3 + import { useTranslation } from "react-i18next"; 3 4 import { 4 5 BlackskyIcon, 5 6 NorthskyIcon, ··· 20 21 wide?: boolean; 21 22 } 22 23 23 - const MARGIN_PROVIDER: Provider = { 24 + type ProviderBase = { 25 + id: string; 26 + service: string; 27 + Icon: React.ComponentType<{ size?: number }> | null; 28 + custom?: boolean; 29 + }; 30 + 31 + const MARGIN_PROVIDER_BASE: ProviderBase = { 24 32 id: "margin", 25 - name: "Margin", 26 33 service: "https://margin.cafe", 27 34 Icon: MarginIcon, 28 - description: "The easiest way to get started", 29 35 }; 30 36 31 - const OTHER_PROVIDERS: Provider[] = [ 32 - { 33 - id: "bluesky", 34 - name: "Bluesky", 35 - service: "https://bsky.social", 36 - Icon: BlueskyIcon, 37 - description: "The largest and most popular community", 38 - }, 39 - { 40 - id: "blacksky", 41 - name: "Blacksky", 42 - service: "https://blacksky.app", 43 - Icon: BlackskyIcon, 44 - description: "For the Culture — a safe space for users and allies", 45 - }, 46 - { 47 - id: "eurosky", 48 - name: "Eurosky", 49 - service: "https://eurosky.social", 50 - Icon: null, 51 - description: "Eurosky is your European home on the Atmosphere", 52 - }, 53 - { 54 - id: "selfhosted.social", 55 - name: "selfhosted.social", 56 - service: "https://selfhosted.social", 57 - Icon: null, 58 - description: "A home for builders, tinkerers, and the curious", 59 - }, 60 - { 61 - id: "northsky", 62 - name: "Northsky", 63 - service: "https://northsky.social", 64 - Icon: NorthskyIcon, 65 - description: "A Canadian worker-owned cooperative", 66 - }, 67 - { 68 - id: "tophhie", 69 - name: "Tophhie", 70 - service: "https://tophhie.social", 71 - Icon: TophhieIcon, 72 - description: "A welcoming and friendly community", 73 - }, 74 - { 75 - id: "custom", 76 - name: "Use a custom PDS", 77 - service: "", 78 - custom: true, 79 - Icon: null, 80 - description: "Already have a PDS? Enter its address.", 81 - }, 37 + const OTHER_PROVIDERS_BASE: ProviderBase[] = [ 38 + { id: "bluesky", service: "https://bsky.social", Icon: BlueskyIcon }, 39 + { id: "blacksky", service: "https://blacksky.app", Icon: BlackskyIcon }, 40 + { id: "eurosky", service: "https://eurosky.social", Icon: null }, 41 + { id: "selfhosted.social", service: "https://selfhosted.social", Icon: null }, 42 + { id: "northsky", service: "https://northsky.social", Icon: NorthskyIcon }, 43 + { id: "tophhie", service: "https://tophhie.social", Icon: TophhieIcon }, 44 + { id: "custom", service: "", custom: true, Icon: null }, 82 45 ]; 83 46 84 47 function shuffleArray<T>(arr: T[]): T[] { ··· 93 56 const inviteStatusPromise: Promise<Record<string, boolean>> = (async () => { 94 57 const results: Record<string, boolean> = {}; 95 58 await Promise.allSettled( 96 - [MARGIN_PROVIDER, ...OTHER_PROVIDERS] 59 + [MARGIN_PROVIDER_BASE, ...OTHER_PROVIDERS_BASE] 97 60 .filter((p) => p.service && !p.custom) 98 61 .map(async (p) => { 99 62 try { ··· 117 80 } 118 81 119 82 export default function SignUpModal({ onClose }: SignUpModalProps) { 83 + const { t } = useTranslation(); 120 84 const [showCustomInput, setShowCustomInput] = useState(false); 121 85 const [customService, setCustomService] = useState(""); 122 86 const [loading, setLoading] = useState(false); ··· 124 88 const [inviteStatus, setInviteStatus] = useState<Record<string, boolean>>({}); 125 89 const [statusLoaded, setStatusLoaded] = useState(false); 126 90 91 + const MARGIN_PROVIDER: Provider = { 92 + ...MARGIN_PROVIDER_BASE, 93 + name: t("signUp.providers.margin.name"), 94 + description: t("signUp.providers.margin.description"), 95 + }; 96 + 97 + const providerI18nKey: Record<string, string> = { 98 + bluesky: "bluesky", 99 + blacksky: "blacksky", 100 + eurosky: "eurosky", 101 + "selfhosted.social": "selfhostedSocial", 102 + northsky: "northsky", 103 + tophhie: "tophhie", 104 + custom: "customPds", 105 + }; 106 + const OTHER_PROVIDERS: Provider[] = OTHER_PROVIDERS_BASE.map((p) => { 107 + const k = providerI18nKey[p.id] || p.id; 108 + return { 109 + ...p, 110 + name: t(`signUp.providers.${k}.name`), 111 + description: t(`signUp.providers.${k}.description`), 112 + }; 113 + }); 114 + 127 115 useEffect(() => { 128 116 inviteStatusPromise.then((status) => { 129 117 setInviteStatus(status); ··· 151 139 ...shuffleArray(inviteOnly), 152 140 ...(custom ? [custom] : []), 153 141 ]; 142 + // eslint-disable-next-line react-hooks/exhaustive-deps 154 143 }, [statusLoaded, inviteStatus]); 155 144 156 145 useEffect(() => { ··· 178 167 } catch (err) { 179 168 console.error(err); 180 169 analytics.captureException(err); 181 - setError("Could not connect to this provider. Please try again."); 170 + setError(t("signUp.providerError")); 182 171 setLoading(false); 183 172 } 184 173 }; ··· 207 196 } catch (err) { 208 197 console.error(err); 209 198 analytics.captureException(err); 210 - setError("Couldn't connect to that PDS. Double-check the address."); 199 + setError(t("signUp.customPdsError")); 211 200 setLoading(false); 212 201 } 213 202 }; ··· 232 221 className="animate-spin text-primary-600 dark:text-primary-400 mx-auto mb-4" 233 222 /> 234 223 <p className="text-surface-600 dark:text-surface-400 font-medium"> 235 - Connecting... 224 + {t("signUp.connecting")} 236 225 </p> 237 226 </div> 238 227 ) : showCustomInput ? ( 239 228 <div> 240 229 <h2 className="text-2xl font-display font-bold text-surface-900 dark:text-white mb-2"> 241 - Use a custom PDS 230 + {t("signUp.customPdsTitle")} 242 231 </h2> 232 + 243 233 <p className="text-sm text-surface-500 dark:text-surface-400 mb-6"> 244 - Enter the address of the PDS hosting your account. 234 + {t("signUp.customPdsSubtitle")} 245 235 </p> 246 236 <form onSubmit={handleCustomSubmit} className="space-y-4"> 247 237 <div> 248 238 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1"> 249 - PDS address 239 + {t("signUp.pdsAddressLabel")} 250 240 </label> 251 241 <input 252 242 type="text" 253 243 className="w-full px-4 py-3 bg-surface-50 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:border-primary-500 dark:focus:border-primary-400 focus:ring-4 focus:ring-primary-500/10 dark:focus:ring-primary-400/10 outline-none transition-all" 254 244 value={customService} 255 245 onChange={(e) => setCustomService(e.target.value)} 256 - placeholder="pds.example.com" 246 + placeholder={t("signUp.pdsAddressPlaceholder")} 257 247 autoFocus 258 248 /> 259 249 </div> ··· 274 264 setError(null); 275 265 }} 276 266 > 277 - Back 267 + {t("signUp.back")} 278 268 </button> 279 269 <button 280 270 type="submit" 281 271 className="flex-1 py-3 bg-primary-600 dark:bg-primary-500 text-white font-semibold rounded-xl hover:bg-primary-700 dark:hover:bg-primary-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" 282 272 disabled={!customService.trim()} 283 273 > 284 - Continue 274 + {t("signUp.continue")} 285 275 </button> 286 276 </div> 287 277 </form> ··· 289 279 ) : ( 290 280 <div> 291 281 <h2 className="text-2xl font-display font-bold text-surface-900 dark:text-white mb-2"> 292 - Create your account 282 + {t("signUp.title")} 293 283 </h2> 294 284 <p className="text-surface-500 dark:text-surface-400 mb-6"> 295 - Margin adheres to the{" "} 285 + {t("signUp.subtitle")}{" "} 296 286 <a 297 287 href="https://atproto.com" 298 288 target="_blank" 299 289 rel="noopener noreferrer" 300 290 className="text-primary-600 dark:text-primary-400 hover:underline" 301 291 > 302 - AT Protocol 292 + {t("signUp.atProtocol")} 303 293 </a> 304 - . Choose a provider to host your account. 294 + {t("signUp.subtitleSuffix")} 305 295 </p> 306 296 307 297 {error && ( ··· 345 335 </div> 346 336 {inviteStatus[p.id] && ( 347 337 <span className="text-[10px] font-medium text-surface-400 dark:text-surface-500 bg-surface-100 dark:bg-surface-800 px-1.5 py-0.5 rounded-md flex-shrink-0"> 348 - Invite 338 + {t("signUp.invite")} 349 339 </span> 350 340 )} 351 341 <ChevronRight
+33 -11
web/src/components/navigation/MobileNav.tsx
··· 18 18 import { getUnreadNotificationCount } from "../../api/client"; 19 19 import { $user, logout } from "../../store/auth"; 20 20 import { AppleIcon } from "../common/Icons"; 21 + import { useTranslation } from "react-i18next"; 21 22 22 23 interface MobileNavProps { 23 24 currentPath?: string; ··· 28 29 currentPath: initialPath, 29 30 onNavigate, 30 31 }: MobileNavProps) { 32 + const { t } = useTranslation(); 31 33 const user = useStore($user); 32 34 const [currentPath, setCurrentPath] = useState(initialPath || "/"); 33 35 const [isMenuOpen, setIsMenuOpen] = useState(false); ··· 102 104 { 103 105 href: "/annotations", 104 106 icon: MessageSquareText, 105 - label: "Annotations", 107 + label: t("nav.annotations"), 106 108 }, 107 109 { 108 110 href: "/highlights", 109 111 icon: Highlighter, 110 - label: "Highlights", 112 + label: t("nav.highlights"), 111 113 }, 112 - { href: "/bookmarks", icon: Bookmark, label: "Bookmarks" }, 113 - { href: "/collections", icon: Folder, label: "Collections" }, 114 - { href: "/settings", icon: Settings, label: "Settings" }, 114 + { 115 + href: "/bookmarks", 116 + icon: Bookmark, 117 + label: t("nav.bookmarks"), 118 + }, 119 + { 120 + href: "/collections", 121 + icon: Folder, 122 + label: t("nav.collections"), 123 + }, 124 + { 125 + href: "/settings", 126 + icon: Settings, 127 + label: t("nav.settings"), 128 + }, 115 129 ].map(({ href, icon: Icon, label }) => ( 116 130 <a 117 131 key={href} ··· 140 154 onClick={closeMenu} 141 155 > 142 156 <AppleIcon size={20} /> 143 - <span>iOS Shortcut</span> 157 + <span>{t("mobileNav.iosShortcut")}</span> 144 158 </a> 145 159 146 160 <div className="h-px bg-surface-200 dark:bg-surface-700 my-2" /> ··· 153 167 }} 154 168 > 155 169 <LogOut size={20} /> 156 - <span>Log Out</span> 170 + <span>{t("nav.logOut")}</span> 157 171 </button> 158 172 </> 159 173 ) : ( ··· 164 178 onClick={closeMenu} 165 179 > 166 180 <User size={20} /> 167 - <span>Sign In</span> 181 + <span>{t("nav.signIn")}</span> 168 182 </a> 169 183 {[ 170 - { href: "/collections", icon: Folder, label: "Collections" }, 171 - { href: "/settings", icon: Settings, label: "Settings" }, 184 + { 185 + href: "/collections", 186 + icon: Folder, 187 + label: t("nav.collections"), 188 + }, 189 + { 190 + href: "/settings", 191 + icon: Settings, 192 + label: t("nav.settings"), 193 + }, 172 194 ].map(({ href, icon: Icon, label }) => ( 173 195 <a 174 196 key={href} ··· 197 219 onClick={closeMenu} 198 220 > 199 221 <AppleIcon size={20} /> 200 - <span>iOS Shortcut</span> 222 + <span>{t("mobileNav.iosShortcut")}</span> 201 223 </a> 202 224 </> 203 225 )}
+16 -15
web/src/components/navigation/RightSidebar.tsx
··· 7 7 type Tag, 8 8 } from "../../api/client"; 9 9 import { Avatar } from "../ui"; 10 + import { useTranslation } from "react-i18next"; 10 11 11 12 function looksLikeUrl(query: string): boolean { 12 13 const q = query.trim().toLowerCase(); ··· 22 23 } 23 24 24 25 export default function RightSidebar({ onNavigate }: RightSidebarProps) { 26 + const { t } = useTranslation(); 25 27 const navigate = (path: string) => { 26 28 if (onNavigate) onNavigate(path); 27 29 else window.location.href = path; ··· 169 171 suggestions.length > 0 && 170 172 setShowSuggestions(true) 171 173 } 172 - placeholder="Search people, tags, URLs..." 174 + placeholder={t("sidebar.searchPlaceholder")} 173 175 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" 174 176 /> 175 177 ··· 202 204 203 205 <div className="rounded-xl p-4 bg-gradient-to-br from-primary-50 to-primary-100/50 dark:from-primary-950/30 dark:to-primary-900/10 border border-primary-200/40 dark:border-primary-800/30"> 204 206 <h3 className="font-semibold text-sm mb-1 text-surface-900 dark:text-white"> 205 - Get the Extension 207 + {t("sidebar.getExtension")} 206 208 </h3> 207 209 <p className="text-surface-500 dark:text-surface-400 text-xs mb-3 leading-relaxed"> 208 - Highlight, annotate, and bookmark from any page. 210 + {t("sidebar.extensionTagline")} 209 211 </p> 210 212 <a 211 213 href={extensionLink} ··· 213 215 rel="noopener noreferrer" 214 216 className="flex items-center justify-center w-full px-4 py-2 bg-primary-600 hover:bg-primary-700 dark:bg-primary-500 dark:hover:bg-primary-400 text-white dark:text-white rounded-lg transition-colors text-sm font-medium" 215 217 > 216 - Download for{" "} 217 218 {browser === "firefox" 218 - ? "Firefox" 219 + ? t("sidebar.downloadForFirefox") 219 220 : browser === "edge" 220 - ? "Edge" 221 - : "Chrome"} 221 + ? t("sidebar.downloadForEdge") 222 + : t("sidebar.downloadForChrome")} 222 223 </a> 223 224 </div> 224 225 225 226 <div> 226 227 <h3 className="font-semibold text-sm px-1 mb-3 text-surface-900 dark:text-white tracking-tight"> 227 - Trending 228 + {t("sidebar.trending")} 228 229 </h3> 229 230 {tags.length > 0 ? ( 230 231 <div className="flex flex-col"> 231 - {tags.map((t) => ( 232 + {tags.map((tag) => ( 232 233 <a 233 - key={t.tag} 234 - href={`/home?tag=${encodeURIComponent(t.tag)}`} 234 + key={tag.tag} 235 + href={`/home?tag=${encodeURIComponent(tag.tag)}`} 235 236 className="px-2 py-2.5 hover:bg-surface-100 dark:hover:bg-surface-800 rounded-lg transition-colors group" 236 237 > 237 238 <div className="font-semibold text-sm text-surface-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors"> 238 - #{t.tag} 239 + #{tag.tag} 239 240 </div> 240 241 <div className="text-xs text-surface-400 dark:text-surface-500 mt-0.5"> 241 - {t.count} {t.count === 1 ? "post" : "posts"} 242 + {t("sidebar.postCount", { count: tag.count })} 242 243 </div> 243 244 </a> 244 245 ))} ··· 246 247 ) : ( 247 248 <div className="px-2"> 248 249 <p className="text-sm text-surface-400 dark:text-surface-500"> 249 - Nothing trending right now. 250 + {t("sidebar.nothingTrending")} 250 251 </p> 251 252 </div> 252 253 )} ··· 321 322 <Coffee size={12} className="shrink-0" /> 322 323 Support on Ko-fi 323 324 </a> 324 - <span>© 2026 Padding Labs LLC</span> 325 + <span>{t("sidebar.copyright")}</span> 325 326 </div> 326 327 </div> 327 328 </div>
+40 -26
web/src/components/navigation/Sidebar.tsx
··· 20 20 import { $theme, cycleTheme } from "../../store/theme"; 21 21 import { getUnreadNotificationCount } from "../../api/client"; 22 22 import { Avatar, CountBadge } from "../ui"; 23 + import { useTranslation } from "react-i18next"; 23 24 24 25 interface SidebarProps { 25 26 currentPath?: string; ··· 30 31 currentPath: initialPath, 31 32 onNavigate, 32 33 }: SidebarProps) { 34 + const { t } = useTranslation(); 33 35 const user = useStore($user); 34 36 const theme = useStore($theme); 35 37 const currentPath = initialPath || "/"; ··· 49 51 }, [user]); 50 52 51 53 const publicNavItems = [ 52 - { icon: Home, label: "Feed", href: "/home", badge: undefined }, 53 - { icon: Compass, label: "Discover", href: "/discover", badge: undefined }, 54 + { icon: Home, label: t("nav.feed"), href: "/home", badge: undefined }, 55 + { 56 + icon: Compass, 57 + label: t("nav.discover"), 58 + href: "/discover", 59 + badge: undefined, 60 + }, 54 61 { 55 62 icon: MessageSquareText, 56 - label: "Annotations", 63 + label: t("nav.annotations"), 57 64 href: "/annotations", 58 65 badge: undefined, 59 66 }, 60 67 { 61 68 icon: Highlighter, 62 - label: "Highlights", 69 + label: t("nav.highlights"), 63 70 href: "/highlights", 64 71 badge: undefined, 65 72 }, 66 73 { 67 74 icon: Bookmark, 68 - label: "Bookmarks", 75 + label: t("nav.bookmarks"), 69 76 href: "/bookmarks", 70 77 badge: undefined, 71 78 }, 72 79 ]; 73 80 74 81 const authNavItems = [ 75 - { icon: Home, label: "Feed", href: "/home" }, 76 - { icon: Compass, label: "Discover", href: "/discover" }, 82 + { icon: Home, label: t("nav.feed"), href: "/home" }, 83 + { icon: Compass, label: t("nav.discover"), href: "/discover" }, 77 84 { 78 85 icon: Bell, 79 - label: "Activity", 86 + label: t("nav.activity"), 80 87 href: "/notifications", 81 88 badge: unreadCount, 82 89 }, 83 - { icon: MessageSquareText, label: "Annotations", href: "/annotations" }, 84 - { icon: Highlighter, label: "Highlights", href: "/highlights" }, 85 - { icon: Bookmark, label: "Bookmarks", href: "/bookmarks" }, 86 - { icon: Folder, label: "Collections", href: "/collections" }, 90 + { 91 + icon: MessageSquareText, 92 + label: t("nav.annotations"), 93 + href: "/annotations", 94 + }, 95 + { icon: Highlighter, label: t("nav.highlights"), href: "/highlights" }, 96 + { icon: Bookmark, label: t("nav.bookmarks"), href: "/bookmarks" }, 97 + { icon: Folder, label: t("nav.collections"), href: "/collections" }, 87 98 ]; 88 99 89 100 const navItems = user ? authNavItems : publicNavItems; 101 + 102 + const themeLabel = 103 + theme === "light" 104 + ? t("nav.themeLight") 105 + : theme === "dark" 106 + ? t("nav.themeDark") 107 + : t("nav.themeSystem"); 90 108 91 109 return ( 92 110 <aside className="sticky top-0 h-screen hidden md:flex flex-col justify-between py-6 px-2 lg:px-4 z-50 w-[68px] lg:w-[260px] transition-all duration-200"> ··· 146 164 {user && ( 147 165 <a 148 166 href="/new" 149 - title="New annotation" 167 + title={t("nav.new")} 150 168 onClick={ 151 169 onNavigate 152 170 ? (e) => { ··· 158 176 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" 159 177 > 160 178 <PenSquare size={20} strokeWidth={1.75} /> 161 - <span className="hidden lg:inline">New</span> 179 + <span className="hidden lg:inline">{t("nav.new")}</span> 162 180 </a> 163 181 )} 164 182 </nav> ··· 167 185 <div className="space-y-1"> 168 186 <button 169 187 onClick={cycleTheme} 170 - title={ 171 - theme === "light" ? "Light" : theme === "dark" ? "Dark" : "System" 172 - } 188 + title={themeLabel} 173 189 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 w-full transition-colors" 174 190 > 175 191 {theme === "light" ? ( ··· 179 195 ) : ( 180 196 <Monitor size={18} /> 181 197 )} 182 - <span className="hidden lg:inline"> 183 - {theme === "light" ? "Light" : theme === "dark" ? "Dark" : "System"} 184 - </span> 198 + <span className="hidden lg:inline">{themeLabel}</span> 185 199 </button> 186 200 187 201 {user ? ( 188 202 <> 189 203 <a 190 204 href="/settings" 191 - title="Settings" 205 + title={t("nav.settings")} 192 206 onClick={ 193 207 onNavigate 194 208 ? (e) => { ··· 200 214 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" 201 215 > 202 216 <Settings size={18} /> 203 - <span className="hidden lg:inline">Settings</span> 217 + <span className="hidden lg:inline">{t("nav.settings")}</span> 204 218 </a> 205 219 206 220 <div className="h-px bg-surface-200/60 dark:bg-surface-800/60 my-2" /> ··· 231 245 232 246 <button 233 247 onClick={logout} 234 - title="Log out" 248 + title={t("nav.logOut")} 235 249 className="flex items-center justify-center lg:justify-start gap-3 px-0 lg:px-3 py-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 text-[13px] font-medium text-surface-400 dark:text-surface-500 hover:text-red-600 dark:hover:text-red-400 w-full text-left transition-colors" 236 250 > 237 251 <LogOut size={16} /> 238 - <span className="hidden lg:inline">Log out</span> 252 + <span className="hidden lg:inline">{t("nav.logOut")}</span> 239 253 </button> 240 254 </> 241 255 ) : ( ··· 244 258 245 259 <a 246 260 href="/login" 247 - title="Sign in" 261 + title={t("nav.signIn")} 248 262 className="flex items-center justify-center lg:justify-start gap-3 px-0 lg:px-3 py-2.5 rounded-lg bg-primary-50 dark:bg-primary-950/40 text-primary-700 dark:text-primary-300 hover:bg-primary-100 dark:hover:bg-primary-950/60 text-[13px] font-semibold transition-colors" 249 263 > 250 264 <LogIn size={18} /> 251 - <span className="hidden lg:inline">Sign in</span> 265 + <span className="hidden lg:inline">{t("nav.signIn")}</span> 252 266 </a> 253 267 </> 254 268 )}
+4
web/src/env.d.ts
··· 1 1 /// <reference types="astro/client" /> 2 2 3 + declare module "virtual:i18n-languages" { 4 + export const languages: { code: string; name: string; nativeName: string }[]; 5 + } 6 + 3 7 declare namespace App { 4 8 interface Locals { 5 9 user: import("./types").UserProfile | null;
+26
web/src/i18n.ts
··· 1 + import i18n from "i18next"; 2 + import { initReactI18next } from "react-i18next"; 3 + import HttpBackend from "i18next-http-backend"; 4 + import LanguageDetector from "i18next-browser-languagedetector"; 5 + 6 + i18n 7 + .use(HttpBackend) 8 + .use(LanguageDetector) 9 + .use(initReactI18next) 10 + .init({ 11 + fallbackLng: "en", 12 + ns: ["translation"], 13 + defaultNS: "translation", 14 + backend: { 15 + loadPath: "/locales/{{lng}}/{{ns}}.json", 16 + }, 17 + detection: { 18 + order: ["localStorage", "navigator", "htmlTag"], 19 + caches: ["localStorage"], 20 + }, 21 + interpolation: { 22 + escapeValue: false, 23 + }, 24 + }); 25 + 26 + export default i18n;
+1 -1
web/src/pages/about.astro
··· 4 4 --- 5 5 6 6 <BaseLayout title="About - Margin" description="Annotate the web using the AT Protocol"> 7 - <About client:idle /> 7 + <About client:only="react" /> 8 8 </BaseLayout>
+1 -1
web/src/pages/index.astro
··· 10 10 --- 11 11 12 12 <BaseLayout title="Margin" description="Annotate the web using the AT Protocol"> 13 - <About client:idle /> 13 + <About client:only="react" /> 14 14 </BaseLayout>
+80 -92
web/src/views/About.tsx
··· 1 1 import React from "react"; 2 2 import { useStore } from "@nanostores/react"; 3 + import { useTranslation } from "react-i18next"; 4 + import "../i18n"; 3 5 4 6 import { $theme, cycleTheme } from "../store/theme"; 5 7 import { $user } from "../store/auth"; ··· 93 95 } 94 96 95 97 export default function About() { 98 + const { t } = useTranslation(); 96 99 const theme = useStore($theme); // ensure theme is applied on this page 97 100 const user = useStore($user); 98 101 ··· 166 169 href="/home" 167 170 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" 168 171 > 169 - Feed 172 + {t("nav.feed")} 170 173 </a> 171 174 <a 172 175 href="/discover" 173 176 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" 174 177 > 175 - Discover 178 + {t("nav.discover")} 176 179 </a> 177 180 </div> 178 181 </div> ··· 182 185 href="/login" 183 186 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" 184 187 > 185 - Sign in 188 + {t("nav.signIn")} 186 189 </a> 187 190 )} 188 191 ··· 193 196 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" 194 197 > 195 198 <ExtensionIcon size={14} /> 196 - <span className="hidden sm:inline">Get Extension</span> 197 - <span className="sm:hidden">Install</span> 199 + <span className="hidden sm:inline"> 200 + {t("about.nav.getExtension")} 201 + </span> 202 + <span className="sm:hidden">{t("about.nav.install")}</span> 198 203 </a> 199 204 </div> 200 205 </div> ··· 230 235 </a> 231 236 </div> 232 237 <span className="pr-4 pl-0.5 text-[13px] font-semibold text-surface-600 dark:text-surface-300"> 233 - Fully open source{" "} 238 + {t("about.hero.openSource")}{" "} 234 239 <span className="text-primary-500 font-normal">✨</span> 235 240 </span> 236 241 </div> 237 242 </div> 238 243 239 244 <h1 className="font-display text-5xl md:text-6xl lg:text-7xl font-bold tracking-tight text-surface-900 dark:text-white leading-[1.05] mb-6"> 240 - Write on the margins <br className="hidden sm:block" /> 245 + {t("about.hero.headline")} <br className="hidden sm:block" /> 241 246 <span className="text-primary-600 dark:text-primary-400"> 242 - of the internet. 247 + {t("about.hero.headlineAccent")} 243 248 </span> 244 249 </h1> 245 250 246 251 <p className="text-lg md:text-xl text-surface-500 dark:text-surface-400 max-w-2xl mx-auto leading-relaxed mb-10"> 247 - Margin is an open annotation layer for the internet. Highlight text, 248 - leave notes, and bookmark pages, all stored on your decentralized 249 - identity with the{" "} 252 + {t("about.hero.descriptionPre")}{" "} 250 253 <a 251 254 href="https://atproto.com" 252 255 target="_blank" 253 256 rel="noreferrer" 254 257 className="text-surface-800 dark:text-surface-200 hover:text-primary-600 dark:hover:text-primary-400 border-b border-surface-300 dark:border-surface-600 hover:border-primary-400 transition-colors font-medium" 255 258 > 256 - AT Protocol 259 + {t("about.hero.atProtocol")} 257 260 </a> 258 - . Not locked in a silo. 261 + {t("about.hero.descriptionPost")} 259 262 </p> 260 263 261 264 <div className="flex flex-col sm:flex-row items-center justify-center gap-4 mt-4"> ··· 263 266 href={user ? "/home" : "/login"} 264 267 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" 265 268 > 266 - {user ? "Open App" : "Get Started"} 269 + {user ? t("about.hero.openApp") : t("about.hero.getStarted")} 267 270 <ArrowRight 268 271 size={18} 269 272 className="transition-transform group-hover:translate-x-1" ··· 276 279 className="inline-flex items-center justify-center gap-2 px-8 py-3.5 bg-surface-50 dark:bg-surface-800/50 text-surface-700 dark:text-surface-200 hover:text-surface-900 dark:hover:text-white rounded-[14px] font-semibold hover:bg-surface-100 dark:hover:bg-surface-800 hover:scale-[1.02] transition-all duration-200 w-full sm:w-auto" 277 280 > 278 281 <ExtensionIcon size={18} /> 279 - Install for {extensionLabel} 282 + {t("about.hero.installFor", { browser: extensionLabel })} 280 283 </a> 281 284 </div> 282 285 </div> ··· 285 288 <section className="max-w-5xl mx-auto px-6 py-20 md:py-24"> 286 289 <div className="text-center mb-12"> 287 290 <h2 className="font-display text-2xl md:text-3xl font-bold tracking-tight text-surface-900 dark:text-white mb-4"> 288 - Everything you need to engage with the web 291 + {t("about.features.title")} 289 292 </h2> 290 293 <p className="text-surface-500 dark:text-surface-400 max-w-xl mx-auto leading-relaxed"> 291 - More than bookmarks. A full toolkit for reading, thinking, and 292 - sharing on the open web. 294 + {t("about.features.subtitle")} 293 295 </p> 294 296 </div> 295 297 296 298 <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5"> 297 299 <FeatureCard 298 300 icon={MessageSquareText} 299 - title="Annotations" 300 - description="Leave notes on any web page. Start discussions, share insights, or just jot down your thoughts for later." 301 + title={t("about.features.annotations.title")} 302 + description={t("about.features.annotations.description")} 301 303 accent 302 304 /> 303 305 <FeatureCard 304 306 icon={Highlighter} 305 - title="Highlights" 306 - description="Select and highlight text on any page with customizable colors. Your highlights are rendered inline with the CSS Highlights API." 307 + title={t("about.features.highlights.title")} 308 + description={t("about.features.highlights.description")} 307 309 /> 308 310 <FeatureCard 309 311 icon={Bookmark} 310 - title="Bookmarks" 311 - description="Save pages with one click or a keyboard shortcut. All your bookmarks are synced to your AT Protocol identity." 312 + title={t("about.features.bookmarks.title")} 313 + description={t("about.features.bookmarks.description")} 312 314 /> 313 315 <FeatureCard 314 316 icon={FolderOpen} 315 - title="Collections" 316 - description="Organize your annotations, highlights, and bookmarks into themed collections. Share them publicly or keep them private." 317 + title={t("about.features.collections.title")} 318 + description={t("about.features.collections.description")} 317 319 /> 318 320 <FeatureCard 319 321 icon={Users} 320 - title="Social Discovery" 321 - description="See what others are saying about the pages you visit. Discover annotations, trending tags, and connect with other readers." 322 + title={t("about.features.socialDiscovery.title")} 323 + description={t("about.features.socialDiscovery.description")} 322 324 /> 323 325 <FeatureCard 324 326 icon={Hash} 325 - title="Tags & Search" 326 - description="Tag your annotations for easy retrieval. Search by URL, tag, or content to find exactly what you're looking for." 327 + title={t("about.features.tagsSearch.title")} 328 + description={t("about.features.tagsSearch.description")} 327 329 /> 328 330 </div> 329 331 </section> ··· 334 336 <div> 335 337 <div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-surface-100 dark:bg-surface-800 text-surface-600 dark:text-surface-400 text-xs font-medium mb-5 border border-surface-200/60 dark:border-surface-700/60"> 336 338 <ExtensionIcon size={13} /> 337 - Browser Extension 339 + {t("about.extension.badge")} 338 340 </div> 339 341 <h2 className="font-display text-2xl md:text-3xl font-bold tracking-tight text-surface-900 dark:text-white mb-4"> 340 - Your annotation toolkit, 342 + {t("about.extension.title")} 341 343 <br /> 342 - right in the browser 344 + {t("about.extension.titleLine2")} 343 345 </h2> 344 346 <p className="text-surface-500 dark:text-surface-400 leading-relaxed mb-8"> 345 - The Margin extension brings the full annotation experience 346 - directly into every page you visit. Just select, annotate, and 347 - go. 347 + {t("about.extension.description")} 348 348 </p> 349 349 350 350 <div className="space-y-5"> 351 351 <ExtensionFeature 352 352 icon={Eye} 353 - title="Inline Overlay" 354 - description="See annotations and highlights rendered directly on the page. Uses the CSS Highlights API for beautiful, native-feeling text underlines." 353 + title={t("about.extension.features.inlineOverlay.title")} 354 + description={t( 355 + "about.extension.features.inlineOverlay.description", 356 + )} 355 357 /> 356 358 <ExtensionFeature 357 359 icon={MousePointerClick} 358 - title="Context Menu & Selection" 359 - description="Right-click any selected text to annotate, highlight, or quote it. Or just right-click the page to bookmark it instantly." 360 + title={t("about.extension.features.contextMenu.title")} 361 + description={t( 362 + "about.extension.features.contextMenu.description", 363 + )} 360 364 /> 361 365 <ExtensionFeature 362 366 icon={Keyboard} 363 - title="Keyboard Shortcuts" 364 - description="Toggle the overlay, bookmark the current page, or annotate selected text without reaching for the mouse." 367 + title={t("about.extension.features.keyboard.title")} 368 + description={t( 369 + "about.extension.features.keyboard.description", 370 + )} 365 371 /> 366 372 <ExtensionFeature 367 373 icon={PanelRight} 368 - title="Side Panel" 369 - description="Open the Margin side panel to browse annotations, bookmarks, and collections without leaving the page you're reading." 374 + title={t("about.extension.features.sidePanel.title")} 375 + description={t( 376 + "about.extension.features.sidePanel.description", 377 + )} 370 378 /> 371 379 </div> 372 380 ··· 378 386 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" 379 387 > 380 388 <ExtensionIcon size={15} /> 381 - Install for {extensionLabel} 389 + {t("about.hero.installFor", { browser: extensionLabel })} 382 390 <ExternalLink size={12} /> 383 391 </a> 384 392 {browser !== "chrome" && ( ··· 424 432 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" 425 433 > 426 434 <AppleIcon size={15} /> 427 - iOS Shortcut 435 + {t("about.extension.iosShortcut")} 428 436 <ExternalLink size={12} /> 429 437 </a> 430 438 </div> ··· 499 507 <div> 500 508 <div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-white dark:bg-surface-800 text-surface-600 dark:text-surface-400 text-xs font-medium mb-5 border border-surface-200/60 dark:border-surface-700/60"> 501 509 <Shield size={13} /> 502 - Decentralized 510 + {t("about.protocol.badge")} 503 511 </div> 504 512 <h2 className="font-display text-2xl md:text-3xl font-bold tracking-tight text-surface-900 dark:text-white mb-4"> 505 - Your data, your identity 513 + {t("about.protocol.title")} 506 514 </h2> 507 515 <p className="text-surface-500 dark:text-surface-400 leading-relaxed mb-6"> 508 - Margin is built on the{" "} 516 + {t("about.protocol.descriptionPre")}{" "} 509 517 <a 510 518 href="https://atproto.com" 511 519 target="_blank" 512 520 rel="noreferrer" 513 521 className="text-primary-600 dark:text-primary-400 hover:underline font-medium" 514 522 > 515 - AT Protocol 523 + {t("about.hero.atProtocol")} 516 524 </a> 517 - , the open protocol that powers apps like Bluesky. Your 518 - annotations, highlights, and bookmarks are stored in your personal 519 - data repository, not locked in a silo. 525 + {t("about.protocol.descriptionPost")} 520 526 </p> 521 527 <ul className="space-y-3 text-sm text-surface-600 dark:text-surface-300"> 522 - <li className="flex items-start gap-3"> 523 - <div className="w-5 h-5 rounded-full bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center flex-shrink-0 mt-0.5"> 524 - <div className="w-1.5 h-1.5 rounded-full bg-primary-600 dark:bg-primary-400" /> 525 - </div> 526 - Sign in with your AT Protocol handle, no new account needed 527 - </li> 528 - <li className="flex items-start gap-3"> 529 - <div className="w-5 h-5 rounded-full bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center flex-shrink-0 mt-0.5"> 530 - <div className="w-1.5 h-1.5 rounded-full bg-primary-600 dark:bg-primary-400" /> 531 - </div> 532 - Your data lives in your PDS, portable and under your control 533 - </li> 534 - <li className="flex items-start gap-3"> 535 - <div className="w-5 h-5 rounded-full bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center flex-shrink-0 mt-0.5"> 536 - <div className="w-1.5 h-1.5 rounded-full bg-primary-600 dark:bg-primary-400" /> 537 - </div> 538 - Custom Lexicon schemas for annotations, highlights, collections 539 - & more 540 - </li> 541 - <li className="flex items-start gap-3"> 542 - <div className="w-5 h-5 rounded-full bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center flex-shrink-0 mt-0.5"> 543 - <div className="w-1.5 h-1.5 rounded-full bg-primary-600 dark:bg-primary-400" /> 544 - </div> 545 - Fully open source, check out the code and contribute 546 - </li> 528 + {([0, 1, 2, 3] as const).map((i) => ( 529 + <li key={i} className="flex items-start gap-3"> 530 + <div className="w-5 h-5 rounded-full bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center flex-shrink-0 mt-0.5"> 531 + <div className="w-1.5 h-1.5 rounded-full bg-primary-600 dark:bg-primary-400" /> 532 + </div> 533 + {t(`about.protocol.point${i}`)} 534 + </li> 535 + ))} 547 536 </ul> 548 537 </div> 549 538 ··· 609 598 <section className="border-t border-surface-200/60 dark:border-surface-800/60"> 610 599 <div className="max-w-5xl mx-auto px-6 py-20 md:py-24 text-center"> 611 600 <h2 className="font-display text-2xl md:text-3xl font-bold tracking-tight text-surface-900 dark:text-white mb-4"> 612 - Start writing on the margins 601 + {t("about.cta.title")} 613 602 </h2> 614 603 <p className="text-surface-500 dark:text-surface-400 max-w-lg mx-auto mb-8"> 615 - Join the open annotation layer. Sign in with your AT Protocol 616 - identity and install the extension to get started. 604 + {t("about.cta.description")} 617 605 </p> 618 606 <div className="flex flex-col sm:flex-row items-center justify-center gap-3"> 619 607 <a 620 608 href={user ? "/home" : "/login"} 621 609 className="inline-flex items-center gap-2 px-7 py-3 bg-primary-600 hover:bg-primary-700 dark:bg-primary-500 dark:hover:bg-primary-400 text-white rounded-xl font-semibold transition-colors" 622 610 > 623 - {user ? "Open App" : "Sign in"} 611 + {user ? t("about.hero.openApp") : t("about.cta.signIn")} 624 612 <ArrowRight size={16} /> 625 613 </a> 626 614 <a ··· 630 618 className="inline-flex items-center gap-2 px-7 py-3 text-surface-600 dark:text-surface-300 hover:text-surface-900 dark:hover:text-white transition-colors font-medium" 631 619 > 632 620 <Github size={16} /> 633 - View on GitHub 621 + {t("about.cta.viewGitHub")} 634 622 </a> 635 623 <a 636 624 href="https://tangled.org/margin.at/margin" ··· 639 627 className="inline-flex items-center gap-2 px-7 py-3 text-surface-600 dark:text-surface-300 hover:text-surface-900 dark:hover:text-white transition-colors font-medium" 640 628 > 641 629 <TangledIcon size={16} /> 642 - View on Tangled 630 + {t("about.cta.viewTangled")} 643 631 </a> 644 632 </div> 645 633 <div className="mt-10 flex items-center gap-5 flex-wrap justify-center"> ··· 666 654 className="w-5 h-5 opacity-60" 667 655 /> 668 656 <span className="text-sm text-surface-400 dark:text-surface-500"> 669 - © 2026 Padding Labs LLC 657 + {t("sidebar.copyright")} 670 658 </span> 671 659 </div> 672 660 <div className="flex items-center gap-5 text-sm text-surface-400 dark:text-surface-500"> ··· 675 663 href="/home" 676 664 className="hover:text-surface-600 dark:hover:text-surface-300 transition-colors" 677 665 > 678 - Feed 666 + {t("nav.feed")} 679 667 </a> 680 668 )} 681 669 <a 682 670 href="/privacy" 683 671 className="hover:text-surface-600 dark:hover:text-surface-300 transition-colors" 684 672 > 685 - Privacy 673 + {t("about.footer.privacy")} 686 674 </a> 687 675 <a 688 676 href="/terms" 689 677 className="hover:text-surface-600 dark:hover:text-surface-300 transition-colors" 690 678 > 691 - Terms 679 + {t("about.footer.terms")} 692 680 </a> 693 681 <a 694 682 href="https://github.com/margin-at" ··· 710 698 href="mailto:hello@margin.at" 711 699 className="hover:text-surface-600 dark:hover:text-surface-300 transition-colors" 712 700 > 713 - Contact 701 + {t("about.footer.contact")} 714 702 </a> 715 703 <div className="w-px h-4 bg-surface-200 dark:bg-surface-700 ml-1" /> 716 704 <button 717 705 onClick={cycleTheme} 718 706 title={ 719 707 theme === "light" 720 - ? "Light mode" 708 + ? t("nav.themeLight") 721 709 : theme === "dark" 722 - ? "Dark mode" 723 - : "System theme" 710 + ? t("nav.themeDark") 711 + : t("nav.themeSystem") 724 712 } 725 713 className="flex items-center gap-1.5 hover:text-surface-600 dark:hover:text-surface-300 transition-colors" 726 714 >
+10 -4
web/src/views/AppShell.tsx
··· 1 1 import { useStore } from "@nanostores/react"; 2 - import { useEffect, useState } from "react"; 2 + import { Suspense, useEffect, useState } from "react"; 3 + import { I18nextProvider } from "react-i18next"; 4 + import i18n from "../i18n"; 3 5 import type { UserProfile } from "../types"; 4 6 5 7 declare global { ··· 344 346 }, []); 345 347 346 348 return ( 347 - <BrowserRouter> 348 - <AppLayout /> 349 - </BrowserRouter> 349 + <I18nextProvider i18n={i18n}> 350 + <Suspense fallback={null}> 351 + <BrowserRouter> 352 + <AppLayout /> 353 + </BrowserRouter> 354 + </Suspense> 355 + </I18nextProvider> 350 356 ); 351 357 }
+12 -10
web/src/views/auth/Login.tsx
··· 1 1 import React, { useState, useEffect, useRef } from "react"; 2 2 import { AtSign } from "lucide-react"; 3 + import { useTranslation } from "react-i18next"; 3 4 import SignUpModal from "../../components/modals/SignUpModal"; 4 5 import { 5 6 searchActors, ··· 16 17 } 17 18 18 19 export default function Login({ initialError }: LoginProps) { 20 + const { t } = useTranslation(); 19 21 useStore($theme); // ensure theme is applied on this page 20 22 const [handle, setHandle] = useState(""); 21 23 const [suggestions, setSuggestions] = useState<ActorSearchItem[]>([]); ··· 32 34 const [providerIndex, setProviderIndex] = useState(0); 33 35 const [morphClass, setMorphClass] = useState( 34 36 "opacity-100 translate-y-0 blur-0", 35 - ); 37 + ); 36 38 const providers = [ 37 39 "AT Protocol", 38 40 "Margin", ··· 162 164 <div className="relative w-full max-w-[440px] bg-white dark:bg-surface-900 rounded-2xl border border-surface-200/60 dark:border-surface-800 p-8 shadow-sm dark:shadow-none"> 163 165 <div className="flex flex-col items-center mb-8"> 164 166 <h1 className="text-2xl font-bold font-display text-surface-900 dark:text-white text-center leading-snug"> 165 - Sign in with your <br /> 167 + {t("login.signInWith")} <br /> 166 168 <span 167 169 className={`inline-block transition-all duration-400 ease-out text-transparent bg-clip-text bg-gradient-to-r from-[#027bff] to-[#0285FF] ${morphClass}`} 168 170 > 169 171 {providers[providerIndex]} 170 172 </span>{" "} 171 - handle 173 + {t("login.handleSuffix")} 172 174 </h1> 173 175 </div> 174 176 ··· 208 210 !handle.includes(".") && 209 211 setShowSuggestions(true) 210 212 } 211 - placeholder="handle.margin.cafe" 213 + placeholder={t("login.handlePlaceholder")} 212 214 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" 213 215 autoCapitalize="none" 214 216 autoCorrect="off" ··· 255 257 disabled={loading || !handle} 256 258 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" 257 259 > 258 - {loading ? "Connecting..." : "Continue"} 260 + {loading ? t("login.connecting") : t("login.continue")} 259 261 </button> 260 262 261 263 <p className="text-center text-sm text-surface-400 dark:text-surface-500 mt-2 leading-relaxed"> 262 - By signing in, you agree to our{" "} 264 + {t("login.termsPrefix")}{" "} 263 265 <a 264 266 href="/terms" 265 267 className="text-surface-900 dark:text-white hover:underline font-medium hover:text-[#027bff] dark:hover:text-[#027bff] transition-colors" 266 268 > 267 - Terms of Service 269 + {t("login.termsLink")} 268 270 </a>{" "} 269 - and{" "} 271 + {t("login.termsAnd")}{" "} 270 272 <a 271 273 href="/privacy" 272 274 className="text-surface-900 dark:text-white hover:underline font-medium hover:text-[#027bff] dark:hover:text-[#027bff] transition-colors" 273 275 > 274 - Privacy Policy 276 + {t("login.privacyLink")} 275 277 </a> 276 278 </p> 277 279 ··· 288 290 onClick={() => setShowSignUp(true)} 289 291 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" 290 292 > 291 - Create New Account 293 + {t("login.createAccount")} 292 294 </button> 293 295 </form> 294 296 </div>
+17 -15
web/src/views/collections/CollectionDetail.tsx
··· 1 1 import React, { useEffect, useState } from "react"; 2 2 import { useNavigate } from "react-router-dom"; 3 + import { useTranslation } from "react-i18next"; 3 4 import { 4 5 getCollection, 5 6 getCollectionItems, ··· 34 35 initialItems, 35 36 resolvedUri, 36 37 }: CollectionDetailProps) { 38 + const { t } = useTranslation(); 37 39 const user = useStore($user); 38 40 const navigate = useNavigate(); 39 41 const [collection, setCollection] = useState<Collection | null>( ··· 59 61 if (did) { 60 62 targetUri = `at://${did}/at.margin.collection/${rkey}`; 61 63 } else { 62 - setError("Collection not found"); 64 + setError(t("collectionDetail.notFound")); 63 65 setLoading(false); 64 66 return; 65 67 } ··· 73 75 const colItems = await getCollectionItems(col.uri); 74 76 setItems(colItems.filter((i) => i && i.uri)); 75 77 } else { 76 - setError("Collection not found"); 78 + setError(t("collectionDetail.notFound")); 77 79 } 78 80 } 79 81 } catch { 80 - setError("Failed to load collection"); 82 + setError(t("collectionDetail.failedToLoad")); 81 83 } finally { 82 84 setLoading(false); 83 85 } 84 86 }; 85 87 86 88 loadData(); 87 - }, [handle, rkey, uri, initialCollection, resolvedUri]); 89 + }, [handle, rkey, uri, initialCollection, resolvedUri, t]); 88 90 89 91 const handleDelete = async () => { 90 92 if (!collection) return; 91 - if (window.confirm("Delete this collection?")) { 93 + if (window.confirm(t("collectionDetail.deleteConfirm"))) { 92 94 await deleteCollection(collection.id); 93 95 navigate("/collections"); 94 96 } ··· 96 98 97 99 const handleRemoveItem = async (item: AnnotationItem) => { 98 100 if (!item.collectionItemUri) return; 99 - if (!window.confirm("Remove from collection?")) return; 101 + if (!window.confirm(t("collectionDetail.removeConfirm"))) return; 100 102 const success = await removeCollectionItem(item.collectionItemUri); 101 103 if (success) { 102 104 setItems((prev) => ··· 119 121 if (error || !collection) { 120 122 return ( 121 123 <div className="text-center py-20 text-red-500 dark:text-red-400"> 122 - {error || "Collection not found"} 124 + {error || t("collectionDetail.notFound")} 123 125 </div> 124 126 ); 125 127 } ··· 146 148 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" 147 149 > 148 150 <ArrowLeft size={16} /> 149 - Collections 151 + {t("collectionDetail.backLink")} 150 152 </a> 151 153 152 154 <div className="bg-white dark:bg-surface-900 rounded-xl p-4 ring-1 ring-black/5 dark:ring-white/5 mb-4"> ··· 165 167 )} 166 168 <div className="flex items-center gap-2 mt-2 text-xs text-surface-500 dark:text-surface-400"> 167 169 <span className="font-medium bg-surface-100 dark:bg-surface-800 px-2 py-0.5 rounded"> 168 - {items.length} items 170 + {t("collections.itemCount", { count: items.length })} 169 171 </span> 170 172 <span> 171 - by{" "} 173 + {t("collectionDetail.by")}{" "} 172 174 <a 173 175 href={`/profile/${collection.creator?.did}`} 174 176 className="hover:text-primary-600 dark:hover:text-primary-400 hover:underline transition-colors" ··· 191 193 <button 192 194 onClick={() => setIsEditModalOpen(true)} 193 195 className="p-2 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 rounded-lg transition-colors" 194 - title="Edit collection" 196 + title={t("collectionDetail.edit")} 195 197 > 196 198 <Edit3 size={18} /> 197 199 </button> 198 200 <button 199 201 onClick={handleDelete} 200 202 className="p-2 text-surface-400 dark:text-surface-500 hover:text-red-500 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors" 201 - title="Delete collection" 203 + title={t("collectionDetail.delete")} 202 204 > 203 205 <Trash2 size={18} /> 204 206 </button> ··· 212 214 className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg bg-surface-100 dark:bg-surface-800 text-surface-600 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors" 213 215 > 214 216 <img src="/semble-logo.svg" alt="" className="w-3.5 h-3.5" /> 215 - View in Semble 217 + {t("collectionDetail.viewInSemble")} 216 218 <ExternalLink size={12} /> 217 219 </a> 218 220 )} ··· 239 241 size={28} 240 242 className="mx-auto mb-2 text-surface-300 dark:text-surface-600" 241 243 /> 242 - <p className="text-sm">Collection is empty</p> 244 + <p className="text-sm">{t("collectionDetail.empty")}</p> 243 245 </div> 244 246 ) : ( 245 247 items.map((item) => ( ··· 249 251 <button 250 252 className="absolute top-3 right-3 p-1.5 bg-white/90 dark:bg-surface-800/90 backdrop-blur text-surface-400 dark:text-surface-500 hover:text-red-500 dark:hover:text-red-400 rounded-lg shadow-sm transition-all" 251 253 onClick={() => handleRemoveItem(item)} 252 - title="Remove from collection" 254 + title={t("collectionDetail.removeFromCollection")} 253 255 > 254 256 <Trash2 size={16} /> 255 257 </button>
+21 -18
web/src/views/collections/Collections.tsx
··· 1 1 import React, { useEffect, useState } from "react"; 2 + import { useTranslation } from "react-i18next"; 2 3 import { 3 4 getCollections, 4 5 createCollection, ··· 28 29 } 29 30 30 31 export default function Collections({ initialCollections }: CollectionsProps) { 32 + const { t } = useTranslation(); 31 33 const user = useStore($user); 32 34 const theme = useStore($theme); 33 35 const [collections, setCollections] = useState<Collection[]>( ··· 107 109 108 110 const handleDelete = async (id: string, e: React.MouseEvent) => { 109 111 e.preventDefault(); 110 - if (window.confirm("Delete this collection?")) { 112 + if (window.confirm(t("collections.deleteConfirm"))) { 111 113 const success = await deleteCollection(id); 112 114 if (success) { 113 115 setCollections((prev) => { ··· 151 153 <div className="flex items-center justify-between mb-6"> 152 154 <div> 153 155 <h1 className="text-3xl font-display font-bold text-surface-900 dark:text-white"> 154 - Collections 156 + {t("collections.title")} 155 157 </h1> 156 158 <p className="text-surface-500 dark:text-surface-400 mt-1"> 157 - Organize your annotations and highlights 159 + {t("collections.subtitle")} 158 160 </p> 159 161 </div> 160 162 <Button 161 163 onClick={() => setShowCreateModal(true)} 162 164 icon={<Plus size={16} />} 163 165 > 164 - New 166 + {t("common.new")} 165 167 </Button> 166 168 </div> 167 169 168 170 {collections.length === 0 ? ( 169 171 <EmptyState 170 172 icon={<Folder size={48} />} 171 - title="No collections yet" 172 - message="Create a collection to organize your highlights and annotations." 173 + title={t("collections.none")} 174 + message={t("collections.noneMessage")} 173 175 action={{ 174 - label: "Create collection", 176 + label: t("collections.createButton"), 175 177 onClick: () => setShowCreateModal(true), 176 178 }} 177 179 /> ··· 193 195 {collection.name} 194 196 </h3> 195 197 <p className="text-sm text-surface-500 dark:text-surface-400"> 196 - {collection.itemCount}{" "} 197 - {collection.itemCount === 1 ? "item" : "items"} 198 + {t("collections.itemCount", { 199 + count: collection.itemCount, 200 + })} 198 201 {collection.createdAt && 199 202 ` · ${formatDistanceToNow(new Date(collection.createdAt), { addSuffix: true })}`} 200 203 </p> ··· 217 220 <div className="bg-white dark:bg-surface-900 rounded-2xl shadow-2xl max-w-md w-full animate-scale-in ring-1 ring-black/5 dark:ring-white/10"> 218 221 <div className="flex items-center justify-between p-5 border-b border-surface-100 dark:border-surface-800"> 219 222 <h2 className="text-xl font-bold text-surface-900 dark:text-white"> 220 - New Collection 223 + {t("collections.newTitle")} 221 224 </h2> 222 225 <button 223 226 onClick={() => setShowCreateModal(false)} ··· 229 232 <form onSubmit={handleCreate} className="p-5"> 230 233 <div className="mb-4"> 231 234 <Input 232 - label="Name" 235 + label={t("collections.nameLabel")} 233 236 value={newItemName} 234 237 onChange={(e) => setNewItemName(e.target.value)} 235 238 placeholder="e.g. Design Inspiration" ··· 239 242 </div> 240 243 <div className="mb-4"> 241 244 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2"> 242 - Icon 245 + {t("collections.iconLabel")} 243 246 </label> 244 247 245 248 <div className="flex gap-2 mb-3 bg-surface-100 dark:bg-surface-800 p-1 rounded-xl"> ··· 252 255 : "text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-200" 253 256 }`} 254 257 > 255 - Icons 258 + {t("collections.iconsTab")} 256 259 </button> 257 260 <button 258 261 type="button" ··· 263 266 : "text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-200" 264 267 }`} 265 268 > 266 - Emojis 269 + {t("collections.emojisTab")} 267 270 </button> 268 271 </div> 269 272 ··· 326 329 </div> 327 330 <div className="mb-6"> 328 331 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2"> 329 - Description 332 + {t("collections.descriptionLabel")} 330 333 </label> 331 334 <textarea 332 335 value={newItemDesc} 333 336 onChange={(e) => setNewItemDesc(e.target.value)} 334 337 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" 335 - placeholder="What's this collection for?" 338 + placeholder={t("collections.descriptionPlaceholder")} 336 339 /> 337 340 </div> 338 341 <div className="flex justify-end gap-2"> ··· 341 344 variant="ghost" 342 345 onClick={() => setShowCreateModal(false)} 343 346 > 344 - Cancel 347 + {t("collections.cancel")} 345 348 </Button> 346 349 <Button type="submit" loading={creating}> 347 - Create Collection 350 + {t("collections.create")} 348 351 </Button> 349 352 </div> 350 353 </form>
+25 -20
web/src/views/content/AnnotationDetail.tsx
··· 1 1 import React, { useEffect, useRef, useState } from "react"; 2 2 import { useNavigate } from "react-router-dom"; 3 3 import { useStore } from "@nanostores/react"; 4 + import { useTranslation } from "react-i18next"; 4 5 import { $user } from "../../store/auth"; 5 6 import { 6 7 getAnnotation, ··· 43 44 initialReplies, 44 45 resolvedUri, 45 46 }: AnnotationDetailProps) { 47 + const { t } = useTranslation(); 46 48 const user = useStore($user); 47 49 const navigate = useNavigate(); 48 50 ··· 88 90 } 89 91 } catch (e) { 90 92 setError( 91 - "Failed to resolve handle: " + 92 - (e instanceof Error ? e.message : "Unknown error"), 93 + t("annotationDetail.failedResolve", { 94 + message: e instanceof Error ? e.message : "Unknown error", 95 + }), 93 96 ); 94 97 setLoading(false); 95 98 } ··· 98 101 } 99 102 } 100 103 resolve(); 101 - }, [uri, did, rkey, handle, type, resolvedUri]); 104 + }, [uri, did, rkey, handle, type, resolvedUri, t]); 102 105 103 106 const refreshReplies = async () => { 104 107 if (!targetUri) return; ··· 125 128 ]); 126 129 127 130 if (!annData) { 128 - setError("Annotation not found"); 131 + setError(t("annotationDetail.notFound")); 129 132 } else { 130 133 setAnnotation(annData); 131 134 setReplies(repliesData.items || []); ··· 137 140 } 138 141 } 139 142 fetchData(); 140 - }, [targetUri]); 143 + }, [targetUri, t]); 141 144 142 145 const handleReply = async (e?: React.FormEvent) => { 143 146 if (e) e.preventDefault(); ··· 170 173 await refreshReplies(); 171 174 } catch (err) { 172 175 alert( 173 - "Failed to post reply: " + 174 - (err instanceof Error ? err.message : "Unknown error"), 176 + t("annotationDetail.failedReply", { 177 + message: err instanceof Error ? err.message : "Unknown error", 178 + }), 175 179 ); 176 180 } finally { 177 181 setPosting(false); ··· 179 183 }; 180 184 181 185 const handleDeleteReply = async (reply: AnnotationItem) => { 182 - if (!window.confirm("Delete this reply?")) return; 186 + if (!window.confirm(t("annotationDetail.deleteReplyConfirm"))) return; 183 187 try { 184 188 await deleteReply(reply.uri || reply.id!); 185 189 await refreshReplies(); 186 190 } catch (err) { 187 191 alert( 188 - "Failed to delete: " + 189 - (err instanceof Error ? err.message : "Unknown error"), 192 + t("annotationDetail.failedDelete", { 193 + message: err instanceof Error ? err.message : "Unknown error", 194 + }), 190 195 ); 191 196 } 192 197 }; ··· 209 214 <AlertTriangle size={28} /> 210 215 </div> 211 216 <h3 className="text-xl font-bold text-surface-900 dark:text-white mb-2"> 212 - Not found 217 + {t("annotationDetail.notFound")} 213 218 </h3> 214 219 <p className="text-surface-500 dark:text-surface-400 text-sm mb-6"> 215 - {error || "This may have been deleted."} 220 + {error || t("annotationDetail.mayBeDeleted")} 216 221 </p> 217 222 <a 218 223 href="/home" ··· 222 227 }} 223 228 className="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors" 224 229 > 225 - Back to Feed 230 + {t("annotationDetail.backToFeed")} 226 231 </a> 227 232 </div> 228 233 ); ··· 240 245 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" 241 246 > 242 247 <ArrowLeft size={16} /> 243 - Back 248 + {t("annotationDetail.back")} 244 249 </a> 245 250 </div> 246 251 ··· 258 263 <div className="mt-6"> 259 264 <h3 className="flex items-center gap-2 text-sm font-semibold text-surface-500 dark:text-surface-400 uppercase tracking-wider mb-4"> 260 265 <MessageSquare size={16} /> 261 - Replies ({replies.length}) 266 + {t("annotationDetail.replies", { count: replies.length })} 262 267 </h3> 263 268 264 269 {user ? ( ··· 266 271 {replyingTo && ( 267 272 <div className="flex items-center justify-between bg-surface-50 dark:bg-surface-800 px-3 py-2 rounded-lg mb-3 border border-surface-200 dark:border-surface-700"> 268 273 <span className="text-sm text-surface-600 dark:text-surface-300"> 269 - Replying to{" "} 274 + {t("annotationDetail.replyingTo")}{" "} 270 275 <span className="font-medium text-surface-900 dark:text-white"> 271 276 @ 272 277 {(replyingTo.author || replyingTo.creator)?.handle || ··· 297 302 <textarea 298 303 value={replyText} 299 304 onChange={(e) => setReplyText(e.target.value)} 300 - placeholder="Write a reply..." 305 + placeholder={t("annotationDetail.replyPlaceholder")} 301 306 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]" 302 307 rows={2} 303 308 disabled={posting} ··· 308 313 disabled={posting || !replyText.trim()} 309 314 onClick={() => handleReply()} 310 315 > 311 - {posting ? "..." : "Reply"} 316 + {posting ? "..." : t("annotationDetail.reply")} 312 317 </button> 313 318 </div> 314 319 </div> ··· 317 322 ) : ( 318 323 <div className="bg-surface-50 dark:bg-surface-800/50 rounded-xl p-5 text-center mb-4 border border-dashed border-surface-200 dark:border-surface-700"> 319 324 <p className="text-surface-500 dark:text-surface-400 text-sm mb-2"> 320 - Sign in to reply 325 + {t("annotationDetail.signInToReply")} 321 326 </p> 322 327 <a 323 328 href="/login" 324 329 className="text-primary-600 dark:text-primary-400 font-medium hover:underline text-sm" 325 330 > 326 - Log in 331 + {t("annotationDetail.logIn")} 327 332 </a> 328 333 </div> 329 334 )}
+24 -23
web/src/views/content/UrlPage.tsx
··· 1 1 import { useStore } from "@nanostores/react"; 2 + import { useTranslation } from "react-i18next"; 2 3 import { 3 4 AlertTriangle, 4 5 Check, ··· 38 39 "all" | "annotations" | "highlights" 39 40 >("all"); 40 41 const [copied, setCopied] = useState(false); 42 + const { t } = useTranslation(); 41 43 const user = useStore($user); 42 44 43 45 const LIMIT = 50; ··· 153 155 /> 154 156 </div> 155 157 <h1 className="text-3xl font-display font-bold text-surface-900 dark:text-white mb-3"> 156 - URL Annotations 158 + {t("urlPage.title")} 157 159 </h1> 158 160 <p className="text-surface-500 dark:text-surface-400 max-w-md mx-auto mb-8"> 159 - Enter a URL to see all public annotations and highlights from the 160 - Margin community. 161 + {t("urlPage.description")} 161 162 </p> 162 163 163 164 <form ··· 175 176 <div className="flex-1"> 176 177 <Input 177 178 name="q" 178 - placeholder="https://example.com/article" 179 + placeholder={t("urlPage.urlPlaceholder")} 179 180 className="w-full bg-surface-50 dark:bg-surface-800" 180 181 autoFocus 181 182 /> 182 183 </div> 183 - <Button type="submit">View</Button> 184 + <Button type="submit">{t("urlPage.view")}</Button> 184 185 </form> 185 186 </div> 186 187 </div> ··· 233 234 <button 234 235 onClick={handleNavigateMyAnnotations} 235 236 className="flex items-center gap-1.5 px-3 py-1.5 bg-surface-100 dark:bg-surface-700 hover:bg-surface-200 dark:hover:bg-surface-600 text-surface-700 dark:text-surface-200 text-sm font-medium rounded-lg transition-colors" 236 - title="See your annotations for this page" 237 + title={t("urlPage.myAnnotations")} 237 238 > 238 - <User size={14} /> My Annotations 239 + <User size={14} /> {t("urlPage.myAnnotations")} 239 240 </button> 240 241 )} 241 242 <button 242 243 onClick={handleCopyLink} 243 244 className="flex items-center gap-1.5 px-3 py-1.5 bg-surface-100 dark:bg-surface-700 hover:bg-surface-200 dark:hover:bg-surface-600 text-surface-700 dark:text-surface-200 text-sm font-medium rounded-lg transition-colors" 244 - title="Copy shareable link" 245 + title={t("urlPage.share")} 245 246 > 246 247 {copied ? <Check size={14} /> : <Copy size={14} />} 247 - {copied ? "Copied!" : "Share"} 248 + {copied ? t("urlPage.copied") : t("urlPage.share")} 248 249 </button> 249 250 </div> 250 251 </div> ··· 253 254 <div className="mt-4 pt-4 border-t border-surface-100 dark:border-surface-700 flex items-center gap-4 text-sm text-surface-500 dark:text-surface-400"> 254 255 <span className="flex items-center gap-1.5"> 255 256 <Users size={14} /> 256 - {authorCount} contributor{authorCount !== 1 ? "s" : ""} 257 + {t("urlPage.contributor", { count: authorCount })} 257 258 </span> 258 259 </div> 259 260 )} ··· 266 267 size={32} 267 268 /> 268 269 <p className="text-surface-500 dark:text-surface-400"> 269 - Loading annotations... 270 + {t("urlPage.loadingAnnotations")} 270 271 </p> 271 272 </div> 272 273 )} ··· 281 282 {!loading && !error && totalItems === 0 && ( 282 283 <EmptyState 283 284 icon={<Search size={48} />} 284 - title="This page is a blank canvas" 285 - message="No one's left notes here yet. Want to be the first? Grab the Margin extension and share what you're thinking." 285 + title={t("urlPage.blankCanvas")} 286 + message={t("urlPage.blankCanvasMessage")} 286 287 /> 287 288 )} 288 289 ··· 291 292 <div className="mb-6"> 292 293 <Tabs 293 294 tabs={[ 294 - { id: "all", label: "All" }, 295 - { id: "annotations", label: "Annotations" }, 296 - { id: "highlights", label: "Highlights" }, 295 + { id: "all", label: t("urlPage.tabs.all") }, 296 + { id: "annotations", label: t("urlPage.tabs.annotations") }, 297 + { id: "highlights", label: t("urlPage.tabs.highlights") }, 297 298 ]} 298 299 activeTab={activeTab} 299 300 onChange={(id: string) => ··· 306 307 {activeTab === "annotations" && annotations.length === 0 && ( 307 308 <EmptyState 308 309 icon={<PenTool size={32} />} 309 - title="No annotations yet" 310 - message="Nobody has left a written note on this page." 310 + title={t("urlPage.noAnnotationsYet")} 311 + message={t("urlPage.noAnnotationsMessage")} 311 312 /> 312 313 )} 313 314 {activeTab === "highlights" && highlights.length === 0 && ( 314 315 <EmptyState 315 316 icon={<Highlighter size={32} />} 316 - title="No highlights yet" 317 - message="Nobody has highlighted a passage from this page." 317 + title={t("urlPage.noHighlightsYet")} 318 + message={t("urlPage.noHighlightsMessage")} 318 319 /> 319 320 )} 320 321 ··· 327 328 <div className="flex flex-col items-center gap-2 py-6"> 328 329 {loadMoreError && ( 329 330 <p className="text-sm text-red-500 dark:text-red-400"> 330 - Failed to load more: {loadMoreError} 331 + {t("urlPage.failedLoadMore", { message: loadMoreError })} 331 332 </p> 332 333 )} 333 334 <button ··· 338 339 {loadingMore ? ( 339 340 <> 340 341 <Loader2 size={16} className="animate-spin" /> 341 - Loading... 342 + {t("urlPage.loading")} 342 343 </> 343 344 ) : ( 344 - "Load more" 345 + t("urlPage.loadMore") 345 346 )} 346 347 </button> 347 348 </div>
+18 -16
web/src/views/content/UserUrlPage.tsx
··· 7 7 Search, 8 8 } from "lucide-react"; 9 9 import React, { useCallback, useEffect, useState } from "react"; 10 + import { useTranslation } from "react-i18next"; 10 11 import { getUserTargetItems } from "../../api/client"; 11 12 import Card from "../../components/common/Card"; 12 13 import Avatar from "../../components/ui/Avatar"; ··· 19 20 } 20 21 21 22 export default function UserUrlPage({ handle, urlPath }: UserUrlPageProps) { 23 + const { t } = useTranslation(); 22 24 const targetUrl = urlPath || ""; 23 25 24 26 const [profile, setProfile] = useState<UserProfile | null>(null); ··· 133 135 return ( 134 136 <EmptyState 135 137 icon={<Search size={48} />} 136 - title="No URL specified" 137 - message="Please provide a URL to view annotations." 138 + title={t("userUrlPage.noUrl")} 139 + message={t("userUrlPage.noUrlMessage")} 138 140 /> 139 141 ); 140 142 } ··· 174 176 <div className="mt-4 pt-4 border-t border-surface-100 dark:border-surface-700"> 175 177 <div className="flex items-center gap-2 text-sm"> 176 178 <span className="text-surface-400 dark:text-surface-500 font-medium shrink-0"> 177 - on 179 + {t("userUrlPage.on")} 178 180 </span> 179 181 <a 180 182 href={decodedTargetUrl} ··· 196 198 size={32} 197 199 /> 198 200 <p className="text-surface-500 dark:text-surface-400"> 199 - Loading annotations... 201 + {t("userUrlPage.loadingAnnotations")} 200 202 </p> 201 203 </div> 202 204 )} ··· 211 213 {!loading && !error && totalItems === 0 && ( 212 214 <EmptyState 213 215 icon={<PenTool size={32} />} 214 - title="No items found" 215 - message={`${displayName} hasn't annotated this page yet.`} 216 + title={t("userUrlPage.noItems")} 217 + message={t("userUrlPage.noItemsMessage", { name: displayName })} 216 218 /> 217 219 )} 218 220 ··· 221 223 <div className="mb-6"> 222 224 <Tabs 223 225 tabs={[ 224 - { id: "all", label: "All" }, 225 - { id: "annotations", label: "Annotations" }, 226 - { id: "highlights", label: "Highlights" }, 226 + { id: "all", label: t("urlPage.tabs.all") }, 227 + { id: "annotations", label: t("urlPage.tabs.annotations") }, 228 + { id: "highlights", label: t("urlPage.tabs.highlights") }, 227 229 ]} 228 230 activeTab={activeTab} 229 231 onChange={(id: string) => ··· 236 238 {activeTab === "annotations" && annotations.length === 0 && ( 237 239 <EmptyState 238 240 icon={<PenTool size={32} />} 239 - title="No annotations" 240 - message={`${displayName} hasn't annotated this page yet.`} 241 + title={t("userUrlPage.noAnnotations")} 242 + message={t("userUrlPage.noItemsMessage", { name: displayName })} 241 243 /> 242 244 )} 243 245 {activeTab === "highlights" && highlights.length === 0 && ( 244 246 <EmptyState 245 247 icon={<Highlighter size={32} />} 246 - title="No highlights" 247 - message={`${displayName} hasn't highlighted this page yet.`} 248 + title={t("userUrlPage.noHighlights")} 249 + message={t("userUrlPage.noItemsMessage", { name: displayName })} 248 250 /> 249 251 )} 250 252 ··· 257 259 <div className="flex flex-col items-center gap-2 py-6"> 258 260 {loadMoreError && ( 259 261 <p className="text-sm text-red-500 dark:text-red-400"> 260 - Failed to load more: {loadMoreError} 262 + {t("userUrlPage.failedLoadMore", { message: loadMoreError })} 261 263 </p> 262 264 )} 263 265 <button ··· 268 270 {loadingMore ? ( 269 271 <> 270 272 <Loader2 size={16} className="animate-spin" /> 271 - Loading... 273 + {t("userUrlPage.loading")} 272 274 </> 273 275 ) : ( 274 - "Load more" 276 + t("userUrlPage.loadMore") 275 277 )} 276 278 </button> 277 279 </div>
+69 -53
web/src/views/core/AdminModeration.tsx
··· 1 1 import React, { useEffect, useState } from "react"; 2 2 import { useStore } from "@nanostores/react"; 3 + import { useTranslation } from "react-i18next"; 3 4 import { $user } from "../../store/auth"; 4 5 import { 5 6 checkAdminAccess, ··· 38 39 "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300", 39 40 }; 40 41 41 - const REASON_LABELS: Record<string, string> = { 42 - spam: "Spam", 43 - violation: "Rule Violation", 44 - misleading: "Misleading", 45 - sexual: "Inappropriate", 46 - rude: "Rude / Harassing", 47 - other: "Other", 42 + const REASON_LABEL_KEYS: Record<string, string> = { 43 + spam: "adminModeration.reasons.spam", 44 + violation: "adminModeration.reasons.violation", 45 + misleading: "adminModeration.reasons.misleading", 46 + sexual: "adminModeration.reasons.sexual", 47 + rude: "adminModeration.reasons.rude", 48 + other: "adminModeration.reasons.other", 48 49 }; 49 50 50 - const LABEL_OPTIONS = [ 51 - { val: "sexual", label: "Sexual Content" }, 52 - { val: "nudity", label: "Nudity" }, 53 - { val: "violence", label: "Violence" }, 54 - { val: "gore", label: "Graphic Content" }, 55 - { val: "spam", label: "Spam" }, 56 - { val: "misleading", label: "Misleading" }, 51 + const LABEL_VALS = [ 52 + "sexual", 53 + "nudity", 54 + "violence", 55 + "gore", 56 + "spam", 57 + "misleading", 57 58 ]; 58 59 59 60 type Tab = "reports" | "labels" | "actions"; 60 61 61 62 export default function AdminModeration() { 63 + const { t } = useTranslation(); 62 64 const user = useStore($user); 63 65 const [isAdmin, setIsAdmin] = useState(false); 64 66 const [loading, setLoading] = useState(true); ··· 141 143 }; 142 144 143 145 const handleDeleteLabel = async (id: number) => { 144 - if (!window.confirm("Remove this label?")) return; 146 + if (!window.confirm(t("adminModeration.labels.removeConfirm"))) return; 145 147 const success = await adminDeleteLabel(id); 146 148 if (success) setLabels((prev) => prev.filter((l) => l.id !== id)); 147 149 }; ··· 163 165 return ( 164 166 <EmptyState 165 167 icon={<Shield size={40} />} 166 - title="Access Denied" 167 - message="You don't have permission to access the moderation dashboard." 168 + title={t("adminModeration.accessDenied")} 169 + message={t("adminModeration.accessDeniedMessage")} 168 170 /> 169 171 ); 170 172 } ··· 178 180 size={24} 179 181 className="text-primary-600 dark:text-primary-400" 180 182 /> 181 - Moderation 183 + {t("adminModeration.title")} 182 184 </h1> 183 185 <p className="text-sm text-surface-500 dark:text-surface-400 mt-1"> 184 - {pendingCount} pending · {totalCount} total reports 186 + {t("adminModeration.stats", { 187 + pending: pendingCount, 188 + total: totalCount, 189 + })} 185 190 </p> 186 191 </div> 187 192 </div> ··· 190 195 {[ 191 196 { 192 197 id: "reports" as Tab, 193 - label: "Reports", 198 + label: t("adminModeration.tabs.reports"), 194 199 icon: <FileText size={15} />, 195 200 }, 196 201 { 197 202 id: "actions" as Tab, 198 - label: "Actions", 203 + label: t("adminModeration.tabs.actions"), 199 204 icon: <EyeOff size={15} />, 200 205 }, 201 - { id: "labels" as Tab, label: "Labels", icon: <Tag size={15} /> }, 206 + { 207 + id: "labels" as Tab, 208 + label: t("adminModeration.tabs.labels"), 209 + icon: <Tag size={15} />, 210 + }, 202 211 ].map((tab) => ( 203 212 <button 204 213 key={tab.id} ··· 230 239 }`} 231 240 > 232 241 {status 233 - ? status.charAt(0).toUpperCase() + status.slice(1) 234 - : "All"} 242 + ? t(`adminModeration.filters.${status}`, { 243 + defaultValue: 244 + status.charAt(0).toUpperCase() + status.slice(1), 245 + }) 246 + : t("adminModeration.filters.all")} 235 247 </button> 236 248 ), 237 249 )} ··· 240 252 {reports.length === 0 ? ( 241 253 <EmptyState 242 254 icon={<CheckCircle size={40} />} 243 - title="No reports" 255 + title={t("adminModeration.reports.empty")} 244 256 message={ 245 257 statusFilter === "pending" 246 - ? "No pending reports to review." 247 - : `No ${statusFilter || ""} reports found.` 258 + ? t("adminModeration.reports.emptyPending") 259 + : t("adminModeration.reports.emptyFiltered", { 260 + status: statusFilter || "", 261 + }) 248 262 } 249 263 /> 250 264 ) : ( ··· 281 295 </span> 282 296 </div> 283 297 <p className="text-xs text-surface-500 dark:text-surface-400"> 284 - {REASON_LABELS[report.reasonType] || report.reasonType}{" "} 298 + {REASON_LABEL_KEYS[report.reasonType] 299 + ? t(REASON_LABEL_KEYS[report.reasonType]) 300 + : report.reasonType}{" "} 285 301 · reported by @ 286 302 {report.reporter.handle || report.reporter.did} ·{" "} 287 303 {new Date(report.createdAt).toLocaleDateString()} ··· 299 315 <div className="grid grid-cols-2 gap-3 text-sm"> 300 316 <div> 301 317 <span className="text-surface-400 dark:text-surface-500 text-xs uppercase tracking-wider"> 302 - Reported User 318 + {t("adminModeration.reports.reportedUser")} 303 319 </span> 304 320 <a 305 321 href={`/profile/${report.subject.did}`} ··· 310 326 </div> 311 327 <div> 312 328 <span className="text-surface-400 dark:text-surface-500 text-xs uppercase tracking-wider"> 313 - Reporter 329 + {t("adminModeration.reports.reporter")} 314 330 </span> 315 331 <a 316 332 href={`/profile/${report.reporter.did}`} ··· 324 340 {report.reasonText && ( 325 341 <div> 326 342 <span className="text-surface-400 dark:text-surface-500 text-xs uppercase tracking-wider"> 327 - Details 343 + {t("adminModeration.reports.details")} 328 344 </span> 329 345 <p className="text-sm text-surface-700 dark:text-surface-300 mt-1"> 330 346 {report.reasonText} ··· 335 351 {report.subjectUri && ( 336 352 <div> 337 353 <span className="text-surface-400 dark:text-surface-500 text-xs uppercase tracking-wider"> 338 - Content URI 354 + {t("adminModeration.reports.contentUri")} 339 355 </span> 340 356 <p className="text-xs text-surface-500 font-mono mt-1 break-all"> 341 357 {report.subjectUri} ··· 354 370 loading={actionLoading === report.id} 355 371 icon={<Eye size={14} />} 356 372 > 357 - Acknowledge 373 + {t("adminModeration.reports.acknowledge")} 358 374 </Button> 359 375 <Button 360 376 size="sm" ··· 363 379 loading={actionLoading === report.id} 364 380 icon={<XCircle size={14} />} 365 381 > 366 - Dismiss 382 + {t("adminModeration.reports.dismiss")} 367 383 </Button> 368 384 <Button 369 385 size="sm" ··· 372 388 icon={<AlertTriangle size={14} />} 373 389 className="!bg-red-600 hover:!bg-red-700 !text-white" 374 390 > 375 - Takedown 391 + {t("adminModeration.reports.takedown")} 376 392 </Button> 377 393 </div> 378 394 )} ··· 393 409 size={16} 394 410 className="text-primary-600 dark:text-primary-400" 395 411 /> 396 - Apply Content Warning 412 + {t("adminModeration.actions.applyWarning")} 397 413 </h3> 398 414 <p className="text-sm text-surface-500 dark:text-surface-400 mb-4"> 399 - Add a content warning label to a specific post or account. Users 400 - will see a blur overlay with the option to reveal. 415 + {t("adminModeration.actions.applyWarningDesc")} 401 416 </p> 402 417 403 418 <div className="space-y-3"> 404 419 <div> 405 420 <label className="block text-xs font-medium text-surface-600 dark:text-surface-400 mb-1.5"> 406 - Account DID 421 + {t("adminModeration.actions.accountDid")} 407 422 </label> 408 423 <input 409 424 type="text" ··· 416 431 417 432 <div> 418 433 <label className="block text-xs font-medium text-surface-600 dark:text-surface-400 mb-1.5"> 419 - Content URI{" "} 434 + {t("adminModeration.actions.contentUri")}{" "} 420 435 <span className="text-surface-400"> 421 - (optional — leave empty for account-level label) 436 + ({t("adminModeration.actions.contentUriOptional")}) 422 437 </span> 423 438 </label> 424 439 <input ··· 432 447 433 448 <div> 434 449 <label className="block text-xs font-medium text-surface-600 dark:text-surface-400 mb-1.5"> 435 - Label Type 450 + {t("adminModeration.actions.labelType")} 436 451 </label> 437 452 <div className="grid grid-cols-3 gap-2"> 438 - {LABEL_OPTIONS.map((opt) => ( 453 + {LABEL_VALS.map((val) => ( 439 454 <button 440 - key={opt.val} 441 - onClick={() => setLabelVal(opt.val)} 455 + key={val} 456 + onClick={() => setLabelVal(val)} 442 457 className={`px-3 py-2 text-sm font-medium rounded-lg border transition-all ${ 443 - labelVal === opt.val 458 + labelVal === val 444 459 ? "border-primary-500 bg-primary-50 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300 ring-2 ring-primary-500/20" 445 460 : "border-surface-200 dark:border-surface-700 text-surface-600 dark:text-surface-400 hover:bg-surface-50 dark:hover:bg-surface-800" 446 461 }`} 447 462 > 448 - {opt.label} 463 + {t(`card.labelDescriptions.${val}`)} 449 464 </button> 450 465 ))} 451 466 </div> ··· 459 474 icon={<Plus size={14} />} 460 475 size="sm" 461 476 > 462 - Apply Label 477 + {t("adminModeration.actions.applyLabel")} 463 478 </Button> 464 479 {labelSuccess && ( 465 480 <span className="text-sm text-green-600 dark:text-green-400 flex items-center gap-1.5"> 466 - <CheckCircle size={14} /> Label applied 481 + <CheckCircle size={14} />{" "} 482 + {t("adminModeration.actions.labelApplied")} 467 483 </span> 468 484 )} 469 485 </div> ··· 477 493 {labels.length === 0 ? ( 478 494 <EmptyState 479 495 icon={<Tag size={40} />} 480 - title="No labels" 481 - message="No content labels have been applied yet." 496 + title={t("adminModeration.labels.empty")} 497 + message={t("adminModeration.labels.emptyMessage")} 482 498 /> 483 499 ) : ( 484 500 <div className="space-y-2"> ··· 519 535 <p className="text-xs text-surface-500 dark:text-surface-400 truncate"> 520 536 {label.uri !== label.src 521 537 ? label.uri 522 - : "Account-level label"}{" "} 538 + : t("adminModeration.labels.accountLevel")}{" "} 523 539 · {new Date(label.createdAt).toLocaleDateString()} · by @ 524 540 {label.createdBy.handle || label.createdBy.did} 525 541 </p> ··· 527 543 <button 528 544 onClick={() => handleDeleteLabel(label.id)} 529 545 className="p-2 rounded-lg text-surface-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors" 530 - title="Remove label" 546 + title={t("adminModeration.labels.removeTitle")} 531 547 > 532 548 <Trash2 size={14} /> 533 549 </button>
+11 -9
web/src/views/core/Discover.tsx
··· 2 2 import { Loader2, ExternalLink, Compass, Tag } from "lucide-react"; 3 3 import { useStore } from "@nanostores/react"; 4 4 import { clsx } from "clsx"; 5 + import { useTranslation } from "react-i18next"; 5 6 import { getDocuments, getRecommendations } from "../../api/client"; 6 7 import type { DocumentItem } from "../../api/client"; 7 8 import { Tabs, EmptyState } from "../../components/ui"; ··· 19 20 initialDocuments, 20 21 initialHasMore, 21 22 }: DiscoverProps) { 23 + const { t } = useTranslation(); 22 24 const user = useStore($user); 23 25 const layout = useStore($feedLayout); 24 26 const [activeTab, setActiveTab] = useState("new"); ··· 32 34 const limit = 30; 33 35 34 36 const tabs = [ 35 - { id: "new", label: "New" }, 36 - { id: "popular", label: "Popular" }, 37 - ...(user ? [{ id: "recommended", label: "For You" }] : []), 37 + { id: "new", label: t("discover.tabs.new") }, 38 + { id: "popular", label: t("discover.tabs.popular") }, 39 + ...(user ? [{ id: "recommended", label: t("discover.tabs.forYou") }] : []), 38 40 ]; 39 41 40 42 const fetchItems = useCallback( ··· 104 106 ) : activeTab === "recommended" && recommendationsUnavailable ? ( 105 107 <EmptyState 106 108 icon={<Compass size={40} />} 107 - title="Coming soon" 108 - message="Personalized recommendations aren't available on this server yet." 109 + title={t("discover.comingSoon")} 110 + message={t("discover.forYouNotAvailable")} 109 111 /> 110 112 ) : items.length === 0 ? ( 111 113 <EmptyState 112 114 icon={<Compass size={40} />} 113 - title="Nothing here yet" 115 + title={t("feed.nothingHereYet")} 114 116 message={ 115 117 activeTab === "recommended" 116 - ? "Start annotating and highlighting to get personalized recommendations." 117 - : "No documents have been discovered yet. Check back soon!" 118 + ? t("discover.startAnnotating") 119 + : t("discover.noDocumentsYet") 118 120 } 119 121 /> 120 122 ) : ( ··· 148 150 onClick={loadMore} 149 151 className="w-full py-3 text-sm font-medium text-surface-500 hover:text-surface-700 dark:text-surface-400 dark:hover:text-surface-200 hover:bg-surface-100 dark:hover:bg-surface-800 rounded-lg transition-colors" 150 152 > 151 - Load more 153 + {t("discover.loadMore")} 152 154 </button> 153 155 )} 154 156 </div>
+30 -20
web/src/views/core/Feed.tsx
··· 8 8 Users, 9 9 } from "lucide-react"; 10 10 import { useState } from "react"; 11 + import { useTranslation } from "react-i18next"; 11 12 import FeedItems from "../../components/feed/FeedItems"; 12 13 import { Button, Tabs } from "../../components/ui"; 13 14 import LayoutToggle from "../../components/ui/LayoutToggle"; ··· 32 33 initialUser, 33 34 motivation, 34 35 showTabs = true, 35 - emptyMessage = "Nothing here yet — annotations from you and people you follow will show up here.", 36 + emptyMessage, 36 37 initialItems, 37 38 initialHasMore, 38 39 }: FeedProps) { 40 + const { t } = useTranslation(); 41 + const resolvedEmptyMessage = emptyMessage ?? t("feed.defaultEmptyMessage"); 39 42 const [tag, setTag] = useState<string | undefined>( 40 43 initialTag || 41 44 (typeof window !== "undefined" ··· 74 77 }; 75 78 76 79 const tabs = [ 77 - { id: "all", label: "Recent" }, 78 - { id: "popular", label: "Popular" }, 79 - { id: "shelved", label: "Shelved" }, 80 - { id: "margin", label: "Margin" }, 81 - { id: "semble", label: "Semble" }, 80 + { id: "all", label: t("feed.tabs.recent") }, 81 + { id: "popular", label: t("feed.tabs.popular") }, 82 + { id: "shelved", label: t("feed.tabs.shelved") }, 83 + { id: "margin", label: t("feed.tabs.margin") }, 84 + { id: "semble", label: t("feed.tabs.semble") }, 82 85 ]; 83 86 84 87 const filters = [ 85 - { id: "all", label: "All", icon: null }, 86 - { id: "commenting", label: "Annotations", icon: MessageSquareText }, 87 - { id: "highlighting", label: "Highlights", icon: Highlighter }, 88 - { id: "bookmarking", label: "Bookmarks", icon: Bookmark }, 88 + { id: "all", label: t("feed.filters.all"), icon: null }, 89 + { 90 + id: "commenting", 91 + label: t("feed.filters.annotations"), 92 + icon: MessageSquareText, 93 + }, 94 + { 95 + id: "highlighting", 96 + label: t("feed.filters.highlights"), 97 + icon: Highlighter, 98 + }, 99 + { id: "bookmarking", label: t("feed.filters.bookmarks"), icon: Bookmark }, 89 100 ]; 90 101 91 102 return ( ··· 96 107 <div className="h-48 w-48 rounded-full bg-primary-200/40 dark:bg-primary-900/20 blur-3xl" /> 97 108 </div> 98 109 <h1 className="text-3xl sm:text-4xl font-display font-bold mb-3 tracking-tight text-surface-900 dark:text-white"> 99 - Welcome to Margin 110 + {t("feed.welcome")} 100 111 </h1> 101 112 <p className="text-surface-500 dark:text-surface-400 mb-5 max-w-md mx-auto leading-relaxed"> 102 - A quiet place to annotate, highlight, and save what you read on the 103 - web. 113 + {t("feed.welcomeTagline")} 104 114 </p> 105 115 <div className="flex gap-3 justify-center"> 106 116 <Button onClick={() => (window.location.href = "/login")}> 107 - Get started 117 + {t("feed.getStarted")} 108 118 </Button> 109 119 <Button 110 120 variant="secondary" 111 121 onClick={() => window.open("/about", "_blank")} 112 122 > 113 - Learn more 123 + {t("feed.learnMore")} 114 124 </Button> 115 125 </div> 116 126 </div> ··· 124 134 <div className="flex items-center justify-between mb-2"> 125 135 <h2 className="text-xl font-bold flex items-center gap-2"> 126 136 <span className="text-surface-500 font-normal"> 127 - Items with tag: 137 + {t("feed.itemsWithTag")} 128 138 </span> 129 139 <span className="bg-primary-50 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400 px-2 py-0.5 rounded-lg"> 130 140 #{tag} ··· 134 144 onClick={clearTag} 135 145 className="text-sm text-surface-500 hover:text-surface-900 dark:hover:text-white" 136 146 > 137 - Clear filter 147 + {t("feed.clearFilter")} 138 148 </button> 139 149 </div> 140 150 )} ··· 167 177 {!showTabs && user && ( 168 178 <div className="flex items-center gap-1.5"> 169 179 {[ 170 - { id: "everyone", label: "Everyone", icon: Users }, 171 - { id: "mine", label: "Mine", icon: User }, 180 + { id: "everyone", label: t("feed.everyone"), icon: Users }, 181 + { id: "mine", label: t("feed.mine"), icon: User }, 172 182 ].map((f) => { 173 183 const isActive = f.id === "mine" ? mineOnly : !mineOnly; 174 184 return ( ··· 199 209 type={activeTab} 200 210 motivation={activeFilter} 201 211 creator={mineOnly && user ? user.did : undefined} 202 - emptyMessage={emptyMessage} 212 + emptyMessage={resolvedEmptyMessage} 203 213 layout={layout} 204 214 tag={tag?.toLowerCase()} 205 215 initialItems={
+31 -15
web/src/views/core/HighlightImporter.tsx
··· 7 7 } from "lucide-react"; 8 8 import type React from "react"; 9 9 import { useRef, useState } from "react"; 10 + import { useTranslation } from "react-i18next"; 10 11 import { createHighlight } from "../../api/client"; 11 12 import type { Selector } from "../../types"; 12 13 import { analytics } from "../../lib/analytics"; ··· 29 30 } 30 31 31 32 export function HighlightImporter() { 33 + const { t } = useTranslation(); 32 34 const [progress, setProgress] = useState<ImportProgress | null>(null); 33 35 const [isImporting, setIsImporting] = useState(false); 34 36 const fileInputRef = useRef<HTMLInputElement>(null); ··· 167 169 const highlights = parseCSV(csv); 168 170 169 171 if (highlights.length === 0) { 170 - alert("No valid highlights found in CSV"); 172 + alert(t("highlightImporter.noHighlights")); 171 173 setIsImporting(false); 172 174 return; 173 175 } ··· 224 226 } catch (error) { 225 227 analytics.captureException(error); 226 228 alert( 227 - `Error parsing CSV: ${error instanceof Error ? error.message : "Unknown error"}`, 229 + t("highlightImporter.errorParsing", { 230 + message: error instanceof Error ? error.message : "Unknown error", 231 + }), 228 232 ); 229 233 setIsImporting(false); 230 234 } ··· 250 254 <div className="flex flex-col items-center gap-2"> 251 255 <Upload className="w-6 h-6 text-surface-500 dark:text-surface-400" /> 252 256 <span className="text-sm font-medium text-surface-700 dark:text-surface-300"> 253 - {isImporting ? "Processing..." : "Click to upload CSV"} 257 + {isImporting 258 + ? t("highlightImporter.processing") 259 + : t("highlightImporter.clickToUpload")} 254 260 </span> 255 261 <span className="text-xs text-surface-500 dark:text-surface-400"> 256 - Required columns: url, text | Optional: title, tags, color, 257 - created_at 262 + {t("highlightImporter.requiredColumns")} 258 263 </span> 259 264 </div> 260 265 </label> ··· 265 270 className="w-full flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-surface-700 dark:text-surface-300 bg-surface-100 dark:bg-surface-700 hover:bg-surface-200 dark:hover:bg-surface-600 rounded-lg transition" 266 271 > 267 272 <Download size={16} /> 268 - Download Template 273 + {t("highlightImporter.downloadTemplate")} 269 274 </button> 270 275 </div> 271 276 ); ··· 282 287 <div className="space-y-2"> 283 288 <div className="flex items-center justify-between"> 284 289 <span className="text-sm font-medium text-surface-700 dark:text-surface-300"> 285 - Import Progress 290 + {t("highlightImporter.importProgress")} 286 291 </span> 287 292 <span className="text-sm text-surface-500 dark:text-surface-400"> 288 293 {progress.completed} / {progress.total} ··· 299 304 </div> 300 305 301 306 <div className="flex items-center justify-between text-xs text-surface-600 dark:text-surface-400"> 302 - <span>{successRate}% complete</span> 307 + <span> 308 + {t("highlightImporter.complete", { rate: successRate })} 309 + </span> 303 310 {progress.failed > 0 && ( 304 - <span className="text-red-500">{progress.failed} failed</span> 311 + <span className="text-red-500"> 312 + {t("highlightImporter.failed", { count: progress.failed })} 313 + </span> 305 314 )} 306 315 </div> 307 316 </div> ··· 311 320 <div className="flex items-center justify-center gap-2 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg"> 312 321 <Loader2 className="w-4 h-4 animate-spin text-blue-500" /> 313 322 <span className="text-sm text-blue-700 dark:text-blue-300"> 314 - Importing highlights... 323 + {t("highlightImporter.importing")} 315 324 </span> 316 325 </div> 317 326 )} ··· 322 331 <div className="flex items-center justify-center gap-2 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg"> 323 332 <CheckCircle2 className="w-4 h-4 text-green-600 dark:text-green-400" /> 324 333 <span className="text-sm text-green-700 dark:text-green-300"> 325 - Successfully imported {progress.completed} highlights! 334 + {t("highlightImporter.success", { count: progress.completed })} 326 335 </span> 327 336 </div> 328 337 )} ··· 333 342 <AlertCircle className="w-4 h-4 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" /> 334 343 <div> 335 344 <p className="text-sm font-medium text-red-700 dark:text-red-300"> 336 - {progress.errors.length} errors during import 345 + {t("highlightImporter.errorsTitle", { 346 + count: progress.errors.length, 347 + })} 337 348 </p> 338 349 <ul className="mt-2 space-y-1"> 339 350 {progress.errors.slice(0, 5).map((err, idx) => ( ··· 341 352 key={idx} 342 353 className="text-xs text-red-600 dark:text-red-400" 343 354 > 344 - Row {err.row}: {err.error} 355 + {t("highlightImporter.row", { 356 + row: err.row, 357 + error: err.error, 358 + })} 345 359 </li> 346 360 ))} 347 361 {progress.errors.length > 5 && ( 348 362 <li className="text-xs text-red-600 dark:text-red-400"> 349 - +{progress.errors.length - 5} more errors 363 + {t("highlightImporter.moreErrors", { 364 + count: progress.errors.length - 5, 365 + })} 350 366 </li> 351 367 )} 352 368 </ul> ··· 360 376 onClick={() => setProgress(null)} 361 377 className="w-full px-4 py-2 text-sm font-medium bg-surface-200 dark:bg-surface-700 hover:bg-surface-300 dark:hover:bg-surface-600 rounded-lg transition" 362 378 > 363 - Import Another File 379 + {t("highlightImporter.importAnother")} 364 380 </button> 365 381 )} 366 382 </div>
+9 -7
web/src/views/core/New.tsx
··· 1 1 import React, { useState } from "react"; 2 2 import { useNavigate } from "react-router-dom"; 3 3 import { useStore } from "@nanostores/react"; 4 + import { useTranslation } from "react-i18next"; 4 5 import { $user } from "../../store/auth"; 5 6 import Composer from "../../components/feed/Composer"; 6 7 import type { Selector } from "../../types"; ··· 16 17 initialSelectorJson, 17 18 initialQuote, 18 19 }: NewAnnotationProps) { 20 + const { t } = useTranslation(); 19 21 const user = useStore($user); 20 22 const navigate = useNavigate(); 21 23 ··· 44 46 <div className="max-w-sm mx-auto py-16 px-4"> 45 47 <div className="bg-white dark:bg-surface-900 rounded-xl ring-1 ring-black/5 dark:ring-white/5 p-6 text-center"> 46 48 <h2 className="text-xl font-bold text-surface-900 dark:text-white mb-2"> 47 - Sign in to create 49 + {t("new.signInRequired")} 48 50 </h2> 49 51 <p className="text-surface-500 dark:text-surface-400 text-sm mb-5"> 50 - You need a Bluesky account 52 + {t("new.needsAccount")} 51 53 </p> 52 54 <a 53 55 href="/login" 54 56 className="block w-full py-2.5 px-4 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors" 55 57 > 56 - Sign in with Bluesky 58 + {t("new.signInButton")} 57 59 </a> 58 60 </div> 59 61 </div> ··· 68 70 <div className="max-w-2xl mx-auto pb-20"> 69 71 <div className="mb-6 text-center sm:text-left"> 70 72 <h1 className="text-2xl font-display font-bold text-surface-900 dark:text-white mb-1"> 71 - Compose 73 + {t("new.composeTitle")} 72 74 </h1> 73 75 <p className="text-surface-500 dark:text-surface-400"> 74 - Highlight a passage, leave a note, or annotate a page — all from here. 76 + {t("new.composeTagline")} 75 77 </p> 76 78 </div> 77 79 ··· 81 83 htmlFor="url-input" 82 84 className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5" 83 85 > 84 - URL to annotate 86 + {t("new.urlLabel")} 85 87 </label> 86 88 <input 87 89 id="url-input" 88 90 type="url" 89 91 value={url} 90 92 onChange={(e) => setUrl(e.target.value)} 91 - placeholder="https://example.com/article" 93 + placeholder={t("new.urlPlaceholder")} 92 94 className="w-full p-3 bg-white dark:bg-surface-900 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 transition-all" 93 95 required 94 96 />
+27 -17
web/src/views/core/Notifications.tsx
··· 1 1 import React, { useEffect, useState } from "react"; 2 + import { useTranslation } from "react-i18next"; 3 + import type { TFunction } from "i18next"; 2 4 3 5 import { getNotifications, markNotificationsRead } from "../../api/client"; 4 6 import type { NotificationItem, AnnotationItem } from "../../types"; ··· 34 36 function getNotificationVerb( 35 37 notifType: string, 36 38 contentType: string, 39 + t: TFunction, 37 40 subject?: AnnotationItem, 38 41 ): string { 39 42 switch (notifType) { 40 43 case "like": 41 44 switch (contentType) { 42 45 case "annotation": 43 - return "liked your annotation"; 46 + return t("notifications.likedAnnotation"); 44 47 case "highlight": 45 - return "liked your highlight"; 48 + return t("notifications.likedHighlight"); 46 49 case "bookmark": 47 - return "liked your bookmark"; 50 + return t("notifications.likedBookmark"); 48 51 case "reply": 49 - return "liked your reply"; 52 + return t("notifications.likedReply"); 50 53 default: 51 - return "liked your post"; 54 + return t("notifications.likedPost"); 52 55 } 53 56 case "reply": { 54 57 const parentUri = subject?.inReplyTo; ··· 56 59 ? getContentType(parentUri) === "reply" 57 60 : false; 58 61 return parentIsReply 59 - ? "replied to your reply" 60 - : "replied to your annotation"; 62 + ? t("notifications.repliedToReply") 63 + : t("notifications.repliedToAnnotation"); 61 64 } 62 65 case "mention": 63 - return "mentioned you in an annotation"; 66 + return t("notifications.mentionedInAnnotation"); 64 67 case "follow": 65 - return "followed you"; 68 + return t("notifications.followedYou"); 66 69 case "highlight": 67 - return "highlighted your page"; 70 + return t("notifications.highlightedPage"); 68 71 default: 69 72 return notifType; 70 73 } ··· 121 124 function SubjectPreview({ 122 125 subject, 123 126 subjectUri, 127 + t, 124 128 }: { 125 129 subject: AnnotationItem | unknown; 126 130 subjectUri: string; 131 + t: TFunction; 127 132 }) { 128 133 const item = subject as AnnotationItem | undefined; 129 134 if (!item?.uri && !subjectUri) return null; ··· 196 201 )} 197 202 {parentUri && ( 198 203 <p className="text-surface-400 dark:text-surface-500 text-xs mt-1"> 199 - in reply to{" "} 204 + {t("notifications.inReplyTo")}{" "} 200 205 <a 201 206 href={`/annotation/${encodeURIComponent(parentUri)}`} 202 207 className="hover:underline text-primary-500" 203 208 onClick={(e) => e.stopPropagation()} 204 209 > 205 - {parentIsReply ? "a reply" : "an annotation"} 210 + {parentIsReply 211 + ? t("notifications.aReply") 212 + : t("notifications.anAnnotation")} 206 213 </a> 207 214 </p> 208 215 )} ··· 229 236 export default function Notifications({ 230 237 initialNotifications, 231 238 }: NotificationsProps) { 239 + const { t } = useTranslation(); 232 240 const [notifications, setNotifications] = useState<NotificationItem[]>( 233 241 initialNotifications || [], 234 242 ); ··· 275 283 return ( 276 284 <div className="max-w-2xl mx-auto animate-fade-in"> 277 285 <h1 className="text-3xl font-display font-bold text-surface-900 dark:text-white mb-6"> 278 - Activity 286 + {t("notifications.title")} 279 287 </h1> 280 288 <div className="space-y-3"> 281 289 {[1, 2, 3].map((i) => ( ··· 296 304 return ( 297 305 <div className="max-w-2xl mx-auto animate-fade-in"> 298 306 <h1 className="text-3xl font-display font-bold text-surface-900 dark:text-white mb-6"> 299 - Activity 307 + {t("notifications.title")} 300 308 </h1> 301 309 <EmptyState 302 310 icon={<Bell size={48} />} 303 - title="No activity yet" 304 - message="Interactions with your content will appear here." 311 + title={t("notifications.noActivity")} 312 + message={t("notifications.noActivityMessage")} 305 313 /> 306 314 </div> 307 315 ); ··· 310 318 return ( 311 319 <div className="max-w-2xl mx-auto animate-slide-up"> 312 320 <h1 className="text-3xl font-display font-bold text-surface-900 dark:text-white mb-6"> 313 - Activity 321 + {t("notifications.title")} 314 322 </h1> 315 323 <div className="space-y-2"> 316 324 {notifications.map((n) => { ··· 318 326 const verb = getNotificationVerb( 319 327 n.type, 320 328 contentType, 329 + t, 321 330 n.subject as AnnotationItem, 322 331 ); 323 332 const timeAgo = formatDistanceToNow(new Date(n.createdAt), { ··· 371 380 <SubjectPreview 372 381 subject={n.subject} 373 382 subjectUri={n.subjectUri || ""} 383 + t={t} 374 384 /> 375 385 )} 376 386 </div>
+26 -13
web/src/views/core/Search.tsx
··· 9 9 } from "lucide-react"; 10 10 import { clsx } from "clsx"; 11 11 import { useStore } from "@nanostores/react"; 12 + import { useTranslation } from "react-i18next"; 12 13 import { searchItems } from "../../api/client"; 13 14 import type { AnnotationItem } from "../../types"; 14 15 import Card from "../../components/common/Card"; ··· 39 40 initialResults, 40 41 initialHasMore, 41 42 }: SearchProps) { 43 + const { t } = useTranslation(); 42 44 const user = useStore($user); 43 45 const layout = useStore($feedLayout); 44 46 ··· 63 65 }, [myItemsOnly]); 64 66 65 67 const filters = [ 66 - { id: "all", label: "All", icon: null }, 67 - { id: "commenting", label: "Annotations", icon: MessageSquareText }, 68 - { id: "highlighting", label: "Highlights", icon: Highlighter }, 69 - { id: "bookmarking", label: "Bookmarks", icon: Bookmark }, 68 + { id: "all", label: t("search.filters.all"), icon: null }, 69 + { 70 + id: "commenting", 71 + label: t("search.filters.annotations"), 72 + icon: MessageSquareText, 73 + }, 74 + { 75 + id: "highlighting", 76 + label: t("search.filters.highlights"), 77 + icon: Highlighter, 78 + }, 79 + { id: "bookmarking", label: t("search.filters.bookmarks"), icon: Bookmark }, 70 80 ]; 71 81 72 82 const doSearch = useCallback( ··· 203 213 type="text" 204 214 value={query} 205 215 onChange={(e) => setQuery(e.target.value)} 206 - placeholder="Search annotations, highlights, bookmarks..." 216 + placeholder={t("search.placeholder")} 207 217 autoFocus 208 218 className="w-full pl-11 pr-4 py-3 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-xl text-sm focus:outline-none focus:border-primary-400 focus:ring-2 focus:ring-primary-400/20 placeholder:text-surface-400" 209 219 /> ··· 252 262 )} 253 263 > 254 264 <SlidersHorizontal size={12} /> 255 - Mine 265 + {t("search.filters.mine")} 256 266 </button> 257 267 )} 258 268 ··· 367 377 {!loading && initialQuery && filteredResults.length === 0 && ( 368 378 <EmptyState 369 379 icon={<SearchIcon size={48} />} 370 - title="No results found" 371 - message={`Nothing matched "${initialQuery}". Try different keywords.`} 380 + title={t("search.noResults")} 381 + message={t("search.noResultsMessage", { query: initialQuery })} 372 382 /> 373 383 )} 374 384 ··· 380 390 )} 381 391 > 382 392 <p className="text-xs text-surface-400 dark:text-surface-500 font-medium mb-3 px-1"> 383 - {filteredResults.length} 384 - {hasMore ? "+" : ""} results for &ldquo;{initialQuery}&rdquo; 393 + {t("search.resultCount", { 394 + count: filteredResults.length, 395 + hasMore: hasMore ? "+" : "", 396 + query: initialQuery, 397 + })} 385 398 </p> 386 399 387 400 {layout === "mosaic" ? ( ··· 414 427 {loading ? ( 415 428 <Loader2 className="animate-spin mx-auto" size={16} /> 416 429 ) : ( 417 - "Load more" 430 + t("search.loadMore") 418 431 )} 419 432 </button> 420 433 )} ··· 424 437 {!initialQuery && !loading && ( 425 438 <EmptyState 426 439 icon={<SearchIcon size={48} />} 427 - title="Search your library" 428 - message="Find annotations, highlights, and bookmarks by keyword, URL, or tag." 440 + title={t("search.emptyTitle")} 441 + message={t("search.emptyMessage")} 429 442 /> 430 443 )} 431 444 </div>
+104 -49
web/src/views/core/Settings.tsx
··· 1 1 import React, { useEffect, useState } from "react"; 2 2 import { useStore } from "@nanostores/react"; 3 + import { useTranslation } from "react-i18next"; 4 + import i18n from "../../i18n"; 5 + import { languages } from "virtual:i18n-languages"; 3 6 import { $user, logout } from "../../store/auth"; 4 7 import { $theme, setTheme, type Theme } from "../../store/theme"; 5 8 import { ··· 65 68 import { analytics } from "../../lib/analytics"; 66 69 67 70 export default function Settings() { 71 + const { t } = useTranslation(); 68 72 const user = useStore($user); 69 73 const theme = useStore($theme); 70 74 const [keys, setKeys] = useState<APIKey[]>([]); ··· 81 85 const [addingLabeler, setAddingLabeler] = useState(false); 82 86 const [isShortcutModalOpen, setIsShortcutModalOpen] = useState(false); 83 87 const preferences = useStore($preferences); 88 + const currentLanguage = i18n.resolvedLanguage ?? i18n.language ?? "en"; 84 89 85 90 useEffect(() => { 86 91 const loadKeys = async () => { ··· 123 128 }; 124 129 125 130 const handleDelete = async (id: string) => { 126 - if (window.confirm("Revoke this key? Apps using it will stop working.")) { 131 + if (window.confirm(t("settings.apiKeys.revokeConfirm"))) { 127 132 const success = await deleteAPIKey(id); 128 133 if (success) { 129 134 setKeys((prev) => prev.filter((k) => k.id !== id)); ··· 140 145 if (!user) return null; 141 146 142 147 const themeOptions: { value: Theme; label: string; icon: typeof Sun }[] = [ 143 - { value: "light", label: "Light", icon: Sun }, 144 - { value: "dark", label: "Dark", icon: Moon }, 145 - { value: "system", label: "System", icon: Monitor }, 148 + { value: "light", label: t("nav.themeLight"), icon: Sun }, 149 + { value: "dark", label: t("nav.themeDark"), icon: Moon }, 150 + { value: "system", label: t("nav.themeSystem"), icon: Monitor }, 146 151 ]; 147 152 148 153 return ( 149 154 <div className="max-w-2xl mx-auto animate-slide-up"> 150 155 <h1 className="text-3xl font-display font-bold text-surface-900 dark:text-white mb-8"> 151 - Settings 156 + {t("settings.title")} 152 157 </h1> 153 158 154 159 <div className="space-y-6"> 155 160 <section className="card p-5"> 156 161 <h2 className="text-xs font-semibold text-surface-500 dark:text-surface-400 uppercase tracking-wider mb-4"> 157 - Profile 162 + {t("settings.sections.profile")} 158 163 </h2> 159 164 <div className="flex gap-4 items-center"> 160 165 <Avatar did={user.did} avatar={user.avatar} size="lg" /> ··· 175 180 176 181 <section className="card p-5"> 177 182 <h2 className="text-xs font-semibold text-surface-500 dark:text-surface-400 uppercase tracking-wider mb-4"> 178 - Appearance 183 + {t("settings.sections.appearance")} 179 184 </h2> 180 185 <div className="flex gap-2"> 181 186 {themeOptions.map((opt) => ( ··· 213 218 <div className="mt-6 flex items-center justify-between"> 214 219 <div> 215 220 <h3 className="text-sm font-medium text-surface-900 dark:text-white"> 216 - Disable external link warning 221 + {t("settings.appearance.disableExternalLinkWarning")} 217 222 </h3> 218 223 <p className="text-sm text-surface-500 dark:text-surface-400"> 219 - Don't ask for confirmation when opening external links 224 + {t("settings.appearance.disableExternalLinkWarningDesc")} 220 225 </p> 221 226 </div> 222 227 <Switch ··· 228 233 <div className="mt-6 flex items-center justify-between"> 229 234 <div> 230 235 <h3 className="text-sm font-medium text-surface-900 dark:text-white"> 231 - Share bookmarks to community feed 236 + {t("settings.appearance.communityBookmarks")} 232 237 </h3> 233 238 <p className="text-sm text-surface-500 dark:text-surface-400"> 234 - Your saved bookmarks will appear in the community bookmarks feed 239 + {t("settings.appearance.communityBookmarksDesc")} 235 240 </p> 236 241 </div> 237 242 <Switch ··· 241 246 </div> 242 247 </section> 243 248 249 + {languages.length > 1 && ( 250 + <section className="card p-5"> 251 + <h2 className="text-xs font-semibold text-surface-500 dark:text-surface-400 uppercase tracking-wider mb-4"> 252 + {t("settings.sections.language")} 253 + </h2> 254 + <div> 255 + <p className="text-sm text-surface-500 dark:text-surface-400 mb-3"> 256 + {t("settings.language.description")} 257 + </p> 258 + <div className="flex flex-wrap gap-2"> 259 + {languages.map((lang) => ( 260 + <button 261 + key={lang.code} 262 + onClick={() => i18n.changeLanguage(lang.code)} 263 + className={`px-4 py-2 rounded-xl text-sm font-medium border-2 transition-all ${ 264 + currentLanguage.startsWith(lang.code) 265 + ? "border-primary-500 bg-primary-50 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300" 266 + : "border-surface-200 dark:border-surface-700 text-surface-600 dark:text-surface-400 hover:border-surface-300 dark:hover:border-surface-600" 267 + }`} 268 + > 269 + {lang.nativeName} 270 + {lang.nativeName !== lang.name && ( 271 + <span className="ml-1.5 text-xs opacity-60"> 272 + ({lang.name}) 273 + </span> 274 + )} 275 + </button> 276 + ))} 277 + </div> 278 + </div> 279 + </section> 280 + )} 281 + 244 282 <section className="card p-5"> 245 283 <h2 className="text-xs font-semibold text-surface-500 dark:text-surface-400 uppercase tracking-wider mb-4 flex items-center gap-2"> 246 284 <Upload size={16} /> 247 - Batch Import Highlights 285 + {t("settings.sections.batchImport")} 248 286 </h2> 249 287 <p className="text-sm text-surface-500 dark:text-surface-400 mb-4"> 250 - Upload highlights from CSV. Required: url, text. Optional: title, 251 - tags, color, created_at 288 + {t("settings.batchImport.description")} 252 289 </p> 253 290 <HighlightImporter /> 254 291 </section> 255 292 256 293 <section className="card p-5"> 257 294 <h2 className="text-xs font-semibold text-surface-500 dark:text-surface-400 uppercase tracking-wider mb-1"> 258 - API Keys 295 + {t("settings.sections.apiKeys")} 259 296 </h2> 260 297 <p className="text-sm text-surface-400 dark:text-surface-500 mb-5"> 261 - For the iOS shortcut and other apps 298 + {t("settings.apiKeys.description")} 262 299 </p> 263 300 264 301 <form onSubmit={handleCreate} className="flex gap-2 mb-5"> ··· 266 303 <Input 267 304 value={newKeyName} 268 305 onChange={(e) => setNewKeyName(e.target.value)} 269 - placeholder="Key name, e.g. iOS Shortcut" 306 + placeholder={t("settings.apiKeys.keyNamePlaceholder")} 270 307 /> 271 308 </div> 272 309 <Button ··· 275 312 loading={creating} 276 313 icon={<Plus size={16} />} 277 314 > 278 - Generate 315 + {t("settings.apiKeys.generate")} 279 316 </Button> 280 317 </form> 281 318 ··· 290 327 </div> 291 328 <div className="flex-1 min-w-0"> 292 329 <p className="text-green-800 dark:text-green-200 text-sm font-medium mb-2"> 293 - Copy now - you won't see this again! 330 + {t("settings.apiKeys.copyNow")} 294 331 </p> 295 332 <div className="flex items-center gap-2"> 296 333 <code className="flex-1 bg-white dark:bg-surface-900 border border-green-200 dark:border-green-800 px-3 py-2 rounded-lg text-xs font-mono text-green-900 dark:text-green-100 break-all"> ··· 318 355 ) : keys.length === 0 ? ( 319 356 <EmptyState 320 357 icon={<Key size={40} />} 321 - message="No API keys yet. Create one to use with the browser extension." 358 + message={t("settings.apiKeys.empty")} 322 359 /> 323 360 ) : ( 324 361 <div className="space-y-2"> ··· 339 376 {key.name} 340 377 </p> 341 378 <p className="text-xs text-surface-500 dark:text-surface-400"> 342 - Created {new Date(key.createdAt).toLocaleDateString()} 379 + {t("settings.apiKeys.created", { 380 + date: new Date(key.createdAt).toLocaleDateString(), 381 + })} 343 382 </p> 344 383 </div> 345 384 </div> ··· 357 396 358 397 <section className="card p-5"> 359 398 <h2 className="text-xs font-semibold text-surface-500 dark:text-surface-400 uppercase tracking-wider mb-1"> 360 - Moderation 399 + {t("settings.sections.moderation")} 361 400 </h2> 362 401 <p className="text-sm text-surface-400 dark:text-surface-500 mb-5"> 363 - Manage blocked and muted accounts 402 + {t("settings.moderation.description")} 364 403 </p> 365 404 366 405 {modLoading ? ( ··· 373 412 <div> 374 413 <h3 className="text-sm font-medium text-surface-700 dark:text-surface-300 mb-2 flex items-center gap-2"> 375 414 <ShieldBan size={14} /> 376 - Blocked accounts ({blocks.length}) 415 + {t("settings.moderation.blockedAccounts", { 416 + count: blocks.length, 417 + })} 377 418 </h3> 378 419 {blocks.length === 0 ? ( 379 420 <p className="text-sm text-surface-400 dark:text-surface-500 pl-6"> 380 - No blocked accounts 421 + {t("settings.moderation.noBlocked")} 381 422 </p> 382 423 ) : ( 383 424 <div className="space-y-1.5"> ··· 418 459 className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-surface-500 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-all opacity-0 group-hover:opacity-100" 419 460 > 420 461 <ShieldOff size={12} /> 421 - Unblock 462 + {t("settings.moderation.unblock")} 422 463 </button> 423 464 </div> 424 465 ))} ··· 429 470 <div> 430 471 <h3 className="text-sm font-medium text-surface-700 dark:text-surface-300 mb-2 flex items-center gap-2"> 431 472 <VolumeX size={14} /> 432 - Muted accounts ({mutes.length}) 473 + {t("settings.moderation.mutedAccounts", { 474 + count: mutes.length, 475 + })} 433 476 </h3> 434 477 {mutes.length === 0 ? ( 435 478 <p className="text-sm text-surface-400 dark:text-surface-500 pl-6"> 436 - No muted accounts 479 + {t("settings.moderation.noMuted")} 437 480 </p> 438 481 ) : ( 439 482 <div className="space-y-1.5"> ··· 474 517 className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-surface-500 hover:text-amber-600 dark:hover:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded-lg transition-all opacity-0 group-hover:opacity-100" 475 518 > 476 519 <Volume2 size={12} /> 477 - Unmute 520 + {t("settings.moderation.unmute")} 478 521 </button> 479 522 </div> 480 523 ))} ··· 487 530 488 531 <section className="card p-5"> 489 532 <h2 className="text-xs font-semibold text-surface-500 dark:text-surface-400 uppercase tracking-wider mb-1"> 490 - Content Filtering 533 + {t("settings.sections.contentFiltering")} 491 534 </h2> 492 535 <p className="text-sm text-surface-400 dark:text-surface-500 mb-5"> 493 - Subscribe to labelers and configure how labeled content appears 536 + {t("settings.contentFiltering.description")} 494 537 </p> 495 538 496 539 <div className="space-y-5"> 497 540 <div> 498 541 <h3 className="text-sm font-medium text-surface-700 dark:text-surface-300 mb-3 flex items-center gap-2"> 499 542 <Shield size={14} /> 500 - Subscribed Labelers 543 + {t("settings.contentFiltering.subscribedLabelers")} 501 544 </h3> 502 545 503 546 {preferences.subscribedLabelers.length === 0 ? ( 504 547 <p className="text-sm text-surface-400 dark:text-surface-500 pl-6 mb-3"> 505 - No labelers subscribed 548 + {t("settings.contentFiltering.noLabelers")} 506 549 </p> 507 550 ) : ( 508 551 <div className="space-y-1.5 mb-3"> ··· 534 577 className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-surface-500 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-all opacity-0 group-hover:opacity-100" 535 578 > 536 579 <XCircle size={12} /> 537 - Remove 580 + {t("settings.contentFiltering.remove")} 538 581 </button> 539 582 </div> 540 583 ))} ··· 556 599 <Input 557 600 value={newLabelerDid} 558 601 onChange={(e) => setNewLabelerDid(e.target.value)} 559 - placeholder="did:plc:... (labeler DID)" 602 + placeholder={t( 603 + "settings.contentFiltering.labelerDidPlaceholder", 604 + )} 560 605 /> 561 606 </div> 562 607 <Button ··· 565 610 loading={addingLabeler} 566 611 icon={<Plus size={16} />} 567 612 > 568 - Add 613 + {t("settings.contentFiltering.add")} 569 614 </Button> 570 615 </form> 571 616 </div> ··· 574 619 <div> 575 620 <h3 className="text-sm font-medium text-surface-700 dark:text-surface-300 mb-3 flex items-center gap-2"> 576 621 <Eye size={14} /> 577 - Label Visibility 622 + {t("settings.contentFiltering.labelVisibility")} 578 623 </h3> 579 624 <p className="text-xs text-surface-400 dark:text-surface-500 mb-3 pl-6"> 580 - Choose how to handle each label type: <strong>Warn</strong>{" "} 581 - shows a blur overlay, <strong>Hide</strong> removes content 582 - entirely, <strong>Ignore</strong> shows content normally. 625 + {t("settings.contentFiltering.labelVisibilityDesc")} 583 626 </p> 584 627 585 628 <div className="space-y-4"> ··· 613 656 label: string; 614 657 icon: typeof Eye; 615 658 }[] = [ 616 - { value: "warn", label: "Warn", icon: EyeOff }, 617 - { value: "hide", label: "Hide", icon: XCircle }, 618 - { value: "ignore", label: "Ignore", icon: Eye }, 659 + { 660 + value: "warn", 661 + label: t("settings.contentFiltering.warn"), 662 + icon: EyeOff, 663 + }, 664 + { 665 + value: "hide", 666 + label: t("settings.contentFiltering.hide"), 667 + icon: XCircle, 668 + }, 669 + { 670 + value: "ignore", 671 + label: t("settings.contentFiltering.ignore"), 672 + icon: Eye, 673 + }, 619 674 ]; 620 675 return ( 621 676 <div 622 677 key={label} 623 678 className="flex items-center justify-between py-1.5" 624 679 > 625 - <span className="text-sm text-surface-600 dark:text-surface-400 capitalize"> 626 - {label} 680 + <span className="text-sm text-surface-600 dark:text-surface-400"> 681 + {t(`card.labelDescriptions.${label}`)} 627 682 </span> 628 683 <div className="flex gap-1"> 629 684 {options.map((opt) => ( ··· 666 721 667 722 <section className="card p-5"> 668 723 <h2 className="text-xs font-semibold text-surface-500 dark:text-surface-400 uppercase tracking-wider mb-1"> 669 - iOS Shortcut 724 + {t("settings.sections.iosShortcut")} 670 725 </h2> 671 726 <p className="text-sm text-surface-400 dark:text-surface-500 mb-4"> 672 - Save pages to Margin from Safari on iPhone and iPad 727 + {t("settings.iosShortcut.description")} 673 728 </p> 674 729 <button 675 730 onClick={() => setIsShortcutModalOpen(true)} 676 731 className="inline-flex items-center gap-2.5 px-4 py-2.5 bg-surface-900 dark:bg-white text-white dark:text-surface-900 rounded-xl font-medium text-sm transition-all hover:opacity-90" 677 732 > 678 733 <AppleIcon size={16} /> 679 - Setup iOS Shortcut 734 + {t("settings.iosShortcut.setupButton")} 680 735 </button> 681 736 </section> 682 737 ··· 686 741 className="flex items-center gap-3 w-full text-left text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 p-3 -m-3 rounded-xl transition-colors" 687 742 > 688 743 <LogOut size={20} /> 689 - <span className="font-medium">Log out</span> 744 + <span className="font-medium">{t("settings.logout")}</span> 690 745 </button> 691 746 </section> 692 747 </div>
+51 -39
web/src/views/profile/Profile.tsx
··· 1 1 import { useStore } from "@nanostores/react"; 2 2 import { clsx } from "clsx"; 3 + import { useTranslation } from "react-i18next"; 3 4 import { 4 5 Edit2, 5 6 Eye, ··· 84 85 }; 85 86 86 87 export default function Profile({ did, initialProfile }: ProfileProps) { 88 + const { t } = useTranslation(); 87 89 const [profile, setProfile] = useState<UserProfile | null>( 88 90 initialProfile || null, 89 91 ); ··· 296 298 if (!profile) { 297 299 return ( 298 300 <EmptyState 299 - title="User not found" 300 - message="This profile doesn't exist or couldn't be loaded." 301 + title={t("profile.notFound")} 302 + message={t("profile.notFoundMessage")} 301 303 /> 302 304 ); 303 305 } 304 306 305 307 const tabs = [ 306 - { id: "all", label: "All" }, 307 - { id: "annotations", label: "Annotations" }, 308 - { id: "highlights", label: "Highlights" }, 309 - { id: "bookmarks", label: "Bookmarks" }, 310 - { id: "collections", label: "Collections" }, 308 + { id: "all", label: t("urlPage.tabs.all") }, 309 + { id: "annotations", label: t("urlPage.tabs.annotations") }, 310 + { id: "highlights", label: t("urlPage.tabs.highlights") }, 311 + { id: "bookmarks", label: t("urlPage.tabs.bookmarks") }, 312 + { id: "collections", label: t("urlPage.tabs.collections") }, 311 313 ]; 312 314 313 315 const LABEL_DESCRIPTIONS: Record<string, string> = { 314 - sexual: "Sexual Content", 315 - nudity: "Nudity", 316 - violence: "Violence", 317 - gore: "Graphic Content", 318 - spam: "Spam", 319 - misleading: "Misleading", 316 + sexual: t("card.labelDescriptions.sexual"), 317 + nudity: t("card.labelDescriptions.nudity"), 318 + violence: t("card.labelDescriptions.violence"), 319 + gore: t("card.labelDescriptions.gore"), 320 + spam: t("card.labelDescriptions.spam"), 321 + misleading: t("card.labelDescriptions.misleading"), 320 322 }; 321 323 322 324 const accountWarning = (() => { ··· 389 391 onClick={() => setShowEdit(true)} 390 392 icon={<Edit2 size={14} />} 391 393 > 392 - <span className="hidden sm:inline">Edit</span> 394 + <span className="hidden sm:inline"> 395 + {t("profile.edit")} 396 + </span> 393 397 </Button> 394 398 )} 395 399 {!isOwner && user && ( ··· 397 401 items={(() => { 398 402 const items: MoreMenuItem[] = []; 399 403 items.push({ 400 - label: "View profile in Bluesky", 404 + label: t("profile.viewInBluesky"), 401 405 icon: <BlueskyIcon size={16} />, 402 406 onClick: () => { 403 407 const handle = profile.handle || did; ··· 409 413 }); 410 414 if (modRelation.blocking) { 411 415 items.push({ 412 - label: `Unblock @${profile.handle || "user"}`, 416 + label: t("profile.unblock", { 417 + handle: profile.handle || "user", 418 + }), 413 419 icon: <ShieldOff size={14} />, 414 420 onClick: async () => { 415 421 await unblockUser(did); ··· 421 427 }); 422 428 } else { 423 429 items.push({ 424 - label: `Block @${profile.handle || "user"}`, 430 + label: t("profile.block", { 431 + handle: profile.handle || "user", 432 + }), 425 433 icon: <ShieldBan size={14} />, 426 434 onClick: async () => { 427 435 await blockUser(did); ··· 435 443 } 436 444 if (modRelation.muting) { 437 445 items.push({ 438 - label: `Unmute @${profile.handle || "user"}`, 446 + label: t("profile.unmute", { 447 + handle: profile.handle || "user", 448 + }), 439 449 icon: <Volume2 size={14} />, 440 450 onClick: async () => { 441 451 await unmuteUser(did); ··· 447 457 }); 448 458 } else { 449 459 items.push({ 450 - label: `Mute @${profile.handle || "user"}`, 460 + label: t("profile.mute", { 461 + handle: profile.handle || "user", 462 + }), 451 463 icon: <VolumeX size={14} />, 452 464 onClick: async () => { 453 465 await muteUser(did); ··· 459 471 }); 460 472 } 461 473 items.push({ 462 - label: "Report", 474 + label: t("profile.report"), 463 475 icon: <Flag size={14} />, 464 476 onClick: () => setShowReportModal(true), 465 477 variant: "danger", ··· 550 562 <EyeOff size={18} className="text-amber-500 flex-shrink-0" /> 551 563 <div className="flex-1"> 552 564 <p className="text-sm font-medium text-amber-700 dark:text-amber-400"> 553 - Account labeled: {accountWarning.description} 565 + {t("profile.accountLabeled", { 566 + description: accountWarning.description, 567 + })} 554 568 </p> 555 569 <p className="text-xs text-amber-600/70 dark:text-amber-400/60 mt-0.5"> 556 - This label was applied by a moderation service you subscribe to. 570 + {t("profile.labelApplied")} 557 571 </p> 558 572 </div> 559 573 {!profileRevealed ? ( ··· 562 576 className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-amber-600 dark:text-amber-400 hover:bg-amber-100 dark:hover:bg-amber-900/30 rounded-lg transition-colors" 563 577 > 564 578 <Eye size={12} /> 565 - Show 579 + {t("profile.show")} 566 580 </button> 567 581 ) : ( 568 582 <button ··· 570 584 className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-amber-600 dark:text-amber-400 hover:bg-amber-100 dark:hover:bg-amber-900/30 rounded-lg transition-colors" 571 585 > 572 586 <EyeOff size={12} /> 573 - Hide 587 + {t("profile.hide")} 574 588 </button> 575 589 )} 576 590 </div> ··· 583 597 <ShieldBan size={18} className="text-red-500 flex-shrink-0" /> 584 598 <div className="flex-1"> 585 599 <p className="text-sm font-medium text-red-700 dark:text-red-400"> 586 - You have blocked @{profile.handle} 600 + {t("profile.blockedBanner", { handle: profile.handle })} 587 601 </p> 588 602 <p className="text-xs text-red-600/70 dark:text-red-400/60 mt-0.5"> 589 - Their content is hidden from your feeds. 603 + {t("profile.blockedMessage")} 590 604 </p> 591 605 </div> 592 606 <button ··· 596 610 }} 597 611 className="px-3 py-1.5 text-xs font-medium text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30 rounded-lg transition-colors" 598 612 > 599 - Unblock 613 + {t("profile.unblock_action")} 600 614 </button> 601 615 </div> 602 616 </div> ··· 608 622 <VolumeX size={18} className="text-amber-500 flex-shrink-0" /> 609 623 <div className="flex-1"> 610 624 <p className="text-sm font-medium text-amber-700 dark:text-amber-400"> 611 - You have muted @{profile.handle} 625 + {t("profile.mutedBanner", { handle: profile.handle })} 612 626 </p> 613 627 <p className="text-xs text-amber-600/70 dark:text-amber-400/60 mt-0.5"> 614 - Their content is hidden from your feeds. 628 + {t("profile.mutedMessage")} 615 629 </p> 616 630 </div> 617 631 <button ··· 621 635 }} 622 636 className="px-3 py-1.5 text-xs font-medium text-amber-600 dark:text-amber-400 hover:bg-amber-100 dark:hover:bg-amber-900/30 rounded-lg transition-colors" 623 637 > 624 - Unmute 638 + {t("profile.unmute_action")} 625 639 </button> 626 640 </div> 627 641 </div> ··· 632 646 <div className="flex items-center gap-3"> 633 647 <ShieldBan size={18} className="text-surface-400 flex-shrink-0" /> 634 648 <p className="text-sm text-surface-500 dark:text-surface-400"> 635 - @{profile.handle} has blocked you. You cannot interact with their 636 - content. 649 + {t("profile.blockedByBanner", { handle: profile.handle })} 637 650 </p> 638 651 </div> 639 652 </div> ··· 654 667 size={24} 655 668 /> 656 669 <p className="text-sm text-surface-400 dark:text-surface-500"> 657 - Loading... 670 + {t("common.loading")} 658 671 </p> 659 672 </div> 660 673 ) : activeTab === "collections" ? ( ··· 663 676 icon={<Folder size={40} />} 664 677 message={ 665 678 isOwner 666 - ? "You haven't created any collections yet." 667 - : "No collections" 679 + ? t("profile.emptyCollectionsOwn") 680 + : t("profile.emptyCollectionsOther") 668 681 } 669 682 /> 670 683 ) : ( ··· 683 696 {collection.name} 684 697 </h3> 685 698 <p className="text-sm text-surface-500 dark:text-surface-400"> 686 - {collection.itemCount}{" "} 687 - {collection.itemCount === 1 ? "item" : "items"} 699 + {t("profile.itemCount", { count: collection.itemCount })} 688 700 </p> 689 701 </div> 690 702 </a> ··· 700 712 layout="list" 701 713 emptyMessage={ 702 714 isOwner 703 - ? `Your ${activeTab} will show up here.` 704 - : `Nothing to see here yet.` 715 + ? t("profile.emptyTabOwn", { tab: activeTab }) 716 + : t("profile.emptyTabOther") 705 717 } 706 718 /> 707 719 )}