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

@ -26,8 +26,10 @@ import { ErrorBoundary } from "react-error-boundary";
import InviteSignup from "@/pages/auth/invite-signup.tsx";
import ForgotPassword from "@/pages/auth/forgot-password.tsx";
import PasswordReset from "./pages/auth/password-reset";
import { useTranslation } from "react-i18next";
export default function App() {
const { t } = useTranslation();
const [, setSocket] = useAtom(socketAtom);
const authToken = useAtomValue(authTokensAtom);
@ -78,7 +80,7 @@ export default function App() {
path={"/s/:spaceSlug/p/:pageSlug"}
element={
<ErrorBoundary
fallback={<>Failed to load page. An error occurred.</>}
fallback={<>{t("Failed to load page. An error occurred.")}</>}
>
<Page />
</ErrorBoundary>

View File

@ -12,6 +12,7 @@ import { useState } from "react";
import { ExportFormat } from "@/features/page/types/page.types.ts";
import { notifications } from "@mantine/notifications";
import { exportSpace } from "@/features/space/services/space-service";
import { useTranslation } from "react-i18next";
interface ExportModalProps {
id: string;
@ -29,6 +30,7 @@ export default function ExportModal({
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
const [includeChildren, setIncludeChildren] = useState<boolean>(false);
const [includeAttachments, setIncludeAttachments] = useState<boolean>(true);
const { t } = useTranslation();
const handleExport = async () => {
try {
@ -73,7 +75,7 @@ export default function ExportModal({
<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>
@ -84,7 +86,7 @@ export default function ExportModal({
<Group justify="space-between" wrap="nowrap">
<div>
<Text size="md">Include subpages</Text>
<Text size="md">{t("Include subpages")}</Text>
</div>
<Switch
onChange={(event) =>
@ -102,7 +104,7 @@ export default function ExportModal({
<Group justify="space-between" wrap="nowrap">
<div>
<Text size="md">Include attachments</Text>
<Text size="md">{t("Include attachments")}</Text>
</div>
<Switch
onChange={(event) =>
@ -116,9 +118,9 @@ export default function ExportModal({
<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>
@ -131,6 +133,8 @@ interface ExportFormatSelection {
onChange: (value: string) => void;
}
function ExportFormatSelection({ format, onChange }: ExportFormatSelection) {
const { t } = useTranslation();
return (
<Select
data={[
@ -143,7 +147,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

@ -8,17 +8,19 @@ import {
} from '@mantine/core';
import {Link} from 'react-router-dom';
import PageListSkeleton from '@/components/ui/page-list-skeleton.tsx';
import {buildPageUrl} from '@/features/page/page.utils.ts';
import {formattedDate} from '@/lib/time.ts';
import {useRecentChangesQuery} from '@/features/page/queries/page-query.ts';
import {IconFileDescription} from '@tabler/icons-react';
import {getSpaceUrl} from '@/lib/config.ts';
import { buildPageUrl } from '@/features/page/page.utils.ts';
import { formattedDate } from '@/lib/time.ts';
import { useRecentChangesQuery } from '@/features/page/queries/page-query.ts';
import { IconFileDescription } from '@tabler/icons-react';
import { getSpaceUrl } from '@/lib/config.ts';
import { useTranslation } from "react-i18next";
interface Props {
spaceId?: string;
}
export default function RecentChanges({spaceId}: Props) {
const { t } = useTranslation();
const {data: pages, isLoading, isError} = useRecentChangesQuery(spaceId);
if (isLoading) {
@ -26,7 +28,7 @@ export default function RecentChanges({spaceId}: Props) {
}
if (isError) {
return <Text>Failed to fetch recent pages</Text>;
return <Text>{t("Failed to fetch recent pages")}</Text>;
}
return pages && pages.items.length > 0 ? (
@ -48,7 +50,7 @@ export default function RecentChanges({spaceId}: Props) {
)}
<Text fw={500} size="md" lineClamp={1}>
{page.title || 'Untitled'}
{page.title || t("Untitled")}
</Text>
</Group>
</UnstyledButton>
@ -78,7 +80,7 @@ export default function RecentChanges({spaceId}: Props) {
</Table.ScrollContainer>
) : (
<Text size="md" ta="center">
No pages yet
{t("No pages yet")}
</Text>
);
}

View File

@ -11,10 +11,12 @@ import {
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import {useToggleSidebar} from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
import { useTranslation } from "react-i18next";
const links = [{link: APP_ROUTE.HOME, label: "Home"}];
export function AppHeader() {
const { t } = useTranslation();
const [mobileOpened] = useAtom(mobileSidebarAtom);
const toggleMobile = useToggleSidebar(mobileSidebarAtom);
@ -25,7 +27,7 @@ export function AppHeader() {
const items = links.map((link) => (
<Link key={link.label} to={link.link} className={classes.link}>
{link.label}
{t(link.label)}
</Link>
));
@ -35,10 +37,10 @@ export function AppHeader() {
<Group wrap="nowrap">
{!isHomeRoute && (
<>
<Tooltip label="Sidebar toggle">
<Tooltip label={t("Sidebar toggle")}>
<SidebarToggle
aria-label="Sidebar toggle"
aria-label={t("Sidebar toggle")}
opened={mobileOpened}
onClick={toggleMobile}
hiddenFrom="sm"
@ -46,9 +48,9 @@ export function AppHeader() {
/>
</Tooltip>
<Tooltip label="Sidebar toggle">
<Tooltip label={t("Sidebar toggle")}>
<SidebarToggle
aria-label="Sidebar toggle"
aria-label={t("Sidebar toggle")}
opened={desktopOpened}
onClick={toggleDesktop}
visibleFrom="sm"

View File

@ -3,9 +3,11 @@ import CommentList from "@/features/comment/components/comment-list.tsx";
import { useAtom } from "jotai";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import React, { ReactNode } from "react";
import { useTranslation } from "react-i18next";
export default function Aside() {
const [{ tab }] = useAtom(asideStateAtom);
const { t } = useTranslation();
let title: string;
let component: ReactNode;
@ -25,7 +27,7 @@ export default function Aside() {
{component && (
<>
<Text mb="md" fw={500}>
{title}
{t(title)}
</Text>
<ScrollArea

View File

@ -13,8 +13,10 @@ import { Link } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route.ts";
import useAuth from "@/features/auth/hooks/use-auth.ts";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { useTranslation } from "react-i18next";
export default function TopMenu() {
const { t } = useTranslation();
const [currentUser] = useAtom(currentUserAtom);
const { logout } = useAuth();
@ -44,14 +46,14 @@ export default function TopMenu() {
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Workspace</Menu.Label>
<Menu.Label>{t("Workspace")}</Menu.Label>
<Menu.Item
component={Link}
to={APP_ROUTE.SETTINGS.WORKSPACE.GENERAL}
leftSection={<IconSettings size={16} />}
>
Workspace settings
{t("Workspace settings")}
</Menu.Item>
<Menu.Item
@ -59,12 +61,12 @@ export default function TopMenu() {
to={APP_ROUTE.SETTINGS.WORKSPACE.MEMBERS}
leftSection={<IconUsers size={16} />}
>
Manage members
{t("Manage members")}
</Menu.Item>
<Menu.Divider />
<Menu.Label>Account</Menu.Label>
<Menu.Label>{t("Account")}</Menu.Label>
<Menu.Item component={Link} to={APP_ROUTE.SETTINGS.ACCOUNT.PROFILE}>
<Group wrap={"nowrap"}>
<CustomAvatar
@ -88,7 +90,7 @@ export default function TopMenu() {
to={APP_ROUTE.SETTINGS.ACCOUNT.PROFILE}
leftSection={<IconUserCircle size={16} />}
>
My profile
{t("My profile")}
</Menu.Item>
<Menu.Item
@ -96,13 +98,13 @@ export default function TopMenu() {
to={APP_ROUTE.SETTINGS.ACCOUNT.PREFERENCES}
leftSection={<IconBrush size={16} />}
>
My preferences
{t("My preferences")}
</Menu.Item>
<Menu.Divider />
<Menu.Item onClick={logout} leftSection={<IconLogout size={16} />}>
Logout
{t("Logout")}
</Menu.Item>
</Menu.Dropdown>
</Menu>

View File

@ -11,6 +11,7 @@ import {
} from "@tabler/icons-react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import classes from "./settings.module.css";
import { useTranslation } from "react-i18next";
interface DataItem {
label: string;
@ -51,6 +52,7 @@ const groupedData: DataGroup[] = [
];
export default function SettingsSidebar() {
const { t } = useTranslation();
const location = useLocation();
const [active, setActive] = useState(location.pathname);
const navigate = useNavigate();
@ -62,7 +64,7 @@ export default function SettingsSidebar() {
const menuItems = groupedData.map((group) => (
<div key={group.heading}>
<Text c="dimmed" className={classes.linkHeader}>
{group.heading}
{t(group.heading)}
</Text>
{group.items.map((item) => (
<Link
@ -72,7 +74,7 @@ export default function SettingsSidebar() {
to={item.path}
>
<item.icon className={classes.linkIcon} stroke={2} />
<span>{item.label}</span>
<span>{t(item.label)}</span>
</Link>
))}
</div>
@ -89,7 +91,7 @@ export default function SettingsSidebar() {
>
<IconArrowLeft stroke={2} />
</ActionIcon>
<Text fw={500}>Settings</Text>
<Text fw={500}>{t("Settings")}</Text>
</Group>
<ScrollArea w="100%">{menuItems}</ScrollArea>

View File

@ -2,21 +2,24 @@ import { Title, Text, Button, Container, Group } from "@mantine/core";
import classes from "./error-404.module.css";
import { Link } from "react-router-dom";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
export function Error404() {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>404 page not found - Docmost</title>
<title>{t("404 page not found")} - Docmost</title>
</Helmet>
<Container className={classes.root}>
<Title className={classes.title}>404 Page Not Found</Title>
<Title className={classes.title}>{t("404 page not found")}</Title>
<Text c="dimmed" size="lg" ta="center" className={classes.description}>
Sorry, we can't find the page you are looking for.
{t("Sorry, we can't find the page you are looking for.")}
</Text>
<Group justify="center">
<Button component={Link} to={"/home"} variant="subtle" size="md">
Take me back to homepage
{t("Take me back to homepage")}
</Button>
</Group>
</Container>

View File

@ -2,6 +2,7 @@ import React, { forwardRef } from "react";
import { IconCheck, IconChevronDown } from "@tabler/icons-react";
import { Group, Text, Menu, Button } from "@mantine/core";
import { IRoleData } from "@/lib/types.ts";
import { useTranslation } from "react-i18next";
interface RoleButtonProps extends React.ComponentPropsWithoutRef<"button"> {
name: string;
@ -36,10 +37,12 @@ export default function RoleSelectMenu({
onChange,
disabled,
}: RoleMenuProps) {
const { t } = useTranslation();
return (
<Menu withArrow>
<Menu.Target>
<RoleButton name={roleName} disabled={disabled} />
<RoleButton name={t(roleName)} disabled={disabled} />
</Menu.Target>
<Menu.Dropdown>
@ -50,9 +53,9 @@ export default function RoleSelectMenu({
>
<Group flex="1" gap="xs">
<div>
<Text size="sm">{item.label}</Text>
<Text size="sm">{t(item.label)}</Text>
<Text size="xs" opacity={0.65}>
{item.description}
{t(item.description)}
</Text>
</div>
{item.label === roleName && <IconCheck size={20} />}

View File

@ -6,6 +6,7 @@ import { IForgotPassword } from "@/features/auth/types/auth.types";
import { Box, Button, Container, Text, TextInput, Title } from "@mantine/core";
import classes from "./auth.module.css";
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
import { useTranslation } from "react-i18next";
const formSchema = z.object({
email: z
@ -15,6 +16,7 @@ const formSchema = z.object({
});
export function ForgotPasswordForm() {
const { t } = useTranslation();
const { forgotPassword, isLoading } = useAuth();
const [isTokenSent, setIsTokenSent] = useState<boolean>(false);
useRedirectIfAuthenticated();
@ -36,7 +38,7 @@ export function ForgotPasswordForm() {
<Container size={420} my={40} className={classes.container}>
<Box p="xl" mt={200}>
<Title order={2} ta="center" fw={500} mb="md">
Forgot password
{t("Forgot password")}
</Title>
<form onSubmit={form.onSubmit(onSubmit)}>
@ -53,14 +55,15 @@ export function ForgotPasswordForm() {
{isTokenSent && (
<Text>
A password reset link has been sent to your email. Please check
your inbox.
{t(
"A password reset link has been sent to your email. Please check your inbox.",
)}
</Text>
)}
{!isTokenSent && (
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
Send reset link
{t("Send reset link")}
</Button>
)}
</form>

View File

@ -17,6 +17,7 @@ import useAuth from "@/features/auth/hooks/use-auth";
import classes from "@/features/auth/components/auth.module.css";
import { useGetInvitationQuery } from "@/features/workspace/queries/workspace-query.ts";
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
import { useTranslation } from "react-i18next";
const formSchema = z.object({
name: z.string().trim().min(1),
@ -26,6 +27,7 @@ const formSchema = z.object({
type FormValues = z.infer<typeof formSchema>;
export function InviteSignUpForm() {
const { t } = useTranslation();
const params = useParams();
const [searchParams] = useSearchParams();
@ -55,7 +57,7 @@ export function InviteSignUpForm() {
}
if (isError) {
return <div>invalid invitation link</div>;
return <div>{t("invalid invitation link")}</div>;
}
if (!invitation) {
@ -66,7 +68,7 @@ export function InviteSignUpForm() {
<Container size={420} my={40} className={classes.container}>
<Box p="xl" mt={200}>
<Title order={2} ta="center" fw={500} mb="md">
Join the workspace
{t("Join the workspace")}
</Title>
<Stack align="stretch" justify="center" gap="xl">
@ -74,8 +76,8 @@ export function InviteSignUpForm() {
<TextInput
id="name"
type="text"
label="Name"
placeholder="enter your full name"
label={t("Name")}
placeholder={t("enter your full name")}
variant="filled"
{...form.getInputProps("name")}
/>
@ -83,7 +85,7 @@ export function InviteSignUpForm() {
<TextInput
id="email"
type="email"
label="Email"
label={t("Email")}
value={invitation.email}
disabled
variant="filled"
@ -91,14 +93,14 @@ export function InviteSignUpForm() {
/>
<PasswordInput
label="Password"
placeholder="Your password"
label={t("Password")}
placeholder={t("Your password")}
variant="filled"
mt="md"
{...form.getInputProps("password")}
/>
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
Sign Up
{t("Sign Up")}
</Button>
</form>
</Stack>

View File

@ -9,13 +9,13 @@ import {
Button,
PasswordInput,
Box,
Anchor,
} from "@mantine/core";
import classes from "./auth.module.css";
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
import { Link, useNavigate } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route.ts";
import { useTranslation } from "react-i18next";
const formSchema = z.object({
email: z
@ -26,6 +26,7 @@ const formSchema = z.object({
});
export function LoginForm() {
const { t } = useTranslation();
const { signIn, isLoading } = useAuth();
useRedirectIfAuthenticated();
@ -45,29 +46,29 @@ export function LoginForm() {
<Container size={420} my={40} className={classes.container}>
<Box p="xl" mt={200}>
<Title order={2} ta="center" fw={500} mb="md">
Login
{t("Login")}
</Title>
<form onSubmit={form.onSubmit(onSubmit)}>
<TextInput
id="email"
type="email"
label="Email"
label={t("Email")}
placeholder="email@example.com"
variant="filled"
{...form.getInputProps("email")}
/>
<PasswordInput
label="Password"
placeholder="Your password"
label={t("Password")}
placeholder={t("Your password")}
variant="filled"
mt="md"
{...form.getInputProps("password")}
/>
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
Sign In
{t("Sign In")}
</Button>
</form>
@ -77,7 +78,7 @@ export function LoginForm() {
underline="never"
size="sm"
>
Forgot your password?
{t("Forgot your password?")}
</Anchor>
</Box>
</Container>

View File

@ -12,6 +12,7 @@ import {
} from "@mantine/core";
import classes from "./auth.module.css";
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
import { useTranslation } from "react-i18next";
const formSchema = z.object({
newPassword: z
@ -24,6 +25,7 @@ interface PasswordResetFormProps {
}
export function PasswordResetForm({ resetToken }: PasswordResetFormProps) {
const { t } = useTranslation();
const { passwordReset, isLoading } = useAuth();
useRedirectIfAuthenticated();
@ -37,28 +39,28 @@ export function PasswordResetForm({ resetToken }: PasswordResetFormProps) {
async function onSubmit(data: IPasswordReset) {
await passwordReset({
token: resetToken,
newPassword: data.newPassword
})
newPassword: data.newPassword,
});
}
return (
<Container size={420} my={40} className={classes.container}>
<Box p="xl" mt={200}>
<Title order={2} ta="center" fw={500} mb="md">
Password reset
{t("Password reset")}
</Title>
<form onSubmit={form.onSubmit(onSubmit)}>
<PasswordInput
label="New password"
placeholder="Your new password"
label={t("New password")}
placeholder={t("Your new password")}
variant="filled"
mt="md"
{...form.getInputProps("newPassword")}
/>
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
Set password
{t("Set password")}
</Button>
</form>
</Box>

View File

@ -13,6 +13,7 @@ import {
import { ISetupWorkspace } from "@/features/auth/types/auth.types";
import useAuth from "@/features/auth/hooks/use-auth";
import classes from "@/features/auth/components/auth.module.css";
import { useTranslation } from "react-i18next";
const formSchema = z.object({
workspaceName: z.string().trim().min(3).max(50),
@ -25,6 +26,7 @@ const formSchema = z.object({
});
export function SetupWorkspaceForm() {
const { t } = useTranslation();
const { setupWorkspace, isLoading } = useAuth();
// useRedirectIfAuthenticated();
@ -46,15 +48,15 @@ export function SetupWorkspaceForm() {
<Container size={420} my={40} className={classes.container}>
<Box p="xl" mt={200}>
<Title order={2} ta="center" fw={500} mb="md">
Create workspace
{t("Create workspace")}
</Title>
<form onSubmit={form.onSubmit(onSubmit)}>
<TextInput
id="workspaceName"
type="text"
label="Workspace Name"
placeholder="e.g ACME Inc"
label={t("Workspace Name")}
placeholder={t("e.g ACME Inc")}
variant="filled"
mt="md"
{...form.getInputProps("workspaceName")}
@ -63,8 +65,8 @@ export function SetupWorkspaceForm() {
<TextInput
id="name"
type="text"
label="Your Name"
placeholder="enter your full name"
label={t("Your Name")}
placeholder={t("enter your full name")}
variant="filled"
mt="md"
{...form.getInputProps("name")}
@ -73,7 +75,7 @@ export function SetupWorkspaceForm() {
<TextInput
id="email"
type="email"
label="Your Email"
label={t("Your Email")}
placeholder="email@example.com"
variant="filled"
mt="md"
@ -81,14 +83,14 @@ export function SetupWorkspaceForm() {
/>
<PasswordInput
label="Password"
placeholder="Enter a strong password"
label={t("Password")}
placeholder={t("Enter a strong password")}
variant="filled"
mt="md"
{...form.getInputProps("password")}
/>
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
Setup workspace
{t("Setup workspace")}
</Button>
</form>
</Box>

View File

@ -1,4 +1,5 @@
import { Button, Group } from '@mantine/core';
import { Button, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
type CommentActionsProps = {
onSave: () => void;
@ -6,9 +7,13 @@ type CommentActionsProps = {
};
function CommentActions({ onSave, isLoading }: CommentActionsProps) {
const { t } = useTranslation();
return (
<Group justify="flex-end" pt={2} wrap="nowrap">
<Button size="compact-sm" loading={isLoading} onClick={onSave}>Save</Button>
<Button size="compact-sm" loading={isLoading} onClick={onSave}>
{t("Save")}
</Button>
</Group>
);
}

View File

@ -14,6 +14,7 @@ import { useCreateCommentMutation } from "@/features/comment/queries/comment-que
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
import { useEditor } from "@tiptap/react";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { useTranslation } from "react-i18next";
interface CommentDialogProps {
editor: ReturnType<typeof useEditor>;
@ -21,6 +22,7 @@ interface CommentDialogProps {
}
function CommentDialog({ editor, pageId }: CommentDialogProps) {
const { t } = useTranslation();
const [comment, setComment] = useState("");
const [, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
@ -107,7 +109,7 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
<CommentEditor
onUpdate={handleCommentEditorChange}
placeholder="Write a comment"
placeholder={t("Write a comment")}
editable={true}
autofocus={true}
/>

View File

@ -7,6 +7,7 @@ import classes from "./comment.module.css";
import { useFocusWithin } from "@mantine/hooks";
import clsx from "clsx";
import { forwardRef, useEffect, useImperativeHandle } from "react";
import { useTranslation } from "react-i18next";
interface CommentEditorProps {
defaultContent?: any;
@ -27,6 +28,7 @@ const CommentEditor = forwardRef(
}: CommentEditorProps,
ref,
) => {
const { t } = useTranslation();
const { ref: focusRef, focused } = useFocusWithin();
const commentEditor = useEditor({
@ -36,7 +38,7 @@ const CommentEditor = forwardRef(
dropcursor: false,
}),
Placeholder.configure({
placeholder: placeholder || "Reply...",
placeholder: placeholder || t("Reply..."),
}),
Underline,
Link,

View File

@ -24,7 +24,6 @@ function CommentListItem({ comment }: CommentListItemProps) {
const { hovered, ref } = useHover();
const [isEditing, setIsEditing] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const editor = useAtomValue(pageEditorAtom);
const [content, setContent] = useState<string>(comment.content);
const updateCommentMutation = useUpdateCommentMutation();

View File

@ -6,7 +6,6 @@ import {
useCommentsQuery,
useCreateCommentMutation,
} from "@/features/comment/queries/comment-query";
import CommentEditor from "@/features/comment/components/comment-editor";
import CommentActions from "@/features/comment/components/comment-actions";
import { useFocusWithin } from "@mantine/hooks";
@ -14,8 +13,10 @@ import { IComment } from "@/features/comment/types/comment.types.ts";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { IPagination } from "@/lib/types.ts";
import { extractPageSlugId } from "@/lib";
import { useTranslation } from "react-i18next";
function CommentList() {
const { t } = useTranslation();
const { pageSlug } = useParams();
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
const {
@ -79,11 +80,11 @@ function CommentList() {
}
if (isError) {
return <div>Error loading comments.</div>;
return <div>{t("Error loading comments.")}</div>;
}
if (!comments || comments.items.length === 0) {
return <>No comments yet.</>;
return <>{t("No comments yet.")}</>;
}
return (

View File

@ -1,6 +1,7 @@
import { ActionIcon, Menu } from '@mantine/core';
import { IconDots, IconEdit, IconTrash } from '@tabler/icons-react';
import { modals } from '@mantine/modals';
import { ActionIcon, Menu } from "@mantine/core";
import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react";
import { modals } from "@mantine/modals";
import { useTranslation } from "react-i18next";
type CommentMenuProps = {
onEditComment: () => void;
@ -8,34 +9,35 @@ type CommentMenuProps = {
};
function CommentMenu({ onEditComment, onDeleteComment }: CommentMenuProps) {
const { t } = useTranslation();
//@ts-ignore
const openDeleteModal = () =>
modals.openConfirmModal({
title: 'Are you sure you want to delete this comment?',
title: t("Are you sure you want to delete this comment?"),
centered: true,
labels: { confirm: 'Delete', cancel: 'Cancel' },
confirmProps: { color: 'red' },
labels: { confirm: t("Delete"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: onDeleteComment,
});
return (
<Menu shadow="md" width={200}>
<Menu.Target>
<ActionIcon variant="default" style={{ border: 'none' }}>
<ActionIcon variant="default" style={{ border: "none" }}>
<IconDots size={20} stroke={2} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={onEditComment}
leftSection={<IconEdit size={14} />}>
Edit comment
<Menu.Item onClick={onEditComment} leftSection={<IconEdit size={14} />}>
{t("Edit comment")}
</Menu.Item>
<Menu.Item leftSection={<IconTrash size={14} />}
onClick={openDeleteModal}
<Menu.Item
leftSection={<IconTrash size={14} />}
onClick={openDeleteModal}
>
Delete comment
{t("Delete comment")}
</Menu.Item>
</Menu.Dropdown>
</Menu>

View File

@ -1,34 +1,44 @@
import { ActionIcon } from '@mantine/core';
import { IconCircleCheck } from '@tabler/icons-react';
import { modals } from '@mantine/modals';
import { useResolveCommentMutation } from '@/features/comment/queries/comment-query';
import { ActionIcon } from "@mantine/core";
import { IconCircleCheck } from "@tabler/icons-react";
import { modals } from "@mantine/modals";
import { useResolveCommentMutation } from "@/features/comment/queries/comment-query";
import { useTranslation } from "react-i18next";
function ResolveComment({ commentId, pageId, resolvedAt }) {
const { t } = useTranslation();
const resolveCommentMutation = useResolveCommentMutation();
const isResolved = resolvedAt != null;
const iconColor = isResolved ? 'green' : 'gray';
const iconColor = isResolved ? "green" : "gray";
//@ts-ignore
const openConfirmModal = () =>
modals.openConfirmModal({
title: 'Are you sure you want to resolve this comment thread?',
title: t("Are you sure you want to resolve this comment thread?"),
centered: true,
labels: { confirm: 'Confirm', cancel: 'Cancel' },
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
onConfirm: handleResolveToggle,
});
const handleResolveToggle = async () => {
try {
await resolveCommentMutation.mutateAsync({ commentId, resolved: !isResolved });
await resolveCommentMutation.mutateAsync({
commentId,
resolved: !isResolved,
});
//TODO: remove comment mark
// Remove comment thread from state on resolve
} catch (error) {
console.error('Failed to toggle resolved state:', error);
console.error("Failed to toggle resolved state:", error);
}
};
return (
<ActionIcon onClick={openConfirmModal} variant="default" style={{ border: 'none' }}>
<ActionIcon
onClick={openConfirmModal}
variant="default"
style={{ border: "none" }}
>
<IconCircleCheck size={20} stroke={2} color={iconColor} />
</ActionIcon>
);

View File

@ -18,6 +18,7 @@ import {
} from "@/features/comment/types/comment.types";
import { notifications } from "@mantine/notifications";
import { IPagination } from "@/lib/types.ts";
import { useTranslation } from "react-i18next";
export const RQ_KEY = (pageId: string) => ["comments", pageId];
@ -33,6 +34,7 @@ export function useCommentsQuery(
export function useCreateCommentMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IComment, Error, Partial<IComment>>({
mutationFn: (data) => createComment(data),
@ -45,28 +47,37 @@ export function useCreateCommentMutation() {
//}
queryClient.refetchQueries({ queryKey: RQ_KEY(data.pageId) });
notifications.show({ message: "Comment created successfully" });
notifications.show({ message: t("Comment created successfully") });
},
onError: (error) => {
notifications.show({ message: "Error creating comment", color: "red" });
notifications.show({
message: t("Error creating comment"),
color: "red",
});
},
});
}
export function useUpdateCommentMutation() {
const { t } = useTranslation();
return useMutation<IComment, Error, Partial<IComment>>({
mutationFn: (data) => updateComment(data),
onSuccess: (data) => {
notifications.show({ message: "Comment updated successfully" });
notifications.show({ message: t("Comment updated successfully") });
},
onError: (error) => {
notifications.show({ message: "Failed to update comment", color: "red" });
notifications.show({
message: t("Failed to update comment"),
color: "red",
});
},
});
}
export function useDeleteCommentMutation(pageId?: string) {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: (commentId: string) => deleteComment(commentId),
@ -86,16 +97,20 @@ export function useDeleteCommentMutation(pageId?: string) {
});
}
notifications.show({ message: "Comment deleted successfully" });
notifications.show({ message: t("Comment deleted successfully") });
},
onError: (error) => {
notifications.show({ message: "Failed to delete comment", color: "red" });
notifications.show({
message: t("Failed to delete comment"),
color: "red",
});
},
});
}
export function useResolveCommentMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: (data: IResolveComment) => resolveComment(data),
@ -114,11 +129,11 @@ export function useResolveCommentMutation() {
queryClient.setQueryData(RQ_KEY(data.pageId), updatedComments);
}*/
notifications.show({ message: "Comment resolved successfully" });
notifications.show({ message: t("Comment resolved successfully") });
},
onError: (error) => {
notifications.show({
message: "Failed to resolve comment",
message: t("Failed to resolve comment"),
color: "red",
});
},

View File

@ -1,8 +1,9 @@
import { handleAttachmentUpload } from "@docmost/editor-ext";
import { uploadFile } from "@/features/page/services/page-service.ts";
import { notifications } from "@mantine/notifications";
import {getFileUploadSizeLimit} from "@/lib/config.ts";
import {formatBytes} from "@/lib";
import { getFileUploadSizeLimit } from "@/lib/config.ts";
import { formatBytes } from "@/lib";
import i18n from "i18next";
export const uploadAttachmentAction = handleAttachmentUpload({
onUpload: async (file: File, pageId: string): Promise<any> => {
@ -23,7 +24,9 @@ export const uploadAttachmentAction = handleAttachmentUpload({
if (file.size > getFileUploadSizeLimit()) {
notifications.show({
color: "red",
message: `File exceeds the ${formatBytes(getFileUploadSizeLimit())} attachment limit`,
message: i18n.t("File exceeds the {{limit}} attachment limit", {
limit: formatBytes(getFileUploadSizeLimit()),
}),
});
return false;
}

View File

@ -26,6 +26,7 @@ import { useAtom } from "jotai";
import { v7 as uuid7 } from "uuid";
import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
import { useTranslation } from "react-i18next";
export interface BubbleMenuItem {
name: string;
@ -39,6 +40,7 @@ type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
};
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const { t } = useTranslation();
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
const showCommentPopupRef = useRef(showCommentPopup);
@ -49,31 +51,31 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const items: BubbleMenuItem[] = [
{
name: "bold",
name: "Bold",
isActive: () => props.editor.isActive("bold"),
command: () => props.editor.chain().focus().toggleBold().run(),
icon: IconBold,
},
{
name: "italic",
name: "Italic",
isActive: () => props.editor.isActive("italic"),
command: () => props.editor.chain().focus().toggleItalic().run(),
icon: IconItalic,
},
{
name: "underline",
name: "Underline",
isActive: () => props.editor.isActive("underline"),
command: () => props.editor.chain().focus().toggleUnderline().run(),
icon: IconUnderline,
},
{
name: "strike",
name: "Strike",
isActive: () => props.editor.isActive("strike"),
command: () => props.editor.chain().focus().toggleStrike().run(),
icon: IconStrikethrough,
},
{
name: "code",
name: "Code",
isActive: () => props.editor.isActive("code"),
command: () => props.editor.chain().focus().toggleCode().run(),
icon: IconCode,
@ -81,7 +83,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
];
const commentItem: BubbleMenuItem = {
name: "comment",
name: "Comment",
isActive: () => props.editor.isActive("comment"),
command: () => {
const commentId = uuid7();
@ -138,13 +140,13 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
<ActionIcon.Group>
{items.map((item, index) => (
<Tooltip key={index} label={item.name} withArrow>
<Tooltip key={index} label={t(item.name)} withArrow>
<ActionIcon
key={index}
variant="default"
size="lg"
radius="0"
aria-label={item.name}
aria-label={t(item.name)}
className={clsx({ [classes.active]: item.isActive() })}
style={{ border: "none" }}
onClick={item.command}
@ -175,7 +177,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
variant="default"
size="lg"
radius="0"
aria-label={commentItem.name}
aria-label={t(commentItem.name)}
style={{ border: "none" }}
onClick={commentItem.command}
>

View File

@ -10,6 +10,7 @@ import {
Tooltip,
} from "@mantine/core";
import { useEditor } from "@tiptap/react";
import { useTranslation } from "react-i18next";
export interface BubbleColorMenuItem {
name: string;
@ -106,6 +107,7 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
isOpen,
setIsOpen,
}) => {
const { t } = useTranslation();
const activeColorItem = TEXT_COLORS.find(({ color }) =>
editor.isActive("textStyle", { color }),
);
@ -117,7 +119,7 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
return (
<Popover width={200} opened={isOpen} withArrow>
<Popover.Target>
<Tooltip label="Text color" withArrow>
<Tooltip label={t("Text color")} withArrow>
<ActionIcon
variant="default"
size="lg"
@ -136,8 +138,8 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
<Popover.Dropdown>
{/* make mah responsive */}
<ScrollArea.Autosize type="scroll" mah="400">
<Text span c="dimmed" inherit>
COLOR
<Text span c="dimmed" tt="uppercase" inherit>
{t("Color")}
</Text>
<Button.Group orientation="vertical">
@ -155,7 +157,7 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
}
onClick={() => {
editor.commands.unsetColor();
name !== "Default" &&
name !== t("Default") &&
editor
.chain()
.focus()
@ -165,7 +167,7 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
}}
style={{ border: "none" }}
>
{name}
{t(name)}
</Button>
))}
</Button.Group>

View File

@ -3,6 +3,7 @@ import { IconLink } from "@tabler/icons-react";
import { ActionIcon, Popover, Tooltip } from "@mantine/core";
import { useEditor } from "@tiptap/react";
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
import { useTranslation } from "react-i18next";
interface LinkSelectorProps {
editor: ReturnType<typeof useEditor>;
@ -15,6 +16,7 @@ export const LinkSelector: FC<LinkSelectorProps> = ({
isOpen,
setIsOpen,
}) => {
const { t } = useTranslation();
const onLink = useCallback(
(url: string) => {
setIsOpen(false);
@ -32,7 +34,7 @@ export const LinkSelector: FC<LinkSelectorProps> = ({
withArrow
>
<Popover.Target>
<Tooltip label="Add link" withArrow>
<Tooltip label={t("Add link")} withArrow>
<ActionIcon
variant="default"
size="lg"

View File

@ -14,6 +14,7 @@ import {
} from "@tabler/icons-react";
import { Popover, Button, ScrollArea } from "@mantine/core";
import { useEditor } from "@tiptap/react";
import { useTranslation } from "react-i18next";
interface NodeSelectorProps {
editor: ReturnType<typeof useEditor>;
@ -33,6 +34,8 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
isOpen,
setIsOpen,
}) => {
const { t } = useTranslation();
const items: BubbleMenuItem[] = [
{
name: "Text",
@ -114,7 +117,7 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
rightSection={<IconChevronDown size={16} />}
onClick={() => setIsOpen(!isOpen)}
>
{activeItem?.name}
{t(activeItem?.name)}
</Button>
</Popover.Target>
@ -137,7 +140,7 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
}}
style={{ border: "none" }}
>
{item.name}
{t(item.name)}
</Button>
))}
</Button.Group>

View File

@ -17,8 +17,10 @@ import {
IconInfoCircleFilled,
} from "@tabler/icons-react";
import { CalloutType } from "@docmost/editor-ext";
import { useTranslation } from "react-i18next";
export function CalloutMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
@ -71,11 +73,11 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
shouldShow={shouldShow}
>
<ActionIcon.Group className="actionIconGroup">
<Tooltip position="top" label="Info">
<Tooltip position="top" label={t("Info")}>
<ActionIcon
onClick={() => setCalloutType("info")}
size="lg"
aria-label="Info"
aria-label={t("Info")}
variant={
editor.isActive("callout", { type: "info" }) ? "light" : "default"
}
@ -84,11 +86,11 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Success">
<Tooltip position="top" label={t("Success")}>
<ActionIcon
onClick={() => setCalloutType("success")}
size="lg"
aria-label="Success"
aria-label={t("Success")}
variant={
editor.isActive("callout", { type: "success" })
? "light"
@ -99,11 +101,11 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Warning">
<Tooltip position="top" label={t("Warning")}>
<ActionIcon
onClick={() => setCalloutType("warning")}
size="lg"
aria-label="Warning"
aria-label={t("Warning")}
variant={
editor.isActive("callout", { type: "warning" })
? "light"
@ -114,11 +116,11 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Danger">
<Tooltip position="top" label={t("Danger")}>
<ActionIcon
onClick={() => setCalloutType("danger")}
size="lg"
aria-label="Danger"
aria-label={t("Danger")}
variant={
editor.isActive("callout", { type: "danger" })
? "light"

View File

@ -2,16 +2,17 @@ import { NodeViewContent, NodeViewProps, NodeViewWrapper } from '@tiptap/react';
import { ActionIcon, CopyButton, Group, Select, Tooltip } from '@mantine/core';
import { useEffect, useState } from 'react';
import { IconCheck, IconCopy } from '@tabler/icons-react';
//import MermaidView from "@/features/editor/components/code-block/mermaid-view.tsx";
import classes from './code-block.module.css';
import React from 'react';
import { Suspense } from 'react';
import { useTranslation } from "react-i18next";
const MermaidView = React.lazy(
() => import('@/features/editor/components/code-block/mermaid-view.tsx')
);
export default function CodeBlockView(props: NodeViewProps) {
const { t } = useTranslation();
const { node, updateAttributes, extension, editor, getPos } = props;
const { language } = node.attrs;
const [languageValue, setLanguageValue] = useState<string | null>(
@ -61,7 +62,7 @@ export default function CodeBlockView(props: NodeViewProps) {
<CopyButton value={node?.textContent} timeout={2000}>
{({ copied, copy }) => (
<Tooltip
label={copied ? 'Copied' : 'Copy'}
label={copied ? t('Copied') : t('Copy')}
withArrow
position="right"
>

View File

@ -3,6 +3,7 @@ import { useEffect, useState } from "react";
import mermaid from "mermaid";
import { v4 as uuidv4 } from "uuid";
import classes from "./code-block.module.css";
import { t } from "i18next";
mermaid.initialize({
startOnLoad: false,
@ -29,11 +30,11 @@ export default function MermaidView({ props }: MermaidViewProps) {
.catch((err) => {
if (props.editor.isEditable) {
setPreview(
`<div class="${classes.error}">Mermaid diagram error: ${err}</div>`,
`<div class="${classes.error}">${t("Mermaid diagram error:")} ${err}</div>`,
);
} else {
setPreview(
`<div class="${classes.error}">Invalid Mermaid Diagram</div>`,
`<div class="${classes.error}">${t("Invalid Mermaid diagram")}</div>`,
);
}
});

View File

@ -1,25 +1,34 @@
import { NodeViewProps, NodeViewWrapper } from '@tiptap/react';
import { ActionIcon, Card, Image, Modal, Text, useComputedColorScheme } from '@mantine/core';
import { useRef, useState } from 'react';
import { uploadFile } from '@/features/page/services/page-service.ts';
import { useDisclosure } from '@mantine/hooks';
import { getDrawioUrl, getFileUrl } from '@/lib/config.ts';
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import {
ActionIcon,
Card,
Image,
Modal,
Text,
useComputedColorScheme,
} from "@mantine/core";
import { useRef, useState } from "react";
import { uploadFile } from "@/features/page/services/page-service.ts";
import { useDisclosure } from "@mantine/hooks";
import { getDrawioUrl, getFileUrl } from "@/lib/config.ts";
import {
DrawIoEmbed,
DrawIoEmbedRef,
EventExit,
EventSave,
} from 'react-drawio';
import { IAttachment } from '@/lib/types';
import { decodeBase64ToSvgString, svgStringToFile } from '@/lib/utils';
import clsx from 'clsx';
import { IconEdit } from '@tabler/icons-react';
} from "react-drawio";
import { IAttachment } from "@/lib/types";
import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils";
import clsx from "clsx";
import { IconEdit } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
export default function DrawioView(props: NodeViewProps) {
const { t } = useTranslation();
const { node, updateAttributes, editor, selected } = props;
const { src, title, width, attachmentId } = node.attrs;
const drawioRef = useRef<DrawIoEmbedRef>(null);
const [initialXML, setInitialXML] = useState<string>('');
const [initialXML, setInitialXML] = useState<string>("");
const [opened, { open, close }] = useDisclosure(false);
const computedColorScheme = useComputedColorScheme();
@ -32,15 +41,15 @@ export default function DrawioView(props: NodeViewProps) {
if (src) {
const url = getFileUrl(src);
const request = await fetch(url, {
credentials: 'include',
cache: 'no-store',
credentials: "include",
cache: "no-store",
});
const blob = await request.blob();
const reader = new FileReader();
reader.readAsDataURL(blob);
reader.onloadend = () => {
const base64data = (reader.result || '') as string;
const base64data = (reader.result || "") as string;
setInitialXML(base64data);
};
}
@ -54,7 +63,7 @@ export default function DrawioView(props: NodeViewProps) {
const handleSave = async (data: EventSave) => {
const svgString = decodeBase64ToSvgString(data.xml);
const fileName = 'diagram.drawio.svg';
const fileName = "diagram.drawio.svg";
const drawioSVGFile = await svgStringToFile(svgString, fileName);
const pageId = editor.storage?.pageId;
@ -81,15 +90,15 @@ export default function DrawioView(props: NodeViewProps) {
<NodeViewWrapper>
<Modal.Root opened={opened} onClose={close} fullScreen>
<Modal.Overlay />
<Modal.Content style={{ overflow: 'hidden' }}>
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Body>
<div style={{ height: '100vh' }}>
<div style={{ height: "100vh" }}>
<DrawIoEmbed
ref={drawioRef}
xml={initialXML}
baseUrl={getDrawioUrl()}
urlParameters={{
ui: computedColorScheme === 'light' ? 'kennedy' : 'dark',
ui: computedColorScheme === "light" ? "kennedy" : "dark",
spin: true,
libraries: true,
saveAndExit: true,
@ -97,7 +106,7 @@ export default function DrawioView(props: NodeViewProps) {
}}
onSave={(data: EventSave) => {
// If the save is triggered by another event, then do nothing
if (data.parentEvent !== 'save') {
if (data.parentEvent !== "save") {
return;
}
handleSave(data);
@ -116,7 +125,7 @@ export default function DrawioView(props: NodeViewProps) {
</Modal.Root>
{src ? (
<div style={{ position: 'relative' }}>
<div style={{ position: "relative" }}>
<Image
onClick={(e) => e.detail === 2 && handleOpen()}
radius="md"
@ -125,8 +134,8 @@ export default function DrawioView(props: NodeViewProps) {
src={getFileUrl(src)}
alt={title}
className={clsx(
selected ? 'ProseMirror-selectednode' : '',
'alignCenter'
selected ? "ProseMirror-selectednode" : "",
"alignCenter",
)}
/>
@ -137,7 +146,7 @@ export default function DrawioView(props: NodeViewProps) {
color="gray"
mx="xs"
style={{
position: 'absolute',
position: "absolute",
top: 8,
right: 8,
}}
@ -152,20 +161,20 @@ export default function DrawioView(props: NodeViewProps) {
onClick={(e) => e.detail === 2 && handleOpen()}
p="xs"
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
withBorder
className={clsx(selected ? 'ProseMirror-selectednode' : '')}
className={clsx(selected ? "ProseMirror-selectednode" : "")}
>
<div style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ display: "flex", alignItems: "center" }}>
<ActionIcon variant="transparent" color="gray">
<IconEdit size={18} />
</ActionIcon>
<Text component="span" size="lg" c="dimmed">
Double-click to edit drawio diagram
{t("Double-click to edit Draw.io diagram")}
</Text>
</div>
</Card>

View File

@ -1,22 +1,37 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { useMemo } from "react";
import clsx from "clsx";
import { ActionIcon, AspectRatio, Button, Card, FocusTrap, Group, Popover, Text, TextInput } from "@mantine/core";
import {
ActionIcon,
AspectRatio,
Button,
Card,
FocusTrap,
Group,
Popover,
Text,
TextInput,
} from "@mantine/core";
import { IconEdit } from "@tabler/icons-react";
import { z } from "zod";
import { useForm, zodResolver } from "@mantine/form";
import {
getEmbedProviderById,
getEmbedUrlAndProvider
getEmbedUrlAndProvider,
} from "@/features/editor/components/embed/providers.ts";
import { notifications } from '@mantine/notifications';
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import i18n from "i18next";
const schema = z.object({
url: z
.string().trim().url({ message: 'please enter a valid url' }),
.string()
.trim()
.url({ message: i18n.t("Please enter a valid url") }),
});
export default function EmbedView(props: NodeViewProps) {
const { t } = useTranslation();
const { node, selected, updateAttributes } = props;
const { src, provider } = node.attrs;
@ -41,9 +56,9 @@ export default function EmbedView(props: NodeViewProps) {
updateAttributes({ src: data.url });
} else {
notifications.show({
message: `Invalid ${provider} embed link`,
position: 'top-right',
color: 'red'
message: t("Invalid {{provider}} embed link", { provider: provider }),
position: "top-right",
color: "red",
});
}
}
@ -62,7 +77,6 @@ export default function EmbedView(props: NodeViewProps) {
frameBorder="0"
></iframe>
</AspectRatio>
</>
) : (
<Popover width={300} position="bottom" withArrow shadow="md">
@ -71,20 +85,22 @@ export default function EmbedView(props: NodeViewProps) {
radius="md"
p="xs"
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
withBorder
className={clsx(selected ? 'ProseMirror-selectednode' : '')}
className={clsx(selected ? "ProseMirror-selectednode" : "")}
>
<div style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ display: "flex", alignItems: "center" }}>
<ActionIcon variant="transparent" color="gray">
<IconEdit size={18} />
</ActionIcon>
<Text component="span" size="lg" c="dimmed">
Embed {getEmbedProviderById(provider).name}
{t("Embed {{provider}}", {
provider: getEmbedProviderById(provider).name,
})}
</Text>
</div>
</Card>
@ -92,15 +108,18 @@ export default function EmbedView(props: NodeViewProps) {
<Popover.Dropdown bg="var(--mantine-color-body)">
<form onSubmit={embedForm.onSubmit(onSubmit)}>
<FocusTrap active={true}>
<TextInput placeholder={`Enter ${getEmbedProviderById(provider).name} link to embed`}
key={embedForm.key('url')}
{... embedForm.getInputProps('url')}
data-autofocus
<TextInput
placeholder={t("Enter {{provider}} link to embed", {
provider: getEmbedProviderById(provider).name,
})}
key={embedForm.key("url")}
{...embedForm.getInputProps("url")}
data-autofocus
/>
</FocusTrap>
<Group justify="center" mt="xs">
<Button type="submit">Embed link</Button>
<Button type="submit">{t("Embed link")}</Button>
</Group>
</form>
</Popover.Dropdown>

View File

@ -1,4 +1,4 @@
import { NodeViewProps, NodeViewWrapper } from '@tiptap/react';
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import {
ActionIcon,
Button,
@ -7,27 +7,29 @@ import {
Image,
Text,
useComputedColorScheme,
} from '@mantine/core';
import { useState } from 'react';
import { uploadFile } from '@/features/page/services/page-service.ts';
import { svgStringToFile } from '@/lib';
import { useDisclosure } from '@mantine/hooks';
import { getFileUrl } from '@/lib/config.ts';
import { ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types/types';
import { IAttachment } from '@/lib/types';
import ReactClearModal from 'react-clear-modal';
import clsx from 'clsx';
import { IconEdit } from '@tabler/icons-react';
import { lazy } from 'react';
import { Suspense } from 'react';
} from "@mantine/core";
import { useState } from "react";
import { uploadFile } from "@/features/page/services/page-service.ts";
import { svgStringToFile } from "@/lib";
import { useDisclosure } from "@mantine/hooks";
import { getFileUrl } from "@/lib/config.ts";
import { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types/types";
import { IAttachment } from "@/lib/types";
import ReactClearModal from "react-clear-modal";
import clsx from "clsx";
import { IconEdit } from "@tabler/icons-react";
import { lazy } from "react";
import { Suspense } from "react";
import { useTranslation } from "react-i18next";
const Excalidraw = lazy(() =>
import('@excalidraw/excalidraw').then((module) => ({
import("@excalidraw/excalidraw").then((module) => ({
default: module.Excalidraw,
}))
})),
);
export default function ExcalidrawView(props: NodeViewProps) {
const { t } = useTranslation();
const { node, updateAttributes, editor, selected } = props;
const { src, title, width, attachmentId } = node.attrs;
@ -46,11 +48,11 @@ export default function ExcalidrawView(props: NodeViewProps) {
if (src) {
const url = getFileUrl(src);
const request = await fetch(url, {
credentials: 'include',
cache: 'no-store',
credentials: "include",
cache: "no-store",
});
const { loadFromBlob } = await import('@excalidraw/excalidraw');
const { loadFromBlob } = await import("@excalidraw/excalidraw");
const data = await loadFromBlob(await request.blob(), null, null);
setExcalidrawData(data);
@ -67,7 +69,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
return;
}
const { exportToSvg } = await import('@excalidraw/excalidraw');
const { exportToSvg } = await import("@excalidraw/excalidraw");
const svg = await exportToSvg({
elements: excalidrawAPI?.getSceneElements(),
@ -83,10 +85,10 @@ export default function ExcalidrawView(props: NodeViewProps) {
svgString = svgString.replace(
/https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g,
'https://unpkg.com/@excalidraw/excalidraw@latest'
"https://unpkg.com/@excalidraw/excalidraw@latest",
);
const fileName = 'diagram.excalidraw.svg';
const fileName = "diagram.excalidraw.svg";
const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
const pageId = editor.storage?.pageId;
@ -112,7 +114,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
<NodeViewWrapper>
<ReactClearModal
style={{
backgroundColor: 'rgba(0, 0, 0, 0.5)',
backgroundColor: "rgba(0, 0, 0, 0.5)",
padding: 0,
zIndex: 200,
}}
@ -122,7 +124,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
contentProps={{
style: {
padding: 0,
width: '90vw',
width: "90vw",
},
}}
>
@ -132,14 +134,14 @@ export default function ExcalidrawView(props: NodeViewProps) {
bg="var(--mantine-color-body)"
p="xs"
>
<Button onClick={handleSave} size={'compact-sm'}>
Save & Exit
<Button onClick={handleSave} size={"compact-sm"}>
{t("Save & Exit")}
</Button>
<Button onClick={close} color="red" size={'compact-sm'}>
Exit
<Button onClick={close} color="red" size={"compact-sm"}>
{t("Exit")}
</Button>
</Group>
<div style={{ height: '90vh' }}>
<div style={{ height: "90vh" }}>
<Suspense fallback={null}>
<Excalidraw
excalidrawAPI={(api) => setExcalidrawAPI(api)}
@ -154,7 +156,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
</ReactClearModal>
{src ? (
<div style={{ position: 'relative' }}>
<div style={{ position: "relative" }}>
<Image
onClick={(e) => e.detail === 2 && handleOpen()}
radius="md"
@ -163,8 +165,8 @@ export default function ExcalidrawView(props: NodeViewProps) {
src={getFileUrl(src)}
alt={title}
className={clsx(
selected ? 'ProseMirror-selectednode' : '',
'alignCenter'
selected ? "ProseMirror-selectednode" : "",
"alignCenter",
)}
/>
@ -175,7 +177,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
color="gray"
mx="xs"
style={{
position: 'absolute',
position: "absolute",
top: 8,
right: 8,
}}
@ -190,20 +192,20 @@ export default function ExcalidrawView(props: NodeViewProps) {
onClick={(e) => e.detail === 2 && handleOpen()}
p="xs"
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
withBorder
className={clsx(selected ? 'ProseMirror-selectednode' : '')}
className={clsx(selected ? "ProseMirror-selectednode" : "")}
>
<div style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ display: "flex", alignItems: "center" }}>
<ActionIcon variant="transparent" color="gray">
<IconEdit size={18} />
</ActionIcon>
<Text component="span" size="lg" c="dimmed">
Double-click to edit Excalidraw diagram
{t("Double-click to edit Excalidraw diagram")}
</Text>
</div>
</Card>

View File

@ -17,8 +17,10 @@ import {
IconLayoutAlignRight,
} from "@tabler/icons-react";
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
import { useTranslation } from "react-i18next";
export function ImageMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
@ -96,11 +98,11 @@ export function ImageMenu({ editor }: EditorMenuProps) {
shouldShow={shouldShow}
>
<ActionIcon.Group className="actionIconGroup">
<Tooltip position="top" label="Align image left">
<Tooltip position="top" label={t("Align left")}>
<ActionIcon
onClick={alignImageLeft}
size="lg"
aria-label="Align image left"
aria-label={t("Align left")}
variant={
editor.isActive("image", { align: "left" }) ? "light" : "default"
}
@ -109,11 +111,11 @@ export function ImageMenu({ editor }: EditorMenuProps) {
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Align image center">
<Tooltip position="top" label={t("Align center")}>
<ActionIcon
onClick={alignImageCenter}
size="lg"
aria-label="Align image center"
aria-label={t("Align center")}
variant={
editor.isActive("image", { align: "center" })
? "light"
@ -124,11 +126,11 @@ export function ImageMenu({ editor }: EditorMenuProps) {
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Align image right">
<Tooltip position="top" label={t("Align right")}>
<ActionIcon
onClick={alignImageRight}
size="lg"
aria-label="Align image right"
aria-label={t("Align right")}
variant={
editor.isActive("image", { align: "right" }) ? "light" : "default"
}

View File

@ -1,8 +1,9 @@
import { handleImageUpload } from "@docmost/editor-ext";
import { uploadFile } from "@/features/page/services/page-service.ts";
import { notifications } from "@mantine/notifications";
import {getFileUploadSizeLimit} from "@/lib/config.ts";
import { getFileUploadSizeLimit } from "@/lib/config.ts";
import { formatBytes } from "@/lib";
import i18n from "i18next";
export const uploadImageAction = handleImageUpload({
onUpload: async (file: File, pageId: string): Promise<any> => {
@ -23,7 +24,9 @@ export const uploadImageAction = handleImageUpload({
if (file.size > getFileUploadSizeLimit()) {
notifications.show({
color: "red",
message: `File exceeds the ${formatBytes(getFileUploadSizeLimit())} attachment limit`,
message: i18n.t("File exceeds the {{limit}} attachment limit", {
limit: formatBytes(getFileUploadSizeLimit()),
}),
});
return false;
}

View File

@ -3,11 +3,13 @@ import { Button, Group, TextInput } from "@mantine/core";
import { IconLink } from "@tabler/icons-react";
import { useLinkEditorState } from "@/features/editor/components/link/use-link-editor-state.tsx";
import { LinkEditorPanelProps } from "@/features/editor/components/link/types.ts";
import { useTranslation } from "react-i18next";
export const LinkEditorPanel = ({
onSetLink,
initialUrl,
}: LinkEditorPanelProps) => {
const { t } = useTranslation();
const state = useLinkEditorState({
onSetLink,
initialUrl,
@ -20,12 +22,12 @@ export const LinkEditorPanel = ({
<TextInput
leftSection={<IconLink size={16} />}
variant="filled"
placeholder="Paste link"
placeholder={t("Paste link")}
value={state.url}
onChange={state.onChange}
/>
<Button p={"xs"} type="submit" disabled={!state.isValidUrl}>
Save
{t("Save")}
</Button>
</Group>
</form>

View File

@ -7,6 +7,7 @@ import {
Flex,
} from "@mantine/core";
import { IconLinkOff, IconPencil } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
export type LinkPreviewPanelProps = {
url: string;
@ -19,6 +20,8 @@ export const LinkPreviewPanel = ({
onEdit,
url,
}: LinkPreviewPanelProps) => {
const { t } = useTranslation();
return (
<>
<Card withBorder radius="md" padding="xs" bg="var(--mantine-color-body)">
@ -42,13 +45,13 @@ export const LinkPreviewPanel = ({
<Flex align="center">
<Divider mx={4} orientation="vertical" />
<Tooltip label="Edit link">
<Tooltip label={t("Edit link")}>
<ActionIcon onClick={onEdit} variant="subtle" color="gray">
<IconPencil size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Remove link">
<Tooltip label={t("Remove link")}>
<ActionIcon onClick={onClear} variant="subtle" color="red">
<IconLinkOff size={16} />
</ActionIcon>

View File

@ -8,8 +8,10 @@ import classes from "./math.module.css";
import { v4 } from "uuid";
import { IconTrashX } from "@tabler/icons-react";
import { useDebouncedValue } from "@mantine/hooks";
import { useTranslation } from "react-i18next";
export default function MathBlockView(props: NodeViewProps) {
const { t } = useTranslation();
const { node, updateAttributes, editor, getPos } = props;
const mathResultContainer = useRef<HTMLDivElement>(null);
const mathPreviewContainer = useRef<HTMLDivElement>(null);
@ -94,9 +96,9 @@ export default function MathBlockView(props: NodeViewProps) {
></div>
{((isEditing && !preview?.trim().length) ||
(!isEditing && !node.attrs.text.trim().length)) && (
<div>Empty equation</div>
<div>{t("Empty equation")}</div>
)}
{error && <div>Invalid equation</div>}
{error && <div>{t("Invalid equation")}</div>}
</NodeViewWrapper>
</Popover.Target>
<Popover.Dropdown>

View File

@ -6,8 +6,10 @@ import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Popover, Textarea } from "@mantine/core";
import classes from "./math.module.css";
import { v4 } from "uuid";
import { useTranslation } from "react-i18next";
export default function MathInlineView(props: NodeViewProps) {
const { t } = useTranslation();
const { node, updateAttributes, editor, getPos } = props;
const mathResultContainer = useRef<HTMLDivElement>(null);
const mathPreviewContainer = useRef<HTMLDivElement>(null);
@ -84,9 +86,9 @@ export default function MathInlineView(props: NodeViewProps) {
></div>
{((isEditing && !preview?.trim().length) ||
(!isEditing && !node.attrs.text.trim().length)) && (
<div>Empty equation</div>
<div>{t("Empty equation")}</div>
)}
{error && <div>Invalid equation</div>}
{error && <div>{t("Invalid equation")}</div>}
</NodeViewWrapper>
</Popover.Target>
<Popover.Dropdown p={"xs"}>

View File

@ -13,6 +13,7 @@ import {
} from "@mantine/core";
import classes from "./slash-menu.module.css";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
const CommandList = ({
items,
@ -25,6 +26,7 @@ const CommandList = ({
editor: any;
range: any;
}) => {
const { t } = useTranslation();
const [selectedIndex, setSelectedIndex] = useState(0);
const viewportRef = useRef<HTMLDivElement>(null);
@ -104,18 +106,17 @@ const CommandList = ({
<ActionIcon
variant="default"
component="div"
aria-label={item.title}
>
<item.icon size={18} />
</ActionIcon>
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{item.title}
{t(item.title)}
</Text>
<Text c="dimmed" size="xs">
{item.description}
{t(item.description)}
</Text>
</div>
</Group>

View File

@ -13,9 +13,11 @@ import {
IconRowRemove,
IconSquareToggle,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
export const TableCellMenu = React.memo(
({ editor, appendTo }: EditorMenuProps): JSX.Element => {
const { t } = useTranslation();
const shouldShow = useCallback(
({ view, state, from }: ShouldShowProps) => {
if (!state) {
@ -58,45 +60,45 @@ export const TableCellMenu = React.memo(
shouldShow={shouldShow}
>
<ActionIcon.Group>
<Tooltip position="top" label="Merge cells">
<Tooltip position="top" label={t("Merge cells")}>
<ActionIcon
onClick={mergeCells}
variant="default"
size="lg"
aria-label="Merge cells"
aria-label={t("Merge cells")}
>
<IconBoxMargin size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Split cell">
<Tooltip position="top" label={t("Split cell")}>
<ActionIcon
onClick={splitCell}
variant="default"
size="lg"
aria-label="Split cell"
aria-label={t("Split cell")}
>
<IconSquareToggle size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Delete column">
<Tooltip position="top" label={t("Delete column")}>
<ActionIcon
onClick={deleteColumn}
variant="default"
size="lg"
aria-label="Delete column"
aria-label={t("Delete column")}
>
<IconColumnRemove size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Delete row">
<Tooltip position="top" label={t("Delete row")}>
<ActionIcon
onClick={deleteRow}
variant="default"
size="lg"
aria-label="Delete row"
aria-label={t("Delete row")}
>
<IconRowRemove size={18} />
</ActionIcon>

View File

@ -21,9 +21,11 @@ import {
IconTrashX,
} from "@tabler/icons-react";
import { isCellSelection } from "@docmost/editor-ext";
import { useTranslation } from "react-i18next";
export const TableMenu = React.memo(
({ editor }: EditorMenuProps): JSX.Element => {
const { t } = useTranslation();
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
@ -111,79 +113,80 @@ export const TableMenu = React.memo(
shouldShow={shouldShow}
>
<ActionIcon.Group>
<Tooltip position="top" label="Add left column">
<Tooltip position="top" label={t("Add left column")}
>
<ActionIcon
onClick={addColumnLeft}
variant="default"
size="lg"
aria-label="Add left column"
aria-label={t("Add left column")}
>
<IconColumnInsertLeft size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Add right column">
<Tooltip position="top" label={t("Add right column")}>
<ActionIcon
onClick={addColumnRight}
variant="default"
size="lg"
aria-label="Add right column"
aria-label={t("Add right column")}
>
<IconColumnInsertRight size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Delete column">
<Tooltip position="top" label={t("Delete column")}>
<ActionIcon
onClick={deleteColumn}
variant="default"
size="lg"
aria-label="Delete column"
aria-label={t("Delete column")}
>
<IconColumnRemove size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Add row above">
<Tooltip position="top" label={t("Add row above")}>
<ActionIcon
onClick={addRowAbove}
variant="default"
size="lg"
aria-label="Add row above"
aria-label={t("Add row above")}
>
<IconRowInsertTop size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Add row below">
<Tooltip position="top" label={t("Add row below")}>
<ActionIcon
onClick={addRowBelow}
variant="default"
size="lg"
aria-label="Add row below"
aria-label={t("Add row below")}
>
<IconRowInsertBottom size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Delete row">
<Tooltip position="top" label={t("Delete row")}>
<ActionIcon
onClick={deleteRow}
variant="default"
size="lg"
aria-label="Delete row"
aria-label={t("Delete row")}
>
<IconRowRemove size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Delete table">
<Tooltip position="top" label={t("Delete table")}>
<ActionIcon
onClick={deleteTable}
variant="default"
size="lg"
color="red"
aria-label="Delete table"
aria-label={t("Delete table")}
>
<IconTrashX size={18} />
</ActionIcon>

View File

@ -1,8 +1,9 @@
import { handleVideoUpload } from "@docmost/editor-ext";
import { uploadFile } from "@/features/page/services/page-service.ts";
import { notifications } from "@mantine/notifications";
import {getFileUploadSizeLimit} from "@/lib/config.ts";
import {formatBytes} from "@/lib";
import { getFileUploadSizeLimit } from "@/lib/config.ts";
import { formatBytes } from "@/lib";
import i18n from "i18next";
export const uploadVideoAction = handleVideoUpload({
onUpload: async (file: File, pageId: string): Promise<any> => {
@ -24,11 +25,12 @@ export const uploadVideoAction = handleVideoUpload({
if (file.size > getFileUploadSizeLimit()) {
notifications.show({
color: "red",
message: `File exceeds the ${formatBytes(getFileUploadSizeLimit())} attachment limit`,
message: i18n.t("File exceeds the {{limit}} attachment limit", {
limit: formatBytes(getFileUploadSizeLimit()),
}),
});
return false;
}
return true;
},
});

View File

@ -17,8 +17,10 @@ import {
IconLayoutAlignRight,
} from "@tabler/icons-react";
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
import { useTranslation } from "react-i18next";
export function VideoMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
@ -96,11 +98,11 @@ export function VideoMenu({ editor }: EditorMenuProps) {
shouldShow={shouldShow}
>
<ActionIcon.Group className="actionIconGroup">
<Tooltip position="top" label="Align video left">
<Tooltip position="top" label={t("Align left")}>
<ActionIcon
onClick={alignVideoLeft}
size="lg"
aria-label="Align video left"
aria-label={t("Align left")}
variant={
editor.isActive("video", { align: "left" }) ? "light" : "default"
}
@ -109,11 +111,11 @@ export function VideoMenu({ editor }: EditorMenuProps) {
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Align video center">
<Tooltip position="top" label={t("Align center")}>
<ActionIcon
onClick={alignVideoCenter}
size="lg"
aria-label="Align video center"
aria-label={t("Align center")}
variant={
editor.isActive("video", { align: "center" })
? "light"
@ -124,11 +126,11 @@ export function VideoMenu({ editor }: EditorMenuProps) {
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Align video right">
<Tooltip position="top" label={t("Align right")}>
<ActionIcon
onClick={alignVideoRight}
size="lg"
aria-label="Align video right"
aria-label={t("Align right")}
variant={
editor.isActive("video", { align: "right" }) ? "light" : "default"
}

View File

@ -35,7 +35,7 @@ import {
CustomCodeBlock,
Drawio,
Excalidraw,
Embed
Embed,
} from "@docmost/editor-ext";
import {
randomElement,
@ -64,6 +64,7 @@ import clojure from "highlight.js/lib/languages/clojure";
import fortran from "highlight.js/lib/languages/fortran";
import haskell from "highlight.js/lib/languages/haskell";
import scala from "highlight.js/lib/languages/scala";
import i18n from "@/i18n.ts";
const lowlight = createLowlight(common);
lowlight.register("mermaid", plaintext);
@ -94,13 +95,13 @@ export const mainExtensions = [
Placeholder.configure({
placeholder: ({ node }) => {
if (node.type.name === "heading") {
return `Heading ${node.attrs.level}`;
return i18n.t("Heading {{level}}", { level: node.attrs.level });
}
if (node.type.name === "detailsSummary") {
return "Toggle title";
return i18n.t("Toggle title");
}
if (node.type.name === "paragraph") {
return 'Write anything. Enter "/" for commands';
return i18n.t('Write anything. Enter "/" for commands');
}
},
includeChildren: true,
@ -184,7 +185,7 @@ export const mainExtensions = [
}),
Embed.configure({
view: EmbedView,
})
}),
] as any;
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];

View File

@ -19,6 +19,7 @@ import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
import { History } from "@tiptap/extension-history";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
export interface TitleEditorProps {
pageId: string;
@ -35,6 +36,7 @@ export function TitleEditor({
spaceSlug,
editable,
}: TitleEditorProps) {
const { t } = useTranslation();
const [debouncedTitleState, setDebouncedTitleState] = useState(null);
const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 500);
const {
@ -59,7 +61,7 @@ export function TitleEditor({
}),
Text,
Placeholder.configure({
placeholder: "Untitled",
placeholder: t("Untitled"),
showOnlyWhenEditable: false,
}),
History.configure({

View File

@ -4,8 +4,10 @@ import React, { useState } from "react";
import { MultiUserSelect } from "@/features/group/components/multi-user-select.tsx";
import { useParams } from "react-router-dom";
import { useAddGroupMemberMutation } from "@/features/group/queries/group-query.ts";
import { useTranslation } from "react-i18next";
export default function AddGroupMemberModal() {
const { t } = useTranslation();
const { groupId } = useParams();
const [opened, { open, close }] = useDisclosure(false);
const [userIds, setUserIds] = useState<string[]>([]);
@ -27,19 +29,19 @@ export default function AddGroupMemberModal() {
return (
<>
<Button onClick={open}>Add group members</Button>
<Button onClick={open}>{t("Add group members")}</Button>
<Modal opened={opened} onClose={close} title="Add group members">
<Modal opened={opened} onClose={close} title={t("Add group members")}>
<Divider size="xs" mb="xs" />
<MultiUserSelect
label={"Add group members"}
label={t("Add group members")}
onChange={handleMultiSelectChange}
/>
<Group justify="flex-end" mt="md">
<Button onClick={handleSubmit} type="submit">
Add
{t("Add")}
</Button>
</Group>
</Modal>

View File

@ -5,6 +5,7 @@ import { useForm, zodResolver } from "@mantine/form";
import * as z from "zod";
import { useNavigate } from "react-router-dom";
import { MultiUserSelect } from "@/features/group/components/multi-user-select.tsx";
import { useTranslation } from "react-i18next";
const formSchema = z.object({
name: z.string().trim().min(2).max(50),
@ -14,6 +15,7 @@ const formSchema = z.object({
type FormValues = z.infer<typeof formSchema>;
export function CreateGroupForm() {
const { t } = useTranslation();
const createGroupMutation = useCreateGroupMutation();
const [userIds, setUserIds] = useState<string[]>([]);
const navigate = useNavigate();
@ -52,16 +54,16 @@ export function CreateGroupForm() {
<TextInput
withAsterisk
id="name"
label="Group name"
placeholder="e.g Developers"
label={t("Group name")}
placeholder={t("e.g Developers")}
variant="filled"
{...form.getInputProps("name")}
/>
<Textarea
id="description"
label="Group description"
placeholder="e.g Group for developers"
label={t("Group description")}
placeholder={t("e.g Group for developers")}
variant="filled"
autosize
minRows={2}
@ -70,13 +72,13 @@ export function CreateGroupForm() {
/>
<MultiUserSelect
label={"Add group members"}
label={t("Add group members")}
onChange={handleMultiSelectChange}
/>
</Stack>
<Group justify="flex-end" mt="md">
<Button type="submit">Create</Button>
<Button type="submit">{t("Create")}</Button>
</Group>
</form>
</Box>

View File

@ -1,15 +1,17 @@
import { Button, Divider, Modal } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { CreateGroupForm } from "@/features/group/components/create-group-form.tsx";
import { useTranslation } from "react-i18next";
export default function CreateGroupModal() {
const { t } = useTranslation();
const [opened, { open, close }] = useDisclosure(false);
return (
<>
<Button onClick={open}>Create group</Button>
<Button onClick={open}>{t("Create group")}</Button>
<Modal opened={opened} onClose={close} title="Create group">
<Modal opened={opened} onClose={close} title={t("Create group")}>
<Divider size="xs" mb="xs" />
<CreateGroupForm />
</Modal>

View File

@ -7,6 +7,7 @@ import {
import { useForm, zodResolver } from "@mantine/form";
import * as z from "zod";
import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
const formSchema = z.object({
name: z.string().min(2).max(50),
@ -18,6 +19,7 @@ interface EditGroupFormProps {
onClose?: () => void;
}
export function EditGroupForm({ onClose }: EditGroupFormProps) {
const { t } = useTranslation();
const updateGroupMutation = useUpdateGroupMutation();
const { isSuccess } = updateGroupMutation;
const { groupId } = useParams();
@ -60,16 +62,16 @@ export function EditGroupForm({ onClose }: EditGroupFormProps) {
<TextInput
withAsterisk
id="name"
label="Group name"
placeholder="e.g Developers"
label={t("Group name")}
placeholder={t("e.g Developers")}
variant="filled"
{...form.getInputProps("name")}
/>
<Textarea
id="description"
label="Group description"
placeholder="e.g Group for developers"
label={t("Group description")}
placeholder={t("e.g Group for developers")}
variant="filled"
autosize
minRows={2}
@ -79,7 +81,7 @@ export function EditGroupForm({ onClose }: EditGroupFormProps) {
</Stack>
<Group justify="flex-end" mt="md">
<Button type="submit">Save</Button>
<Button type="submit">{t("Save")}</Button>
</Group>
</form>
</Box>

View File

@ -1,5 +1,6 @@
import { Divider, Modal } from "@mantine/core";
import { EditGroupForm } from "@/features/group/components/edit-group-form.tsx";
import { useTranslation } from "react-i18next";
interface EditGroupModalProps {
opened: boolean;
@ -10,9 +11,11 @@ export default function EditGroupModal({
opened,
onClose,
}: EditGroupModalProps) {
const { t } = useTranslation();
return (
<>
<Modal opened={opened} onClose={onClose} title="Edit group">
<Modal opened={opened} onClose={onClose} title={t("Edit group")}>
<Divider size="xs" mb="xs" />
<EditGroupForm onClose={onClose} />
</Modal>

View File

@ -9,8 +9,10 @@ import { IconDots, IconTrash } from "@tabler/icons-react";
import { useDisclosure } from "@mantine/hooks";
import EditGroupModal from "@/features/group/components/edit-group-modal.tsx";
import { modals } from "@mantine/modals";
import { useTranslation } from "react-i18next";
export default function GroupActionMenu() {
const { t } = useTranslation();
const { groupId } = useParams();
const { data: group, isLoading } = useGroupQuery(groupId);
const deleteGroupMutation = useDeleteGroupMutation();
@ -24,15 +26,16 @@ export default function GroupActionMenu() {
const openDeleteModal = () =>
modals.openConfirmModal({
title: "Delete group",
title: t("Delete group"),
children: (
<Text size="sm">
Are you sure you want to delete this group? Members will lose access
to resources this group has access to.
{t(
"Are you sure you want to delete this group? Members will lose access to resources this group has access to.",
)}
</Text>
),
centered: true,
labels: { confirm: "Delete", cancel: "Cancel" },
labels: { confirm: t("Delete"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: onDelete,
});
@ -57,7 +60,7 @@ export default function GroupActionMenu() {
<Menu.Dropdown>
<Menu.Item onClick={open} disabled={group.isDefault}>
Edit group
{t("Edit group")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
@ -66,7 +69,7 @@ export default function GroupActionMenu() {
disabled={group.isDefault}
leftSection={<IconTrash size={16} stroke={2} />}
>
Delete group
{t("Delete group")}
</Menu.Item>
</Menu.Dropdown>
</Menu>

View File

@ -7,6 +7,7 @@ import { useDisclosure } from "@mantine/hooks";
import EditGroupModal from "@/features/group/components/edit-group-modal.tsx";
import GroupActionMenu from "@/features/group/components/group-action-menu.tsx";
import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next";
export default function GroupDetails() {
const { groupId } = useParams();

View File

@ -1,11 +1,15 @@
import {Table, Group, Text, Anchor} from "@mantine/core";
import {useGetGroupsQuery} from "@/features/group/queries/group-query";
import { Table, Group, Text, Anchor } from "@mantine/core";
import { useGetGroupsQuery } from "@/features/group/queries/group-query";
import React from "react";
import {Link} from "react-router-dom";
import {IconGroupCircle} from "@/components/icons/icon-people-circle.tsx";
import { Link } from "react-router-dom";
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
import { useTranslation } from "react-i18next";
import { formatMemberCount } from "@/lib";
import { IGroup } from "@/features/group/types/group.types.ts";
export default function GroupList() {
const {data, isLoading} = useGetGroupsQuery();
const { t } = useTranslation();
const { data, isLoading } = useGetGroupsQuery();
return (
<>
@ -14,13 +18,13 @@ export default function GroupList() {
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Group</Table.Th>
<Table.Th>Members</Table.Th>
<Table.Th>{t("Group")}</Table.Th>
<Table.Th>{t("Members")}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data?.items.map((group, index) => (
{data?.items.map((group: IGroup, index: number) => (
<Table.Tr key={index}>
<Table.Td>
<Anchor
@ -34,7 +38,7 @@ export default function GroupList() {
to={`/settings/groups/${group.id}`}
>
<Group gap="sm" wrap="nowrap">
<IconGroupCircle/>
<IconGroupCircle />
<div>
<Text fz="sm" fw={500} lineClamp={1}>
{group.name}
@ -46,7 +50,6 @@ export default function GroupList() {
</Group>
</Anchor>
</Table.Td>
<Table.Td>
<Anchor
size="sm"
@ -54,12 +57,12 @@ export default function GroupList() {
style={{
cursor: "pointer",
color: "var(--mantine-color-text)",
whiteSpace: "nowrap"
whiteSpace: "nowrap",
}}
component={Link}
to={`/settings/groups/${group.id}`}
>
{group.memberCount} members
{formatMemberCount(group.memberCount, t)}
</Anchor>
</Table.Td>
</Table.Tr>

View File

@ -1,20 +1,23 @@
import {Group, Table, Text, Badge, Menu, ActionIcon} from "@mantine/core";
import { Group, Table, Text, Badge, Menu, ActionIcon } from "@mantine/core";
import {
useGroupMembersQuery,
useRemoveGroupMemberMutation,
} from "@/features/group/queries/group-query";
import {useParams} from "react-router-dom";
import { useParams } from "react-router-dom";
import React from "react";
import {IconDots} from "@tabler/icons-react";
import {modals} from "@mantine/modals";
import {CustomAvatar} from "@/components/ui/custom-avatar.tsx";
import { IconDots } from "@tabler/icons-react";
import { modals } from "@mantine/modals";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next";
import { IUser } from "@/features/user/types/user.types.ts";
export default function GroupMembersList() {
const {groupId} = useParams();
const {data, isLoading} = useGroupMembersQuery(groupId);
const { t } = useTranslation();
const { groupId } = useParams();
const { data, isLoading } = useGroupMembersQuery(groupId);
const removeGroupMember = useRemoveGroupMemberMutation();
const {isAdmin} = useUserRole();
const { isAdmin } = useUserRole();
const onRemove = async (userId: string) => {
const memberToRemove = {
@ -26,16 +29,17 @@ export default function GroupMembersList() {
const openRemoveModal = (userId: string) =>
modals.openConfirmModal({
title: "Remove group member",
title: t("Remove group member"),
children: (
<Text size="sm">
Are you sure you want to remove this user from the group? The user
will lose access to resources this group has access to.
{t(
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.",
)}
</Text>
),
centered: true,
labels: {confirm: "Delete", cancel: "Cancel"},
confirmProps: {color: "red"},
labels: { confirm: t("Delete"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => onRemove(userId),
});
@ -46,18 +50,21 @@ export default function GroupMembersList() {
<Table verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>User</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>{t("User")}</Table.Th>
<Table.Th>{t("Status")}</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data?.items.map((user, index) => (
{data?.items.map((user: IUser, index: number) => (
<Table.Tr key={index}>
<Table.Td>
<Group gap="sm">
<CustomAvatar avatarUrl={user.avatarUrl} name={user.name}/>
<CustomAvatar
avatarUrl={user.avatarUrl}
name={user.name}
/>
<div>
<Text fz="sm" fw={500}>
{user.name}
@ -68,11 +75,9 @@ export default function GroupMembersList() {
</div>
</Group>
</Table.Td>
<Table.Td>
<Badge variant="light">Active</Badge>
<Badge variant="light">{t("Active")}</Badge>
</Table.Td>
<Table.Td>
{isAdmin && (
<Menu
@ -85,13 +90,12 @@ export default function GroupMembersList() {
>
<Menu.Target>
<ActionIcon variant="subtle" c="gray">
<IconDots size={20} stroke={2}/>
<IconDots size={20} stroke={2} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={() => openRemoveModal(user.id)}>
Remove group member
{t("Remove group member")}
</Menu.Item>
</Menu.Dropdown>
</Menu>

View File

@ -4,6 +4,7 @@ import { Group, MultiSelect, MultiSelectProps, Text } from "@mantine/core";
import { useGetGroupsQuery } from "@/features/group/queries/group-query.ts";
import { IGroup } from "@/features/group/types/group.types.ts";
import { IconUsersGroup } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
interface MultiGroupSelectProps {
onChange: (value: string[]) => void;
@ -29,6 +30,7 @@ export function MultiGroupSelect({
description,
mt,
}: MultiGroupSelectProps) {
const { t } = useTranslation();
const [searchValue, setSearchValue] = useState("");
const [debouncedQuery] = useDebouncedValue(searchValue, 500);
const { data: groups, isLoading } = useGetGroupsQuery({
@ -66,8 +68,8 @@ export function MultiGroupSelect({
hidePickedOptions
maxDropdownHeight={300}
description={description}
label={label || "Add groups"}
placeholder="Search for groups"
label={label || t("Add groups")}
placeholder={t("Search for groups")}
mt={mt}
searchable
searchValue={searchValue}
@ -75,7 +77,7 @@ export function MultiGroupSelect({
clearable
variant="filled"
onChange={onChange}
nothingFoundMessage="No group found"
nothingFoundMessage={t("No group found")}
maxValues={50}
/>
);

View File

@ -4,6 +4,7 @@ import { useWorkspaceMembersQuery } from "@/features/workspace/queries/workspace
import { IUser } from "@/features/user/types/user.types.ts";
import { Group, MultiSelect, MultiSelectProps, Text } from "@mantine/core";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { useTranslation } from "react-i18next";
interface MultiUserSelectProps {
onChange: (value: string[]) => void;
@ -29,6 +30,7 @@ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
);
export function MultiUserSelect({ onChange, label }: MultiUserSelectProps) {
const { t } = useTranslation();
const [searchValue, setSearchValue] = useState("");
const [debouncedQuery] = useDebouncedValue(searchValue, 500);
const { data: users, isLoading } = useWorkspaceMembersQuery({
@ -65,15 +67,15 @@ export function MultiUserSelect({ onChange, label }: MultiUserSelectProps) {
renderOption={renderMultiSelectOption}
hidePickedOptions
maxDropdownHeight={300}
label={label || "Add members"}
placeholder="Search for users"
label={label || t("Add members")}
placeholder={t("Search for users")}
searchable
searchValue={searchValue}
onSearchChange={setSearchValue}
clearable
variant="filled"
onChange={onChange}
nothingFoundMessage="No user found"
nothingFoundMessage={t("No user found")}
maxValues={50}
/>
);

View File

@ -1,14 +1,17 @@
import { Text, Tabs, Space } from "@mantine/core";
import { IconClockHour3 } from "@tabler/icons-react";
import RecentChanges from "@/components/common/recent-changes.tsx";
import { useTranslation } from "react-i18next";
export default function HomeTabs() {
const { t } = useTranslation();
return (
<Tabs defaultValue="recent">
<Tabs.List>
<Tabs.Tab value="recent" leftSection={<IconClockHour3 size={18} />}>
<Text size="sm" fw={500}>
Recently updated
{t("Recently updated")}
</Text>
</Tabs.Tab>
</Tabs.List>

View File

@ -16,12 +16,14 @@ import {
} from "@/features/editor/atoms/editor-atoms";
import { modals } from "@mantine/modals";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
interface Props {
pageId: string;
}
function HistoryList({ pageId }: Props) {
const { t } = useTranslation();
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
const {
data: pageHistoryList,
@ -36,14 +38,15 @@ function HistoryList({ pageId }: Props) {
const confirmModal = () =>
modals.openConfirmModal({
title: "Please confirm your action",
title: t("Please confirm your action"),
children: (
<Text size="sm">
Are you sure you want to restore this version? Any changes not
versioned will be lost.
{t(
"Are you sure you want to restore this version? Any changes not versioned will be lost.",
)}
</Text>
),
labels: { confirm: "Confirm", cancel: "Cancel" },
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
onConfirm: handleRestore,
});
@ -60,7 +63,7 @@ function HistoryList({ pageId }: Props) {
.setContent(activeHistoryData.content)
.run();
setHistoryModalOpen(false);
notifications.show({ message: "Successfully restored" });
notifications.show({ message: t("Successfully restored") });
}
}, [activeHistoryData]);
@ -79,11 +82,11 @@ function HistoryList({ pageId }: Props) {
}
if (isError) {
return <div>Error loading page history.</div>;
return <div>{t("Error loading page history.")}</div>;
}
if (!pageHistoryList || pageHistoryList.items.length === 0) {
return <>No page history saved yet.</>;
return <>{t("No page history saved yet.")}</>;
}
return (
@ -104,14 +107,14 @@ function HistoryList({ pageId }: Props) {
<Group p="xs" wrap="nowrap">
<Button size="compact-md" onClick={confirmModal}>
Restore
{t("Restore")}
</Button>
<Button
variant="default"
size="compact-md"
onClick={() => setHistoryModalOpen(false)}
>
Cancel
{t("Cancel")}
</Button>
</Group>
</div>

View File

@ -2,11 +2,13 @@ import { Modal, Text } from "@mantine/core";
import { useAtom } from "jotai";
import { historyAtoms } from "@/features/page-history/atoms/history-atoms";
import HistoryModalBody from "@/features/page-history/components/history-modal-body";
import { useTranslation } from "react-i18next";
interface Props {
pageId: string;
}
export default function HistoryModal({ pageId }: Props) {
const { t } = useTranslation();
const [isModalOpen, setModalOpen] = useAtom(historyAtoms);
return (
@ -21,7 +23,7 @@ export default function HistoryModal({ pageId }: Props) {
<Modal.Header>
<Modal.Title>
<Text size="md" fw={500}>
Page history
{t("Page history")}
</Text>
</Modal.Title>
<Modal.CloseButton />

View File

@ -1,11 +1,13 @@
import { usePageHistoryQuery } from '@/features/page-history/queries/page-history-query';
import { HistoryEditor } from '@/features/page-history/components/history-editor';
import { usePageHistoryQuery } from "@/features/page-history/queries/page-history-query";
import { HistoryEditor } from "@/features/page-history/components/history-editor";
import { useTranslation } from "react-i18next";
interface HistoryProps {
historyId: string;
}
function HistoryView({ historyId }: HistoryProps) {
const { t } = useTranslation();
const { data, isLoading, isError } = usePageHistoryQuery(historyId);
if (isLoading) {
@ -13,13 +15,15 @@ function HistoryView({ historyId }: HistoryProps) {
}
if (isError || !data) {
return <div>Error fetching page data.</div>;
return <div>{t("Error fetching page data.")}</div>;
}
return (data &&
<div>
<HistoryEditor content={data.content} title={data.title} />
</div>
return (
data && (
<div>
<HistoryEditor content={data.content} title={data.title} />
</div>
)
);
}

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>
</>
)}

View File

@ -6,11 +6,13 @@ import { useNavigate } from "react-router-dom";
import { useDebouncedValue } from "@mantine/hooks";
import { usePageSearchQuery } from "@/features/search/queries/search-query";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { useTranslation } from "react-i18next";
interface SearchSpotlightProps {
spaceId?: string;
}
export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const [query, setQuery] = useState("");
const [debouncedSearchQuery] = useDebouncedValue(query, 300);
@ -65,16 +67,16 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
}}
>
<Spotlight.Search
placeholder="Search..."
placeholder={t("Search...")}
leftSection={<IconSearch size={20} stroke={1.5} />}
/>
<Spotlight.ActionsList>
{query.length === 0 && pages.length === 0 && (
<Spotlight.Empty>Start typing to search...</Spotlight.Empty>
<Spotlight.Empty>{t("Start typing to search...")}</Spotlight.Empty>
)}
{query.length > 0 && pages.length === 0 && (
<Spotlight.Empty>No results found...</Spotlight.Empty>
<Spotlight.Empty>{t("No results found...")}</Spotlight.Empty>
)}
{pages.length > 0 && pages}

View File

@ -5,6 +5,7 @@ import { useAddSpaceMemberMutation } from "@/features/space/queries/space-query.
import { MultiMemberSelect } from "@/features/space/components/multi-member-select.tsx";
import { SpaceMemberRole } from "@/features/space/components/space-member-role.tsx";
import { SpaceRole } from "@/lib/types.ts";
import { useTranslation } from "react-i18next";
interface AddSpaceMemberModalProps {
spaceId: string;
@ -12,6 +13,7 @@ interface AddSpaceMemberModalProps {
export default function AddSpaceMembersModal({
spaceId,
}: AddSpaceMemberModalProps) {
const { t } = useTranslation();
const [opened, { open, close }] = useDisclosure(false);
const [memberIds, setMemberIds] = useState<string[]>([]);
const [role, setRole] = useState<string>(SpaceRole.WRITER);
@ -48,8 +50,8 @@ export default function AddSpaceMembersModal({
return (
<>
<Button onClick={open}>Add space members</Button>
<Modal opened={opened} onClose={close} title="Add space members">
<Button onClick={open}>{t("Add space members")}</Button>
<Modal opened={opened} onClose={close} title={t("Add space members")}>
<Divider size="xs" mb="xs" />
<Stack>
@ -57,13 +59,13 @@ export default function AddSpaceMembersModal({
<SpaceMemberRole
onSelect={handleRoleSelection}
defaultRole={role}
label="Select role"
label={t("Select role")}
/>
</Stack>
<Group justify="flex-end" mt="md">
<Button onClick={handleSubmit} type="submit">
Add
{t("Add")}
</Button>
</Group>
</Modal>

View File

@ -6,6 +6,7 @@ import { useNavigate } from "react-router-dom";
import { useCreateSpaceMutation } from "@/features/space/queries/space-query.ts";
import { computeSpaceSlug } from "@/lib";
import { getSpaceUrl } from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
const formSchema = z.object({
name: z.string().trim().min(2).max(50),
@ -23,6 +24,7 @@ const formSchema = z.object({
type FormValues = z.infer<typeof formSchema>;
export function CreateSpaceForm() {
const { t } = useTranslation();
const createSpaceMutation = useCreateSpaceMutation();
const navigate = useNavigate();
@ -74,8 +76,8 @@ export function CreateSpaceForm() {
<TextInput
withAsterisk
id="name"
label="Space name"
placeholder="e.g Product Team"
label={t("Space name")}
placeholder={t("e.g Product Team")}
variant="filled"
{...form.getInputProps("name")}
/>
@ -83,16 +85,16 @@ export function CreateSpaceForm() {
<TextInput
withAsterisk
id="slug"
label="Space slug"
placeholder="e.g product"
label={t("Space slug")}
placeholder={t("e.g product")}
variant="filled"
{...form.getInputProps("slug")}
/>
<Textarea
id="description"
label="Space description"
placeholder="e.g Space for product team"
label={t("Space description")}
placeholder={t("e.g Space for product team")}
variant="filled"
autosize
minRows={2}
@ -102,7 +104,7 @@ export function CreateSpaceForm() {
</Stack>
<Group justify="flex-end" mt="md">
<Button type="submit">Create</Button>
<Button type="submit">{t("Create")}</Button>
</Group>
</form>
</Box>

View File

@ -1,15 +1,17 @@
import { Button, Divider, Modal } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { CreateSpaceForm } from "@/features/space/components/create-space-form.tsx";
import { useTranslation } from "react-i18next";
export default function CreateSpaceModal() {
const { t } = useTranslation();
const [opened, { open, close }] = useDisclosure(false);
return (
<>
<Button onClick={open}>Create space</Button>
<Button onClick={open}>{t("Create space")}</Button>
<Modal opened={opened} onClose={close} title="Create space">
<Modal opened={opened} onClose={close} title={t("Create space")}>
<Divider size="xs" mb="xs" />
<CreateSpaceForm />
</Modal>

View File

@ -4,6 +4,7 @@ import { useForm, zodResolver } from "@mantine/form";
import * as z from "zod";
import { useUpdateSpaceMutation } from "@/features/space/queries/space-query.ts";
import { ISpace } from "@/features/space/types/space.types.ts";
import { useTranslation } from "react-i18next";
const formSchema = z.object({
name: z.string().min(2).max(50),
@ -24,6 +25,7 @@ interface EditSpaceFormProps {
readOnly?: boolean;
}
export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
const { t } = useTranslation();
const updateSpaceMutation = useUpdateSpaceMutation();
const form = useForm<FormValues>({
@ -65,8 +67,8 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
<Stack>
<TextInput
id="name"
label="Name"
placeholder="e.g Sales"
label={t("Name")}
placeholder={t("e.g Sales")}
variant="filled"
readOnly={readOnly}
{...form.getInputProps("name")}
@ -74,7 +76,7 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
<TextInput
id="slug"
label="Slug"
label={t("Slug")}
variant="filled"
readOnly={readOnly}
{...form.getInputProps("slug")}
@ -82,8 +84,8 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
<Textarea
id="description"
label="Description"
placeholder="e.g Space for sales team to collaborate"
label={t("Description")}
placeholder={t("e.g Space for sales team to collaborate")}
variant="filled"
readOnly={readOnly}
autosize
@ -96,7 +98,7 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
{!readOnly && (
<Group justify="flex-end" mt="md">
<Button type="submit" disabled={!form.isDirty()}>
Save
{t("Save")}
</Button>
</Group>
)}

View File

@ -6,6 +6,7 @@ import { useSearchSuggestionsQuery } from "@/features/search/queries/search-quer
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { IUser } from "@/features/user/types/user.types.ts";
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
import { useTranslation } from "react-i18next";
interface MultiMemberSelectProps {
onChange: (value: string[]) => void;
@ -30,6 +31,7 @@ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
);
export function MultiMemberSelect({ onChange }: MultiMemberSelectProps) {
const { t } = useTranslation();
const [searchValue, setSearchValue] = useState("");
const [debouncedQuery] = useDebouncedValue(searchValue, 500);
const { data: suggestion, isLoading } = useSearchSuggestionsQuery({
@ -83,14 +85,14 @@ export function MultiMemberSelect({ onChange }: MultiMemberSelectProps) {
const updatedUserGroups = mergeItemsIntoGroups(
data,
userItems,
"Select a user",
t("Select a user"),
);
// Merge group items into groups
const finalData = mergeItemsIntoGroups(
updatedUserGroups,
groupItems,
"Select a group",
t("Select a group"),
);
setData(finalData);
@ -103,8 +105,8 @@ export function MultiMemberSelect({ onChange }: MultiMemberSelectProps) {
renderOption={renderMultiSelectOption}
hidePickedOptions
maxDropdownHeight={300}
label="Add members"
placeholder="Search for users and groups"
label={t("Add members")}
placeholder={t("Search for users and groups")}
searchable
searchValue={searchValue}
onSearchChange={setSearchValue}

View File

@ -9,6 +9,7 @@ import {
SpaceCaslAction,
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type.ts";
import { useTranslation } from "react-i18next";
interface SpaceSettingsModalProps {
spaceId: string;
@ -17,11 +18,12 @@ interface SpaceSettingsModalProps {
}
export default function SpaceSettingsModal({
spaceId,
opened,
onClose,
}: SpaceSettingsModalProps) {
const {data: space, isLoading} = useSpaceQuery(spaceId);
spaceId,
opened,
onClose,
}: SpaceSettingsModalProps) {
const { t } = useTranslation();
const { data: space, isLoading } = useSpaceQuery(spaceId);
const spaceRules = space?.membership?.permissions;
const spaceAbility = useSpaceAbility(spaceRules);
@ -50,10 +52,10 @@ export default function SpaceSettingsModal({
<Tabs defaultValue="members">
<Tabs.List>
<Tabs.Tab fw={500} value="general">
Settings
{t("Settings")}
</Tabs.Tab>
<Tabs.Tab fw={500} value="members">
Members
{t("Members")}
</Tabs.Tab>
</Tabs.List>

View File

@ -1,8 +1,9 @@
import { useEffect, useState } from 'react';
import { useDebouncedValue } from '@mantine/hooks';
import { Avatar, Group, Select, SelectProps, Text } from '@mantine/core';
import { useGetSpacesQuery } from '@/features/space/queries/space-query.ts';
import { ISpace } from '../../types/space.types';
import { useEffect, useState } from "react";
import { useDebouncedValue } from "@mantine/hooks";
import { Avatar, Group, Select, SelectProps, Text } from "@mantine/core";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
import { ISpace } from "../../types/space.types";
import { useTranslation } from "react-i18next";
interface SpaceSelectProps {
onChange: (value: string) => void;
@ -10,7 +11,7 @@ interface SpaceSelectProps {
label?: string;
}
const renderSelectOption: SelectProps['renderOption'] = ({ option }) => (
const renderSelectOption: SelectProps["renderOption"] = ({ option }) => (
<Group gap="sm">
<Avatar color="initials" variant="filled" name={option.label} size={20} />
<div>
@ -20,7 +21,8 @@ const renderSelectOption: SelectProps['renderOption'] = ({ option }) => (
);
export function SpaceSelect({ onChange, label, value }: SpaceSelectProps) {
const [searchValue, setSearchValue] = useState('');
const { t } = useTranslation();
const [searchValue, setSearchValue] = useState("");
const [debouncedQuery] = useDebouncedValue(searchValue, 500);
const { data: spaces, isLoading } = useGetSpacesQuery({
query: debouncedQuery,
@ -41,7 +43,7 @@ export function SpaceSelect({ onChange, label, value }: SpaceSelectProps) {
const filteredSpaceData = spaceData.filter(
(user) =>
!data.find((existingUser) => existingUser.value === user.value)
!data.find((existingUser) => existingUser.value === user.value),
);
setData((prevData) => [...prevData, ...filteredSpaceData]);
}
@ -53,14 +55,14 @@ export function SpaceSelect({ onChange, label, value }: SpaceSelectProps) {
renderOption={renderSelectOption}
maxDropdownHeight={300}
//label={label || 'Select space'}
placeholder="Search for spaces"
placeholder={t("Search for spaces")}
searchable
searchValue={searchValue}
onSearchChange={setSearchValue}
clearable
variant="filled"
onChange={onChange}
nothingFoundMessage="No space found"
nothingFoundMessage={t("No space found")}
limit={50}
checkIconPosition="right"
comboboxProps={{ width: 300, withinPortal: false }}

View File

@ -35,10 +35,12 @@ import {
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type.ts";
import PageImportModal from "@/features/page/components/page-import-modal.tsx";
import { useTranslation } from "react-i18next";
import { SwitchSpace } from "./switch-space";
import ExportModal from "@/components/common/export-modal";
export function SpaceSidebar() {
const { t } = useTranslation();
const [tree] = useAtom(treeApiAtom);
const location = useLocation();
const [opened, { open: openSettings, close: closeSettings }] =
@ -89,7 +91,7 @@ export function SpaceSidebar() {
className={classes.menuItemIcon}
stroke={2}
/>
<span>Overview</span>
<span>{t("Overview")}</span>
</div>
</UnstyledButton>
@ -100,7 +102,7 @@ export function SpaceSidebar() {
className={classes.menuItemIcon}
stroke={2}
/>
<span>Search</span>
<span>{t("Search")}</span>
</div>
</UnstyledButton>
@ -111,7 +113,7 @@ export function SpaceSidebar() {
className={classes.menuItemIcon}
stroke={2}
/>
<span>Space settings</span>
<span>{t("Space settings")}</span>
</div>
</UnstyledButton>
@ -129,7 +131,7 @@ export function SpaceSidebar() {
className={classes.menuItemIcon}
stroke={2}
/>
<span>New page</span>
<span>{t("New page")}</span>
</div>
</UnstyledButton>
)}
@ -139,7 +141,7 @@ export function SpaceSidebar() {
<div className={clsx(classes.section, classes.sectionPages)}>
<Group className={classes.pagesHeader} justify="space-between">
<Text size="xs" fw={500} c="dimmed">
Pages
{t("Pages")}
</Text>
{spaceAbility.can(
@ -149,12 +151,12 @@ export function SpaceSidebar() {
<Group gap="xs">
<SpaceMenu spaceId={space.id} onSpaceSettings={openSettings} />
<Tooltip label="Create page" withArrow position="right">
<Tooltip label={t("Create page")} withArrow position="right">
<ActionIcon
variant="default"
size={18}
onClick={handleCreatePage}
aria-label="Create page"
aria-label={t("Create page")}
>
<IconPlus />
</ActionIcon>
@ -191,6 +193,7 @@ interface SpaceMenuProps {
onSpaceSettings: () => void;
}
function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
const { t } = useTranslation();
const [importOpened, { open: openImportModal, close: closeImportModal }] =
useDisclosure(false);
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
@ -201,11 +204,15 @@ function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
<Menu width={200} shadow="md" withArrow>
<Menu.Target>
<Tooltip
label="Import pages & space settings"
label={t("Import pages & space settings")}
withArrow
position="top"
>
<ActionIcon variant="default" size={18} aria-label="Space menu">
<ActionIcon
variant="default"
size={18}
aria-label={t("Space menu")}
>
<IconDots />
</ActionIcon>
</Tooltip>
@ -216,7 +223,7 @@ function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
onClick={openImportModal}
leftSection={<IconArrowDown size={16} />}
>
Import pages
{t("Import pages")}
</Menu.Item>
<Menu.Item
@ -232,7 +239,7 @@ function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
onClick={onSpaceSettings}
leftSection={<IconSettings size={16} />}
>
Space settings
{t("Space settings")}
</Menu.Item>
</Menu.Dropdown>
</Menu>

View File

@ -5,12 +5,14 @@ import { Button, Divider, Group, Text } from '@mantine/core';
import DeleteSpaceModal from './delete-space-modal';
import { useDisclosure } from "@mantine/hooks";
import ExportModal from "@/components/common/export-modal.tsx";
import { useTranslation } from "react-i18next";
interface SpaceDetailsProps {
spaceId: string;
readOnly?: boolean;
}
export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
const { t } = useTranslation();
const { data: space, isLoading } = useSpaceQuery(spaceId);
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
useDisclosure(false);
@ -20,7 +22,7 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
{space && (
<div>
<Text my="md" fw={600}>
Details
{t("Details")}
</Text>
<EditSpaceForm space={space} readOnly={readOnly} />
@ -33,12 +35,12 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
<div>
<Text size="md">Export space</Text>
<Text size="sm" c="dimmed">
Export all pages and attachments in this space
{t("Export all pages and attachments in this space.")}
</Text>
</div>
<Button onClick={openExportModal}>
Export
{t("Export")}
</Button>
</Group>
@ -46,9 +48,9 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">Delete space</Text>
<Text size="md">{t("Delete space")}</Text>
<Text size="sm" c="dimmed">
Delete this space with all its pages and data.
{t("Delete this space with all its pages and data.")}
</Text>
</div>

View File

@ -5,8 +5,10 @@ import { getSpaceUrl } from "@/lib/config.ts";
import { Link } from "react-router-dom";
import classes from "./space-grid.module.css";
import { formatMemberCount } from "@/lib";
import { useTranslation } from "react-i18next";
export default function SpaceGrid() {
const { t } = useTranslation();
const { data, isLoading } = useGetSpacesQuery();
const cards = data?.items.map((space, index) => (
@ -33,7 +35,7 @@ export default function SpaceGrid() {
</Text>
<Text c="dimmed" size="xs" fw={700} mt="md">
{formatMemberCount(space.memberCount)}
{formatMemberCount(space.memberCount, t)}
</Text>
</Card>
));
@ -41,7 +43,7 @@ export default function SpaceGrid() {
return (
<>
<Text fz="sm" fw={500} mb={"md"}>
Spaces you belong to
{t("Spaces you belong to")}
</Text>
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3 }}>{cards}</SimpleGrid>

View File

@ -3,8 +3,10 @@ import { IconClockHour3 } from "@tabler/icons-react";
import RecentChanges from "@/components/common/recent-changes.tsx";
import { useParams } from "react-router-dom";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { useTranslation } from "react-i18next";
export default function SpaceHomeTabs() {
const { t } = useTranslation();
const { spaceSlug } = useParams();
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
@ -13,7 +15,7 @@ export default function SpaceHomeTabs() {
<Tabs.List>
<Tabs.Tab value="recent" leftSection={<IconClockHour3 size={18} />}>
<Text size="sm" fw={500}>
Recently updated
{t("Recently updated")}
</Text>
</Tabs.Tab>
</Tabs.List>

View File

@ -1,13 +1,15 @@
import {Table, Group, Text, Avatar} from "@mantine/core";
import React, {useState} from "react";
import {useGetSpacesQuery} from "@/features/space/queries/space-query.ts";
import { Table, Group, Text, Avatar } from "@mantine/core";
import React, { useState } from "react";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
import {useDisclosure} from "@mantine/hooks";
import {formatMemberCount} from "@/lib";
import { useDisclosure } from "@mantine/hooks";
import { formatMemberCount } from "@/lib";
import { useTranslation } from "react-i18next";
export default function SpaceList() {
const {data, isLoading} = useGetSpacesQuery();
const [opened, {open, close}] = useDisclosure(false);
const { t } = useTranslation();
const { data, isLoading } = useGetSpacesQuery();
const [opened, { open, close }] = useDisclosure(false);
const [selectedSpaceId, setSelectedSpaceId] = useState<string>(null);
const handleClick = (spaceId: string) => {
@ -22,8 +24,8 @@ export default function SpaceList() {
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Space</Table.Th>
<Table.Th>Members</Table.Th>
<Table.Th>{t("Space")}</Table.Th>
<Table.Th>{t("Members")}</Table.Th>
</Table.Tr>
</Table.Thead>
@ -31,7 +33,7 @@ export default function SpaceList() {
{data?.items.map((space, index) => (
<Table.Tr
key={index}
style={{cursor: "pointer"}}
style={{ cursor: "pointer" }}
onClick={() => handleClick(space.id)}
>
<Table.Td>
@ -51,9 +53,10 @@ export default function SpaceList() {
</div>
</Group>
</Table.Td>
<Table.Td>
<Text size="sm" style={{whiteSpace: 'nowrap'}}>{formatMemberCount(space.memberCount)}</Text>
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
{formatMemberCount(space.memberCount, t)}
</Text>
</Table.Td>
</Table.Tr>
))}

View File

@ -1,21 +1,22 @@
import {Group, Table, Text, Menu, ActionIcon} from "@mantine/core";
import { Group, Table, Text, Menu, ActionIcon } from "@mantine/core";
import React from "react";
import {IconDots} from "@tabler/icons-react";
import {modals} from "@mantine/modals";
import {CustomAvatar} from "@/components/ui/custom-avatar.tsx";
import { IconDots } from "@tabler/icons-react";
import { modals } from "@mantine/modals";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import {
useChangeSpaceMemberRoleMutation,
useRemoveSpaceMemberMutation,
useSpaceMembersQuery,
} from "@/features/space/queries/space-query.ts";
import {IconGroupCircle} from "@/components/icons/icon-people-circle.tsx";
import {IRemoveSpaceMember} from "@/features/space/types/space.types.ts";
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
import { IRemoveSpaceMember } from "@/features/space/types/space.types.ts";
import RoleSelectMenu from "@/components/ui/role-select-menu.tsx";
import {
getSpaceRoleLabel,
spaceRoleData,
} from "@/features/space/types/space-role-data.ts";
import {formatMemberCount} from "@/lib";
import { formatMemberCount } from "@/lib";
import { useTranslation } from "react-i18next";
type MemberType = "user" | "group";
@ -25,10 +26,12 @@ interface SpaceMembersProps {
}
export default function SpaceMembersList({
spaceId,
readOnly,
}: SpaceMembersProps) {
const {data, isLoading} = useSpaceMembersQuery(spaceId);
spaceId,
readOnly,
}: SpaceMembersProps) {
const { t } = useTranslation();
const { data, isLoading } = useSpaceMembersQuery(spaceId);
const removeSpaceMember = useRemoveSpaceMemberMutation();
const changeSpaceMemberRoleMutation = useChangeSpaceMemberRoleMutation();
@ -79,16 +82,17 @@ export default function SpaceMembersList({
const openRemoveModal = (memberId: string, type: MemberType) =>
modals.openConfirmModal({
title: "Remove space member",
title: t("Remove space member"),
children: (
<Text size="sm">
Are you sure you want to remove this user from the space? The user
will lose all access to this space.
{t(
"Are you sure you want to remove this user from the space? The user will lose all access to this space.",
)}
</Text>
),
centered: true,
labels: {confirm: "Remove", cancel: "Cancel"},
confirmProps: {color: "red"},
labels: { confirm: t("Remove"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => onRemove(memberId, type),
});
@ -99,8 +103,8 @@ export default function SpaceMembersList({
<Table verticalSpacing={8}>
<Table.Thead>
<Table.Tr>
<Table.Th>Member</Table.Th>
<Table.Th>Role</Table.Th>
<Table.Th>{t("Member")}</Table.Th>
<Table.Th>{t("Role")}</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
@ -117,7 +121,7 @@ export default function SpaceMembersList({
/>
)}
{member.type === "group" && <IconGroupCircle/>}
{member.type === "group" && <IconGroupCircle />}
<div>
<Text fz="sm" fw={500}>
@ -127,7 +131,7 @@ export default function SpaceMembersList({
{member.type == "user" && member?.email}
{member.type == "group" &&
`Group - ${formatMemberCount(member?.memberCount)}`}
`${t("Group")} - ${formatMemberCount(member?.memberCount, t)}`}
</Text>
</div>
</Group>
@ -161,7 +165,7 @@ export default function SpaceMembersList({
>
<Menu.Target>
<ActionIcon variant="subtle" c="gray">
<IconDots size={20} stroke={2}/>
<IconDots size={20} stroke={2} />
</ActionIcon>
</Menu.Target>
@ -171,7 +175,7 @@ export default function SpaceMembersList({
openRemoveModal(member.id, member.type)
}
>
Remove space member
{t("Remove space member")}
</Menu.Item>
</Menu.Dropdown>
</Menu>

View File

@ -9,7 +9,7 @@ export const spaceRoleData: IRoleData[] = [
{
label: "Can edit",
value: SpaceRole.WRITER,
description: "Can create and edit pages in space.",
description: "Can create and edit pages in space",
},
{
label: "Can view",

View File

@ -5,10 +5,12 @@ import { useAtom } from "jotai";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { FileButton, Tooltip } from "@mantine/core";
import { uploadAvatar } from "@/features/user/services/user-service.ts";
import { useTranslation } from "react-i18next";
const userAtom = focusAtom(currentUserAtom, (optic) => optic.prop("user"));
export default function AccountAvatar() {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [currentUser] = useAtom(currentUserAtom);
const [, setUser] = useAtom(userAtom);
@ -36,7 +38,7 @@ export default function AccountAvatar() {
<>
<FileButton onChange={handleFileChange} accept="image/png,image/jpeg">
{(props) => (
<Tooltip label="Change photo" position="bottom">
<Tooltip label={t("Change photo")} position="bottom">
<CustomAvatar
{...props}
component="button"

View File

@ -0,0 +1,53 @@
import { Group, Text, Select } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { updateUser } from "../services/user-service";
import { useAtom } from "jotai";
import { userAtom } from "../atoms/current-user-atom";
import { useState } from "react";
export default function AccountLanguage() {
const { t } = useTranslation();
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Language")}</Text>
<Text size="sm" c="dimmed">
{t("Choose your preferred interface language.")}
</Text>
</div>
<LanguageSwitcher />
</Group>
);
}
function LanguageSwitcher() {
const { t, i18n } = useTranslation();
const [user, setUser] = useAtom(userAtom);
const [language, setLanguage] = useState(
user?.locale === "en" ? "en-US" : user.locale,
);
const handleChange = async (value: string) => {
const updatedUser = await updateUser({ locale: value });
setLanguage(value);
setUser(updatedUser);
i18n.changeLanguage(value);
};
return (
<Select
label={t("Select language")}
data={[
{ value: "en-US", label: "English (United States)" },
{ value: "zh-CN", label: "中文 (简体)" },
]}
value={language}
onChange={handleChange}
allowDeselect={false}
checkIconPosition="right"
/>
);
}

View File

@ -8,9 +8,10 @@ import { IUser } from "@/features/user/types/user.types.ts";
import { useState } from "react";
import { TextInput, Button } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
const formSchema = z.object({
name: z.string().min(2).max(40).nonempty("Your name cannot be blank"),
name: z.string().min(2).max(40),
});
type FormValues = z.infer<typeof formSchema>;
@ -18,6 +19,7 @@ type FormValues = z.infer<typeof formSchema>;
const userAtom = focusAtom(currentUserAtom, (optic) => optic.prop("user"));
export default function AccountNameForm() {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [currentUser] = useAtom(currentUserAtom);
const [, setUser] = useAtom(userAtom);
@ -36,12 +38,12 @@ export default function AccountNameForm() {
const updatedUser = await updateUser(data);
setUser(updatedUser);
notifications.show({
message: "Updated successfully",
message: t("Updated successfully"),
});
} catch (err) {
console.log(err);
notifications.show({
message: "Failed to update data",
message: t("Failed to update data"),
color: "red",
});
}
@ -53,13 +55,13 @@ export default function AccountNameForm() {
<form onSubmit={form.onSubmit(handleSubmit)}>
<TextInput
id="name"
label="Name"
placeholder="Your name"
label={t("Name")}
placeholder={t("Your name")}
variant="filled"
{...form.getInputProps("name")}
/>
<Button type="submit" mt="sm" disabled={isLoading} loading={isLoading}>
Save
{t("Save")}
</Button>
</form>
);

View File

@ -5,14 +5,17 @@ import {
Select,
MantineColorScheme,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
export default function AccountTheme() {
const { t } = useTranslation();
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">Theme</Text>
<Text size="md">{t("Theme")}</Text>
<Text size="sm" c="dimmed">
Choose your preferred color scheme.
{t("Choose your preferred color scheme.")}
</Text>
</div>
@ -22,6 +25,7 @@ export default function AccountTheme() {
}
function ThemeSwitcher() {
const { t } = useTranslation();
const { colorScheme, setColorScheme } = useMantineColorScheme();
const handleChange = (value: MantineColorScheme) => {
@ -30,11 +34,11 @@ function ThemeSwitcher() {
return (
<Select
label="Select theme"
label={t("Select theme")}
data={[
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
{ value: "auto", label: "System settings" },
{ value: "light", label: t("Light") },
{ value: "dark", label: t("Dark") },
{ value: "auto", label: t("System settings") },
]}
value={colorScheme}
onChange={handleChange}

View File

@ -13,15 +13,17 @@ import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useDisclosure } from "@mantine/hooks";
import * as React from "react";
import { useForm, zodResolver } from "@mantine/form";
import { useTranslation } from "react-i18next";
export default function ChangeEmail() {
const { t } = useTranslation();
const [currentUser] = useAtom(currentUserAtom);
const [opened, { open, close }] = useDisclosure(false);
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">Email</Text>
<Text size="md">{t("Email")}</Text>
<Text size="sm" c="dimmed">
{currentUser?.user.email}
</Text>
@ -29,13 +31,15 @@ export default function ChangeEmail() {
{/*
<Button onClick={open} variant="default">
Change email
{t("Change email")}
</Button>
*/}
<Modal opened={opened} onClose={close} title="Change email" centered>
<Modal opened={opened} onClose={close} title={t("Change email")} centered>
<Text mb="md">
To change your email, you have to enter your password and new email.
{t(
"To change your email, you have to enter your password and new email.",
)}
</Text>
<ChangeEmailForm />
</Modal>
@ -53,6 +57,7 @@ const formSchema = z.object({
type FormValues = z.infer<typeof formSchema>;
function ChangeEmailForm() {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const form = useForm<FormValues>({
@ -71,8 +76,8 @@ function ChangeEmailForm() {
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<PasswordInput
label="Password"
placeholder="Enter your password"
label={t("Password")}
placeholder={t("Enter your password")}
variant="filled"
mb="md"
{...form.getInputProps("password")}
@ -80,16 +85,16 @@ function ChangeEmailForm() {
<TextInput
id="email"
label="Email"
description="Enter your new preferred email"
placeholder="New email"
label={t("Email")}
description={t("Enter your new preferred email")}
placeholder={t("New email")}
variant="filled"
mb="md"
{...form.getInputProps("email")}
/>
<Button type="submit" disabled={isLoading} loading={isLoading}>
Change email
{t("Change email")}
</Button>
</form>
);

View File

@ -6,25 +6,34 @@ import * as React from "react";
import { useForm, zodResolver } from "@mantine/form";
import { changePassword } from "@/features/auth/services/auth-service.ts";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
export default function ChangePassword() {
const { t } = useTranslation();
const [opened, { open, close }] = useDisclosure(false);
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">Password</Text>
<Text size="md">{t("Password")}</Text>
<Text size="sm" c="dimmed">
You can change your password here.
{t("You can change your password here.")}
</Text>
</div>
<Button onClick={open} variant="default">
Change password
{t("Change password")}
</Button>
<Modal opened={opened} onClose={close} title="Change password" centered>
<Text mb="md">Your password must be a minimum of 8 characters.</Text>
<Modal
opened={opened}
onClose={close}
title={t("Change password")}
centered
>
<Text mb="md">
{t("Your password must be a minimum of 8 characters.")}
</Text>
<ChangePasswordForm onClose={close} />
</Modal>
</Group>
@ -44,6 +53,7 @@ interface ChangePasswordFormProps {
onClose?: () => void;
}
function ChangePasswordForm({ onClose }: ChangePasswordFormProps) {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const form = useForm<FormValues>({
@ -62,7 +72,7 @@ function ChangePasswordForm({ onClose }: ChangePasswordFormProps) {
newPassword: data.newPassword,
});
notifications.show({
message: "Password changed successfully",
message: t("Password changed successfully"),
});
onClose();
@ -78,9 +88,9 @@ function ChangePasswordForm({ onClose }: ChangePasswordFormProps) {
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<PasswordInput
label="Current password"
label={t("Current password")}
name="oldPassword"
placeholder="Enter your current password"
placeholder={t("Enter your current password")}
variant="filled"
mb="md"
data-autofocus
@ -88,8 +98,8 @@ function ChangePasswordForm({ onClose }: ChangePasswordFormProps) {
/>
<PasswordInput
label="New password"
placeholder="Enter your new password"
label={t("New password")}
placeholder={t("Enter your new password")}
variant="filled"
mb="md"
{...form.getInputProps("newPassword")}
@ -97,7 +107,7 @@ function ChangePasswordForm({ onClose }: ChangePasswordFormProps) {
<Group justify="flex-end" mt="md">
<Button type="submit" disabled={isLoading} loading={isLoading}>
Change password
{t("Change password")}
</Button>
</Group>
</form>

View File

@ -3,14 +3,17 @@ import { useAtom } from "jotai/index";
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
import { updateUser } from "@/features/user/services/user-service.ts";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
export default function PageWidthPref() {
const { t } = useTranslation();
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">Full page width</Text>
<Text size="md">{t("Full page width")}</Text>
<Text size="sm" c="dimmed">
Choose your preferred page width.
{t("Choose your preferred page width.")}
</Text>
</div>
@ -24,6 +27,7 @@ interface PageWidthToggleProps {
label?: string;
}
export function PageWidthToggle({ size, label }: PageWidthToggleProps) {
const { t } = useTranslation();
const [user, setUser] = useAtom(userAtom);
const [checked, setChecked] = useState(
user.settings?.preferences?.fullPageWidth,
@ -43,7 +47,7 @@ export function PageWidthToggle({ size, label }: PageWidthToggleProps) {
labelPosition="left"
defaultChecked={checked}
onChange={handleChange}
aria-label="Toggle full page width"
aria-label={t("Toggle full page width")}
/>
);
}

View File

@ -11,6 +11,7 @@ export interface IUser {
invitedById: string;
lastLoginAt: string;
lastActiveAt: Date;
locale: string;
createdAt: Date;
updatedAt: Date;
role: string;

View File

@ -2,14 +2,19 @@ import { useAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import React, { useEffect } from "react";
import useCurrentUser from "@/features/user/hooks/use-current-user";
import { useTranslation } from "react-i18next";
export function UserProvider({ children }: React.PropsWithChildren) {
const [, setCurrentUser] = useAtom(currentUserAtom);
const { data, isLoading, error } = useCurrentUser();
const { i18n } = useTranslation();
useEffect(() => {
if (data && data.user && data.workspace) {
setCurrentUser(data);
i18n.changeLanguage(
data.user.locale === "en" ? "en-US" : data.user.locale,
);
}
}, [data, isLoading]);

View File

@ -6,11 +6,13 @@ import {
useResendInvitationMutation,
useRevokeInvitationMutation,
} from "@/features/workspace/queries/workspace-query.ts";
import { useTranslation } from "react-i18next";
interface Props {
invitationId: string;
}
export default function InviteActionMenu({ invitationId }: Props) {
const { t } = useTranslation();
const resendInvitationMutation = useResendInvitationMutation();
const revokeInvitationMutation = useRevokeInvitationMutation();
@ -24,15 +26,16 @@ export default function InviteActionMenu({ invitationId }: Props) {
const openRevokeModal = () =>
modals.openConfirmModal({
title: "Revoke invitation",
title: t("Revoke invitation"),
children: (
<Text size="sm">
Are you sure you want to revoke this invitation? The user will not be
able to join the workspace.
{t(
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.",
)}
</Text>
),
centered: true,
labels: { confirm: "Revoke", cancel: "Don't" },
labels: { confirm: t("Revoke"), cancel: t("Don't") },
confirmProps: { color: "red" },
onConfirm: onRevoke,
});
@ -54,14 +57,14 @@ export default function InviteActionMenu({ invitationId }: Props) {
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={onResend}>Resend invitation</Menu.Item>
<Menu.Item onClick={onResend}>{t("Resend invitation")}</Menu.Item>
<Menu.Divider />
<Menu.Item
c="red"
onClick={openRevokeModal}
leftSection={<IconTrash size={16} stroke={2} />}
>
Revoke invitation
{t("Revoke invitation")}
</Menu.Item>
</Menu.Dropdown>
</Menu>

View File

@ -5,11 +5,13 @@ import { UserRole } from "@/lib/types.ts";
import { userRoleData } from "@/features/workspace/types/user-role-data.ts";
import { useCreateInvitationMutation } from "@/features/workspace/queries/workspace-query.ts";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
interface Props {
onClose: () => void;
}
export function WorkspaceInviteForm({ onClose }: Props) {
const { t } = useTranslation();
const [emails, setEmails] = useState<string[]>([]);
const [role, setRole] = useState<string | null>(UserRole.MEMBER);
const [groupIds, setGroupIds] = useState<string[]>([]);
@ -44,9 +46,11 @@ export function WorkspaceInviteForm({ onClose }: Props) {
<TagsInput
mt="sm"
description="Enter valid email addresses separated by comma or space [max: 50]"
label="Invite by email"
placeholder="enter valid emails addresses"
description={t(
"Enter valid email addresses separated by comma or space max_50",
)}
label={t("Invite by email")}
placeholder={t("enter valid emails addresses")}
variant="filled"
splitChars={[",", " "]}
maxDropdownHeight={200}
@ -56,11 +60,17 @@ export function WorkspaceInviteForm({ onClose }: Props) {
<Select
mt="sm"
description="Select role to assign to all invited members"
label="Select role"
placeholder="Choose a role"
description={t("Select role to assign to all invited members")}
label={t("Select role")}
placeholder={t("Choose a role")}
variant="filled"
data={userRoleData.filter((role) => role.value !== UserRole.OWNER)}
data={userRoleData
.filter((role) => role.value !== UserRole.OWNER)
.map((role) => ({
...role,
label: t(`${role.label}`),
description: t(`${role.description}`),
}))}
defaultValue={UserRole.MEMBER}
allowDeselect={false}
checkIconPosition="right"
@ -69,8 +79,10 @@ export function WorkspaceInviteForm({ onClose }: Props) {
<MultiGroupSelect
mt="sm"
description="Invited members will be granted access to spaces the groups can access"
label={"Add to groups"}
description={t(
"Invited members will be granted access to spaces the groups can access",
)}
label={t("Add to groups")}
onChange={handleGroupSelect}
/>
@ -79,7 +91,7 @@ export function WorkspaceInviteForm({ onClose }: Props) {
onClick={handleSubmit}
loading={createInvitationMutation.isPending}
>
Send invitation
{t("Send invitation")}
</Button>
</Group>
</Box>

View File

@ -1,19 +1,21 @@
import { WorkspaceInviteForm } from "@/features/workspace/components/members/components/workspace-invite-form.tsx";
import { Button, Divider, Modal, ScrollArea } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { useTranslation } from "react-i18next";
export default function WorkspaceInviteModal() {
const { t } = useTranslation();
const [opened, { open, close }] = useDisclosure(false);
return (
<>
<Button onClick={open}>Invite members</Button>
<Button onClick={open}>{t("Invite members")}</Button>
<Modal
size="550"
opened={opened}
onClose={close}
title="Invite new members"
title={t("Invite new members")}
centered
>
<Divider size="xs" mb="xs" />

View File

@ -2,8 +2,10 @@ import { useAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useEffect, useState } from "react";
import { Button, CopyButton, Group, Text, TextInput } from "@mantine/core";
import { useTranslation } from "react-i18next";
export default function WorkspaceInviteSection() {
const { t } = useTranslation();
const [currentUser] = useAtom(currentUserAtom);
const [inviteLink, setInviteLink] = useState<string>("");
@ -17,10 +19,10 @@ export default function WorkspaceInviteSection() {
<>
<div>
<Text fw={500} mb="sm">
Invite link
{t("Invite link")}
</Text>
<Text c="dimmed" mb="sm">
Anyone with this link can join this workspace.
{t("Anyone with this link can join this workspace.")}
</Text>
</div>
@ -31,7 +33,7 @@ export default function WorkspaceInviteSection() {
<CopyButton value={inviteLink}>
{({ copied, copy }) => (
<Button color={copied ? "teal" : ""} onClick={copy}>
{copied ? "Copied" : "Copy"}
{copied ? t("Copied") : t("Copy")}
</Button>
)}
</CopyButton>

View File

@ -6,17 +6,21 @@ import InviteActionMenu from "@/features/workspace/components/members/components
import {IconInfoCircle} from "@tabler/icons-react";
import {formattedDate, timeAgo} from "@/lib/time.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next";
export default function WorkspaceInvitesTable() {
const {data, isLoading} = useWorkspaceInvitationsQuery({
const { t } = useTranslation();
const { data, isLoading } = useWorkspaceInvitationsQuery({
limit: 100,
});
const {isAdmin} = useUserRole();
return (
<>
<Alert variant="light" color="blue" icon={<IconInfoCircle/>}>
Invited members who are yet to accept their invitation will appear here.
<Alert variant="light" color="blue" icon={<IconInfoCircle />}>
{t(
"Invited members who are yet to accept their invitation will appear here.",
)}
</Alert>
{data && (
@ -25,9 +29,9 @@ export default function WorkspaceInvitesTable() {
<Table verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Email</Table.Th>
<Table.Th>Role</Table.Th>
<Table.Th>Date</Table.Th>
<Table.Th>{t("Email")}</Table.Th>
<Table.Th>{t("Role")}</Table.Th>
<Table.Th>{t("Date")}</Table.Th>
</Table.Tr>
</Table.Thead>
@ -45,7 +49,7 @@ export default function WorkspaceInvitesTable() {
</Group>
</Table.Td>
<Table.Td>{getUserRoleLabel(invitation.role)}</Table.Td>
<Table.Td>{t(getUserRoleLabel(invitation.role))}</Table.Td>
<Table.Td>{timeAgo(invitation.createdAt)}</Table.Td>

View File

@ -11,14 +11,18 @@ import {
userRoleData,
} from "@/features/workspace/types/user-role-data.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
import {UserRole} from "@/lib/types.ts";
import { UserRole } from "@/lib/types.ts";
import { useTranslation } from "react-i18next";
export default function WorkspaceMembersTable() {
const {data, isLoading} = useWorkspaceMembersQuery({limit: 100});
const { t } = useTranslation();
const { data, isLoading } = useWorkspaceMembersQuery({ limit: 100 });
const changeMemberRoleMutation = useChangeMemberRoleMutation();
const {isAdmin, isOwner} = useUserRole();
const assignableUserRoles = isOwner ? userRoleData : userRoleData.filter((role) => role.value !== UserRole.OWNER);
const assignableUserRoles = isOwner
? userRoleData
: userRoleData.filter((role) => role.value !== UserRole.OWNER);
const handleRoleChange = async (
userId: string,
@ -44,9 +48,9 @@ export default function WorkspaceMembersTable() {
<Table verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>User</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Role</Table.Th>
<Table.Th>{t("User")}</Table.Th>
<Table.Th>{t("Status")}</Table.Th>
<Table.Th>{t("Role")}</Table.Th>
</Table.Tr>
</Table.Thead>
@ -66,11 +70,9 @@ export default function WorkspaceMembersTable() {
</div>
</Group>
</Table.Td>
<Table.Td>
<Badge variant="light">Active</Badge>
<Badge variant="light">{t("Active")}</Badge>
</Table.Td>
<Table.Td>
<RoleSelectMenu
roles={assignableUserRoles}

View File

@ -9,9 +9,10 @@ import { TextInput, Button } from "@mantine/core";
import { useForm, zodResolver } from "@mantine/form";
import { notifications } from "@mantine/notifications";
import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next";
const formSchema = z.object({
name: z.string().min(4).nonempty("Workspace name cannot be blank"),
name: z.string().min(4),
});
type FormValues = z.infer<typeof formSchema>;
@ -21,6 +22,7 @@ const workspaceAtom = focusAtom(currentUserAtom, (optic) =>
);
export default function WorkspaceNameForm() {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [currentUser] = useAtom(currentUserAtom);
const [, setWorkspace] = useAtom(workspaceAtom);
@ -39,11 +41,11 @@ export default function WorkspaceNameForm() {
try {
const updatedWorkspace = await updateWorkspace(data);
setWorkspace(updatedWorkspace);
notifications.show({ message: "Updated successfully" });
notifications.show({ message: t("Updated successfully") });
} catch (err) {
console.log(err);
notifications.show({
message: "Failed to update data",
message: t("Failed to update data"),
color: "red",
});
}
@ -55,8 +57,8 @@ export default function WorkspaceNameForm() {
<form onSubmit={form.onSubmit(handleSubmit)}>
<TextInput
id="name"
label="Name"
placeholder="e.g ACME"
label={t("Name")}
placeholder={t("e.g ACME")}
variant="filled"
readOnly={!isAdmin}
{...form.getInputProps("name")}
@ -69,7 +71,7 @@ export default function WorkspaceNameForm() {
disabled={isLoading || !form.isDirty()}
loading={isLoading}
>
Save
{t("Save")}
</Button>
)}
</form>

View File

@ -14,7 +14,7 @@ export const userRoleData: IRoleData[] = [
{
label: "Member",
value: UserRole.MEMBER,
description: "Can become members of groups and spaces in workspace.",
description: "Can become members of groups and spaces in workspace",
},
];

27
apps/client/src/i18n.ts Normal file
View File

@ -0,0 +1,27 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import Backend from "i18next-http-backend";
i18n
// load translation using http -> see /public/locales (i.e. https://github.com/i18next/react-i18next/tree/master/example/react/public/locales)
// learn more: https://github.com/i18next/i18next-http-backend
// want your translations to be loaded from a professional CDN? => https://github.com/locize/react-tutorial#step-2---use-the-locize-cdn
.use(Backend)
// pass the i18n instance to react-i18next.
.use(initReactI18next)
// init i18next
// for all options read: https://www.i18next.com/overview/configuration-options
.init({
fallbackLng: "en-US",
debug: false,
load: 'currentOnly',
interpolation: {
escapeValue: false, // not needed for react as it escapes by default
},
react: {
useSuspense: false,
}
});
export default i18n;

Some files were not shown because too many files have changed in this diff Show More