aboutsummaryrefslogtreecommitdiffstats
path: root/apps
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2024-11-17 00:33:28 +0000
committerGitHub <noreply@github.com>2024-11-17 00:33:28 +0000
commit4354ee7ba1c6ac9a9567944ae6169b1664e0ea8a (patch)
treee27c9070930514d77582bae00b3350274116179c /apps
parent9f2c7be23769bb0f4102736a683710b1a1939661 (diff)
downloadkarakeep-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')
-rw-r--r--apps/web/@types/i18next.d.ts13
-rw-r--r--apps/web/app/dashboard/cleanups/page.tsx9
-rw-r--r--apps/web/app/dashboard/lists/page.tsx4
-rw-r--r--apps/web/app/dashboard/tags/page.tsx4
-rw-r--r--apps/web/app/layout.tsx13
-rw-r--r--apps/web/app/settings/info/page.tsx4
-rw-r--r--apps/web/components/dashboard/BulkBookmarksAction.tsx28
-rw-r--r--apps/web/components/dashboard/ChangeLayout.tsx13
-rw-r--r--apps/web/components/dashboard/EditableText.tsx9
-rw-r--r--apps/web/components/dashboard/admin/AdminActions.tsx19
-rw-r--r--apps/web/components/dashboard/admin/ServerStats.tsx38
-rw-r--r--apps/web/components/dashboard/admin/UserList.tsx30
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx43
-rw-r--r--apps/web/components/dashboard/bookmarks/EditorCard.tsx26
-rw-r--r--apps/web/components/dashboard/bookmarks/ManageListsModal.tsx16
-rw-r--r--apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx4
-rw-r--r--apps/web/components/dashboard/bookmarks/TagModal.tsx6
-rw-r--r--apps/web/components/dashboard/cleanups/TagDuplicationDetention.tsx13
-rw-r--r--apps/web/components/dashboard/header/ProfileOptions.tsx13
-rw-r--r--apps/web/components/dashboard/lists/AllListsView.tsx8
-rw-r--r--apps/web/components/dashboard/lists/EditListModal.tsx14
-rw-r--r--apps/web/components/dashboard/lists/ListOptions.tsx9
-rw-r--r--apps/web/components/dashboard/preview/ActionBar.tsx10
-rw-r--r--apps/web/components/dashboard/preview/AttachmentBox.tsx4
-rw-r--r--apps/web/components/dashboard/preview/BookmarkPreview.tsx8
-rw-r--r--apps/web/components/dashboard/preview/LinkContentSection.tsx12
-rw-r--r--apps/web/components/dashboard/search/SearchInput.tsx4
-rw-r--r--apps/web/components/dashboard/sidebar/AllLists.tsx6
-rw-r--r--apps/web/components/dashboard/sidebar/Sidebar.tsx10
-rw-r--r--apps/web/components/dashboard/tags/AllTagsView.tsx25
-rw-r--r--apps/web/components/dashboard/tags/TagOptions.tsx6
-rw-r--r--apps/web/components/settings/AISettings.tsx34
-rw-r--r--apps/web/components/settings/AddApiKey.tsx32
-rw-r--r--apps/web/components/settings/ApiKeySettings.tsx14
-rw-r--r--apps/web/components/settings/ChangePassword.tsx20
-rw-r--r--apps/web/components/settings/DeleteApiKey.tsx4
-rw-r--r--apps/web/components/settings/FeedSettings.tsx35
-rw-r--r--apps/web/components/settings/ImportExport.tsx20
-rw-r--r--apps/web/components/settings/UserDetails.tsx10
-rw-r--r--apps/web/components/settings/UserOptions.tsx55
-rw-r--r--apps/web/components/settings/sidebar/ModileSidebar.tsx4
-rw-r--r--apps/web/components/settings/sidebar/Sidebar.tsx4
-rw-r--r--apps/web/components/settings/sidebar/items.tsx19
-rw-r--r--apps/web/components/ui/action-confirming-dialog.tsx4
-rw-r--r--apps/web/lib/i18n/client.ts33
-rw-r--r--apps/web/lib/i18n/locales/en/translation.json206
-rw-r--r--apps/web/lib/i18n/provider.tsx18
-rw-r--r--apps/web/lib/i18n/server.ts36
-rw-r--r--apps/web/lib/i18n/settings.ts17
-rw-r--r--apps/web/lib/providers.tsx21
-rw-r--r--apps/web/lib/userLocalSettings/bookmarksLayout.tsx7
-rw-r--r--apps/web/lib/userLocalSettings/types.ts1
-rw-r--r--apps/web/lib/userLocalSettings/userLocalSettings.ts21
-rw-r--r--apps/web/package.json4
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&apos;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",