diff options
| author | Mohamed Bassem <me@mbassem.com> | 2024-11-17 00:33:28 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2024-11-17 00:33:28 +0000 |
| commit | 4354ee7ba1c6ac9a9567944ae6169b1664e0ea8a (patch) | |
| tree | e27c9070930514d77582bae00b3350274116179c /apps | |
| parent | 9f2c7be23769bb0f4102736a683710b1a1939661 (diff) | |
| download | karakeep-4354ee7ba1c6ac9a9567944ae6169b1664e0ea8a.tar.zst | |
feature: Add i18n support. Fixes #57 (#635)
* feature(web): Add basic scaffolding for i18n
* refactor: Switch most of the app's strings to use i18n strings
* fix: Remove unused i18next-resources-for-ts command
* Add user setting
* More translations
* Drop the german translation for now
Diffstat (limited to 'apps')
54 files changed, 790 insertions, 250 deletions
diff --git a/apps/web/@types/i18next.d.ts b/apps/web/@types/i18next.d.ts new file mode 100644 index 00000000..62c42b7b --- /dev/null +++ b/apps/web/@types/i18next.d.ts @@ -0,0 +1,13 @@ +import "i18next"; + +import translation from "../lib/i18n/locales/en/translation.json"; + +declare module "i18next" { + // Extend CustomTypeOptions + interface CustomTypeOptions { + defaultNS: "translation"; + resources: { + translation: typeof translation; + }; + } +} diff --git a/apps/web/app/dashboard/cleanups/page.tsx b/apps/web/app/dashboard/cleanups/page.tsx index ca9187ee..1974d2a7 100644 --- a/apps/web/app/dashboard/cleanups/page.tsx +++ b/apps/web/app/dashboard/cleanups/page.tsx @@ -1,18 +1,21 @@ import { TagDuplicationDetection } from "@/components/dashboard/cleanups/TagDuplicationDetention"; import { Separator } from "@/components/ui/separator"; +import { useTranslation } from "@/lib/i18n/server"; import { Paintbrush, Tags } from "lucide-react"; -export default function Cleanups() { +export default async function Cleanups() { + const { t } = await useTranslation(); + return ( <div className="flex flex-col gap-y-4 rounded-md border bg-background p-4"> <span className="flex items-center gap-1 text-2xl"> <Paintbrush /> - Cleanups + {t("cleanups.cleanups")} </span> <Separator /> <span className="flex items-center gap-1 text-xl"> <Tags /> - Duplicate Tags + {t("cleanups.duplicate_tags.title")} </span> <Separator /> <TagDuplicationDetection /> diff --git a/apps/web/app/dashboard/lists/page.tsx b/apps/web/app/dashboard/lists/page.tsx index 1c22ac32..36eb8b7a 100644 --- a/apps/web/app/dashboard/lists/page.tsx +++ b/apps/web/app/dashboard/lists/page.tsx @@ -1,13 +1,15 @@ import AllListsView from "@/components/dashboard/lists/AllListsView"; import { Separator } from "@/components/ui/separator"; +import { useTranslation } from "@/lib/i18n/server"; import { api } from "@/server/api/client"; export default async function ListsPage() { + const { t } = await useTranslation(); const lists = await api.lists.list(); return ( <div className="flex flex-col gap-3 rounded-md border bg-background p-4"> - <p className="text-2xl">📋 All Lists</p> + <p className="text-2xl">📋 {t("lists.all_lists")}</p> <Separator /> <AllListsView initialData={lists.lists} /> </div> diff --git a/apps/web/app/dashboard/tags/page.tsx b/apps/web/app/dashboard/tags/page.tsx index 6caea513..1639e4c5 100644 --- a/apps/web/app/dashboard/tags/page.tsx +++ b/apps/web/app/dashboard/tags/page.tsx @@ -1,13 +1,15 @@ import AllTagsView from "@/components/dashboard/tags/AllTagsView"; import { Separator } from "@/components/ui/separator"; +import { useTranslation } from "@/lib/i18n/server"; import { api } from "@/server/api/client"; export default async function TagsPage() { + const { t } = await useTranslation(); const allTags = (await api.tags.list()).tags; return ( <div className="space-y-4 rounded-md border bg-background p-4"> - <span className="text-2xl">All Tags</span> + <span className="text-2xl">{t("tags.all_tags")}</span> <Separator /> <AllTagsView initialData={allTags} /> </div> diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 6d8b10ed..7d3858eb 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -5,14 +5,9 @@ import "@hoarder/tailwind-config/globals.css"; import type { Viewport } from "next"; import React from "react"; -import { cookies } from "next/headers"; import { Toaster } from "@/components/ui/toaster"; import Providers from "@/lib/providers"; -import { - defaultUserLocalSettings, - parseUserLocalSettings, - USER_LOCAL_SETTINGS_COOKIE_NAME, -} from "@/lib/userLocalSettings/types"; +import { getUserLocalSettings } from "@/lib/userLocalSettings/userLocalSettings"; import { getServerAuthSession } from "@/server/auth"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; @@ -57,11 +52,7 @@ export default async function RootLayout({ <Providers session={session} clientConfig={clientConfig} - userLocalSettings={ - parseUserLocalSettings( - cookies().get(USER_LOCAL_SETTINGS_COOKIE_NAME)?.value, - ) ?? defaultUserLocalSettings() - } + userLocalSettings={await getUserLocalSettings()} > {children} <ReactQueryDevtools initialIsOpen={false} /> diff --git a/apps/web/app/settings/info/page.tsx b/apps/web/app/settings/info/page.tsx index 8027b09f..c7d8f808 100644 --- a/apps/web/app/settings/info/page.tsx +++ b/apps/web/app/settings/info/page.tsx @@ -1,11 +1,13 @@ import { ChangePassword } from "@/components/settings/ChangePassword"; import UserDetails from "@/components/settings/UserDetails"; +import { UserOptions } from "@/components/settings/UserOptions"; export default async function InfoPage() { return ( - <div className="rounded-md border bg-background p-4"> + <div className="flex flex-col gap-8 rounded-md border bg-background p-4"> <UserDetails /> <ChangePassword /> + <UserOptions /> </div> ); } diff --git a/apps/web/components/dashboard/BulkBookmarksAction.tsx b/apps/web/components/dashboard/BulkBookmarksAction.tsx index c78b65db..eb0e0cec 100644 --- a/apps/web/components/dashboard/BulkBookmarksAction.tsx +++ b/apps/web/components/dashboard/BulkBookmarksAction.tsx @@ -9,6 +9,7 @@ import { import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; import { useToast } from "@/components/ui/use-toast"; import useBulkActionsStore from "@/lib/bulkActions"; +import { useTranslation } from "@/lib/i18n/client"; import { CheckCheck, FileDown, @@ -33,6 +34,7 @@ import BulkTagModal from "./bookmarks/BulkTagModal"; import { ArchivedActionIcon, FavouritedActionIcon } from "./bookmarks/icons"; export default function BulkBookmarksAction() { + const { t } = useTranslation(); const { selectedBookmarks, isBulkEditEnabled } = useBulkActionsStore(); const setIsBulkEditEnabled = useBulkActionsStore( (state) => state.setIsBulkEditEnabled, @@ -179,7 +181,7 @@ export default function BulkBookmarksAction() { const actionList = [ { name: isClipboardAvailable() - ? "Copy Links" + ? t("actions.copy_link") : "Copying is only available over https", icon: <Link size={18} />, action: () => copyLinks(), @@ -187,55 +189,57 @@ export default function BulkBookmarksAction() { hidden: !isBulkEditEnabled, }, { - name: "Add to List", + name: t("actions.add_to_list"), icon: <List size={18} />, action: () => setManageListsModalOpen(true), isPending: false, hidden: !isBulkEditEnabled, }, { - name: "Edit Tags", + name: t("actions.edit_tags"), icon: <Hash size={18} />, action: () => setBulkTagModalOpen(true), isPending: false, hidden: !isBulkEditEnabled, }, { - name: alreadyFavourited ? "Unfavourite" : "Favourite", + name: alreadyFavourited ? t("actions.unfavorite") : t("actions.favorite"), icon: <FavouritedActionIcon favourited={!!alreadyFavourited} size={18} />, action: () => updateBookmarks({ favourited: !alreadyFavourited }), isPending: updateBookmarkMutator.isPending, hidden: !isBulkEditEnabled, }, { - name: alreadyArchived ? "Un-archive" : "Archive", + name: alreadyArchived ? t("actions.unarchive") : t("actions.archive"), icon: <ArchivedActionIcon size={18} archived={!!alreadyArchived} />, action: () => updateBookmarks({ archived: !alreadyArchived }), isPending: updateBookmarkMutator.isPending, hidden: !isBulkEditEnabled, }, { - name: "Download Full Page Archive", + name: t("actions.download_full_page_archive"), icon: <FileDown size={18} />, action: () => recrawlBookmarks(true), isPending: recrawlBookmarkMutator.isPending, hidden: !isBulkEditEnabled, }, { - name: "Refresh", + name: t("actions.refresh"), icon: <RotateCw size={18} />, action: () => recrawlBookmarks(false), isPending: recrawlBookmarkMutator.isPending, hidden: !isBulkEditEnabled, }, { - name: "Delete", + name: t("actions.delete"), icon: <Trash2 size={18} color="red" />, action: () => setIsDeleteDialogOpen(true), hidden: !isBulkEditEnabled, }, { - name: isEverythingSelected() ? "Unselect All" : "Select All", + name: isEverythingSelected() + ? t("actions.unselect_all") + : t("actions.select_all"), icon: ( <p className="flex items-center gap-2"> ( <CheckCheck size={18} /> {selectedBookmarks.length} ) @@ -247,14 +251,14 @@ export default function BulkBookmarksAction() { hidden: !isBulkEditEnabled, }, { - name: "Close bulk edit", + name: t("actions.close_bulk_edit"), icon: <X size={18} />, action: () => setIsBulkEditEnabled(false), alwaysEnable: true, hidden: !isBulkEditEnabled, }, { - name: "Bulk Edit", + name: t("actions.bulk_edit"), icon: <Pencil size={18} />, action: () => setIsBulkEditEnabled(true), alwaysEnable: true, @@ -276,7 +280,7 @@ export default function BulkBookmarksAction() { loading={deleteBookmarkMutator.isPending} onClick={() => deleteBookmarks()} > - Delete + {t("actions.delete")} </ActionButton> )} /> diff --git a/apps/web/components/dashboard/ChangeLayout.tsx b/apps/web/components/dashboard/ChangeLayout.tsx index 6ec38245..c7f44a73 100644 --- a/apps/web/components/dashboard/ChangeLayout.tsx +++ b/apps/web/components/dashboard/ChangeLayout.tsx @@ -8,6 +8,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { useTranslation } from "@/lib/i18n/client"; import { useBookmarkLayout } from "@/lib/userLocalSettings/bookmarksLayout"; import { updateBookmarksLayout } from "@/lib/userLocalSettings/userLocalSettings"; import { @@ -16,11 +17,12 @@ import { LayoutGrid, LayoutList, List, + LucideIcon, } from "lucide-react"; -type LayoutType = "masonry" | "grid" | "list"; +type LayoutType = "masonry" | "grid" | "list" | "compact"; -const iconMap = { +const iconMap: Record<LayoutType, LucideIcon> = { masonry: LayoutDashboard, grid: LayoutGrid, list: LayoutList, @@ -28,13 +30,14 @@ const iconMap = { }; export default function ChangeLayout() { + const { t } = useTranslation(); const layout = useBookmarkLayout(); return ( <DropdownMenu> <DropdownMenuTrigger asChild> <ButtonWithTooltip - tooltip="Change layout" + tooltip={t("actions.change_layout")} delayDuration={100} variant="ghost" > @@ -42,7 +45,7 @@ export default function ChangeLayout() { </ButtonWithTooltip> </DropdownMenuTrigger> <DropdownMenuContent className="w-fit"> - {Object.keys(iconMap).map((key) => ( + {(Object.keys(iconMap) as LayoutType[]).map((key) => ( <DropdownMenuItem key={key} className="cursor-pointer justify-between" @@ -50,7 +53,7 @@ export default function ChangeLayout() { > <div className="flex items-center gap-2"> {React.createElement(iconMap[key as LayoutType], { size: 18 })} - <span className="capitalize">{key}</span> + <span>{t(`layouts.${key}`)}</span> </div> {layout == key && <Check className="ml-2 size-4" />} </DropdownMenuItem> diff --git a/apps/web/components/dashboard/EditableText.tsx b/apps/web/components/dashboard/EditableText.tsx index 55ce10c6..e5027b93 100644 --- a/apps/web/components/dashboard/EditableText.tsx +++ b/apps/web/components/dashboard/EditableText.tsx @@ -7,6 +7,7 @@ import { TooltipPortal, TooltipTrigger, } from "@/components/ui/tooltip"; +import { useTranslation } from "@/lib/i18n/client"; import { Check, Pencil, X } from "lucide-react"; interface Props { @@ -26,6 +27,7 @@ function EditMode({ originalText, setEditable, }: Props) { + const { t } = useTranslation(); const ref = useRef<HTMLDivElement>(null); useEffect(() => { @@ -63,7 +65,7 @@ function EditMode({ }} /> <ActionButtonWithTooltip - tooltip="Save" + tooltip={t("actions.save")} delayDuration={500} size="none" variant="ghost" @@ -74,7 +76,7 @@ function EditMode({ <Check className="size-4" /> </ActionButtonWithTooltip> <ButtonWithTooltip - tooltip="Cancel" + tooltip={t("actions.cancel")} delayDuration={500} size="none" variant="ghost" @@ -95,6 +97,7 @@ function ViewMode({ viewClassName, untitledClassName, }: Props) { + const { t } = useTranslation(); return ( <Tooltip delayDuration={500}> <div className="flex max-w-full items-center gap-3"> @@ -107,7 +110,7 @@ function ViewMode({ </TooltipTrigger> <ButtonWithTooltip delayDuration={500} - tooltip="Edit title" + tooltip={t("actions.edit_title")} size="none" variant="ghost" className="align-middle text-gray-400" diff --git a/apps/web/components/dashboard/admin/AdminActions.tsx b/apps/web/components/dashboard/admin/AdminActions.tsx index a97552f8..3b95045c 100644 --- a/apps/web/components/dashboard/admin/AdminActions.tsx +++ b/apps/web/components/dashboard/admin/AdminActions.tsx @@ -2,9 +2,11 @@ import { ActionButton } from "@/components/ui/action-button"; import { toast } from "@/components/ui/use-toast"; +import { useTranslation } from "@/lib/i18n/client"; import { api } from "@/lib/trpc"; export default function AdminActions() { + const { t } = useTranslation(); const { mutate: recrawlLinks, isPending: isRecrawlPending } = api.admin.recrawlLinks.useMutation({ onSuccess: () => { @@ -69,7 +71,7 @@ export default function AdminActions() { return ( <div> - <div className="mb-2 mt-8 text-xl font-medium">Actions</div> + <div className="mb-2 mt-8 text-xl font-medium">{t("common.actions")}</div> <div className="flex flex-col gap-2 sm:w-1/2"> <ActionButton variant="destructive" @@ -78,7 +80,7 @@ export default function AdminActions() { recrawlLinks({ crawlStatus: "failure", runInference: true }) } > - Recrawl Failed Links Only + {t("admin.actions.recrawl_failed_links_only")} </ActionButton> <ActionButton variant="destructive" @@ -87,7 +89,7 @@ export default function AdminActions() { recrawlLinks({ crawlStatus: "all", runInference: true }) } > - Recrawl All Links + {t("admin.actions.recrawl_all_links")} </ActionButton> <ActionButton variant="destructive" @@ -96,7 +98,8 @@ export default function AdminActions() { recrawlLinks({ crawlStatus: "all", runInference: false }) } > - Recrawl All Links (Without Inference) + {t("admin.actions.recrawl_all_links")} ( + {t("admin.actions.without_inference")}) </ActionButton> <ActionButton variant="destructive" @@ -105,28 +108,28 @@ export default function AdminActions() { reRunInferenceOnAllBookmarks({ taggingStatus: "failure" }) } > - Regenerate AI Tags for Failed Bookmarks Only + {t("admin.actions.regenerate_ai_tags_for_failed_bookmarks_only")} </ActionButton> <ActionButton variant="destructive" loading={isInferencePending} onClick={() => reRunInferenceOnAllBookmarks({ taggingStatus: "all" })} > - Regenerate AI Tags for All Bookmarks + {t("admin.actions.regenerate_ai_tags_for_all_bookmarks")} </ActionButton> <ActionButton variant="destructive" loading={isReindexPending} onClick={() => reindexBookmarks()} > - Reindex All Bookmarks + {t("admin.actions.reindex_all_bookmarks")} </ActionButton> <ActionButton variant="destructive" loading={isTidyAssetsPending} onClick={() => tidyAssets()} > - Compact Assets + {t("admin.actions.compact_assets")} </ActionButton> </div> </div> diff --git a/apps/web/components/dashboard/admin/ServerStats.tsx b/apps/web/components/dashboard/admin/ServerStats.tsx index f45d86c5..da69390b 100644 --- a/apps/web/components/dashboard/admin/ServerStats.tsx +++ b/apps/web/components/dashboard/admin/ServerStats.tsx @@ -10,6 +10,7 @@ import { TableRow, } from "@/components/ui/table"; import { useClientConfig } from "@/lib/clientConfig"; +import { useTranslation } from "@/lib/i18n/client"; import { api } from "@/lib/trpc"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; @@ -61,6 +62,7 @@ function ReleaseInfo() { } export default function ServerStats() { + const { t } = useTranslation(); const { data: serverStats } = api.admin.stats.useQuery(undefined, { refetchInterval: 1000, placeholderData: keepPreviousData, @@ -72,15 +74,19 @@ export default function ServerStats() { return ( <> - <div className="mb-2 text-xl font-medium">Server Stats</div> + <div className="mb-2 text-xl font-medium"> + {t("admin.server_stats.server_stats")} + </div> <div className="flex flex-col gap-4 sm:flex-row"> <div className="rounded-md border bg-background p-4 sm:w-1/4"> - <div className="text-sm font-medium text-gray-400">Total Users</div> + <div className="text-sm font-medium text-gray-400"> + {t("admin.server_stats.total_users")} + </div> <div className="text-3xl font-semibold">{serverStats.numUsers}</div> </div> <div className="rounded-md border bg-background p-4 sm:w-1/4"> <div className="text-sm font-medium text-gray-400"> - Total Bookmarks + {t("admin.server_stats.total_bookmarks")} </div> <div className="text-3xl font-semibold"> {serverStats.numBookmarks} @@ -88,42 +94,48 @@ export default function ServerStats() { </div> <div className="rounded-md border bg-background p-4 sm:w-1/4"> <div className="text-sm font-medium text-gray-400"> - Server Version + {t("admin.server_stats.server_version")} </div> <ReleaseInfo /> </div> </div> <div className="sm:w-1/2"> - <div className="mb-2 mt-8 text-xl font-medium">Background Jobs</div> + <div className="mb-2 mt-8 text-xl font-medium"> + {t("admin.background_jobs.background_jobs")} + </div> <Table className="rounded-md border"> <TableHeader className="bg-gray-200"> - <TableHead>Job</TableHead> - <TableHead>Queued</TableHead> - <TableHead>Pending</TableHead> - <TableHead>Failed</TableHead> + <TableHead>{t("admin.background_jobs.job")}</TableHead> + <TableHead>{t("admin.background_jobs.queued")}</TableHead> + <TableHead>{t("admin.background_jobs.pending")}</TableHead> + <TableHead>{t("admin.background_jobs.failed")}</TableHead> </TableHeader> <TableBody> <TableRow> - <TableCell className="lg:w-2/3">Crawling Jobs</TableCell> + <TableCell className="lg:w-2/3"> + {t("admin.background_jobs.crawler_jobs")} + </TableCell> <TableCell>{serverStats.crawlStats.queued}</TableCell> <TableCell>{serverStats.crawlStats.pending}</TableCell> <TableCell>{serverStats.crawlStats.failed}</TableCell> </TableRow> <TableRow> - <TableCell>Indexing Jobs</TableCell> + <TableCell>{t("admin.background_jobs.indexing_jobs")}</TableCell> <TableCell>{serverStats.indexingStats.queued}</TableCell> <TableCell>-</TableCell> <TableCell>-</TableCell> </TableRow> <TableRow> - <TableCell>Inference Jobs</TableCell> + <TableCell>{t("admin.background_jobs.inference_jobs")}</TableCell> <TableCell>{serverStats.inferenceStats.queued}</TableCell> <TableCell>{serverStats.inferenceStats.pending}</TableCell> <TableCell>{serverStats.inferenceStats.failed}</TableCell> </TableRow> <TableRow> - <TableCell>Tidy Assets Jobs</TableCell> + <TableCell> + {t("admin.background_jobs.tidy_assets_jobs")} + </TableCell> <TableCell>{serverStats.tidyAssetsStats.queued}</TableCell> <TableCell>-</TableCell> <TableCell>-</TableCell> diff --git a/apps/web/components/dashboard/admin/UserList.tsx b/apps/web/components/dashboard/admin/UserList.tsx index 2937df28..8c788ef4 100644 --- a/apps/web/components/dashboard/admin/UserList.tsx +++ b/apps/web/components/dashboard/admin/UserList.tsx @@ -12,6 +12,7 @@ import { TableRow, } from "@/components/ui/table"; import { toast } from "@/components/ui/use-toast"; +import { useTranslation } from "@/lib/i18n/client"; import { api } from "@/lib/trpc"; import { Check, KeyRound, Pencil, Trash, UserPlus, X } from "lucide-react"; import { useSession } from "next-auth/react"; @@ -28,6 +29,7 @@ function toHumanReadableSize(size: number) { } export default function UsersSection() { + const { t } = useTranslation(); const { data: session } = useSession(); const invalidateUserList = api.useUtils().users.list.invalidate; const { data: users } = api.users.list.useQuery(); @@ -55,7 +57,7 @@ export default function UsersSection() { return ( <> <div className="mb-2 flex items-center justify-between text-xl font-medium"> - <span>Users List</span> + <span>{t("admin.users_list.users_list")}</span> <AddUserDialog> <ButtonWithTooltip tooltip="Create User" variant="outline"> <UserPlus size={16} /> @@ -65,13 +67,13 @@ export default function UsersSection() { <Table> <TableHeader className="bg-gray-200"> - <TableHead>Name</TableHead> - <TableHead>Email</TableHead> - <TableHead>Num Bookmarks</TableHead> - <TableHead>Asset Sizes</TableHead> - <TableHead>Role</TableHead> - <TableHead>Local User</TableHead> - <TableHead>Actions</TableHead> + <TableHead>{t("common.name")}</TableHead> + <TableHead>{t("common.email")}</TableHead> + <TableHead>{t("admin.users_list.num_bookmarks")}</TableHead> + <TableHead>{t("admin.users_list.asset_sizes")}</TableHead> + <TableHead>{t("common.role")}</TableHead> + <TableHead>{t("admin.users_list.local_user")}</TableHead> + <TableHead>{t("common.actions")}</TableHead> </TableHeader> <TableBody> {users.users.map((u) => ( @@ -84,13 +86,15 @@ export default function UsersSection() { <TableCell className="py-1"> {toHumanReadableSize(userStats[u.id].assetSizes)} </TableCell> - <TableCell className="py-1 capitalize">{u.role}</TableCell> - <TableCell className="py-1 capitalize"> + <TableCell className="py-1"> + {u.role && t(`common.roles.${u.role}`)} + </TableCell> + <TableCell className="py-1"> {u.localUser ? <Check /> : <X />} </TableCell> <TableCell className="flex gap-1 py-1"> <ActionButtonWithTooltip - tooltip="Delete user" + tooltip={t("admin.users_list.delete_user")} variant="outline" onClick={() => deleteUser({ userId: u.id })} loading={isDeletionPending} @@ -100,7 +104,7 @@ export default function UsersSection() { </ActionButtonWithTooltip> <ResetPasswordDialog userId={u.id}> <ButtonWithTooltip - tooltip="Reset password" + tooltip={t("admin.users_list.reset_password")} variant="outline" disabled={session!.user.id == u.id || !u.localUser} > @@ -109,7 +113,7 @@ export default function UsersSection() { </ResetPasswordDialog> <ChangeRoleDialog userId={u.id} currentRole={u.role!}> <ButtonWithTooltip - tooltip="Change role" + tooltip={t("admin.users_list.change_role")} variant="outline" disabled={session!.user.id == u.id} > diff --git a/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx b/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx index c09d2e50..8dfb96fd 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx @@ -10,6 +10,7 @@ import { } from "@/components/ui/dropdown-menu"; import { useToast } from "@/components/ui/use-toast"; import { useClientConfig } from "@/lib/clientConfig"; +import { useTranslation } from "@/lib/i18n/client"; import { FileDown, Link, @@ -41,6 +42,7 @@ import { useManageListsModal } from "./ManageListsModal"; import { useTagModel } from "./TagModal"; export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { + const { t } = useTranslation(); const { toast } = useToast(); const linkId = bookmark.id; @@ -58,14 +60,13 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { const onError = () => { toast({ variant: "destructive", - title: "Something went wrong", - description: "There was a problem with your request.", + title: t("common.something_went_wrong"), }); }; const deleteBookmarkMutator = useDeleteBookmark({ onSuccess: () => { toast({ - description: "The bookmark has been deleted!", + description: t("toasts.bookmarks.deleted"), }); }, onError, @@ -74,7 +75,7 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { const updateBookmarkMutator = useUpdateBookmark({ onSuccess: () => { toast({ - description: "The bookmark has been updated!", + description: t("toasts.bookmarks.updated"), }); }, onError, @@ -83,7 +84,7 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { const crawlBookmarkMutator = useRecrawlBookmark({ onSuccess: () => { toast({ - description: "Re-fetch has been enqueued!", + description: t("toasts.bookmarks.refetch"), }); }, onError, @@ -92,7 +93,7 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { const fullPageArchiveBookmarkMutator = useRecrawlBookmark({ onSuccess: () => { toast({ - description: "Full Page Archive creation has been triggered", + description: t("toasts.bookmarks.full_page_archive"), }); }, onError, @@ -101,7 +102,7 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { const removeFromListMutator = useRemoveBookmarkFromList({ onSuccess: () => { toast({ - description: "The bookmark has been deleted from the list", + description: t("toasts.bookmarks.delete_from_list"), }); }, onError, @@ -145,7 +146,11 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { className="mr-2 size-4" favourited={bookmark.favourited} /> - <span>{bookmark.favourited ? "Un-favourite" : "Favourite"}</span> + <span> + {bookmark.favourited + ? t("actions.unfavorite") + : t("actions.favorite")} + </span> </DropdownMenuItem> <DropdownMenuItem disabled={demoMode} @@ -160,7 +165,11 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { className="mr-2 size-4" archived={bookmark.archived} /> - <span>{bookmark.archived ? "Un-archive" : "Archive"}</span> + <span> + {bookmark.archived + ? t("actions.unarchive") + : t("actions.archive")} + </span> </DropdownMenuItem> {bookmark.content.type === BookmarkTypes.LINK && ( @@ -173,7 +182,7 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { }} > <FileDown className="mr-2 size-4" /> - <span>Download Full Page Archive</span> + <span>{t("actions.download_full_page_archive")}</span> </DropdownMenuItem> )} @@ -184,22 +193,22 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { (bookmark.content as ZBookmarkedLink).url, ); toast({ - description: "Link was added to your clipboard!", + description: t("toasts.bookmarks.clipboard_copied"), }); }} > <Link className="mr-2 size-4" /> - <span>Copy Link</span> + <span>{t("actions.copy_link")}</span> </DropdownMenuItem> )} <DropdownMenuItem onClick={() => setTagModalIsOpen(true)}> <Tags className="mr-2 size-4" /> - <span>Edit Tags</span> + <span>{t("actions.edit_tags")}</span> </DropdownMenuItem> <DropdownMenuItem onClick={() => setManageListsModalOpen(true)}> <List className="mr-2 size-4" /> - <span>Manage Lists</span> + <span>{t("actions.manage_lists")}</span> </DropdownMenuItem> {listId && ( @@ -213,7 +222,7 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { } > <ListX className="mr-2 size-4" /> - <span>Remove from List</span> + <span>{t("actions.remove_from_list")}</span> </DropdownMenuItem> )} @@ -225,7 +234,7 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { } > <RotateCw className="mr-2 size-4" /> - <span>Refresh</span> + <span>{t("actions.refresh")}</span> </DropdownMenuItem> )} <DropdownMenuItem @@ -236,7 +245,7 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { } > <Trash2 className="mr-2 size-4" /> - <span>Delete</span> + <span>{t("actions.delete")}</span> </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> diff --git a/apps/web/components/dashboard/bookmarks/EditorCard.tsx b/apps/web/components/dashboard/bookmarks/EditorCard.tsx index 4d851d1c..cb4bfdce 100644 --- a/apps/web/components/dashboard/bookmarks/EditorCard.tsx +++ b/apps/web/components/dashboard/bookmarks/EditorCard.tsx @@ -9,6 +9,7 @@ import { Textarea } from "@/components/ui/textarea"; import { toast } from "@/components/ui/use-toast"; import BookmarkAlreadyExistsToast from "@/components/utils/BookmarkAlreadyExistsToast"; import { useClientConfig } from "@/lib/clientConfig"; +import { useTranslation } from "@/lib/i18n/client"; import { useBookmarkLayout, useBookmarkLayoutSwitch, @@ -48,6 +49,7 @@ interface MultiUrlImportState { } export default function EditorCard({ className }: { className?: string }) { + const { t } = useTranslation(); const inputRef = useRef<HTMLTextAreaElement>(null); const [multiUrlImportState, setMultiUrlImportState] = @@ -181,11 +183,9 @@ export default function EditorCard({ className }: { className?: string }) { onSubmit={form.handleSubmit(onSubmit, onError)} > <div className="flex justify-between"> - <p className="text-sm">NEW ITEM</p> + <p className="text-sm">{t("editor.new_item")}</p> <InfoTooltip size={15}> - <p className="text-center"> - You can quickly focus on this field by pressing ⌘ + E - </p> + <p className="text-center">{t("editor.quickly_focus")}</p> </InfoTooltip> </div> <Separator /> @@ -198,9 +198,7 @@ export default function EditorCard({ className }: { className?: string }) { "h-full w-full border-none p-0 text-lg focus-visible:ring-0", { "resize-none": bookmarkLayout !== "list" }, )} - placeholder={ - "Paste a link or an image, write a note or drag and drop an image in here ..." - } + placeholder={t("editor.placeholder")} onKeyDown={(e) => { if (demoMode) { return; @@ -223,16 +221,16 @@ export default function EditorCard({ className }: { className?: string }) { <ActionButton loading={isPending} type="submit" variant="default"> {form.formState.dirtyFields.text ? demoMode - ? "Submissions are disabled" - : `Save (${OS === "macos" ? "⌘" : "Ctrl"} + Enter)` - : "Save"} + ? t("editor.disabled_submissions") + : `${t("actions.save")} (${OS === "macos" ? "⌘" : "Ctrl"} + Enter)` + : t("actions.save")} </ActionButton> {multiUrlImportState && ( <MultipleChoiceDialog open={true} - title={`Import URLs as separate Bookmarks?`} - description={`The input contains multiple URLs on separate lines. Do you want to import them as separate bookmarks?`} + title={t("editor.multiple_urls_dialog_title")} + description={t("editor.multiple_urls_dialog_desc")} onOpenChange={(open) => { if (!open) { setMultiUrlImportState(null); @@ -252,7 +250,7 @@ export default function EditorCard({ className }: { className?: string }) { setMultiUrlImportState(null); }} > - Import as Text Bookmark + {t("editor.import_as_text")} </ActionButton> ), () => ( @@ -267,7 +265,7 @@ export default function EditorCard({ className }: { className?: string }) { setMultiUrlImportState(null); }} > - Import as separate Bookmarks + {t("editor.import_as_separate_bookmarks")} </ActionButton> ), ]} diff --git a/apps/web/components/dashboard/bookmarks/ManageListsModal.tsx b/apps/web/components/dashboard/bookmarks/ManageListsModal.tsx index c1a75a43..dfbd6d45 100644 --- a/apps/web/components/dashboard/bookmarks/ManageListsModal.tsx +++ b/apps/web/components/dashboard/bookmarks/ManageListsModal.tsx @@ -18,6 +18,7 @@ import { } from "@/components/ui/form"; import LoadingSpinner from "@/components/ui/spinner"; import { toast } from "@/components/ui/use-toast"; +import { useTranslation } from "@/lib/i18n/client"; import { api } from "@/lib/trpc"; import { zodResolver } from "@hookform/resolvers/zod"; import { Archive, X } from "lucide-react"; @@ -42,6 +43,7 @@ export default function ManageListsModal({ open: boolean; setOpen: (open: boolean) => void; }) { + const { t } = useTranslation(); const formSchema = z.object({ listId: z.string({ required_error: "Please select a list", @@ -73,7 +75,7 @@ export default function ManageListsModal({ useAddBookmarkToList({ onSuccess: () => { toast({ - description: "List has been updated!", + description: t("toasts.lists.updated"), }); form.resetField("listId"); }, @@ -86,7 +88,7 @@ export default function ManageListsModal({ } else { toast({ variant: "destructive", - title: "Something went wrong", + title: t("common.something_went_wrong"), }); } }, @@ -96,7 +98,7 @@ export default function ManageListsModal({ useRemoveBookmarkFromList({ onSuccess: () => { toast({ - description: "List has been updated!", + description: t("toasts.lists.updated"), }); form.resetField("listId"); }, @@ -109,7 +111,7 @@ export default function ManageListsModal({ } else { toast({ variant: "destructive", - title: "Something went wrong", + title: t("common.something_went_wrong"), }); } }, @@ -188,7 +190,7 @@ export default function ManageListsModal({ <DialogFooter className="sm:justify-end"> <DialogClose asChild> <Button type="button" variant="secondary"> - Close + {t("actions.close")} </Button> </DialogClose> <ArchiveBookmarkButton @@ -196,14 +198,14 @@ export default function ManageListsModal({ bookmarkId={bookmarkId} onDone={() => setOpen(false)} > - <Archive className="mr-2 size-4" /> Archive + <Archive className="mr-2 size-4" /> {t("actions.archive")} </ArchiveBookmarkButton> <ActionButton type="submit" loading={isAddingToListPending} disabled={isAddingToListPending} > - Add + {t("actions.add")} </ActionButton> </DialogFooter> </form> diff --git a/apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx b/apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx index 5dfa3166..21554556 100644 --- a/apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx +++ b/apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx @@ -2,6 +2,7 @@ import React from "react"; import { ActionButton } from "@/components/ui/action-button"; import LoadingSpinner from "@/components/ui/spinner"; import { toast } from "@/components/ui/use-toast"; +import { useTranslation } from "@/lib/i18n/client"; import { cn } from "@/lib/utils"; import { ChevronUp, RefreshCw, Sparkles, Trash2 } from "lucide-react"; @@ -99,6 +100,7 @@ export default function SummarizeBookmarkArea({ }: { bookmark: ZBookmark; }) { + const { t } = useTranslation(); const { mutate, isPending } = useSummarizeBookmark({ onError: () => { toast({ @@ -132,7 +134,7 @@ export default function SummarizeBookmarkArea({ </div> )} <span className="relative z-10 flex items-center gap-1.5"> - Summarize with AI + {t("actions.summarize_with_ai")} <Sparkles className="size-4" /> </span> </ActionButton> diff --git a/apps/web/components/dashboard/bookmarks/TagModal.tsx b/apps/web/components/dashboard/bookmarks/TagModal.tsx index c2f081be..61f462b1 100644 --- a/apps/web/components/dashboard/bookmarks/TagModal.tsx +++ b/apps/web/components/dashboard/bookmarks/TagModal.tsx @@ -8,6 +8,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { useTranslation } from "@/lib/i18n/client"; import type { ZBookmark } from "@hoarder/shared/types/bookmarks"; @@ -22,17 +23,18 @@ export default function TagModal({ open: boolean; setOpen: (open: boolean) => void; }) { + const { t } = useTranslation(); return ( <Dialog open={open} onOpenChange={setOpen}> <DialogContent> <DialogHeader> - <DialogTitle>Edit Tags</DialogTitle> + <DialogTitle>{t("actions.edit_tags")}</DialogTitle> </DialogHeader> <BookmarkTagsEditor bookmark={bookmark} /> <DialogFooter className="sm:justify-end"> <DialogClose asChild> <Button type="button" variant="secondary"> - Close + {t("actions.close")} </Button> </DialogClose> </DialogFooter> diff --git a/apps/web/components/dashboard/cleanups/TagDuplicationDetention.tsx b/apps/web/components/dashboard/cleanups/TagDuplicationDetention.tsx index 61132a60..c9db3dfa 100644 --- a/apps/web/components/dashboard/cleanups/TagDuplicationDetention.tsx +++ b/apps/web/components/dashboard/cleanups/TagDuplicationDetention.tsx @@ -21,6 +21,7 @@ import { TableRow, } from "@/components/ui/table"; import { toast } from "@/components/ui/use-toast"; +import { useTranslation } from "@/lib/i18n/client"; import { api } from "@/lib/trpc"; import { cn } from "@/lib/utils"; import { distance } from "fastest-levenshtein"; @@ -56,6 +57,7 @@ const useSuggestions = () => { }; function ApplyAllButton({ suggestions }: { suggestions: Suggestion[] }) { + const { t } = useTranslation(); const [applying, setApplying] = useState(false); const { mutateAsync } = useMergeTag({ onError: (e) => { @@ -91,7 +93,7 @@ function ApplyAllButton({ suggestions }: { suggestions: Suggestion[] }) { return ( <ActionConfirmingDialog - title="Merge all suggestions?" + title={t("cleanups.duplicate_tags.merge_all_suggestions")} description={`Are you sure you want to apply all ${suggestions.length} suggestions?`} actionButton={(setDialogOpen) => ( <ActionButton @@ -100,13 +102,13 @@ function ApplyAllButton({ suggestions }: { suggestions: Suggestion[] }) { onClick={() => applyAll(setDialogOpen)} > <Check className="mr-2 size-4" /> - Apply All + {t("actions.apply_all")} </ActionButton> )} > <Button variant="destructive"> <Check className="mr-2 size-4" /> - Apply All + {t("actions.apply_all")} </Button> </ActionConfirmingDialog> ); @@ -121,6 +123,7 @@ function SuggestionRow({ updateMergeInto: (suggestion: Suggestion, newMergeIntoId: string) => void; deleteSuggestion: (suggestion: Suggestion) => void; }) { + const { t } = useTranslation(); const { mutate, isPending } = useMergeTag({ onSuccess: () => { toast({ @@ -180,7 +183,7 @@ function SuggestionRow({ } > <Combine className="mr-2 size-4" /> - Merge + {t("actions.merge")} </ActionButton> <Button @@ -188,7 +191,7 @@ function SuggestionRow({ onClick={() => deleteSuggestion(suggestion)} > <X className="mr-2 size-4" /> - Ignore + {t("actions.ignore")} </Button> </TableCell> </TableRow> diff --git a/apps/web/components/dashboard/header/ProfileOptions.tsx b/apps/web/components/dashboard/header/ProfileOptions.tsx index ee6cac01..e715048e 100644 --- a/apps/web/components/dashboard/header/ProfileOptions.tsx +++ b/apps/web/components/dashboard/header/ProfileOptions.tsx @@ -11,31 +11,34 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Separator } from "@/components/ui/separator"; +import { useTranslation } from "@/lib/i18n/client"; import { LogOut, Moon, Paintbrush, Settings, Shield, Sun } from "lucide-react"; import { signOut, useSession } from "next-auth/react"; import { useTheme } from "next-themes"; function DarkModeToggle() { + const { t } = useTranslation(); const { theme } = useTheme(); if (theme == "dark") { return ( <> <Sun className="mr-2 size-4" /> - <span>Light Mode</span> + <span>{t("options.light_mode")}</span> </> ); } else { return ( <> <Moon className="mr-2 size-4" /> - <span>Dark Mode</span> + <span>{t("options.dark_mode")}</span> </> ); } } export default function SidebarProfileOptions() { + const { t } = useTranslation(); const toggleTheme = useToggleTheme(); const { data: session } = useSession(); if (!session) return redirect("/"); @@ -64,14 +67,14 @@ export default function SidebarProfileOptions() { <DropdownMenuItem asChild> <Link href="/settings"> <Settings className="mr-2 size-4" /> - User Settings + {t("settings.user_settings")} </Link> </DropdownMenuItem> {session.user.role == "admin" && ( <DropdownMenuItem asChild> <Link href="/dashboard/admin"> <Shield className="mr-2 size-4" /> - Admin Settings + {t("admin.admin_settings")} </Link> </DropdownMenuItem> )} @@ -94,7 +97,7 @@ export default function SidebarProfileOptions() { } > <LogOut className="mr-2 size-4" /> - <span>Sign Out</span> + <span>{t("actions.sign_out")}</span> </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> diff --git a/apps/web/components/dashboard/lists/AllListsView.tsx b/apps/web/components/dashboard/lists/AllListsView.tsx index 308af5db..ab6e31d3 100644 --- a/apps/web/components/dashboard/lists/AllListsView.tsx +++ b/apps/web/components/dashboard/lists/AllListsView.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { EditListModal } from "@/components/dashboard/lists/EditListModal"; import { Button } from "@/components/ui/button"; import { CollapsibleTriggerChevron } from "@/components/ui/collapsible"; +import { useTranslation } from "@/lib/i18n/client"; import { MoreHorizontal, Plus } from "lucide-react"; import type { ZBookmarkList } from "@hoarder/shared/types/lists"; @@ -62,23 +63,24 @@ export default function AllListsView({ }: { initialData: ZBookmarkList[]; }) { + const { t } = useTranslation(); return ( <ul> <EditListModal> <Button className="mb-2 flex h-full w-full items-center"> <Plus /> - <span>New List</span> + <span>{t("lists.new_list")}</span> </Button> </EditListModal> <ListItem collapsible={false} - name="Favourites" + name={t("lists.favourites")} icon="⭐️" path={`/dashboard/favourites`} /> <ListItem collapsible={false} - name="Archive" + name={t("common.archive")} icon="🗄️" path={`/dashboard/archive`} /> diff --git a/apps/web/components/dashboard/lists/EditListModal.tsx b/apps/web/components/dashboard/lists/EditListModal.tsx index cba1a0e6..d66d7096 100644 --- a/apps/web/components/dashboard/lists/EditListModal.tsx +++ b/apps/web/components/dashboard/lists/EditListModal.tsx @@ -26,6 +26,7 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { toast } from "@/components/ui/use-toast"; +import { useTranslation } from "@/lib/i18n/client"; import data from "@emoji-mart/data"; import Picker from "@emoji-mart/react"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -54,6 +55,7 @@ export function EditListModal({ parent?: ZBookmarkList; children?: React.ReactNode; }) { + const { t } = useTranslation(); const router = useRouter(); if ( (userOpen !== undefined && !userSetOpen) || @@ -91,7 +93,7 @@ export function EditListModal({ const { mutate: createList, isPending: isCreating } = useCreateBookmarkList({ onSuccess: (resp) => { toast({ - description: "List has been created!", + description: t("toasts.lists.created"), }); setOpen(false); router.push(`/dashboard/lists/${resp.id}`); @@ -115,7 +117,7 @@ export function EditListModal({ } else { toast({ variant: "destructive", - title: "Something went wrong", + title: t("common.something_went_wrong"), }); } }, @@ -124,7 +126,7 @@ export function EditListModal({ const { mutate: editList, isPending: isEditing } = useEditBookmarkList({ onSuccess: () => { toast({ - description: "List has been updated!", + description: t("toasts.lists.updated"), }); setOpen(false); form.reset(); @@ -147,7 +149,7 @@ export function EditListModal({ } else { toast({ variant: "destructive", - title: "Something went wrong", + title: t("common.something_went_wrong"), }); } }, @@ -259,7 +261,7 @@ export function EditListModal({ <DialogFooter className="sm:justify-end"> <DialogClose asChild> <Button type="button" variant="secondary"> - Close + {t("actions.close")} </Button> </DialogClose> <ActionButton @@ -267,7 +269,7 @@ export function EditListModal({ onClick={onSubmit} loading={isPending} > - {list ? "Save" : "Create"} + {list ? t("actions.save") : t("actions.create")} </ActionButton> </DialogFooter> </form> diff --git a/apps/web/components/dashboard/lists/ListOptions.tsx b/apps/web/components/dashboard/lists/ListOptions.tsx index b44d8a23..e663a2e0 100644 --- a/apps/web/components/dashboard/lists/ListOptions.tsx +++ b/apps/web/components/dashboard/lists/ListOptions.tsx @@ -7,6 +7,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { useTranslation } from "@/lib/i18n/client"; import { Pencil, Plus, Trash2 } from "lucide-react"; import { ZBookmarkList } from "@hoarder/shared/types/lists"; @@ -21,6 +22,8 @@ export function ListOptions({ list: ZBookmarkList; children?: React.ReactNode; }) { + const { t } = useTranslation(); + const [deleteListDialogOpen, setDeleteListDialogOpen] = useState(false); const [newNestedListModalOpen, setNewNestedListModalOpen] = useState(false); const [editModalOpen, setEditModalOpen] = useState(false); @@ -49,21 +52,21 @@ export function ListOptions({ onClick={() => setEditModalOpen(true)} > <Pencil className="size-4" /> - <span>Edit</span> + <span>{t("actions.edit")}</span> </DropdownMenuItem> <DropdownMenuItem className="flex gap-2" onClick={() => setNewNestedListModalOpen(true)} > <Plus className="size-4" /> - <span>New nested list</span> + <span>{t("lists.new_nested_list")}</span> </DropdownMenuItem> <DropdownMenuItem className="flex gap-2" onClick={() => setDeleteListDialogOpen(true)} > <Trash2 className="size-4" /> - <span>Delete</span> + <span>{t("actions.delete")}</span> </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> diff --git a/apps/web/components/dashboard/preview/ActionBar.tsx b/apps/web/components/dashboard/preview/ActionBar.tsx index 3505d0a5..38ad8fa2 100644 --- a/apps/web/components/dashboard/preview/ActionBar.tsx +++ b/apps/web/components/dashboard/preview/ActionBar.tsx @@ -6,6 +6,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { toast } from "@/components/ui/use-toast"; +import { useTranslation } from "@/lib/i18n/client"; import { Trash2 } from "lucide-react"; import type { ZBookmark } from "@hoarder/shared/types/bookmarks"; @@ -17,6 +18,7 @@ import { import { ArchivedActionIcon, FavouritedActionIcon } from "../bookmarks/icons"; export default function ActionBar({ bookmark }: { bookmark: ZBookmark }) { + const { t } = useTranslation(); const router = useRouter(); const onError = () => { toast({ @@ -72,7 +74,9 @@ export default function ActionBar({ bookmark }: { bookmark: ZBookmark }) { </ActionButton> </TooltipTrigger> <TooltipContent side="bottom"> - {bookmark.favourited ? "Un-favourite" : "Favourite"} + {bookmark.favourited + ? t("actions.unfavorite") + : t("actions.favorite")} </TooltipContent> </Tooltip> <Tooltip delayDuration={0}> @@ -92,7 +96,7 @@ export default function ActionBar({ bookmark }: { bookmark: ZBookmark }) { </ActionButton> </TooltipTrigger> <TooltipContent side="bottom"> - {bookmark.archived ? "Un-archive" : "Archive"} + {bookmark.archived ? t("actions.unarchive") : t("actions.archive")} </TooltipContent> </Tooltip> <Tooltip delayDuration={0}> @@ -108,7 +112,7 @@ export default function ActionBar({ bookmark }: { bookmark: ZBookmark }) { <Trash2 /> </ActionButton> </TooltipTrigger> - <TooltipContent side="bottom">Delete</TooltipContent> + <TooltipContent side="bottom">{t("actions.delete")}</TooltipContent> </Tooltip> </div> ); diff --git a/apps/web/components/dashboard/preview/AttachmentBox.tsx b/apps/web/components/dashboard/preview/AttachmentBox.tsx index d631f4d9..32184c30 100644 --- a/apps/web/components/dashboard/preview/AttachmentBox.tsx +++ b/apps/web/components/dashboard/preview/AttachmentBox.tsx @@ -10,6 +10,7 @@ import { import FilePickerButton from "@/components/ui/file-picker-button"; import { toast } from "@/components/ui/use-toast"; import useUpload from "@/lib/hooks/upload-file"; +import { useTranslation } from "@/lib/i18n/client"; import { Archive, Camera, @@ -41,6 +42,7 @@ import { } from "@hoarder/trpc/lib/attachments"; export default function AttachmentBox({ bookmark }: { bookmark: ZBookmark }) { + const { t } = useTranslation(); const typeToIcon: Record<ZAssetType, React.ReactNode> = { screenshot: <Camera className="size-4" />, fullPageArchive: <Archive className="size-4" />, @@ -109,7 +111,7 @@ export default function AttachmentBox({ bookmark }: { bookmark: ZBookmark }) { return ( <Collapsible> <CollapsibleTrigger className="flex w-full items-center justify-between gap-2 text-sm text-gray-400"> - Attachments + {t("common.attachments")} <ChevronsDownUp className="size-4" /> </CollapsibleTrigger> <CollapsibleContent className="flex flex-col gap-1 py-2 text-sm"> diff --git a/apps/web/components/dashboard/preview/BookmarkPreview.tsx b/apps/web/components/dashboard/preview/BookmarkPreview.tsx index e37c4b86..ff6330fa 100644 --- a/apps/web/components/dashboard/preview/BookmarkPreview.tsx +++ b/apps/web/components/dashboard/preview/BookmarkPreview.tsx @@ -12,6 +12,7 @@ import { TooltipPortal, TooltipTrigger, } from "@/components/ui/tooltip"; +import { useTranslation } from "@/lib/i18n/client"; import { api } from "@/lib/trpc"; import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; @@ -75,6 +76,7 @@ export default function BookmarkPreview({ bookmarkId: string; initialData?: ZBookmark; }) { + const { t } = useTranslation(); const { data: bookmark } = api.bookmarks.getBookmark.useQuery( { bookmarkId, @@ -130,7 +132,7 @@ export default function BookmarkPreview({ href={sourceUrl} className="flex items-center gap-2 text-gray-400" > - <span>View Original</span> + <span>{t("preview.view_original")}</span> <ExternalLink /> </Link> )} @@ -140,11 +142,11 @@ export default function BookmarkPreview({ <CreationTime createdAt={bookmark.createdAt} /> <SummarizeBookmarkArea bookmark={bookmark} /> <div className="flex items-center gap-4"> - <p className="text-sm text-gray-400">Tags</p> + <p className="text-sm text-gray-400">{t("common.tags")}</p> <BookmarkTagsEditor bookmark={bookmark} /> </div> <div className="flex gap-4"> - <p className="pt-2 text-sm text-gray-400">Note</p> + <p className="pt-2 text-sm text-gray-400">{t("common.note")}</p> <NoteEditor bookmark={bookmark} /> </div> <AttachmentBox bookmark={bookmark} /> diff --git a/apps/web/components/dashboard/preview/LinkContentSection.tsx b/apps/web/components/dashboard/preview/LinkContentSection.tsx index bf0d8f90..320fc561 100644 --- a/apps/web/components/dashboard/preview/LinkContentSection.tsx +++ b/apps/web/components/dashboard/preview/LinkContentSection.tsx @@ -8,6 +8,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { useTranslation } from "@/lib/i18n/client"; import { ScrollArea } from "@radix-ui/react-scroll-area"; import { @@ -79,6 +80,7 @@ export default function LinkContentSection({ }: { bookmark: ZBookmark; }) { + const { t } = useTranslation(); const [section, setSection] = useState<string>("cached"); if (bookmark.content.type != BookmarkTypes.LINK) { @@ -104,21 +106,23 @@ export default function LinkContentSection({ </SelectTrigger> <SelectContent> <SelectGroup> - <SelectItem value="cached">Cached Content</SelectItem> + <SelectItem value="cached"> + {t("preview.cached_content")} + </SelectItem> <SelectItem value="screenshot" disabled={!bookmark.content.screenshotAssetId} > - Screenshot + {t("common.screenshot")} </SelectItem> <SelectItem value="archive" disabled={!bookmark.content.fullPageArchiveAssetId} > - Archive + {t("common.archive")} </SelectItem> <SelectItem value="video" disabled={!bookmark.content.videoAssetId}> - Video + {t("common.video")} </SelectItem> </SelectGroup> </SelectContent> diff --git a/apps/web/components/dashboard/search/SearchInput.tsx b/apps/web/components/dashboard/search/SearchInput.tsx index a7caf44e..55f304e3 100644 --- a/apps/web/components/dashboard/search/SearchInput.tsx +++ b/apps/web/components/dashboard/search/SearchInput.tsx @@ -3,6 +3,7 @@ import React, { useEffect, useImperativeHandle, useRef } from "react"; import { Input } from "@/components/ui/input"; import { useDoBookmarkSearch } from "@/lib/hooks/bookmark-search"; +import { useTranslation } from "@/lib/i18n/client"; function useFocusSearchOnKeyPress( inputRef: React.RefObject<HTMLInputElement>, @@ -45,6 +46,7 @@ const SearchInput = React.forwardRef< HTMLInputElement, React.HTMLAttributes<HTMLInputElement> & { loading?: boolean } >(({ className, ...props }, ref) => { + const { t } = useTranslation(); const { debounceSearch, searchQuery, isInSearchPage } = useDoBookmarkSearch(); const [value, setValue] = React.useState(searchQuery); @@ -69,7 +71,7 @@ const SearchInput = React.forwardRef< ref={inputRef} value={value} onChange={onChange} - placeholder="Search" + placeholder={t("common.search")} className={className} {...props} /> diff --git a/apps/web/components/dashboard/sidebar/AllLists.tsx b/apps/web/components/dashboard/sidebar/AllLists.tsx index c48ddb0f..7341e118 100644 --- a/apps/web/components/dashboard/sidebar/AllLists.tsx +++ b/apps/web/components/dashboard/sidebar/AllLists.tsx @@ -6,6 +6,7 @@ import { usePathname } from "next/navigation"; import SidebarItem from "@/components/shared/sidebar/SidebarItem"; import { Button } from "@/components/ui/button"; import { CollapsibleTriggerTriangle } from "@/components/ui/collapsible"; +import { useTranslation } from "@/lib/i18n/client"; import { MoreHorizontal, Plus } from "lucide-react"; import type { ZBookmarkList } from "@hoarder/shared/types/lists"; @@ -20,6 +21,7 @@ export default function AllLists({ }: { initialData: { lists: ZBookmarkList[] }; }) { + const { t } = useTranslation(); const pathName = usePathname(); const isNodeOpen = useCallback( (node: ZBookmarkListTreeNode) => pathName.includes(node.item.id), @@ -37,13 +39,13 @@ export default function AllLists({ </li> <SidebarItem logo={<span className="text-lg">📋</span>} - name="All Lists" + name={t("lists.all_lists")} path={`/dashboard/lists`} linkClassName="py-0.5" /> <SidebarItem logo={<span className="text-lg">⭐️</span>} - name="Favourites" + name={t("lists.favourites")} path={`/dashboard/favourites`} linkClassName="py-0.5" /> diff --git a/apps/web/components/dashboard/sidebar/Sidebar.tsx b/apps/web/components/dashboard/sidebar/Sidebar.tsx index 8021ad36..8891d9bc 100644 --- a/apps/web/components/dashboard/sidebar/Sidebar.tsx +++ b/apps/web/components/dashboard/sidebar/Sidebar.tsx @@ -1,6 +1,7 @@ import { redirect } from "next/navigation"; import SidebarItem from "@/components/shared/sidebar/SidebarItem"; import { Separator } from "@/components/ui/separator"; +import { useTranslation } from "@/lib/i18n/server"; import { api } from "@/server/api/client"; import { getServerAuthSession } from "@/server/auth"; import { Archive, Home, Search, Tag } from "lucide-react"; @@ -10,6 +11,7 @@ import serverConfig from "@hoarder/shared/config"; import AllLists from "./AllLists"; export default async function Sidebar() { + const { t } = await useTranslation(); const session = await getServerAuthSession(); if (!session) { redirect("/"); @@ -20,7 +22,7 @@ export default async function Sidebar() { const searchItem = serverConfig.meilisearch ? [ { - name: "Search", + name: t("common.search"), icon: <Search size={18} />, path: "/dashboard/search", }, @@ -33,18 +35,18 @@ export default async function Sidebar() { path: string; }[] = [ { - name: "Home", + name: t("common.home"), icon: <Home size={18} />, path: "/dashboard/bookmarks", }, ...searchItem, { - name: "Tags", + name: t("common.tags"), icon: <Tag size={18} />, path: "/dashboard/tags", }, { - name: "Archive", + name: t("common.archive"), icon: <Archive size={18} />, path: "/dashboard/archive", }, diff --git a/apps/web/components/dashboard/tags/AllTagsView.tsx b/apps/web/components/dashboard/tags/AllTagsView.tsx index 72f4dc11..1efc6090 100644 --- a/apps/web/components/dashboard/tags/AllTagsView.tsx +++ b/apps/web/components/dashboard/tags/AllTagsView.tsx @@ -13,6 +13,7 @@ import InfoTooltip from "@/components/ui/info-tooltip"; import { Separator } from "@/components/ui/separator"; import { Toggle } from "@/components/ui/toggle"; import { toast } from "@/components/ui/use-toast"; +import { useTranslation } from "@/lib/i18n/client"; import { api } from "@/lib/trpc"; import { ArrowDownAZ, Combine } from "lucide-react"; @@ -23,6 +24,7 @@ import DeleteTagConfirmationDialog from "./DeleteTagConfirmationDialog"; import { TagPill } from "./TagPill"; function DeleteAllUnusedTags({ numUnusedTags }: { numUnusedTags: number }) { + const { t } = useTranslation(); const { mutate, isPending } = useDeleteUnusedTags({ onSuccess: () => { toast({ @@ -38,7 +40,7 @@ function DeleteAllUnusedTags({ numUnusedTags }: { numUnusedTags: number }) { }); return ( <ActionConfirmingDialog - title="Delete all unused tags?" + title={t("tags.delete_all_unused_tags")} description={`Are you sure you want to delete the ${numUnusedTags} unused tags?`} actionButton={() => ( <ActionButton @@ -51,7 +53,7 @@ function DeleteAllUnusedTags({ numUnusedTags }: { numUnusedTags: number }) { )} > <Button variant="destructive" disabled={numUnusedTags == 0}> - Delete All Unused Tags + {t("tags.delete_all_unused_tags")} </Button> </ActionConfirmingDialog> ); @@ -72,6 +74,7 @@ export default function AllTagsView({ }: { initialData: ZGetTagResponse[]; }) { + const { t } = useTranslation(); interface Tag { id: string; name: string; @@ -154,9 +157,9 @@ export default function AllTagsView({ onPressedChange={toggleDraggingEnabled} > <Combine className="mr-2 size-4" /> - Drag & Drop Merging + {t("tags.drag_and_drop_merging")} <InfoTooltip size={15} className="my-auto ml-2" variant="explain"> - <p>Drag and drop tags on each other to merge them</p> + <p>{t("tags.drag_and_drop_merging_info")}</p> </InfoTooltip> </Toggle> <Toggle @@ -165,29 +168,29 @@ export default function AllTagsView({ pressed={sortByName} onPressedChange={toggleSortByName} > - <ArrowDownAZ className="mr-2 size-4" /> Sort by Name + <ArrowDownAZ className="mr-2 size-4" /> {t("tags.sort_by_name")} </Toggle> </div> <span className="flex items-center gap-2"> - <p className="text-lg">Your Tags</p> + <p className="text-lg">{t("tags.your_tags")}</p> <InfoTooltip size={15} className="my-auto" variant="explain"> - <p>Tags that were attached at least once by you</p> + <p>{t("tags.your_tags_info")}</p> </InfoTooltip> </span> {tagsToPill(humanTags)} <Separator /> <span className="flex items-center gap-2"> - <p className="text-lg">AI Tags</p> + <p className="text-lg">{t("tags.ai_tags")}</p> <InfoTooltip size={15} className="my-auto" variant="explain"> - <p>Tags that were only attached automatically (by AI)</p> + <p>{t("tags.ai_tags_info")}</p> </InfoTooltip> </span> {tagsToPill(aiTags)} <Separator /> <span className="flex items-center gap-2"> - <p className="text-lg">Unused Tags</p> + <p className="text-lg">{t("tags.unused_tags")}</p> <InfoTooltip size={15} className="my-auto" variant="explain"> - <p>Tags that are not attached to any bookmarks</p> + <p>{t("tags.unused_tags_info")}</p> </InfoTooltip> </span> <Collapsible> diff --git a/apps/web/components/dashboard/tags/TagOptions.tsx b/apps/web/components/dashboard/tags/TagOptions.tsx index 1bd17902..8d8cc9db 100644 --- a/apps/web/components/dashboard/tags/TagOptions.tsx +++ b/apps/web/components/dashboard/tags/TagOptions.tsx @@ -7,6 +7,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { useTranslation } from "@/lib/i18n/client"; import { Combine, Trash2 } from "lucide-react"; import DeleteTagConfirmationDialog from "./DeleteTagConfirmationDialog"; @@ -19,6 +20,7 @@ export function TagOptions({ tag: { id: string; name: string }; children?: React.ReactNode; }) { + const { t } = useTranslation(); const [deleteTagDialogOpen, setDeleteTagDialogOpen] = useState(false); const [mergeTagDialogOpen, setMergeTagDialogOpen] = useState(false); @@ -41,7 +43,7 @@ export function TagOptions({ onClick={() => setMergeTagDialogOpen(true)} > <Combine className="size-4" /> - <span>Merge</span> + <span>{t("actions.merge")}</span> </DropdownMenuItem> <DropdownMenuItem @@ -49,7 +51,7 @@ export function TagOptions({ onClick={() => setDeleteTagDialogOpen(true)} > <Trash2 className="size-4" /> - <span>Delete</span> + <span>{t("actions.delete")}</span> </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> diff --git a/apps/web/components/settings/AISettings.tsx b/apps/web/components/settings/AISettings.tsx index 0a8db147..79a9e558 100644 --- a/apps/web/components/settings/AISettings.tsx +++ b/apps/web/components/settings/AISettings.tsx @@ -20,6 +20,7 @@ import { } from "@/components/ui/select"; import { toast } from "@/components/ui/use-toast"; import { useClientConfig } from "@/lib/clientConfig"; +import { useTranslation } from "@/lib/i18n/client"; import { api } from "@/lib/trpc"; import { zodResolver } from "@hookform/resolvers/zod"; import { Plus, Save, Trash2 } from "lucide-react"; @@ -34,6 +35,7 @@ import { } from "@hoarder/shared/types/prompts"; export function PromptEditor() { + const { t } = useTranslation(); const apiUtils = api.useUtils(); const form = useForm<z.infer<typeof zNewPromptSchema>>({ @@ -117,7 +119,7 @@ export function PromptEditor() { className="items-center" > <Plus className="mr-2 size-4" /> - Add + {t("actions.add")} </ActionButton> </form> </Form> @@ -125,6 +127,7 @@ export function PromptEditor() { } export function PromptRow({ prompt }: { prompt: ZPrompt }) { + const { t } = useTranslation(); const apiUtils = api.useUtils(); const { mutateAsync: updatePrompt, isPending: isUpdating } = api.prompts.update.useMutation({ @@ -169,11 +172,7 @@ export function PromptRow({ prompt }: { prompt: ZPrompt }) { return ( <FormItem className="hidden"> <FormControl> - <Input - placeholder="Add a custom prompt" - type="hidden" - {...field} - /> + <Input type="hidden" {...field} /> </FormControl> <FormMessage /> </FormItem> @@ -234,7 +233,7 @@ export function PromptRow({ prompt }: { prompt: ZPrompt }) { className="items-center" > <Save className="mr-2 size-4" /> - Save + {t("actions.save")} </ActionButton> <ActionButton loading={isDeleting} @@ -244,7 +243,7 @@ export function PromptRow({ prompt }: { prompt: ZPrompt }) { type="button" > <Trash2 className="mr-2 size-4" /> - Delete + {t("actions.delete")} </ActionButton> </form> </Form> @@ -252,15 +251,16 @@ export function PromptRow({ prompt }: { prompt: ZPrompt }) { } export function TaggingRules() { + const { t } = useTranslation(); const { data: prompts, isLoading } = api.prompts.list.useQuery(); return ( <div className="mt-2 flex flex-col gap-2"> - <div className="w-full text-xl font-medium sm:w-1/3">Tagging Rules</div> + <div className="w-full text-xl font-medium sm:w-1/3"> + {t("settings.ai.tagging_rules")} + </div> <p className="mb-1 text-xs italic text-muted-foreground"> - Prompts that you add here will be included as rules to the model during - tag generation. You can view the final prompts in the prompt preview - section. + {t("settings.ai.tagging_rule_description")} </p> {isLoading && <FullPageSpinner />} {prompts && prompts.length == 0 && ( @@ -276,14 +276,15 @@ export function TaggingRules() { } export function PromptDemo() { + const { t } = useTranslation(); const { data: prompts } = api.prompts.list.useQuery(); const clientConfig = useClientConfig(); return ( <div className="flex flex-col gap-2"> <div className="mb-4 w-full text-xl font-medium sm:w-1/3"> - Prompt Preview + {t("settings.ai.prompt_preview")} </div> - <p>Text Prompt</p> + <p>{t("settings.ai.text_prompt")}</p> <code className="whitespace-pre-wrap rounded-md bg-muted p-3 text-sm text-muted-foreground"> {buildTextPrompt( clientConfig.inference.inferredTagLang, @@ -294,7 +295,7 @@ export function PromptDemo() { /* context length */ 1024 /* The value here doesn't matter */, ).trim()} </code> - <p>Image Prompt</p> + <p>{t("settings.ai.images_prompt")}</p> <code className="whitespace-pre-wrap rounded-md bg-muted p-3 text-sm text-muted-foreground"> {buildImagePrompt( clientConfig.inference.inferredTagLang, @@ -308,12 +309,13 @@ export function PromptDemo() { } export default function AISettings() { + const { t } = useTranslation(); return ( <> <div className="rounded-md border bg-background p-4"> <div className="mb-2 flex flex-col gap-3"> <div className="w-full text-2xl font-medium sm:w-1/3"> - AI Settings + {t("settings.ai.ai_settings")} </div> <TaggingRules /> </div> diff --git a/apps/web/components/settings/AddApiKey.tsx b/apps/web/components/settings/AddApiKey.tsx index 34fd2df7..00e70d3f 100644 --- a/apps/web/components/settings/AddApiKey.tsx +++ b/apps/web/components/settings/AddApiKey.tsx @@ -27,17 +27,18 @@ import { } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { toast } from "@/components/ui/use-toast"; +import { useTranslation } from "@/lib/i18n/client"; import { api } from "@/lib/trpc"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { z } from "zod"; function ApiKeySuccess({ apiKey }: { apiKey: string }) { + const { t } = useTranslation(); return ( <div> <div className="py-4"> - Note: please copy the key and store it somewhere safe. Once you close - the dialog, you won't be able to access it again. + {t("settings.api_keys.key_success_please_copy")} </div> <div className="flex space-x-2 pt-2"> <Input value={apiKey} readOnly /> @@ -52,6 +53,7 @@ function ApiKeySuccess({ apiKey }: { apiKey: string }) { } function AddApiKeyForm({ onSuccess }: { onSuccess: (key: string) => void }) { + const { t } = useTranslation(); const formSchema = z.object({ name: z.string(), }); @@ -62,7 +64,10 @@ function AddApiKeyForm({ onSuccess }: { onSuccess: (key: string) => void }) { router.refresh(); }, onError: () => { - toast({ description: "Something went wrong", variant: "destructive" }); + toast({ + description: t("common.something_went_wrong"), + variant: "destructive", + }); }, }); @@ -95,12 +100,16 @@ function AddApiKeyForm({ onSuccess }: { onSuccess: (key: string) => void }) { render={({ field }) => { return ( <FormItem className="flex-1"> - <FormLabel>Name</FormLabel> + <FormLabel>{t("common.name")}</FormLabel> <FormControl> - <Input type="text" placeholder="Name" {...field} /> + <Input + type="text" + placeholder={t("common.name")} + {...field} + /> </FormControl> <FormDescription> - Give your API key a unique name + {t("settings.api_keys.new_api_key_desc")} </FormDescription> <FormMessage /> </FormItem> @@ -112,7 +121,7 @@ function AddApiKeyForm({ onSuccess }: { onSuccess: (key: string) => void }) { type="submit" loading={mutator.isPending} > - Create + {t("actions.create")} </ActionButton> </form> </Form> @@ -120,17 +129,20 @@ function AddApiKeyForm({ onSuccess }: { onSuccess: (key: string) => void }) { } export default function AddApiKey() { + const { t } = useTranslation(); const [key, setKey] = useState<string | undefined>(undefined); const [dialogOpen, setDialogOpen] = useState<boolean>(false); return ( <Dialog open={dialogOpen} onOpenChange={setDialogOpen}> <DialogTrigger asChild> - <Button>New API Key</Button> + <Button>{t("settings.api_keys.new_api_key")}</Button> </DialogTrigger> <DialogContent> <DialogHeader> <DialogTitle> - {key ? "Key was successfully created" : "Create API key"} + {key + ? t("settings.api_keys.key_success") + : t("settings.api_keys.new_api_key")} </DialogTitle> <DialogDescription> {key ? ( @@ -147,7 +159,7 @@ export default function AddApiKey() { variant="outline" onClick={() => setKey(undefined)} > - Close + {t("actions.close")} </Button> </DialogClose> </DialogFooter> diff --git a/apps/web/components/settings/ApiKeySettings.tsx b/apps/web/components/settings/ApiKeySettings.tsx index 4d43be7a..8f07e5a4 100644 --- a/apps/web/components/settings/ApiKeySettings.tsx +++ b/apps/web/components/settings/ApiKeySettings.tsx @@ -6,27 +6,31 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { useTranslation } from "@/lib/i18n/server"; import { api } from "@/server/api/client"; import AddApiKey from "./AddApiKey"; import DeleteApiKey from "./DeleteApiKey"; export default async function ApiKeys() { + const { t } = await useTranslation(); const keys = await api.apiKeys.list(); return ( <div> <div className="flex items-center justify-between"> - <div className="mb-2 text-lg font-medium">API Keys</div> + <div className="mb-2 text-lg font-medium"> + {t("settings.api_keys.api_keys")} + </div> <AddApiKey /> </div> <div className="mt-2"> <Table> <TableHeader> <TableRow> - <TableHead>Name</TableHead> - <TableHead>Key</TableHead> - <TableHead>Created At</TableHead> - <TableHead>Action</TableHead> + <TableHead>{t("common.name")}</TableHead> + <TableHead>{t("common.key")}</TableHead> + <TableHead>{t("common.created_at")}</TableHead> + <TableHead>{t("common.action")}</TableHead> </TableRow> </TableHeader> <TableBody> diff --git a/apps/web/components/settings/ChangePassword.tsx b/apps/web/components/settings/ChangePassword.tsx index aa27f223..e9f426a6 100644 --- a/apps/web/components/settings/ChangePassword.tsx +++ b/apps/web/components/settings/ChangePassword.tsx @@ -12,6 +12,7 @@ import { } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { toast } from "@/components/ui/use-toast"; +import { useTranslation } from "@/lib/i18n/client"; import { api } from "@/lib/trpc"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; @@ -19,6 +20,7 @@ import { useForm } from "react-hook-form"; import { zChangePasswordSchema } from "@hoarder/shared/types/users"; export function ChangePassword() { + const { t } = useTranslation(); const form = useForm<z.infer<typeof zChangePasswordSchema>>({ resolver: zodResolver(zChangePasswordSchema), defaultValues: { @@ -55,7 +57,7 @@ export function ChangePassword() { return ( <div className="flex flex-col sm:flex-row"> <div className="mb-4 w-full text-lg font-medium sm:w-1/3"> - Change Password + {t("settings.info.change_password")} </div> <Form {...form}> <form @@ -68,11 +70,11 @@ export function ChangePassword() { render={({ field }) => { return ( <FormItem className="flex-1"> - <FormLabel>Current Password</FormLabel> + <FormLabel>{t("settings.info.current_password")}</FormLabel> <FormControl> <Input type="password" - placeholder="Current Password" + placeholder={t("settings.info.current_password")} {...field} /> </FormControl> @@ -87,11 +89,11 @@ export function ChangePassword() { render={({ field }) => { return ( <FormItem className="flex-1"> - <FormLabel>New Password</FormLabel> + <FormLabel>{t("settings.info.new_password")}</FormLabel> <FormControl> <Input type="password" - placeholder="New Password" + placeholder={t("settings.info.new_password")} {...field} /> </FormControl> @@ -106,11 +108,13 @@ export function ChangePassword() { render={({ field }) => { return ( <FormItem className="flex-1"> - <FormLabel>Confirm New Password</FormLabel> + <FormLabel> + {t("settings.info.confirm_new_password")} + </FormLabel> <FormControl> <Input type="Password" - placeholder="Confirm New Password" + placeholder={t("settings.info.confirm_new_password")} {...field} /> </FormControl> @@ -124,7 +128,7 @@ export function ChangePassword() { type="submit" loading={mutator.isPending} > - Save + {t("actions.save")} </ActionButton> </form> </Form> diff --git a/apps/web/components/settings/DeleteApiKey.tsx b/apps/web/components/settings/DeleteApiKey.tsx index e2334c44..4efb7ea8 100644 --- a/apps/web/components/settings/DeleteApiKey.tsx +++ b/apps/web/components/settings/DeleteApiKey.tsx @@ -5,6 +5,7 @@ import { ActionButton } from "@/components/ui/action-button"; import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; import { Button } from "@/components/ui/button"; import { toast } from "@/components/ui/use-toast"; +import { useTranslation } from "@/lib/i18n/client"; import { api } from "@/lib/trpc"; import { Trash } from "lucide-react"; @@ -15,6 +16,7 @@ export default function DeleteApiKey({ name: string; id: string; }) { + const { t } = useTranslation(); const router = useRouter(); const mutator = api.apiKeys.revoke.useMutation({ onSuccess: () => { @@ -43,7 +45,7 @@ export default function DeleteApiKey({ mutator.mutate({ id }, { onSuccess: () => setDialogOpen(false) }) } > - Delete + {t("actions.delete")} </ActionButton> )} > diff --git a/apps/web/components/settings/FeedSettings.tsx b/apps/web/components/settings/FeedSettings.tsx index 4880132c..e3999cb5 100644 --- a/apps/web/components/settings/FeedSettings.tsx +++ b/apps/web/components/settings/FeedSettings.tsx @@ -14,6 +14,7 @@ import { import { FullPageSpinner } from "@/components/ui/full-page-spinner"; import { Input } from "@/components/ui/input"; import { toast } from "@/components/ui/use-toast"; +import { useTranslation } from "@/lib/i18n/client"; import { api } from "@/lib/trpc"; import { cn } from "@/lib/utils"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -59,6 +60,7 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; export function FeedsEditorDialog() { + const { t } = useTranslation(); const [open, setOpen] = React.useState(false); const apiUtils = api.useUtils(); @@ -92,7 +94,7 @@ export function FeedsEditorDialog() { <DialogTrigger asChild> <Button> <Plus className="mr-2 size-4" /> - Add a Subscription + {t("settings.feeds.add_a_subscription")} </Button> </DialogTrigger> <DialogContent> @@ -164,6 +166,7 @@ export function FeedsEditorDialog() { } export function EditFeedDialog({ feed }: { feed: ZFeed }) { + const { t } = useTranslation(); const apiUtils = api.useUtils(); const [open, setOpen] = React.useState(false); React.useEffect(() => { @@ -198,7 +201,7 @@ export function EditFeedDialog({ feed }: { feed: ZFeed }) { <DialogTrigger asChild> <Button variant="secondary"> <Edit className="mr-2 size-4" /> - Edit + {t("actions.edit")} </Button> </DialogTrigger> <DialogContent> @@ -233,7 +236,7 @@ export function EditFeedDialog({ feed }: { feed: ZFeed }) { render={({ field }) => { return ( <FormItem className="flex-1"> - <FormLabel>Name</FormLabel> + <FormLabel>{t("common.name")}</FormLabel> <FormControl> <Input placeholder="Feed name" type="text" {...field} /> </FormControl> @@ -248,7 +251,7 @@ export function EditFeedDialog({ feed }: { feed: ZFeed }) { render={({ field }) => { return ( <FormItem className="flex-1"> - <FormLabel>URL</FormLabel> + <FormLabel>{t("common.url")}</FormLabel> <FormControl> <Input placeholder="Feed url" type="text" {...field} /> </FormControl> @@ -262,7 +265,7 @@ export function EditFeedDialog({ feed }: { feed: ZFeed }) { <DialogFooter> <DialogClose asChild> <Button type="button" variant="secondary"> - Close + {t("actions.close")} </Button> </DialogClose> <ActionButton @@ -274,7 +277,7 @@ export function EditFeedDialog({ feed }: { feed: ZFeed }) { className="items-center" > <Save className="mr-2 size-4" /> - Save + {t("actions.save")} </ActionButton> </DialogFooter> </DialogContent> @@ -283,6 +286,7 @@ export function EditFeedDialog({ feed }: { feed: ZFeed }) { } export function FeedRow({ feed }: { feed: ZFeed }) { + const { t } = useTranslation(); const apiUtils = api.useUtils(); const { mutate: deleteFeed, isPending: isDeleting } = api.feeds.delete.useMutation({ @@ -340,7 +344,7 @@ export function FeedRow({ feed }: { feed: ZFeed }) { onClick={() => fetchNow({ feedId: feed.id })} > <ArrowDownToLine className="mr-2 size-4" /> - Fetch Now + {t("actions.fetch_now")} </ActionButton> <ActionConfirmingDialog title={`Delete Feed "${feed.name}"?`} @@ -354,13 +358,13 @@ export function FeedRow({ feed }: { feed: ZFeed }) { type="button" > <Trash2 className="mr-2 size-4" /> - Delete + {t("actions.delete")} </ActionButton> )} > <Button variant="destructive" disabled={isDeleting}> <Trash2 className="mr-2 size-4" /> - Delete + {t("actions.delete")} </Button> </ActionConfirmingDialog> </TableCell> @@ -369,6 +373,7 @@ export function FeedRow({ feed }: { feed: ZFeed }) { } export default function FeedSettings() { + const { t } = useTranslation(); const { data: feeds, isLoading } = api.feeds.list.useQuery(); return ( <> @@ -376,12 +381,14 @@ export default function FeedSettings() { <div className="flex flex-col gap-2"> <div className="flex items-center justify-between"> <span className="flex items-center gap-2 text-lg font-medium"> - RSS Subscriptions + {t("settings.feeds.rss_subscriptions")} <Tooltip> <TooltipTrigger className="text-muted-foreground"> <FlaskConical size={15} /> </TooltipTrigger> - <TooltipContent side="bottom">Experimental</TooltipContent> + <TooltipContent side="bottom"> + {t("common.experimental")} + </TooltipContent> </Tooltip> </span> <FeedsEditorDialog /> @@ -396,11 +403,11 @@ export default function FeedSettings() { <Table> <TableHeader> <TableRow> - <TableHead>Name</TableHead> - <TableHead>URL</TableHead> + <TableHead>{t("common.name")}</TableHead> + <TableHead>{t("common.url")}</TableHead> <TableHead>Last Fetch</TableHead> <TableHead>Last Status</TableHead> - <TableHead>Actions</TableHead> + <TableHead>{t("common.actions")}</TableHead> </TableRow> </TableHeader> <TableBody> diff --git a/apps/web/components/settings/ImportExport.tsx b/apps/web/components/settings/ImportExport.tsx index 7889b4d8..5cb35def 100644 --- a/apps/web/components/settings/ImportExport.tsx +++ b/apps/web/components/settings/ImportExport.tsx @@ -7,6 +7,7 @@ import { buttonVariants } from "@/components/ui/button"; import FilePickerButton from "@/components/ui/file-picker-button"; import { Progress } from "@/components/ui/progress"; import { toast } from "@/components/ui/use-toast"; +import { useTranslation } from "@/lib/i18n/client"; import { ParsedBookmark, parseHoarderBookmarkFile, @@ -31,6 +32,7 @@ import { import { BookmarkTypes } from "@hoarder/shared/types/bookmarks"; export function ExportButton() { + const { t } = useTranslation(); return ( <Link href="/api/bookmarks/export" @@ -40,12 +42,13 @@ export function ExportButton() { )} > <Download /> - <p>Export Links and Notes</p> + <p>{t("settings.import.export_links_and_notes")}</p> </Link> ); } export function ImportExportRow() { + const { t } = useTranslation(); const router = useRouter(); const [importProgress, setImportProgress] = useState<{ @@ -145,7 +148,7 @@ export function ImportExportRow() { }, onSuccess: async (resp) => { const importList = await createList({ - name: `Imported Bookmarks`, + name: t("settings.import.imported_bookmarks"), icon: "⬆️", }); setImportProgress({ done: 0, total: resp.length }); @@ -211,7 +214,7 @@ export function ImportExportRow() { } > <Upload /> - <p>Import Bookmarks from HTML file</p> + <p>{t("settings.import.import_bookmarks_from_html_file")}</p> </FilePickerButton> <FilePickerButton @@ -224,7 +227,7 @@ export function ImportExportRow() { } > <Upload /> - <p>Import Bookmarks from Pocket export</p> + <p>{t("settings.import.import_bookmarks_from_pocket_export")}</p> </FilePickerButton> <FilePickerButton loading={false} @@ -236,7 +239,7 @@ export function ImportExportRow() { } > <Upload /> - <p>Import Bookmarks from Omnivore export</p> + <p>{t("settings.import.import_bookmarks_from_omnivore_export")}</p> </FilePickerButton> <FilePickerButton loading={false} @@ -248,7 +251,7 @@ export function ImportExportRow() { } > <Upload /> - <p>Import Bookmarks from Hoarder export</p> + <p>{t("settings.import.import_bookmarks_from_hoarder_export")}</p> </FilePickerButton> <ExportButton /> </div> @@ -269,9 +272,12 @@ export function ImportExportRow() { } export default function ImportExport() { + const { t } = useTranslation(); return ( <div className="flex w-full flex-col gap-2"> - <p className="mb-4 text-lg font-medium">Import / Export Bookmarks</p> + <p className="mb-4 text-lg font-medium"> + {t("settings.import.import_export_bookmarks")} + </p> <ImportExportRow /> </div> ); diff --git a/apps/web/components/settings/UserDetails.tsx b/apps/web/components/settings/UserDetails.tsx index 471a6e09..af6698ad 100644 --- a/apps/web/components/settings/UserDetails.tsx +++ b/apps/web/components/settings/UserDetails.tsx @@ -1,24 +1,26 @@ import { Input } from "@/components/ui/input"; +import { useTranslation } from "@/lib/i18n/server"; import { api } from "@/server/api/client"; export default async function UserDetails() { + const { t } = await useTranslation(); const whoami = await api.users.whoami(); const details = [ { - label: "Name", + label: t("common.name"), value: whoami.name ?? undefined, }, { - label: "Email", + label: t("common.email"), value: whoami.email ?? undefined, }, ]; return ( - <div className="mb-8 flex w-full flex-col sm:flex-row"> + <div className="flex w-full flex-col sm:flex-row"> <div className="mb-4 w-full text-lg font-medium sm:w-1/3"> - Basic Details + {t("settings.info.basic_details")} </div> <div className="w-full"> {details.map(({ label, value }) => ( diff --git a/apps/web/components/settings/UserOptions.tsx b/apps/web/components/settings/UserOptions.tsx new file mode 100644 index 00000000..38dc1520 --- /dev/null +++ b/apps/web/components/settings/UserOptions.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useTranslation } from "@/lib/i18n/client"; +import { useInterfaceLang } from "@/lib/userLocalSettings/bookmarksLayout"; +import { updateInterfaceLang } from "@/lib/userLocalSettings/userLocalSettings"; + +import { langNameMappings } from "@hoarder/shared/langs"; + +import { Label } from "../ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../ui/select"; + +const LanguageSelect = () => { + const lang = useInterfaceLang(); + return ( + <Select + value={lang} + onValueChange={async (val) => { + await updateInterfaceLang(val); + }} + > + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {Object.entries(langNameMappings).map(([lang, name]) => ( + <SelectItem key={lang} value={lang}> + {name} + </SelectItem> + ))} + </SelectContent> + </Select> + ); +}; + +export function UserOptions() { + const { t } = useTranslation(); + + return ( + <div className="flex flex-col sm:flex-row"> + <div className="mb-4 w-full text-lg font-medium sm:w-1/3"> + {t("settings.info.options")} + </div> + <div className="flex w-full flex-col gap-2"> + <Label>{t("settings.info.interface_lang")}</Label> + <LanguageSelect /> + </div> + </div> + ); +} diff --git a/apps/web/components/settings/sidebar/ModileSidebar.tsx b/apps/web/components/settings/sidebar/ModileSidebar.tsx index 2016c931..cbed9ef9 100644 --- a/apps/web/components/settings/sidebar/ModileSidebar.tsx +++ b/apps/web/components/settings/sidebar/ModileSidebar.tsx @@ -1,12 +1,14 @@ import MobileSidebarItem from "@/components/shared/sidebar/ModileSidebarItem"; +import { useTranslation } from "@/lib/i18n/server"; import { settingsSidebarItems } from "./items"; export default async function MobileSidebar() { + const { t } = await useTranslation(); return ( <aside className="w-full"> <ul className="flex justify-between space-x-2 border-b-black px-5 py-2 pt-5"> - {settingsSidebarItems.map((item) => ( + {settingsSidebarItems(t).map((item) => ( <MobileSidebarItem key={item.name} logo={item.icon} diff --git a/apps/web/components/settings/sidebar/Sidebar.tsx b/apps/web/components/settings/sidebar/Sidebar.tsx index 247e0916..a1b61e98 100644 --- a/apps/web/components/settings/sidebar/Sidebar.tsx +++ b/apps/web/components/settings/sidebar/Sidebar.tsx @@ -1,5 +1,6 @@ import { redirect } from "next/navigation"; import SidebarItem from "@/components/shared/sidebar/SidebarItem"; +import { useTranslation } from "@/lib/i18n/server"; import { getServerAuthSession } from "@/server/auth"; import serverConfig from "@hoarder/shared/config"; @@ -7,6 +8,7 @@ import serverConfig from "@hoarder/shared/config"; import { settingsSidebarItems } from "./items"; export default async function Sidebar() { + const { t } = await useTranslation(); const session = await getServerAuthSession(); if (!session) { redirect("/"); @@ -16,7 +18,7 @@ export default async function Sidebar() { <aside className="flex h-[calc(100vh-64px)] w-60 flex-col gap-5 border-r p-4 "> <div> <ul className="space-y-2 text-sm font-medium"> - {settingsSidebarItems.map((item) => ( + {settingsSidebarItems(t).map((item) => ( <SidebarItem key={item.name} logo={item.icon} diff --git a/apps/web/components/settings/sidebar/items.tsx b/apps/web/components/settings/sidebar/items.tsx index 047ee233..43dfabdd 100644 --- a/apps/web/components/settings/sidebar/items.tsx +++ b/apps/web/components/settings/sidebar/items.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { TFunction } from "i18next"; import { ArrowLeft, Download, @@ -8,38 +9,40 @@ import { User, } from "lucide-react"; -export const settingsSidebarItems: { +export const settingsSidebarItems = ( + t: TFunction, +): { name: string; icon: JSX.Element; path: string; -}[] = [ +}[] => [ { - name: "Back To App", + name: t("settings.back_to_app"), icon: <ArrowLeft size={18} />, path: "/dashboard/bookmarks", }, { - name: "User Info", + name: t("settings.info.user_info"), icon: <User size={18} />, path: "/settings/info", }, { - name: "AI Settings", + name: t("settings.ai.ai_settings"), icon: <Sparkles size={18} />, path: "/settings/ai", }, { - name: "RSS Subscriptions", + name: t("settings.feeds.rss_subscriptions"), icon: <Rss size={18} />, path: "/settings/feeds", }, { - name: "Import / Export", + name: t("settings.import.import_export"), icon: <Download size={18} />, path: "/settings/import", }, { - name: "API Keys", + name: t("settings.api_keys.api_keys"), icon: <KeyRound size={18} />, path: "/settings/api-keys", }, diff --git a/apps/web/components/ui/action-confirming-dialog.tsx b/apps/web/components/ui/action-confirming-dialog.tsx index cfd38fc3..e624d287 100644 --- a/apps/web/components/ui/action-confirming-dialog.tsx +++ b/apps/web/components/ui/action-confirming-dialog.tsx @@ -8,6 +8,7 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; +import { useTranslation } from "@/lib/i18n/client"; import { Button } from "./button"; @@ -26,6 +27,7 @@ export default function ActionConfirmingDialog({ actionButton: (setDialogOpen: (open: boolean) => void) => React.ReactNode; children?: React.ReactNode; }) { + const { t } = useTranslation(); const [customIsOpen, setCustomIsOpen] = useState(false); const [isDialogOpen, setDialogOpen] = [ userIsOpen ?? customIsOpen, @@ -42,7 +44,7 @@ export default function ActionConfirmingDialog({ <DialogFooter className="sm:justify-end"> <DialogClose asChild> <Button type="button" variant="secondary"> - Close + {t("actions.close")} </Button> </DialogClose> {actionButton(setDialogOpen)} diff --git a/apps/web/lib/i18n/client.ts b/apps/web/lib/i18n/client.ts new file mode 100644 index 00000000..1c56a88a --- /dev/null +++ b/apps/web/lib/i18n/client.ts @@ -0,0 +1,33 @@ +"use client"; + +import i18next from "i18next"; +import resourcesToBackend from "i18next-resources-to-backend"; +import { + initReactI18next, + useTranslation as useTranslationOrg, +} from "react-i18next"; + +import { getOptions, languages } from "./settings"; + +const runsOnServerSide = typeof window === "undefined"; + +i18next + .use(initReactI18next) + .use( + resourcesToBackend( + (language: string, namespace: string) => + import(`./locales/${language}/${namespace}.json`), + ), + ) + .init({ + ...getOptions(), + lng: undefined, // let detect the language on client side + debug: false, + interpolation: { + escapeValue: false, // not needed for react as it escapes by default + }, + preload: runsOnServerSide ? languages : [], + }); + +export const useTranslation = useTranslationOrg; +export const i18n = i18next; diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json new file mode 100644 index 00000000..530d489a --- /dev/null +++ b/apps/web/lib/i18n/locales/en/translation.json @@ -0,0 +1,206 @@ +{ + "common": { + "url": "URL", + "name": "Name", + "email": "Email", + "password": "Password", + "action": "Action", + "actions": "Actions", + "created_at": "Created At", + "key": "Key", + "role": "Role", + "roles": { + "user": "User", + "admin": "Admin" + }, + "something_went_wrong": "Something went wrong", + "experimental": "Experimental", + "search": "Search", + "tags": "Tags", + "note": "Note", + "attachments": "Attachments", + "screenshot": "Screenshot", + "video": "Video", + "archive": "Archive", + "home": "Home" + }, + "layouts": { + "masonry": "Masonry", + "grid": "Grid", + "list": "List", + "compact": "Compact" + }, + "actions": { + "change_layout": "Change Layout", + "archive": "Archive", + "unarchive": "Un-archive", + "favorite": "Favorite", + "unfavorite": "Unfavorite", + "delete": "Delete", + "refresh": "Refresh", + "download_full_page_archive": "Download Full Page Archive", + "edit_tags": "Edit Tags", + "add_to_list": "Add to List", + "select_all": "Select All", + "unselect_all": "Unselect All", + "copy_link": "Copy Link", + "close_bulk_edit": "Close Bulk Edit", + "bulk_edit": "Bulk Edit", + "manage_lists": "Manage Lists", + "remove_from_list": "Remove from List", + "save": "Save", + "add": "Add", + "edit": "Edit", + "create": "Create", + "fetch_now": "Fetch Now", + "summarize_with_ai": "Summarize with AI", + "edit_title": "Edit Title", + "sign_out": "Sign Out", + "close": "Close", + "merge": "Merge", + "cancel": "Cancel", + "apply_all": "Apply All", + "ignore": "Ignore" + }, + "settings": { + "back_to_app": "Back To App", + "user_settings": "User Settings", + "info": { + "user_info": "User Info", + "basic_details": "Basic Details", + "change_password": "Change Password", + "current_password": "Current Password", + "new_password": "New Password", + "confirm_new_password": "Confirm New Password", + "options": "Options", + "interface_lang": "Interface Language" + }, + "ai": { + "ai_settings": "AI Settings", + "tagging_rules": "Tagging Rules", + "tagging_rule_description": "Prompts that you add here will be included as rules to the model during tag generation. You can view the final prompts in the prompt preview section.", + "prompt_preview": "Prompt Preview", + "text_prompt": "Text Prompt", + "images_prompt": "Image Prompt" + }, + "feeds": { + "rss_subscriptions": "RSS Subscriptions", + "add_a_subscription": "Add a Subscription" + }, + "import": { + "import_export": "Import / Export", + "import_export_bookmarks": "Import / Export Bookmarks", + "import_bookmarks_from_html_file": "Import Bookmarks from HTML file", + "import_bookmarks_from_pocket_export": "Import Bookmarks from Pocket export", + "import_bookmarks_from_omnivore_export": "Import Bookmarks from Omnivore export", + "import_bookmarks_from_hoarder_export": "Import Bookmarks from Hoarder export", + "export_links_and_notes": "Export Links and Notes", + "imported_bookmarks": "Imported Bookmarks" + }, + "api_keys": { + "api_keys": "API Keys", + "new_api_key": "New API Key", + "new_api_key_desc": "Give your API key a unique name", + "key_success": "Key was successfully created", + "key_success_please_copy": "Please copy the key and store it somewhere safe. Once you close the dialog, you won't be able to access it again." + } + }, + "admin": { + "admin_settings": "Admin Settings", + "server_stats": { + "server_stats": "Server Stats", + "total_users": "Total Users", + "total_bookmarks": "Total Bookmarks", + "server_version": "Server Version" + }, + "background_jobs": { + "background_jobs": "Background Jobs", + "crawler_jobs": "Crawler Jobs", + "indexing_jobs": "Indexing Jobs", + "inference_jobs": "Inference Jobs", + "tidy_assets_jobs": "Tidy Assets Jobs", + "job": "Job", + "queued": "Queued", + "pending": "Pending", + "failed": "Failed" + }, + "actions": { + "recrawl_failed_links_only": "Recrawl Failed Links Only", + "recrawl_all_links": "Recrawl All Links", + "without_inference": "Without Inference", + "regenerate_ai_tags_for_failed_bookmarks_only": "Regenerate AI Tags for Failed Bookmarks Only", + "regenerate_ai_tags_for_all_bookmarks": "Regenerate AI Tags for All Bookmarks", + "reindex_all_bookmarks": "Reindex All Bookmarks", + "compact_assets": "Compact Assets" + }, + "users_list": { + "users_list": "Users List", + "create_user": "Create User", + "change_role": "Change Role", + "reset_password": "Reset Password", + "delete_user": "Delete User", + "num_bookmarks": "Num Bookmarks", + "asset_sizes": "Asset Sizes", + "local_user": "Local User", + "confirm_password": "Confirm Password" + } + }, + "options": { + "dark_mode": "Dark Mode", + "light_mode": "Light Mode" + }, + "lists": { + "all_lists": "All Lists", + "favourites": "Favourites", + "new_list": "New List", + "new_nested_list": "New Nested List" + }, + "tags": { + "all_tags": "All Tags", + "your_tags": "Your Tags", + "your_tags_info": "Tags that were attached at least once by you", + "ai_tags": "AI Tags", + "ai_tags_info": "Tags that were only attached automatically (by AI)", + "unused_tags": "Unused Tags", + "unused_tags_info": "Tags that are not attached to any bookmarks", + "delete_all_unused_tags": "Delete All Unused Tags", + "drag_and_drop_merging": "Drag & Drop Merging", + "drag_and_drop_merging_info": "Drag and drop tags on each other to merge them", + "sort_by_name": "Sort by Name" + }, + "preview": { + "view_original": "View Original", + "cached_content": "Cached Content" + }, + "editor": { + "quickly_focus": "You can quickly focus on this field by pressing ⌘ + E", + "multiple_urls_dialog_title": "Importing URLs as separate Bookmarks?", + "multiple_urls_dialog_desc": "The input contains multiple URLs on separate lines. Do you want to import them as separate bookmarks?", + "import_as_text": "Import as Text Bookmark", + "import_as_separate_bookmarks": "Import as separate Bookmarks", + "placeholder": "Paste a link or an image, write a note or drag and drop an image in here ...", + "new_item": "NEW ITEM", + "disabled_submissions": "Submissions are disabled" + }, + "toasts": { + "bookmarks": { + "updated": "The bookmark has been updated!", + "deleted": "The bookmark has been deleted!", + "refetch": "Re-fetch has been enqueued!", + "full_page_archive": "Full Page Archive creation has been triggered", + "delete_from_list": "The bookmark has been deleted from the list", + "clipboard_copied": "Link has been added to your clipboard!" + }, + "lists": { + "created": "List has been created!", + "updated": "List has been updated!" + } + }, + "cleanups": { + "cleanups": "Cleanups", + "duplicate_tags": { + "title": "Duplicate Tags", + "merge_all_suggestions": "Merge all suggestions?" + } + } +} diff --git a/apps/web/lib/i18n/provider.tsx b/apps/web/lib/i18n/provider.tsx new file mode 100644 index 00000000..303e24bf --- /dev/null +++ b/apps/web/lib/i18n/provider.tsx @@ -0,0 +1,18 @@ +import { i18n } from "@/lib/i18n/client"; +import { I18nextProvider } from "react-i18next"; + +const CustomI18nextProvider = ({ + lang, + children, +}: { + lang: string; + children: React.ReactNode; +}) => { + if (i18n.language !== lang) { + i18n.changeLanguage(lang); + } + + return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>; +}; + +export default CustomI18nextProvider; diff --git a/apps/web/lib/i18n/server.ts b/apps/web/lib/i18n/server.ts new file mode 100644 index 00000000..0473fd77 --- /dev/null +++ b/apps/web/lib/i18n/server.ts @@ -0,0 +1,36 @@ +import { getUserLocalSettings } from "@/lib/userLocalSettings/userLocalSettings"; +import { createInstance, FlatNamespace, KeyPrefix } from "i18next"; +import resourcesToBackend from "i18next-resources-to-backend"; +import { FallbackNs } from "react-i18next"; +import { initReactI18next } from "react-i18next/initReactI18next"; + +import { getOptions } from "./settings"; + +const initI18next = async (lng: string, ns: string | string[]) => { + const i18nInstance = createInstance(); + await i18nInstance + .use(initReactI18next) + .use( + resourcesToBackend( + (language: string, namespace: string) => + import(`./locales/${language}/${namespace}.json`), + ), + ) + .init(getOptions(lng, ns?.toString())); + return i18nInstance; +}; + +export async function useTranslation< + Ns extends FlatNamespace, + KPrefix extends KeyPrefix<FallbackNs<Ns>> = undefined, +>(ns?: Ns, options: { keyPrefix?: KPrefix } = {}) { + const lng = (await getUserLocalSettings()).lang; + const i18nextInstance = await initI18next( + lng, + Array.isArray(ns) ? (ns as string[]) : (ns as string), + ); + return { + t: i18nextInstance.getFixedT(lng, ns as FlatNamespace, options.keyPrefix), + i18n: i18nextInstance, + }; +} diff --git a/apps/web/lib/i18n/settings.ts b/apps/web/lib/i18n/settings.ts new file mode 100644 index 00000000..5787a55e --- /dev/null +++ b/apps/web/lib/i18n/settings.ts @@ -0,0 +1,17 @@ +import { supportedLangs } from "@hoarder/shared/langs"; + +export const fallbackLng = "en"; +export const languages = supportedLangs; +export const defaultNS = "translation"; +export const cookieName = "i18next"; + +export function getOptions(lng: string = fallbackLng, ns: string = defaultNS) { + return { + supportedLngs: languages, + fallbackLng, + lng, + fallbackNS: defaultNS, + defaultNS, + ns, + }; +} diff --git a/apps/web/lib/providers.tsx b/apps/web/lib/providers.tsx index b4066808..e1223382 100644 --- a/apps/web/lib/providers.tsx +++ b/apps/web/lib/providers.tsx @@ -14,6 +14,7 @@ import superjson from "superjson"; import type { ClientConfig } from "@hoarder/shared/config"; import { ClientConfigCtx } from "./clientConfig"; +import CustomI18nextProvider from "./i18n/provider"; import { api } from "./trpc"; function makeQueryClient() { @@ -81,14 +82,18 @@ export default function Providers({ <SessionProvider session={session}> <api.Provider client={trpcClient} queryClient={queryClient}> <QueryClientProvider client={queryClient}> - <ThemeProvider - attribute="class" - defaultTheme="system" - enableSystem - disableTransitionOnChange - > - <TooltipProvider delayDuration={0}>{children}</TooltipProvider> - </ThemeProvider> + <CustomI18nextProvider lang={userLocalSettings.lang}> + <ThemeProvider + attribute="class" + defaultTheme="system" + enableSystem + disableTransitionOnChange + > + <TooltipProvider delayDuration={0}> + {children} + </TooltipProvider> + </ThemeProvider> + </CustomI18nextProvider> </QueryClientProvider> </api.Provider> </SessionProvider> diff --git a/apps/web/lib/userLocalSettings/bookmarksLayout.tsx b/apps/web/lib/userLocalSettings/bookmarksLayout.tsx index 424046b9..a122c6e7 100644 --- a/apps/web/lib/userLocalSettings/bookmarksLayout.tsx +++ b/apps/web/lib/userLocalSettings/bookmarksLayout.tsx @@ -2,6 +2,7 @@ import type { z } from "zod"; import { createContext, useContext } from "react"; +import { fallbackLng } from "@/lib/i18n/settings"; import type { BookmarksLayoutTypes, zUserLocalSettings } from "./types"; @@ -11,6 +12,7 @@ export const UserLocalSettingsCtx = createContext< z.infer<typeof zUserLocalSettings> >({ bookmarkGridLayout: defaultLayout, + lang: fallbackLng, }); function useUserLocalSettings() { @@ -22,6 +24,11 @@ export function useBookmarkLayout() { return settings.bookmarkGridLayout; } +export function useInterfaceLang() { + const settings = useUserLocalSettings(); + return settings.lang; +} + export function bookmarkLayoutSwitch<T>( layout: BookmarksLayoutTypes, data: Record<BookmarksLayoutTypes, T>, diff --git a/apps/web/lib/userLocalSettings/types.ts b/apps/web/lib/userLocalSettings/types.ts index 08e38638..bcd2ff26 100644 --- a/apps/web/lib/userLocalSettings/types.ts +++ b/apps/web/lib/userLocalSettings/types.ts @@ -7,6 +7,7 @@ export type BookmarksLayoutTypes = z.infer<typeof zBookmarkGridLayout>; export const zUserLocalSettings = z.object({ bookmarkGridLayout: zBookmarkGridLayout.optional().default("masonry"), + lang: z.string().optional().default("en"), }); export type UserLocalSettings = z.infer<typeof zUserLocalSettings>; diff --git a/apps/web/lib/userLocalSettings/userLocalSettings.ts b/apps/web/lib/userLocalSettings/userLocalSettings.ts index 826e6cf0..311ad99f 100644 --- a/apps/web/lib/userLocalSettings/userLocalSettings.ts +++ b/apps/web/lib/userLocalSettings/userLocalSettings.ts @@ -2,12 +2,20 @@ import { cookies } from "next/headers"; -import type { BookmarksLayoutTypes } from "./types"; +import type { BookmarksLayoutTypes, UserLocalSettings } from "./types"; import { + defaultUserLocalSettings, parseUserLocalSettings, USER_LOCAL_SETTINGS_COOKIE_NAME, } from "./types"; +export async function getUserLocalSettings(): Promise<UserLocalSettings> { + const userSettings = cookies().get(USER_LOCAL_SETTINGS_COOKIE_NAME); + return ( + parseUserLocalSettings(userSettings?.value) ?? defaultUserLocalSettings() + ); +} + export async function updateBookmarksLayout(layout: BookmarksLayoutTypes) { const userSettings = cookies().get(USER_LOCAL_SETTINGS_COOKIE_NAME); const parsed = parseUserLocalSettings(userSettings?.value); @@ -18,3 +26,14 @@ export async function updateBookmarksLayout(layout: BookmarksLayoutTypes) { sameSite: "lax", }); } + +export async function updateInterfaceLang(lang: string) { + const userSettings = cookies().get(USER_LOCAL_SETTINGS_COOKIE_NAME); + const parsed = parseUserLocalSettings(userSettings?.value); + cookies().set({ + name: USER_LOCAL_SETTINGS_COOKIE_NAME, + value: JSON.stringify({ ...parsed, lang }), + maxAge: 34560000, // Chrome caps max age to 400 days + sameSite: "lax", + }); +} diff --git a/apps/web/package.json b/apps/web/package.json index 849f434a..e33ffdf3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -51,9 +51,12 @@ "dayjs": "^1.11.10", "drizzle-orm": "^0.33.0", "fastest-levenshtein": "^1.0.16", + "i18next": "^23.16.5", + "i18next-resources-to-backend": "^1.2.1", "lucide-react": "^0.330.0", "next": "14.2.13", "next-auth": "^4.24.5", + "next-i18next": "^15.3.1", "next-pwa": "^5.6.0", "next-themes": "^0.3.0", "prettier": "^3.2.5", @@ -62,6 +65,7 @@ "react-draggable": "^4.4.6", "react-dropzone": "^14.2.3", "react-hook-form": "^7.50.1", + "react-i18next": "^15.1.1", "react-intersection-observer": "^9.13.1", "react-markdown": "^9.0.1", "react-masonry-css": "^1.0.16", |
