diff --git a/web/astro.config.mjs b/web/astro.config.mjs index ed7789e..80fb200 100644 --- a/web/astro.config.mjs +++ b/web/astro.config.mjs @@ -4,12 +4,46 @@ import react from "@astrojs/react"; import tailwind from "@astrojs/tailwind"; import node from "@astrojs/node"; import { fileURLToPath } from "url"; -import { dirname, resolve } from "path"; +import { dirname, resolve, join } from "path"; +import { readdirSync, existsSync } from "fs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const API_PORT = process.env.API_PORT || 8081; +function i18nLanguagesPlugin() { + const virtualId = "virtual:i18n-languages"; + const resolvedId = "\0" + virtualId; + return { + name: "i18n-languages", + resolveId(id) { + if (id === virtualId) return resolvedId; + }, + load(id) { + if (id !== resolvedId) return; + const localesDir = join(__dirname, "public/locales"); + const languages = readdirSync(localesDir, { withFileTypes: true }) + .filter( + (d) => + d.isDirectory() && + existsSync(join(localesDir, d.name, "translation.json")), + ) + .map((d) => { + const code = d.name; + const name = + new Intl.DisplayNames(["en"], { type: "language" }).of(code) ?? + code; + const nativeName = + new Intl.DisplayNames([code], { type: "language" }).of(code) ?? + name; + return { code, name, nativeName }; + }) + .sort((a, b) => a.name.localeCompare(b.name)); + return `export const languages = ${JSON.stringify(languages)};`; + }, + }; +} + // https://astro.build/config export default defineConfig({ output: "server", @@ -21,6 +55,7 @@ export default defineConfig({ defaultStrategy: "viewport", }, vite: { + plugins: [i18nLanguagesPlugin()], resolve: { alias: { "@": resolve(__dirname, "src"), diff --git a/web/bun.lock b/web/bun.lock index 021854e..e64b2c9 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "web", @@ -15,11 +16,15 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "emoji-picker-react": "^4.18.0", + "i18next": "^26.0.6", + "i18next-browser-languagedetector": "^8.2.1", + "i18next-http-backend": "^3.0.5", "lucide-react": "^0.563.0", "nanostores": "^1.1.0", "postcss": "^8.5.6", "react": "^19.2.4", "react-dom": "^19.2.4", + "react-i18next": "^17.0.4", "react-router-dom": "^7.13.2", "satori": "^0.19.2", "tailwind-merge": "^3.4.0", @@ -97,6 +102,8 @@ "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], + "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], @@ -549,6 +556,8 @@ "cookie-es": ["cookie-es@1.2.2", "", {}, "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="], + "cross-fetch": ["cross-fetch@4.1.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="], @@ -813,12 +822,20 @@ "html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="], + "html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="], + "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + "i18next": ["i18next@26.0.6", "", { "dependencies": { "@babel/runtime": "^7.29.2" }, "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-A4U6eCXodIbrhf8EarRurB9/4ebyaurH4+fu4gig9bqxmpSt+fCAFm/GpRQDcN1Xzu/LdFCx4nYHsnM1edIIbg=="], + + "i18next-browser-languagedetector": ["i18next-browser-languagedetector@8.2.1", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw=="], + + "i18next-http-backend": ["i18next-http-backend@3.0.5", "", { "dependencies": { "cross-fetch": "4.1.0" } }, "sha512-QaWHnsxieEDcqKe+vo/RFqpiIFRi/KBqlOSPcUlvinBaISCeiTRCbtrazHAjtHtsLC66oDsROAH8frWkQzfMMQ=="], + "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], @@ -1075,6 +1092,8 @@ "nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="], + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], "node-mock-http": ["node-mock-http@1.0.4", "", {}, "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ=="], @@ -1191,6 +1210,8 @@ "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], + "react-i18next": ["react-i18next@17.0.4", "", { "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.0.1", "react": ">= 16.8.0", "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-hQipmK4EF0y6RO6tt6WuqnmWpWYEXmQUUzecmMBuNsIgYd3smXcG4GtYPWhvgxn0pqMOItKlEO8H24HCs5hc3g=="], + "react-icons": ["react-icons@5.5.0", "", { "peerDependencies": { "react": "*" } }, "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw=="], "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], @@ -1349,6 +1370,8 @@ "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], @@ -1415,6 +1438,8 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], @@ -1427,8 +1452,14 @@ "vitefu": ["vitefu@1.1.2", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw=="], + "void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="], + "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], diff --git a/web/package.json b/web/package.json index ef54075..8d627d1 100644 --- a/web/package.json +++ b/web/package.json @@ -21,11 +21,15 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "emoji-picker-react": "^4.18.0", + "i18next": "^26.0.6", + "i18next-browser-languagedetector": "^8.2.1", + "i18next-http-backend": "^3.0.5", "lucide-react": "^0.563.0", "nanostores": "^1.1.0", "postcss": "^8.5.6", "react": "^19.2.4", "react-dom": "^19.2.4", + "react-i18next": "^17.0.4", "react-router-dom": "^7.13.2", "satori": "^0.19.2", "tailwind-merge": "^3.4.0", diff --git a/web/public/locales/en/translation.json b/web/public/locales/en/translation.json new file mode 100644 index 0000000..e405f28 --- /dev/null +++ b/web/public/locales/en/translation.json @@ -0,0 +1,881 @@ +{ + "appTitle": "Margin", + + "nav": { + "feed": "Feed", + "discover": "Discover", + "annotations": "Annotations", + "highlights": "Highlights", + "bookmarks": "Bookmarks", + "collections": "Collections", + "activity": "Activity", + "settings": "Settings", + "new": "New", + "signIn": "Sign in", + "logOut": "Log out", + "themeLight": "Light", + "themeDark": "Dark", + "themeSystem": "System" + }, + + "pageTitles": { + "home": "Home — Margin", + "bookmarks": "Bookmarks — Margin", + "highlights": "Highlights — Margin", + "annotations": "Annotations — Margin", + "discover": "Discover — Margin", + "search": "Search — Margin", + "notifications": "Notifications — Margin", + "new": "New Annotation — Margin", + "settings": "Settings — Margin", + "collections": "Collections — Margin", + "admin": "Admin — Margin" + }, + + "sidebar": { + "getExtension": "Get the Extension", + "extensionTagline": "Highlight, annotate, and bookmark from any page.", + "downloadForFirefox": "Download for Firefox", + "downloadForEdge": "Download for Edge", + "downloadForChrome": "Download for Chrome", + "trending": "Trending", + "nothingTrending": "Nothing trending right now.", + "searchPlaceholder": "Search people, tags, URLs...", + "copyright": "© 2026 Padding Labs LLC", + "postCount_one": "{{count}} post", + "postCount_other": "{{count}} posts" + }, + + "mobileNav": { + "iosShortcut": "iOS Shortcut" + }, + + "feed": { + "welcome": "Welcome to Margin", + "welcomeTagline": "A quiet place to annotate, highlight, and save what you read on the web.", + "getStarted": "Get started", + "learnMore": "Learn more", + "tabs": { + "recent": "Recent", + "popular": "Popular", + "shelved": "Shelved", + "margin": "Margin", + "semble": "Semble" + }, + "filters": { + "all": "All", + "annotations": "Annotations", + "highlights": "Highlights", + "bookmarks": "Bookmarks" + }, + "itemsWithTag": "Items with tag:", + "clearFilter": "Clear filter", + "everyone": "Everyone", + "mine": "Mine", + "defaultEmptyMessage": "Nothing here yet — annotations from you and people you follow will show up here.", + "nothingHereYet": "Nothing here yet", + "loading": "Loading..." + }, + + "discover": { + "tabs": { + "new": "New", + "popular": "Popular", + "forYou": "For You" + }, + "comingSoon": "Coming soon", + "forYouNotAvailable": "Personalized recommendations aren't available on this server yet.", + "noDocumentsYet": "No documents have been discovered yet. Check back soon!", + "startAnnotating": "Start annotating and highlighting to get personalized recommendations.", + "loadMore": "Load more" + }, + + "search": { + "placeholder": "Search annotations, highlights, bookmarks...", + "noResults": "No results found", + "noResultsMessage": "Nothing matched \"{{query}}\". Try different keywords.", + "emptyTitle": "Search your library", + "emptyMessage": "Find annotations, highlights, and bookmarks by keyword, URL, or tag.", + "filters": { + "all": "All", + "annotations": "Annotations", + "highlights": "Highlights", + "bookmarks": "Bookmarks", + "mine": "Mine" + }, + "resultCount": "{{count}}{{hasMore}} results for \"{{query}}\"", + "loadMore": "Load more" + }, + + "notifications": { + "title": "Activity", + "noActivity": "No activity yet", + "noActivityMessage": "Interactions with your content will appear here.", + "likedAnnotation": "liked your annotation", + "likedHighlight": "liked your highlight", + "likedBookmark": "liked your bookmark", + "likedReply": "liked your reply", + "likedPost": "liked your post", + "repliedToReply": "replied to your reply", + "repliedToAnnotation": "replied to your annotation", + "mentionedInAnnotation": "mentioned you in an annotation", + "followedYou": "followed you", + "highlightedPage": "highlighted your page", + "inReplyTo": "in reply to", + "aReply": "a reply", + "anAnnotation": "an annotation" + }, + + "collections": { + "title": "Collections", + "subtitle": "Organize your annotations and highlights", + "none": "No collections yet", + "noneMessage": "Create a collection to organize your highlights and annotations.", + "createButton": "Create collection", + "newTitle": "New Collection", + "editTitle": "Edit Collection", + "namePlaceholder": "My Collection", + "namePlaceholderEdit": "Collection name", + "nameLabel": "Name", + "iconLabel": "Icon", + "iconsTab": "Icons", + "emojisTab": "Emojis", + "selectedIcon": "Selected:", + "descriptionLabel": "Description (optional)", + "descriptionPlaceholder": "What's this collection for?", + "descriptionPlaceholderEdit": "What's this collection about?", + "cancel": "Cancel", + "create": "Create Collection", + "creating": "Creating...", + "save": "Save Changes", + "saving": "Saving...", + "deleteConfirm": "Delete this collection?", + "failedUpdate": "Failed to update collection", + "errorUpdating": "An error occurred while updating", + "itemCount_one": "{{count}} item", + "itemCount_other": "{{count}} items" + }, + + "collectionDetail": { + "backLink": "Collections", + "by": "by", + "edit": "Edit collection", + "delete": "Delete collection", + "removeFromCollection": "Remove from collection", + "viewInSemble": "View in Semble", + "empty": "Collection is empty", + "notFound": "Collection not found", + "failedToLoad": "Failed to load collection", + "deleteConfirm": "Delete this collection?", + "removeConfirm": "Remove from collection?" + }, + + "profile": { + "notFound": "User not found", + "notFoundMessage": "This profile doesn't exist or couldn't be loaded.", + "tabs": { + "all": "All", + "annotations": "Annotations", + "highlights": "Highlights", + "bookmarks": "Bookmarks", + "collections": "Collections" + }, + "loading": "Loading...", + "noCollections": "You haven't created any collections yet.", + "noCollectionsTitle": "No collections", + "labelDescriptions": { + "sexual": "Sexual Content", + "nudity": "Nudity", + "violence": "Violence", + "gore": "Graphic Content", + "spam": "Spam", + "misleading": "Misleading" + }, + "accountLabeled": "Account labeled: {{label}}", + "labelAppliedBy": "This label was applied by a moderation service you subscribe to.", + "show": "Show", + "hide": "Hide", + "blockedBy": "@{{handle}} has blocked you. You cannot interact with their content.", + "youBlocked": "You have blocked @{{handle}}", + "blockedContent": "Their content is hidden from your feeds.", + "unblock": "Unblock", + "youMuted": "You have muted @{{handle}}", + "unmute": "Unmute", + "viewInBluesky": "View profile in Bluesky", + "blockUser": "Block @{{handle}}", + "unblockUser": "Unblock @{{handle}}", + "muteUser": "Mute @{{handle}}", + "unmuteUser": "Unmute @{{handle}}", + "report": "Report", + "emptyOwn": "Your {{tab}} will show up here.", + "emptyOther": "Nothing to see here yet." + }, + + "login": { + "signInWith": "Sign in with your", + "handleSuffix": "handle", + "handlePlaceholder": "handle.margin.cafe", + "connecting": "Connecting...", + "continue": "Continue", + "createAccount": "Create New Account", + "termsPrefix": "By signing in, you agree to our", + "termsLink": "Terms of Service", + "termsAnd": "and", + "privacyLink": "Privacy Policy" + }, + + "signUp": { + "title": "Create your account", + "subtitle": "Margin adheres to the", + "atProtocol": "AT Protocol", + "subtitleSuffix": ". Choose a provider to host your account.", + "customPdsTitle": "Use a custom PDS", + "customPdsSubtitle": "Enter the address of the PDS hosting your account.", + "pdsAddressLabel": "PDS address", + "pdsAddressPlaceholder": "pds.example.com", + "connecting": "Connecting...", + "back": "Back", + "continue": "Continue", + "invite": "Invite", + "providerError": "Could not connect to this provider. Please try again.", + "customPdsError": "Couldn't connect to that PDS. Double-check the address.", + "providers": { + "margin": { + "name": "Margin", + "description": "The easiest way to get started" + }, + "bluesky": { + "name": "Bluesky", + "description": "The largest and most popular community" + }, + "blacksky": { + "name": "Blacksky", + "description": "For the Culture — a safe space for users and allies" + }, + "eurosky": { + "name": "Eurosky", + "description": "Eurosky is your European home on the Atmosphere" + }, + "selfhostedSocial": { + "name": "selfhosted.social", + "description": "A home for builders, tinkerers, and the curious" + }, + "northsky": { + "name": "Northsky", + "description": "A Canadian worker-owned cooperative" + }, + "tophhie": { + "name": "Tophhie", + "description": "A welcoming and friendly community" + }, + "customPds": { + "name": "Use a custom PDS", + "description": "Already have a PDS? Enter its address." + } + } + }, + + "composer": { + "newHighlight": "New highlight", + "newAnnotation": "New annotation", + "newNote": "New note", + "saveHighlight": "Save highlight", + "postAnnotation": "Post annotation", + "postNote": "Post note", + "highlightHint": "Saving a passage without a comment. Add text below to turn it into an annotation.", + "addQuote": "+ Add a quote from the page", + "quotePlaceholder": "Paste or type the text you're annotating...", + "removeQuote": "Remove Quote", + "thoughtsPlaceholder": "Add your thoughts on this passage...", + "mindPlaceholder": "What's on your mind?", + "tagsPlaceholder": "Add tags...", + "contentWarning": "Content Warning", + "contentWarningCount": "Content Warning ({{count}})", + "cancel": "Cancel", + "failedToPost": "Failed to post", + "labels": { + "sexual": "Sexual", + "nudity": "Nudity", + "violence": "Violence", + "gore": "Gore", + "spam": "Spam", + "misleading": "Misleading" + } + }, + + "card": { + "addedTo": "Added to", + "addedToLower": "added to", + "and": "and", + "communityBookmark": "Community bookmark", + "openInSemble": "Open in Semble", + "deleteConfirm": "Delete this item?", + "hideContent": "Hide Content", + "show": "Show", + "edited": "(edited)", + "annotate": "Annotate", + "untitledBookmark": "Untitled Bookmark", + "addNotePlaceholder": "Add your note to convert this highlight into an annotation...", + "addToCollectionTitle": "Add to Collection", + "annotateTitle": "Annotate this highlight", + "editTitle": "Edit", + "deleteTitle": "Delete", + "report": "Report", + "muteUser": "Mute @{{handle}}", + "blockUser": "Block @{{handle}}", + "convertToAnnotation": "Convert to annotation", + "justNow": "just now", + "labelDescriptions": { + "sexual": "Sexual Content", + "nudity": "Nudity", + "violence": "Violence", + "gore": "Graphic Content", + "spam": "Spam", + "misleading": "Misleading" + } + }, + + "profileHoverCard": { + "viewProfile": "View Profile", + "notFound": "Profile not found" + }, + + "replyList": { + "noReplies": "No replies yet" + }, + + "shareMenu": { + "sembleIntegration": "Semble Integration", + "openOnSemble": "Open on Semble", + "copySembleLink": "Copy Semble Link", + "copyLink": "Copy Link", + "shareViaApp": "Share via App", + "copyUniversalLink": "Copy Universal Link", + "moreOptions": "More Options...", + "copied": "Copied!" + }, + + "addToCollection": { + "title": "Add to Collection", + "loading": "Loading collections...", + "collectionNameLabel": "Collection name", + "namePlaceholder": "My Collection", + "descriptionLabel": "Description (optional)", + "descriptionPlaceholder": "What's this collection about?", + "iconLabel": "Icon", + "iconsTab": "Icons", + "emojisTab": "Emojis", + "selected": "Selected:", + "back": "Back", + "create": "Create", + "creating": "Creating...", + "newCollectionButton": "New Collection", + "createNewDescription": "Create a new collection", + "none": "No collections yet", + "done": "Done", + "failedLoad": "Failed to load collections", + "failedAdd": "Failed to add to collection", + "failedCreate": "Failed to create collection" + }, + + "editItem": { + "editAnnotation": "Edit Annotation", + "editHighlight": "Edit Highlight", + "editBookmark": "Edit Bookmark", + "textLabel": "Text", + "textPlaceholder": "Write your annotation...", + "colorLabel": "Color", + "tagsLabel": "Tags", + "tagPlaceholder": "Add a tag...", + "contentWarning": "Content Warning", + "cancel": "Cancel", + "save": "Save", + "saving": "Saving...", + "failedSave": "Failed to save changes. Please try again.", + "titleLabel": "Title", + "titlePlaceholder": "Bookmark title", + "descriptionLabel": "Description", + "descriptionPlaceholder": "Optional description..." + }, + + "editCollection": { + "title": "Edit Collection", + "nameLabel": "Collection name", + "namePlaceholder": "My Collection", + "descriptionLabel": "Description (optional)", + "descriptionPlaceholder": "What's this collection about?", + "iconLabel": "Icon", + "iconsTab": "Icons", + "emojisTab": "Emojis", + "selected": "Selected:", + "cancel": "Cancel", + "save": "Save Changes", + "saving": "Saving...", + "failedUpdate": "Failed to update collection", + "errorUpdating": "An error occurred while updating" + }, + + "externalLink": { + "title": "Leaving Margin", + "message": "You're about to visit an external site.", + "alwaysAllow": "Always allow links to {{hostname}}", + "cancel": "Cancel", + "open": "Open Link" + }, + + "report": { + "submitted": "Report submitted", + "submittedMessage": "Thank you. We'll review this shortly.", + "titleUser": "Report @{{handle}}", + "titleGeneric": "Report user", + "reportingContent": "Reporting specific content", + "issueLabel": "What's the issue?", + "cancel": "Cancel", + "submit": "Submit Report", + "submitting": "Submitting…", + "detailsPlaceholder": "Additional details (optional)", + "reasons": { + "spam": "Spam", + "ruleViolation": "Rule violation", + "misleading": "Misleading", + "rudeOrHarassing": "Rude or harassing", + "inappropriateContent": "Inappropriate content", + "other": "Other" + } + }, + + "editProfile": { + "title": "Edit Profile", + "avatarLabel": "Avatar", + "uploadButton": "Upload", + "uploading": "Uploading...", + "displayNameLabel": "Display Name", + "bioLabel": "Bio", + "websiteLabel": "Website", + "linksLabel": "Links", + "addLinkPlaceholder": "Add a link...", + "cancel": "Cancel", + "save": "Save", + "saving": "Saving...", + "avatarTypeError": "Please select a JPEG or PNG image", + "avatarSizeError": "Image must be under 2MB", + "avatarUploadError": "Failed to upload: {{message}}" + }, + + "iosShortcut": { + "title": "Save from iOS Safari", + "howTo": "How to use the shortcut", + "step1Title": "Install the shortcut", + "step1Link": "Get iOS Shortcut", + "step2Title": "Generate an API Key", + "step2Description": "Create a new key on this settings page and copy it.", + "step3Title": "Configure the shortcut", + "step3Description": "In the Shortcuts app, click the menu on the Save to Margin shortcut, and paste your API key in the Text action right below the setup comment.", + "step4Title": "To Bookmark a page", + "step4Description": "Don't select any text. Click the menu in Safari, press Share, and select Save to Margin.", + "step5Title": "To Highlight text", + "step5Description": "Select text on the page, click the menu, press Share, and select Save to Margin. Leave the Note field empty.", + "step6Title": "To Add an Annotation", + "step6Description": "Select text, share to Save to Margin (via the menu), enter your custom note in the Note field, and press Done!", + "gotIt": "Got it" + }, + + "editHistory": { + "title": "Edit History", + "noHistory": "No edit history found.", + "currentVersion": "Current Version", + "previousVersion": "Previous Version", + "editedAgo": "Edited {{time}} ago", + "postedAgo": "Posted {{time}} ago", + "timeAgo": "{{time}} ago", + "close": "Close", + "failedLoad": "Failed to load edit history" + }, + + "settings": { + "sections": { + "profile": "Profile", + "appearance": "Appearance", + "batchImport": "Batch Import Highlights", + "apiKeys": "API Keys", + "moderation": "Moderation", + "contentFiltering": "Content Filtering", + "iosShortcut": "iOS Shortcut" + }, + "apiKeys": { + "noKeys": "No API keys yet. Create one to use with the browser extension.", + "forIos": "For the iOS shortcut and other apps", + "namePlaceholder": "Key name, e.g. iOS Shortcut", + "created": "Created {{date}}", + "revokeConfirm": "Revoke this key? Apps using it will stop working.", + "copyNow": "Copy now - you won't see this again!" + }, + "moderation": { + "blockedAccounts": "Blocked accounts ({{count}})", + "mutedAccounts": "Muted accounts ({{count}})", + "noBlocked": "No blocked accounts", + "noMuted": "No muted accounts", + "unblock": "Unblock", + "unmute": "Unmute", + "remove": "Remove", + "add": "Add", + "noLabelers": "No labelers subscribed", + "labelerPlaceholder": "did:plc:... (labeler DID)" + }, + "contentFiltering": { + "warn": "Warn", + "hide": "Hide", + "ignore": "Ignore" + }, + "ios": { + "description": "Save pages to Margin from Safari on iPhone and iPad", + "setupButton": "Setup iOS Shortcut" + }, + "logOut": "Log out" + }, + + "new": { + "signInRequired": "Sign in to create", + "needsAccount": "You need a Bluesky account", + "signInButton": "Sign in with Bluesky", + "composeTitle": "Compose", + "composeTagline": "Highlight a passage, leave a note, or annotate a page — all from here.", + "urlLabel": "URL to annotate", + "urlPlaceholder": "https://example.com/article" + }, + + "annotationDetail": { + "back": "Back", + "replies": "Replies ({{count}})", + "replyingTo": "Replying to", + "replyPlaceholder": "Write a reply...", + "reply": "Reply", + "signInToReply": "Sign in to reply", + "logIn": "Log in", + "notFound": "Not found", + "mayBeDeleted": "This may have been deleted.", + "backToFeed": "Back to Feed", + "deleteReplyConfirm": "Delete this reply?", + "failedReply": "Failed to post reply: {{message}}", + "failedDelete": "Failed to delete: {{message}}", + "failedResolve": "Failed to resolve handle: {{message}}" + }, + + "urlPage": { + "title": "URL Annotations", + "description": "Enter a URL to see all public annotations and highlights from the Margin community.", + "urlPlaceholder": "https://example.com/article", + "view": "View", + "myAnnotations": "My Annotations", + "share": "Share", + "copied": "Copied!", + "contributor_one": "{{count}} contributor", + "contributor_other": "{{count}} contributors", + "loadingAnnotations": "Loading annotations...", + "blankCanvas": "This page is a blank canvas", + "blankCanvasMessage": "No one's left notes here yet. Want to be the first? Grab the Margin extension and share what you're thinking.", + "tabs": { + "all": "All", + "annotations": "Annotations", + "highlights": "Highlights", + "bookmarks": "Bookmarks", + "collections": "Collections" + }, + "noAnnotationsYet": "No annotations yet", + "noAnnotationsMessage": "Nobody has left a written note on this page.", + "noHighlightsYet": "No highlights yet", + "noHighlightsMessage": "Nobody has highlighted a passage from this page.", + "loadMore": "Load more", + "loading": "Loading...", + "failedLoadMore": "Failed to load more: {{message}}" + }, + + "userUrlPage": { + "on": "on", + "loadingAnnotations": "Loading annotations...", + "noUrl": "No URL specified", + "noUrlMessage": "Please provide a URL to view annotations.", + "noItems": "No items found", + "noItemsMessage": "{{name}} hasn't annotated this page yet.", + "noAnnotations": "No annotations", + "noHighlights": "No highlights", + "loadMore": "Load more", + "loading": "Loading...", + "failedLoadMore": "Failed to load more: {{message}}" + }, + + "settings": { + "title": "Settings", + "sections": { + "profile": "Profile", + "appearance": "Appearance", + "language": "Language", + "batchImport": "Batch Import Highlights", + "apiKeys": "API Keys", + "moderation": "Moderation", + "contentFiltering": "Content Filtering", + "iosShortcut": "iOS Shortcut" + }, + "language": { + "label": "Interface Language", + "description": "Choose the language for the Margin interface." + }, + "appearance": { + "disableExternalLinkWarning": "Disable external link warning", + "disableExternalLinkWarningDesc": "Don't ask for confirmation when opening external links", + "communityBookmarks": "Share bookmarks to community feed", + "communityBookmarksDesc": "Your saved bookmarks will appear in the community bookmarks feed" + }, + "batchImport": { + "description": "Upload highlights from CSV. Required: url, text. Optional: title, tags, color, created_at" + }, + "apiKeys": { + "description": "For the iOS shortcut and other apps", + "keyNamePlaceholder": "Key name, e.g. iOS Shortcut", + "generate": "Generate", + "copyNow": "Copy now - you won't see this again!", + "empty": "No API keys yet. Create one to use with the browser extension.", + "created": "Created {{date}}", + "revokeConfirm": "Revoke this key? Apps using it will stop working." + }, + "moderation": { + "description": "Manage blocked and muted accounts", + "blockedAccounts": "Blocked accounts ({{count}})", + "noBlocked": "No blocked accounts", + "unblock": "Unblock", + "mutedAccounts": "Muted accounts ({{count}})", + "noMuted": "No muted accounts", + "unmute": "Unmute" + }, + "contentFiltering": { + "description": "Subscribe to labelers and configure how labeled content appears", + "subscribedLabelers": "Subscribed Labelers", + "noLabelers": "No labelers subscribed", + "labelerDidPlaceholder": "did:plc:... (labeler DID)", + "remove": "Remove", + "add": "Add", + "labelVisibility": "Label Visibility", + "labelVisibilityDesc": "Choose how to handle each label type: Warn shows a blur overlay, Hide removes content entirely, Ignore shows content normally.", + "warn": "Warn", + "hide": "Hide", + "ignore": "Ignore" + }, + "iosShortcut": { + "description": "Save pages to Margin from Safari on iPhone and iPad", + "setupButton": "Setup iOS Shortcut" + }, + "logout": "Log out" + }, + + "adminModeration": { + "accessDenied": "Access Denied", + "accessDeniedMessage": "You don't have permission to access the moderation dashboard.", + "title": "Moderation", + "stats": "{{pending}} pending · {{total}} total reports", + "tabs": { + "reports": "Reports", + "actions": "Actions", + "labels": "Labels" + }, + "filters": { + "all": "All", + "pending": "Pending", + "resolved": "Resolved", + "dismissed": "Dismissed", + "escalated": "Escalated" + }, + "reports": { + "empty": "No reports", + "emptyPending": "No pending reports to review.", + "emptyFiltered": "No {{status}} reports found.", + "reportedUser": "Reported User", + "reporter": "Reporter", + "details": "Details", + "contentUri": "Content URI", + "acknowledge": "Acknowledge", + "dismiss": "Dismiss", + "takedown": "Takedown" + }, + "reasons": { + "spam": "Spam", + "violation": "Rule Violation", + "misleading": "Misleading", + "sexual": "Inappropriate", + "rude": "Rude / Harassing", + "other": "Other" + }, + "actions": { + "applyWarning": "Apply Content Warning", + "applyWarningDesc": "Add a content warning label to a specific post or account. Users will see a blur overlay with the option to reveal.", + "accountDid": "Account DID", + "contentUri": "Content URI", + "contentUriOptional": "optional — leave empty for account-level label", + "labelType": "Label Type", + "applyLabel": "Apply Label", + "labelApplied": "Label applied" + }, + "labels": { + "empty": "No labels", + "emptyMessage": "No content labels have been applied yet.", + "accountLevel": "Account-level label", + "removeConfirm": "Remove this label?", + "removeTitle": "Remove label" + } + }, + + "highlightImporter": { + "clickToUpload": "Click to upload CSV", + "processing": "Processing...", + "requiredColumns": "Required columns: url, text | Optional: title, tags, color, created_at", + "downloadTemplate": "Download Template", + "importProgress": "Import Progress", + "complete": "{{rate}}% complete", + "failed_one": "{{count}} failed", + "failed_other": "{{count}} failed", + "importing": "Importing highlights...", + "success": "Successfully imported {{count}} highlights!", + "errorsTitle": "{{count}} errors during import", + "row": "Row {{row}}: {{error}}", + "moreErrors": "+{{count}} more errors", + "importAnother": "Import Another File", + "noHighlights": "No valid highlights found in CSV", + "csvMustHaveUrl": "CSV must have a 'url' column", + "csvMustHaveText": "CSV must have a 'text' column (also matches: highlight, excerpt)", + "errorParsing": "Error parsing CSV: {{message}}" + }, + + "profile": { + "notFound": "User not found", + "notFoundMessage": "This profile doesn't exist or couldn't be loaded.", + "edit": "Edit", + "viewInBluesky": "View profile in Bluesky", + "unblock": "Unblock @{{handle}}", + "block": "Block @{{handle}}", + "unmute": "Unmute @{{handle}}", + "mute": "Mute @{{handle}}", + "report": "Report", + "accountLabeled": "Account labeled: {{description}}", + "labelApplied": "This label was applied by a moderation service you subscribe to.", + "show": "Show", + "hide": "Hide", + "blockedBanner": "You have blocked @{{handle}}", + "blockedMessage": "Their content is hidden from your feeds.", + "mutedBanner": "You have muted @{{handle}}", + "mutedMessage": "Their content is hidden from your feeds.", + "blockedByBanner": "@{{handle}} has blocked you. You cannot interact with their content.", + "unblock_action": "Unblock", + "unmute_action": "Unmute", + "emptyCollectionsOwn": "You haven't created any collections yet.", + "emptyCollectionsOther": "No collections", + "itemCount_one": "{{count}} item", + "itemCount_other": "{{count}} items", + "emptyTabOwn": "Your {{tab}} will show up here.", + "emptyTabOther": "Nothing to see here yet." + }, + + "common": { + "loading": "Loading...", + "cancel": "Cancel", + "save": "Save", + "close": "Close", + "back": "Back", + "continue": "Continue", + "error": "Error", + "retry": "Retry", + "loadMore": "Load more", + "new": "New" + }, + + "about": { + "nav": { + "getExtension": "Get Extension", + "install": "Install" + }, + "hero": { + "openSource": "Fully open source", + "headline": "Write on the margins", + "headlineAccent": "of the internet.", + "descriptionPre": "Margin is an open annotation layer for the internet. Highlight text, leave notes, and bookmark pages, all stored on your decentralized identity with the", + "atProtocol": "AT Protocol", + "descriptionPost": ". Not locked in a silo.", + "openApp": "Open App", + "getStarted": "Get Started", + "installFor": "Install for {{browser}}" + }, + "features": { + "title": "Everything you need to engage with the web", + "subtitle": "More than bookmarks. A full toolkit for reading, thinking, and sharing on the open web.", + "annotations": { + "title": "Annotations", + "description": "Leave notes on any web page. Start discussions, share insights, or just jot down your thoughts for later." + }, + "highlights": { + "title": "Highlights", + "description": "Select and highlight text on any page with customizable colors. Your highlights are rendered inline with the CSS Highlights API." + }, + "bookmarks": { + "title": "Bookmarks", + "description": "Save pages with one click or a keyboard shortcut. All your bookmarks are synced to your AT Protocol identity." + }, + "collections": { + "title": "Collections", + "description": "Organize your annotations, highlights, and bookmarks into themed collections. Share them publicly or keep them private." + }, + "socialDiscovery": { + "title": "Social Discovery", + "description": "See what others are saying about the pages you visit. Discover annotations, trending tags, and connect with other readers." + }, + "tagsSearch": { + "title": "Tags & Search", + "description": "Tag your annotations for easy retrieval. Search by URL, tag, or content to find exactly what you're looking for." + } + }, + "extension": { + "badge": "Browser Extension", + "title": "Your annotation toolkit,", + "titleLine2": "right in the browser", + "description": "The Margin extension brings the full annotation experience directly into every page you visit. Just select, annotate, and go.", + "iosShortcut": "iOS Shortcut", + "features": { + "inlineOverlay": { + "title": "Inline Overlay", + "description": "See annotations and highlights rendered directly on the page. Uses the CSS Highlights API for beautiful, native-feeling text underlines." + }, + "contextMenu": { + "title": "Context Menu & Selection", + "description": "Right-click any selected text to annotate, highlight, or quote it. Or just right-click the page to bookmark it instantly." + }, + "keyboard": { + "title": "Keyboard Shortcuts", + "description": "Toggle the overlay, bookmark the current page, or annotate selected text without reaching for the mouse." + }, + "sidePanel": { + "title": "Side Panel", + "description": "Open the Margin side panel to browse annotations, bookmarks, and collections without leaving the page you're reading." + } + } + }, + "protocol": { + "badge": "Decentralized", + "title": "Your data, your identity", + "descriptionPre": "Margin is built on the", + "descriptionPost": ", the open protocol that powers apps like Bluesky. Your annotations, highlights, and bookmarks are stored in your personal data repository, not locked in a silo.", + "point0": "Sign in with your AT Protocol handle, no new account needed", + "point1": "Your data lives in your PDS, portable and under your control", + "point2": "Custom Lexicon schemas for annotations, highlights, collections & more", + "point3": "Fully open source, check out the code and contribute" + }, + "cta": { + "title": "Start writing on the margins", + "description": "Join the open annotation layer. Sign in with your AT Protocol identity and install the extension to get started.", + "signIn": "Sign in", + "viewGitHub": "View on GitHub", + "viewTangled": "View on Tangled" + }, + "footer": { + "privacy": "Privacy", + "terms": "Terms", + "contact": "Contact" + } + } +} diff --git a/web/src/components/common/Card.tsx b/web/src/components/common/Card.tsx index ca90f45..ffee1fc 100644 --- a/web/src/components/common/Card.tsx +++ b/web/src/components/common/Card.tsx @@ -1,5 +1,6 @@ import React, { useState } from "react"; import { formatDistanceToNow } from "date-fns"; +import { useTranslation } from "react-i18next"; import RichText from "./RichText"; import MoreMenu from "./MoreMenu"; import type { MoreMenuItem } from "./MoreMenu"; @@ -119,6 +120,7 @@ export default function Card({ hideCollection = false, layout = "list", }: CardProps) { + const { t } = useTranslation(); const [item, setItem] = useState(initialItem); const user = useStore($user); const preferences = useStore($preferences); @@ -229,7 +231,7 @@ export default function Card({ }; const handleDelete = async () => { - if (window.confirm("Delete this item?")) { + if (window.confirm(t("card.deleteConfirm"))) { const success = await deleteItem(item.uri, type); if (success && onDelete) { analytics.capture("item_deleted", { type }); @@ -297,7 +299,7 @@ export default function Card({ const timestamp = item.createdAt ? formatDistanceToNow(new Date(item.createdAt), { addSuffix: false }) - .replace("less than a minute", "just now") + .replace("less than a minute", t("card.justNow")) .replace("about ", "") .replace(" hours", "h") .replace(" hour", "h") @@ -347,7 +349,7 @@ export default function Card({ }; const displayTitle = decodeHTMLEntities( - item.title || ogData?.title || pageTitle || "Untitled Bookmark", + item.title || ogData?.title || pageTitle || t("card.untitledBookmark"), ); const displayDescription = item.description || ogData?.description @@ -377,10 +379,10 @@ export default function Card({ - added to + {t("card.addedToLower")} ) : ( - Added to + {t("card.addedTo")} )} {item.context && item.context.length > 0 ? ( @@ -392,7 +394,7 @@ export default function Card({ )} {index > 0 && index === item.context!.length - 1 && ( - and + {t("card.and")} )} - (edited) + {t("card.edited")} )} @@ -495,7 +497,7 @@ export default function Card({ className="h-3.5" /> - Open in Semble + {t("card.openInSemble")} @@ -514,7 +516,7 @@ export default function Card({ className="text-surface-400 dark:text-surface-500 fill-current" /> - Community bookmark + {t("card.communityBookmark")} @@ -548,7 +550,9 @@ export default function Card({
- {contentWarning.description} + {t(`card.labelDescriptions.${contentWarning.label}`, { + defaultValue: contentWarning.description, + })}
)} @@ -566,7 +570,7 @@ export default function Card({ className="flex items-center gap-1.5 mb-2 px-2.5 py-1 text-xs font-medium rounded-lg bg-surface-100 dark:bg-surface-800 text-surface-500 dark:text-surface-400 hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors" > - Hide Content + {t("card.hideContent")} )} {!(contentWarning && !contentRevealed) && isBookmark && ( @@ -741,7 +745,7 @@ export default function Card({ @@ -764,23 +768,23 @@ export default function Card({ )} @@ -794,13 +798,15 @@ export default function Card({ items={(() => { const menuItems: MoreMenuItem[] = [ { - label: "Report", + label: t("card.report"), icon: , onClick: () => setShowReportModal(true), variant: "danger", }, { - label: `Mute @${item.author?.handle || "user"}`, + label: t("card.muteUser", { + handle: item.author?.handle || "user", + }), icon: , onClick: async () => { if (item.author?.did) { @@ -810,7 +816,9 @@ export default function Card({ }, }, { - label: `Block @${item.author?.handle || "user"}`, + label: t("card.blockUser", { + handle: item.author?.handle || "user", + }), icon: , onClick: async () => { if (item.author?.did) { @@ -839,7 +847,7 @@ export default function Card({