Support I18n (#243)

* feat: support i18n

* feat: wip support i18n

* feat: complete space translation

* feat: complete page translation

* feat: update space translation

* feat: update workspace translation

* feat: update group translation

* feat: update workspace translation

* feat: update page translation

* feat: update user translation

* chore: update pnpm-lock

* feat: add query translation

* refactor: merge to single file

* chore: remove necessary code

* feat: save language to BE

* fix: only load current language

* feat: save language to locale column

* fix: cleanups

* add language menu to preferences page

* new translations

* translate editor

* Translate editor placeholders

* translate space selection component

---------

Co-authored-by: Philip Okugbe <phil@docmost.com>
Co-authored-by: Philip Okugbe <16838612+Philipinho@users.noreply.github.com>
This commit is contained in:
lleohao
2025-01-04 21:17:17 +08:00
committed by GitHub
parent 290b7d9d94
commit 670ee64179
119 changed files with 1672 additions and 649 deletions

View File

@ -24,6 +24,7 @@ import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx";
import PageExportModal from "@/features/page/components/page-export-modal.tsx";
import { useTranslation } from "react-i18next";
import ExportModal from "@/components/common/export-modal";
interface PageHeaderMenuProps {
@ -53,6 +54,7 @@ interface PageActionMenuProps {
readOnly?: boolean;
}
function PageActionMenu({ readOnly }: PageActionMenuProps) {
const { t } = useTranslation();
const [, setHistoryModalOpen] = useAtom(historyAtoms);
const clipboard = useClipboard({ timeout: 500 });
const { pageSlug, spaceSlug } = useParams();
@ -69,7 +71,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
getAppUrl() + buildPageUrl(spaceSlug, page.slugId, page.title);
clipboard.copy(pageUrl);
notifications.show({ message: "Link copied" });
notifications.show({ message: t("Link copied") });
};
const handlePrint = () => {
@ -107,13 +109,13 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
leftSection={<IconLink size={16} />}
onClick={handleCopyLink}
>
Copy link
{t("Copy link")}
</Menu.Item>
<Menu.Divider />
<Menu.Item leftSection={<IconArrowsHorizontal size={16} />}>
<Group wrap="nowrap">
<PageWidthToggle label="Full width" />
<PageWidthToggle label={t("Full width")} />
</Group>
</Menu.Item>
@ -121,7 +123,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
leftSection={<IconHistory size={16} />}
onClick={openHistoryModal}
>
Page history
{t("Page history")}
</Menu.Item>
<Menu.Divider />
@ -130,14 +132,14 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
leftSection={<IconFileExport size={16} />}
onClick={openExportModal}
>
Export
{t("Export")}
</Menu.Item>
<Menu.Item
leftSection={<IconPrinter size={16} />}
onClick={handlePrint}
>
Print PDF
{t("Print PDF")}
</Menu.Item>
{!readOnly && (
@ -148,7 +150,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
leftSection={<IconTrash size={16} />}
onClick={handleDeletePage}
>
Delete
{t("Delete")}
</Menu.Item>
</>
)}

View File

@ -4,6 +4,7 @@ import { useState } from "react";
import * as React from "react";
import { ExportFormat } from "@/features/page/types/page.types.ts";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
interface PageExportModalProps {
pageId: string;
@ -16,6 +17,7 @@ export default function PageExportModal({
open,
onClose,
}: PageExportModalProps) {
const { t } = useTranslation();
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
const handleExport = async () => {
@ -24,7 +26,7 @@ export default function PageExportModal({
onClose();
} catch (err) {
notifications.show({
message: "Export failed:" + err.response?.data.message,
message: t("Export failed:") + err.response?.data.message,
color: "red",
});
console.error("export error", err);
@ -48,32 +50,29 @@ export default function PageExportModal({
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Header py={0}>
<Modal.Title fw={500}>Export page</Modal.Title>
<Modal.Title fw={500}>{t("Export page")}</Modal.Title>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>
<Group justify="space-between" wrap="nowrap">
<div>
<Text size="md">Format</Text>
<Text size="md">{t("Format")}</Text>
</div>
<ExportFormatSelection format={format} onChange={handleChange} />
</Group>
<Group justify="space-between" wrap="nowrap" pt="md">
<div>
<Text size="md">Include subpages</Text>
<Text size="md">{t("Include subpages")}</Text>
</div>
<Switch defaultChecked />
</Group>
<Group justify="center" mt="md">
<Button onClick={onClose} variant="default">
Cancel
{t("Cancel")}
</Button>
<Button onClick={handleExport}>Export</Button>
<Button onClick={handleExport}>{t("Export")}</Button>
</Group>
</Modal.Body>
</Modal.Content>
@ -86,6 +85,8 @@ interface ExportFormatSelection {
onChange: (value: string) => void;
}
function ExportFormatSelection({ format, onChange }: ExportFormatSelection) {
const { t } = useTranslation();
return (
<Select
data={[
@ -98,7 +99,7 @@ function ExportFormatSelection({ format, onChange }: ExportFormatSelection) {
comboboxProps={{ width: "120" }}
allowDeselect={false}
withCheckIcon={false}
aria-label="Select export format"
aria-label={t("Select export format")}
/>
);
}

View File

@ -12,6 +12,7 @@ import { useAtom } from "jotai";
import { buildTree } from "@/features/page/tree/utils";
import { IPage } from "@/features/page/types/page.types.ts";
import React from "react";
import { useTranslation } from "react-i18next";
interface PageImportModalProps {
spaceId: string;
@ -24,6 +25,7 @@ export default function PageImportModal({
open,
onClose,
}: PageImportModalProps) {
const { t } = useTranslation();
return (
<>
<Modal.Root
@ -38,7 +40,7 @@ export default function PageImportModal({
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Header py={0}>
<Modal.Title fw={500}>Import pages</Modal.Title>
<Modal.Title fw={500}>{t("Import pages")}</Modal.Title>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>
@ -55,6 +57,7 @@ interface ImportFormatSelection {
onClose: () => void;
}
function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
const { t } = useTranslation();
const [treeData, setTreeData] = useAtom(treeDataAtom);
const handleFileUpload = async (selectedFiles: File[]) => {
@ -65,8 +68,8 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
onClose();
const alert = notifications.show({
title: "Importing pages",
message: "Page import is in progress. Please do not close this tab.",
title: t("Importing pages"),
message: t("Page import is in progress. Please do not close this tab."),
loading: true,
autoClose: false,
});
@ -92,13 +95,14 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
setTreeData(fullTree);
}
const pageCountText = pageCount === 1 ? "1 page" : `${pageCount} pages`;
const pageCountText =
pageCount === 1 ? `1 ${t("page")}` : `${pageCount} ${t("pages")}`;
notifications.update({
id: alert,
color: "teal",
title: `Successfully imported ${pageCountText}`,
message: "Your import is complete.",
title: `${t("Successfully imported")} ${pageCountText}`,
message: t("Your import is complete."),
icon: <IconCheck size={18} />,
loading: false,
autoClose: 5000,
@ -107,8 +111,8 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
notifications.update({
id: alert,
color: "red",
title: `Failed to import pages`,
message: "Unable to import pages. Please try again.",
title: t("Failed to import pages"),
message: t("Unable to import pages. Please try again."),
icon: <IconX size={18} />,
loading: false,
autoClose: 5000,

View File

@ -1,22 +1,25 @@
import { modals } from "@mantine/modals";
import { Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
type UseDeleteModalProps = {
onConfirm: () => void;
};
export function useDeletePageModal() {
const { t } = useTranslation();
const openDeleteModal = ({ onConfirm }: UseDeleteModalProps) => {
modals.openConfirmModal({
title: "Are you sure you want to delete this page?",
title: t("Are you sure you want to delete this page?"),
children: (
<Text size="sm">
Are you sure you want to delete this page? This will delete its
children and page history. This action is irreversible.
{t(
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.",
)}
</Text>
),
centered: true,
labels: { confirm: "Delete", cancel: "Cancel" },
labels: { confirm: t("Delete"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm,
});

View File

@ -25,6 +25,7 @@ import { notifications } from "@mantine/notifications";
import { IPagination } from "@/lib/types.ts";
import { queryClient } from "@/main.tsx";
import { buildTree } from "@/features/page/tree/utils";
import { useTranslation } from "react-i18next";
export function usePageQuery(
pageInput: Partial<IPageInput>,
@ -38,11 +39,12 @@ export function usePageQuery(
}
export function useCreatePageMutation() {
const { t } = useTranslation();
return useMutation<IPage, Error, Partial<IPageInput>>({
mutationFn: (data) => createPage(data),
onSuccess: (data) => {},
onError: (error) => {
notifications.show({ message: "Failed to create page", color: "red" });
notifications.show({ message: t("Failed to create page"), color: "red" });
},
});
}
@ -74,13 +76,14 @@ export function useUpdatePageMutation() {
}
export function useDeletePageMutation() {
const { t } = useTranslation();
return useMutation({
mutationFn: (pageId: string) => deletePage(pageId),
onSuccess: () => {
notifications.show({ message: "Page deleted successfully" });
notifications.show({ message: t("Page deleted successfully") });
},
onError: (error) => {
notifications.show({ message: "Failed to delete page", color: "red" });
notifications.show({ message: t("Failed to delete page"), color: "red" });
},
});
}

View File

@ -52,6 +52,7 @@ import { notifications } from "@mantine/notifications";
import { getAppUrl } from "@/lib/config.ts";
import { extractPageSlugId } from "@/lib";
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
import { useTranslation } from "react-i18next";
import ExportModal from "@/components/common/export-modal";
interface SpaceTreeProps {
@ -405,6 +406,7 @@ interface NodeMenuProps {
}
function NodeMenu({ node, treeApi }: NodeMenuProps) {
const { t } = useTranslation();
const clipboard = useClipboard({ timeout: 500 });
const { spaceSlug } = useParams();
const { openDeleteModal } = useDeletePageModal();
@ -415,7 +417,7 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
const pageUrl =
getAppUrl() + buildPageUrl(spaceSlug, node.data.slugId, node.data.name);
clipboard.copy(pageUrl);
notifications.show({ message: "Link copied" });
notifications.show({ message: t("Link copied") });
};
return (
@ -446,7 +448,7 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
handleCopyLink();
}}
>
Copy link
{t("Copy link")}
</Menu.Item>
<Menu.Item
@ -457,7 +459,7 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
openExportModal();
}}
>
Export page
{t("Export page")}
</Menu.Item>
{!(treeApi.props.disableEdit as boolean) && (
@ -475,7 +477,7 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
openDeleteModal({ onConfirm: () => treeApi?.delete(node) });
}}
>
Delete
{t("Delete")}
</Menu.Item>
</>
)}