mirror of
https://github.com/Shadowfita/docmost.git
synced 2026-07-14 06:36:39 +10:00
Merge branch 'main' into Merged-Downstream
# Conflicts: # apps/client/package.json # apps/client/src/features/editor/extensions/extensions.ts # apps/client/src/pages/auth/login.tsx # apps/server/package.json # apps/server/src/integrations/environment/environment.service.ts # packages/editor-ext/src/index.ts # pnpm-lock.yaml
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
Modal,
|
||||
Button,
|
||||
Group,
|
||||
Text,
|
||||
Select,
|
||||
Switch,
|
||||
Divider,
|
||||
} from "@mantine/core";
|
||||
import { exportPage } from "@/features/page/services/page-service.ts";
|
||||
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;
|
||||
type: "space" | "page";
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ExportModal({
|
||||
id,
|
||||
type,
|
||||
open,
|
||||
onClose,
|
||||
}: ExportModalProps) {
|
||||
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 {
|
||||
if (type === "page") {
|
||||
await exportPage({ pageId: id, format, includeChildren });
|
||||
}
|
||||
if (type === "space") {
|
||||
await exportSpace({ spaceId: id, format, includeAttachments });
|
||||
}
|
||||
setIncludeChildren(false);
|
||||
setIncludeAttachments(true);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
message: "Export failed:" + err.response?.data.message,
|
||||
color: "red",
|
||||
});
|
||||
console.error("export error", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (format: ExportFormat) => {
|
||||
setFormat(format);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal.Root
|
||||
opened={open}
|
||||
onClose={onClose}
|
||||
size={500}
|
||||
padding="xl"
|
||||
yOffset="10vh"
|
||||
xOffset={0}
|
||||
mah={400}
|
||||
>
|
||||
<Modal.Overlay />
|
||||
<Modal.Content style={{ overflow: "hidden" }}>
|
||||
<Modal.Header py={0}>
|
||||
<Modal.Title fw={500}>Export {type}</Modal.Title>
|
||||
<Modal.CloseButton />
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div>
|
||||
<Text size="md">{t("Format")}</Text>
|
||||
</div>
|
||||
<ExportFormatSelection format={format} onChange={handleChange} />
|
||||
</Group>
|
||||
|
||||
{type === "page" && (
|
||||
<>
|
||||
<Divider my="sm" />
|
||||
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div>
|
||||
<Text size="md">{t("Include subpages")}</Text>
|
||||
</div>
|
||||
<Switch
|
||||
onChange={(event) =>
|
||||
setIncludeChildren(event.currentTarget.checked)
|
||||
}
|
||||
checked={includeChildren}
|
||||
/>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === "space" && (
|
||||
<>
|
||||
<Divider my="sm" />
|
||||
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div>
|
||||
<Text size="md">{t("Include attachments")}</Text>
|
||||
</div>
|
||||
<Switch
|
||||
onChange={(event) =>
|
||||
setIncludeAttachments(event.currentTarget.checked)
|
||||
}
|
||||
checked={includeAttachments}
|
||||
/>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Group justify="center" mt="md">
|
||||
<Button onClick={onClose} variant="default">
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleExport}>{t("Export")}</Button>
|
||||
</Group>
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExportFormatSelection {
|
||||
format: ExportFormat;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
function ExportFormatSelection({ format, onChange }: ExportFormatSelection) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Select
|
||||
data={[
|
||||
{ value: "markdown", label: "Markdown" },
|
||||
{ value: "html", label: "HTML" },
|
||||
]}
|
||||
defaultValue={format}
|
||||
onChange={onChange}
|
||||
styles={{ wrapper: { maxWidth: 120 } }}
|
||||
comboboxProps={{ width: "120" }}
|
||||
allowDeselect={false}
|
||||
withCheckIcon={false}
|
||||
aria-label={t("Select export format")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -4,33 +4,35 @@ import {
|
||||
UnstyledButton,
|
||||
Badge,
|
||||
Table,
|
||||
ScrollArea,
|
||||
ActionIcon,
|
||||
} from '@mantine/core';
|
||||
import { Link } from 'react-router-dom';
|
||||
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 { useTranslation } from "react-i18next";
|
||||
|
||||
interface Props {
|
||||
spaceId?: string;
|
||||
}
|
||||
export default function RecentChanges({ spaceId }: Props) {
|
||||
const { data: pages, isLoading, isError } = useRecentChangesQuery(spaceId);
|
||||
|
||||
export default function RecentChanges({spaceId}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const {data: pages, isLoading, isError} = useRecentChangesQuery(spaceId);
|
||||
|
||||
if (isLoading) {
|
||||
return <PageListSkeleton />;
|
||||
return <PageListSkeleton/>;
|
||||
}
|
||||
|
||||
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 ? (
|
||||
<ScrollArea>
|
||||
<Table.ScrollContainer minWidth={500}>
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Tbody>
|
||||
{pages.items.map((page) => (
|
||||
@@ -43,12 +45,12 @@ export default function RecentChanges({ spaceId }: Props) {
|
||||
<Group wrap="nowrap">
|
||||
{page.icon || (
|
||||
<ActionIcon variant='transparent' color='gray' size={18}>
|
||||
<IconFileDescription size={18} />
|
||||
<IconFileDescription size={18}/>
|
||||
</ActionIcon>
|
||||
)}
|
||||
|
||||
<Text fw={500} size="md" lineClamp={1}>
|
||||
{page.title || 'Untitled'}
|
||||
{page.title || t("Untitled")}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
@@ -60,14 +62,14 @@ export default function RecentChanges({ spaceId }: Props) {
|
||||
variant="light"
|
||||
component={Link}
|
||||
to={getSpaceUrl(page?.space.slug)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
style={{cursor: 'pointer'}}
|
||||
>
|
||||
{page?.space.name}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
)}
|
||||
<Table.Td>
|
||||
<Text c="dimmed" size="xs" fw={500}>
|
||||
<Text c="dimmed" style={{whiteSpace: 'nowrap'}} size="xs" fw={500}>
|
||||
{formattedDate(page.updatedAt)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
@@ -75,10 +77,10 @@ export default function RecentChanges({ spaceId }: Props) {
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</Table.ScrollContainer>
|
||||
) : (
|
||||
<Text size="md" ta="center">
|
||||
No pages yet
|
||||
{t("No pages yet")}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { rem } from '@mantine/core';
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
export function AirtableIcon({ size }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 215"
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
>
|
||||
<path
|
||||
fill="#ffbf00"
|
||||
d="M114.259 2.701 18.86 42.176c-5.305 2.195-5.25 9.73.089 11.847l95.797 37.989a35.544 35.544 0 0 0 26.208 0l95.799-37.99c5.337-2.115 5.393-9.65.086-11.846L141.442 2.7a35.549 35.549 0 0 0-27.183 0"
|
||||
/>
|
||||
<path
|
||||
fill="#26b5f8"
|
||||
d="M136.35 112.757v94.902c0 4.514 4.55 7.605 8.746 5.942l106.748-41.435a6.39 6.39 0 0 0 4.035-5.941V71.322c0-4.514-4.551-7.604-8.747-5.941l-106.748 41.434a6.392 6.392 0 0 0-4.035 5.942"
|
||||
/>
|
||||
<path
|
||||
fill="#ed3049"
|
||||
d="m111.423 117.654-31.68 15.296-3.217 1.555L9.65 166.548C5.411 168.593 0 165.504 0 160.795V71.72c0-1.704.874-3.175 2.046-4.283a7.266 7.266 0 0 1 1.618-1.213c1.598-.959 3.878-1.215 5.816-.448l101.41 40.18c5.155 2.045 5.56 9.268.533 11.697"
|
||||
/>
|
||||
<path
|
||||
fillOpacity={0.25}
|
||||
d="m111.423 117.654-31.68 15.296L2.045 67.438a7.266 7.266 0 0 1 1.618-1.213c1.598-.959 3.878-1.215 5.816-.448l101.41 40.18c5.155 2.045 5.56 9.268.533 11.697"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { rem } from '@mantine/core';
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
export function FigmaIcon({ size }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
>
|
||||
<g fill="none" fillRule="evenodd" transform="translate(4)">
|
||||
<circle cx={12} cy={12} r={4} fill="#19bcfe" />
|
||||
<path fill="#09cf83" d="M4 24a4 4 0 0 0 4-4v-4H4a4 4 0 1 0 0 8z" />
|
||||
<path fill="#a259ff" d="M4 16h4V8H4a4 4 0 1 0 0 8z" />
|
||||
<path fill="#f24e1e" d="M4 8h4V0H4a4 4 0 1 0 0 8z" />
|
||||
<path fill="#ff7262" d="M12 8H8V0h4a4 4 0 1 1 0 8z" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { rem } from '@mantine/core';
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
export function FramerIcon({ size }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
>
|
||||
<path d="M4 0h16v8h-8zm0 8h8l8 8H4zm0 8h8v8z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { rem } from '@mantine/core';
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
export function GoogleDriveIcon({ size }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 87.3 78"
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
>
|
||||
<path d="m6.6 66.85 3.85 6.65c.8 1.4 1.95 2.5 3.3 3.3l13.75-23.8h-27.5c0 1.55.4 3.1 1.2 4.5z" fill="#0066da" />
|
||||
<path d="m43.65 25-13.75-23.8c-1.35.8-2.5 1.9-3.3 3.3l-25.4 44a9.06 9.06 0 0 0 -1.2 4.5h27.5z" fill="#00ac47" />
|
||||
<path d="m73.55 76.8c1.35-.8 2.5-1.9 3.3-3.3l1.6-2.75 7.65-13.25c.8-1.4 1.2-2.95 1.2-4.5h-27.502l5.852 11.5z"
|
||||
fill="#ea4335" />
|
||||
<path d="m43.65 25 13.75-23.8c-1.35-.8-2.9-1.2-4.5-1.2h-18.5c-1.6 0-3.15.45-4.5 1.2z" fill="#00832d" />
|
||||
<path d="m59.8 53h-32.3l-13.75 23.8c1.35.8 2.9 1.2 4.5 1.2h50.8c1.6 0 3.15-.45 4.5-1.2z" fill="#2684fc" />
|
||||
<path d="m73.4 26.5-12.7-22c-.8-1.4-1.95-2.5-3.3-3.3l-13.75 23.8 16.15 28h27.45c0-1.55-.4-3.1-1.2-4.5z"
|
||||
fill="#ffba00" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { rem } from '@mantine/core';
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
export function GoogleSheetsIcon({ size }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 48 48"
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
>
|
||||
<path fill="#43a047" d="M37,45H11c-1.657,0-3-1.343-3-3V6c0-1.657,1.343-3,3-3h19l10,10v29C40,43.657,38.657,45,37,45z"/>
|
||||
<path fill="#c8e6c9" d="M40 13L30 13 30 3z"/>
|
||||
<path fill="#2e7d32" d="M30 13L40 23 40 13z"/>
|
||||
<path
|
||||
fill="#e8f5e9"
|
||||
d="M31,23H17h-2v2v2v2v2v2v2v2h18v-2v-2v-2v-2v-2v-2v-2H31z M17,25h4v2h-4V25z M17,29h4v2h-4V29z M17,33h4v2h-4V33z M31,35h-8v-2h8V35z M31,31h-8v-2h8V31z M31,27h-8v-2h8V27z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export { AirtableIcon } from "./airtable-icon.tsx";
|
||||
export { FigmaIcon } from "./figma-icon.tsx";
|
||||
export { TypeformIcon } from "./typeform-icon.tsx";
|
||||
export { VimeoIcon } from "./vimeo-icon.tsx";
|
||||
export { MiroIcon } from "./miro-icon.tsx";
|
||||
export { GoogleDriveIcon } from "./google-drive-icon.tsx";
|
||||
export { GoogleSheetsIcon } from "./google-sheets-icon.tsx";
|
||||
export { FramerIcon } from "./framer-icon.tsx";
|
||||
export { LoomIcon } from "./loom-icon.tsx";
|
||||
export { YoutubeIcon } from "./youtube-icon.tsx";
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { rem } from '@mantine/core';
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
export function LoomIcon({ size }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="#625DF5"
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
>
|
||||
<path
|
||||
d="M24 10.665h-7.018l6.078-3.509-1.335-2.312-6.078 3.509 3.508-6.077L16.843.94l-3.508 6.077V0h-2.67v7.018L7.156.94 4.844 2.275l3.509 6.077-6.078-3.508L.94 7.156l6.078 3.509H0v2.67h7.017L.94 16.844l1.335 2.313 6.077-3.508-3.509 6.077 2.312 1.335 3.509-6.078V24h2.67v-7.017l3.508 6.077 2.312-1.335-3.509-6.078 6.078 3.509 1.335-2.313-6.077-3.508h7.017v-2.67H24zm-12 4.966a3.645 3.645 0 1 1 0-7.29 3.645 3.645 0 0 1 0 7.29z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { rem } from '@mantine/core';
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
export function MiroIcon({ size }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
>
|
||||
<path
|
||||
d="M17.392 0H13.9L17 4.808 10.444 0H6.949l3.102 6.3L3.494 0H0l3.05 8.131L0 24h3.494L10.05 6.985 6.949 24h3.494L17 5.494 13.899 24h3.493L24 3.672 17.392 0z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { rem } from '@mantine/core';
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
export function TypeformIcon({ size }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
>
|
||||
<path
|
||||
d="M15.502 13.035c-.5 0-.756-.411-.756-.917 0-.505.252-.894.756-.894.513 0 .756.407.756.894-.004.515-.261.917-.756.917Zm-4.888-1.81c.292 0 .414.17.414.317 0 .357-.365.514-1.126.536 0-.442.253-.854.712-.854Zm-3.241 1.81c-.473 0-.67-.384-.67-.917 0-.527.202-.894.67-.894.477 0 .702.38.702.894 0 .537-.234.917-.702.917Zm-3.997-2.334h-.738l1.224 2.808c-.234.519-.36.648-.522.648-.171 0-.333-.138-.45-.259l-.324.43c.22.232.522.366.832.366.387 0 .685-.224.856-.626l1.413-3.371h-.725l-.738 2.012-.828-2.008Zm19.553.523c.36 0 .432.246.432.823v1.516H24v-1.914c0-.689-.473-.988-.91-.988-.386 0-.742.241-.94.688a.901.901 0 0 0-.891-.688c-.365 0-.73.232-.927.666v-.626h-.64v2.857h.64v-1.22c0-.617.324-1.114.765-1.114.36 0 .427.246.427.823v1.516h.64l-.005-1.225c0-.617.329-1.114.77-1.114Zm-5.1-.523h-.324v2.857h.639v-1.095c0-.693.306-1.163.76-1.163.118 0 .217.005.325.05l.099-.676c-.081-.009-.153-.018-.225-.018-.45 0-.774.309-.964.707V10.7h-.31Zm-2.327-.045c-.846 0-1.418.644-1.418 1.458 0 .845.58 1.475 1.418 1.475.85 0 1.431-.648 1.431-1.475-.004-.818-.594-1.458-1.431-1.458Zm-4.852 2.38c-.333 0-.581-.17-.685-.515.847-.036 1.675-.242 1.675-.988 0-.43-.423-.872-1.03-.872-.82 0-1.374.666-1.374 1.457 0 .828.545 1.476 1.36 1.476.567 0 .927-.228 1.21-.559l-.31-.42c-.329.335-.531.42-.846.42Zm-3.151-2.38c-.324 0-.648.188-.774.483v-.438h-.64v3.98h.64v-1.422c.135.205.445.34.72.34.85 0 1.3-.631 1.3-1.48-.004-.841-.445-1.463-1.246-1.463Zm-4.483-1.1H0v.622h1.18v3.38h.67v-3.38h1.166v-.622Zm9.502 1.145h-.383v.572h.383v2.285h.639v-2.285h.621v-.572h-.621v-.447c0-.286.117-.385.382-.385.1 0 .19.027.311.068l.144-.537c-.117-.067-.351-.094-.504-.094-.612 0-.972.367-.972 1.002v.393Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { rem } from '@mantine/core';
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
export function VimeoIcon({ size }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="#1AB7EA"
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
>
|
||||
<path
|
||||
d="M23.9765 6.4168c-.105 2.338-1.739 5.5429-4.894 9.6088-3.2679 4.247-6.0258 6.3699-8.2898 6.3699-1.409 0-2.578-1.294-3.553-3.881l-1.9179-7.1138c-.719-2.584-1.488-3.878-2.312-3.878-.179 0-.806.378-1.8809 1.132l-1.129-1.457a315.06 315.06 0 003.501-3.1279c1.579-1.368 2.765-2.085 3.5539-2.159 1.867-.18 3.016 1.1 3.447 3.838.465 2.953.789 4.789.971 5.5069.5389 2.45 1.1309 3.674 1.7759 3.674.502 0 1.256-.796 2.265-2.385 1.004-1.589 1.54-2.797 1.612-3.628.144-1.371-.395-2.061-1.614-2.061-.574 0-1.167.121-1.777.391 1.186-3.8679 3.434-5.7568 6.7619-5.6368 2.4729.06 3.6279 1.664 3.4929 4.7969z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { rem } from '@mantine/core';
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
export function YoutubeIcon({ size }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="#FF0000"
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
>
|
||||
<path
|
||||
d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,22 @@
|
||||
import { Group, Text } from "@mantine/core";
|
||||
import {Group, Text, Tooltip} from "@mantine/core";
|
||||
import classes from "./app-header.module.css";
|
||||
import React from "react";
|
||||
import TopMenu from "@/components/layouts/global/top-menu.tsx";
|
||||
import { Link } from "react-router-dom";
|
||||
import {Link} from "react-router-dom";
|
||||
import APP_ROUTE from "@/lib/app-route.ts";
|
||||
import { useAtom } from "jotai/index";
|
||||
import {useAtom} from "jotai/index";
|
||||
import {
|
||||
desktopSidebarAtom,
|
||||
mobileSidebarAtom,
|
||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.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" }];
|
||||
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,28 +37,33 @@ export function AppHeader() {
|
||||
<Group wrap="nowrap">
|
||||
{!isHomeRoute && (
|
||||
<>
|
||||
<SidebarToggle
|
||||
aria-label="sidebar toggle"
|
||||
opened={mobileOpened}
|
||||
onClick={toggleMobile}
|
||||
hiddenFrom="sm"
|
||||
size="sm"
|
||||
/>
|
||||
<Tooltip label={t("Sidebar toggle")}>
|
||||
|
||||
<SidebarToggle
|
||||
aria-label="sidebar toggle"
|
||||
opened={desktopOpened}
|
||||
onClick={toggleDesktop}
|
||||
visibleFrom="sm"
|
||||
size="sm"
|
||||
/>
|
||||
<SidebarToggle
|
||||
aria-label={t("Sidebar toggle")}
|
||||
opened={mobileOpened}
|
||||
onClick={toggleMobile}
|
||||
hiddenFrom="sm"
|
||||
size="sm"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label={t("Sidebar toggle")}>
|
||||
<SidebarToggle
|
||||
aria-label={t("Sidebar toggle")}
|
||||
opened={desktopOpened}
|
||||
onClick={toggleDesktop}
|
||||
visibleFrom="sm"
|
||||
size="sm"
|
||||
/>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Text
|
||||
size="lg"
|
||||
fw={600}
|
||||
style={{ cursor: "pointer", userSelect: "none" }}
|
||||
style={{cursor: "pointer", userSelect: "none"}}
|
||||
component={Link}
|
||||
to="/home"
|
||||
>
|
||||
@@ -69,7 +76,7 @@ export function AppHeader() {
|
||||
</Group>
|
||||
|
||||
<Group px={"xl"}>
|
||||
<TopMenu />
|
||||
<TopMenu/>
|
||||
</Group>
|
||||
</Group>
|
||||
</>
|
||||
|
||||
@@ -14,3 +14,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
.resizeHandle {
|
||||
width: 3px;
|
||||
cursor: col-resize;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
|
||||
&:hover, &:active {
|
||||
width: 5px;
|
||||
background: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-5))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { AppShell, Container } from "@mantine/core";
|
||||
import React from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
asideStateAtom,
|
||||
desktopSidebarAtom,
|
||||
mobileSidebarAtom,
|
||||
mobileSidebarAtom, sidebarWidthAtom,
|
||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
|
||||
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
|
||||
@@ -21,6 +21,46 @@ export default function GlobalAppShell({
|
||||
const [mobileOpened] = useAtom(mobileSidebarAtom);
|
||||
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
||||
const [{ isAsideOpen }] = useAtom(asideStateAtom);
|
||||
const [sidebarWidth, setSidebarWidth] = useAtom(sidebarWidthAtom);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const sidebarRef = useRef(null);
|
||||
|
||||
const startResizing = React.useCallback((mouseDownEvent) => {
|
||||
mouseDownEvent.preventDefault();
|
||||
setIsResizing(true);
|
||||
}, []);
|
||||
|
||||
const stopResizing = React.useCallback(() => {
|
||||
setIsResizing(false);
|
||||
}, []);
|
||||
|
||||
const resize = React.useCallback(
|
||||
(mouseMoveEvent) => {
|
||||
if (isResizing) {
|
||||
const newWidth = mouseMoveEvent.clientX - sidebarRef.current.getBoundingClientRect().left;
|
||||
if (newWidth < 220) {
|
||||
setSidebarWidth(220);
|
||||
return;
|
||||
}
|
||||
if (newWidth > 600) {
|
||||
setSidebarWidth(600);
|
||||
return;
|
||||
}
|
||||
setSidebarWidth(newWidth);
|
||||
}
|
||||
},
|
||||
[isResizing]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
//https://codesandbox.io/p/sandbox/kz9de
|
||||
window.addEventListener("mousemove", resize);
|
||||
window.addEventListener("mouseup", stopResizing);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", resize);
|
||||
window.removeEventListener("mouseup", stopResizing);
|
||||
};
|
||||
}, [resize, stopResizing]);
|
||||
|
||||
const location = useLocation();
|
||||
const isSettingsRoute = location.pathname.startsWith("/settings");
|
||||
@@ -33,7 +73,7 @@ export default function GlobalAppShell({
|
||||
header={{ height: 45 }}
|
||||
navbar={
|
||||
!isHomeRoute && {
|
||||
width: 300,
|
||||
width: isSpaceRoute ? sidebarWidth : 300,
|
||||
breakpoint: "sm",
|
||||
collapsed: {
|
||||
mobile: !mobileOpened,
|
||||
@@ -54,7 +94,8 @@ export default function GlobalAppShell({
|
||||
<AppHeader />
|
||||
</AppShell.Header>
|
||||
{!isHomeRoute && (
|
||||
<AppShell.Navbar className={classes.navbar} withBorder={false}>
|
||||
<AppShell.Navbar className={classes.navbar} withBorder={false} ref={sidebarRef}>
|
||||
<div className={classes.resizeHandle} onMouseDown={startResizing} />
|
||||
{isSpaceRoute && <SpaceSidebar />}
|
||||
{isSettingsRoute && <SettingsSidebar />}
|
||||
</AppShell.Navbar>
|
||||
|
||||
@@ -19,3 +19,5 @@ export const asideStateAtom = atom<AsideStateType>({
|
||||
tab: "",
|
||||
isAsideOpen: false,
|
||||
});
|
||||
|
||||
export const sidebarWidthAtom = atomWithWebStorage<number>('sidebarWidth', 300);
|
||||
|
||||
@@ -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
|
||||
@@ -73,11 +75,11 @@ export default function TopMenu() {
|
||||
name={user.name}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div style={{width: 190}}>
|
||||
<Text size="sm" fw={500} lineClamp={1}>
|
||||
{user.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
<Text size="xs" c="dimmed" truncate="end">
|
||||
{user.email}
|
||||
</Text>
|
||||
</div>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import React, { ReactNode, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Popover,
|
||||
Button,
|
||||
useMantineColorScheme,
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
const Picker = React.lazy(() => import('@emoji-mart/react'));
|
||||
} from "@mantine/core";
|
||||
import { useClickOutside, useDisclosure, useWindowEvent } from "@mantine/hooks";
|
||||
import { Suspense } from "react";
|
||||
const Picker = React.lazy(() => import("@emoji-mart/react"));
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface EmojiPickerInterface {
|
||||
onEmojiSelect: (emoji: any) => void;
|
||||
@@ -23,8 +23,26 @@ function EmojiPicker({
|
||||
removeEmojiAction,
|
||||
readOnly,
|
||||
}: EmojiPickerInterface) {
|
||||
const { t } = useTranslation();
|
||||
const [opened, handlers] = useDisclosure(false);
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const [target, setTarget] = useState<HTMLElement | null>(null);
|
||||
const [dropdown, setDropdown] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
useClickOutside(
|
||||
() => handlers.close(),
|
||||
["mousedown", "touchstart"],
|
||||
[dropdown, target],
|
||||
);
|
||||
|
||||
// We need this because the default Mantine popover closeOnEscape does not work
|
||||
useWindowEvent("keydown", (event) => {
|
||||
if (opened && event.key === "Escape") {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
handlers.close();
|
||||
}
|
||||
});
|
||||
|
||||
const handleEmojiSelect = (emoji) => {
|
||||
onEmojiSelect(emoji);
|
||||
@@ -43,16 +61,17 @@ function EmojiPicker({
|
||||
width={332}
|
||||
position="bottom"
|
||||
disabled={readOnly}
|
||||
closeOnEscape={true}
|
||||
>
|
||||
<Popover.Target>
|
||||
<Popover.Target ref={setTarget}>
|
||||
<ActionIcon c="gray" variant="transparent" onClick={handlers.toggle}>
|
||||
{icon}
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown bg="000" style={{ border: 'none' }}>
|
||||
<Popover.Dropdown bg="000" style={{ border: "none" }} ref={setDropdown}>
|
||||
<Suspense fallback={null}>
|
||||
<Picker
|
||||
data={async () => (await import('@emoji-mart/data')).default}
|
||||
data={async () => (await import("@emoji-mart/data")).default}
|
||||
onEmojiSelect={handleEmojiSelect}
|
||||
perLine={8}
|
||||
skinTonePosition="search"
|
||||
@@ -64,14 +83,14 @@ function EmojiPicker({
|
||||
c="gray"
|
||||
size="xs"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
position: "absolute",
|
||||
zIndex: 2,
|
||||
bottom: '1rem',
|
||||
right: '1rem',
|
||||
bottom: "1rem",
|
||||
right: "1rem",
|
||||
}}
|
||||
onClick={handleRemoveEmoji}
|
||||
>
|
||||
Remove
|
||||
{t("Remove")}
|
||||
</Button>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import React from "react";
|
||||
import {
|
||||
IconLayoutSidebarRightCollapse,
|
||||
IconLayoutSidebarRightExpand,
|
||||
IconLayoutSidebarRightExpand
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
ActionIcon,
|
||||
BoxProps,
|
||||
ElementProps,
|
||||
MantineColor,
|
||||
MantineSize,
|
||||
} from "@mantine/core";
|
||||
import React from "react";
|
||||
import { ActionIcon, BoxProps, ElementProps, MantineColor, MantineSize } from "@mantine/core";
|
||||
|
||||
export interface SidebarToggleProps extends BoxProps, ElementProps<"button"> {
|
||||
size?: MantineSize | `compact-${MantineSize}` | (string & {});
|
||||
@@ -17,18 +11,18 @@ export interface SidebarToggleProps extends BoxProps, ElementProps<"button"> {
|
||||
opened?: boolean;
|
||||
}
|
||||
|
||||
export default function SidebarToggle({
|
||||
opened,
|
||||
size = "sm",
|
||||
...others
|
||||
}: SidebarToggleProps) {
|
||||
return (
|
||||
<ActionIcon size={size} {...others} variant="subtle" color="gray">
|
||||
{opened ? (
|
||||
<IconLayoutSidebarRightExpand />
|
||||
) : (
|
||||
<IconLayoutSidebarRightCollapse />
|
||||
)}
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
const SidebarToggle = React.forwardRef<HTMLButtonElement, SidebarToggleProps>(
|
||||
({ opened, size = "sm", ...others }, ref) => {
|
||||
return (
|
||||
<ActionIcon size={size} {...others} variant="subtle" color="gray" ref={ref}>
|
||||
{opened ? (
|
||||
<IconLayoutSidebarRightExpand />
|
||||
) : (
|
||||
<IconLayoutSidebarRightCollapse />
|
||||
)}
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default SidebarToggle;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -17,15 +17,17 @@ 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().min(2),
|
||||
name: z.string().trim().min(1),
|
||||
password: z.string().min(8),
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -13,10 +13,11 @@ 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().min(2).max(60),
|
||||
name: z.string().min(2).max(60),
|
||||
workspaceName: z.string().trim().min(3).max(50),
|
||||
name: z.string().min(1).max(50),
|
||||
email: z
|
||||
.string()
|
||||
.min(1, { message: "email is required" })
|
||||
@@ -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>
|
||||
|
||||
@@ -24,6 +24,7 @@ import { acceptInvitation } from "@/features/workspace/services/workspace-servic
|
||||
import Cookies from "js-cookie";
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
import APP_ROUTE from "@/lib/app-route.ts";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
export default function useAuth() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -31,6 +32,7 @@ export default function useAuth() {
|
||||
|
||||
const [, setCurrentUser] = useAtom(currentUserAtom);
|
||||
const [authToken, setAuthToken] = useAtom(authTokensAtom);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const handleSignIn = async (data: ILogin) => {
|
||||
setIsLoading(true);
|
||||
@@ -156,7 +158,8 @@ export default function useAuth() {
|
||||
setAuthToken(null);
|
||||
setCurrentUser(null);
|
||||
Cookies.remove("authTokens");
|
||||
navigate(APP_ROUTE.AUTH.LOGIN);
|
||||
queryClient.clear();
|
||||
window.location.replace(APP_ROUTE.AUTH.LOGIN);;
|
||||
};
|
||||
|
||||
const handleForgotPassword = async (data: IForgotPassword) => {
|
||||
|
||||
@@ -1,14 +1,32 @@
|
||||
import { Button, Group } from '@mantine/core';
|
||||
import { Button, Group } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type CommentActionsProps = {
|
||||
onSave: () => void;
|
||||
isLoading?: boolean;
|
||||
onCancel?: () => void;
|
||||
isCommentEditor?: boolean;
|
||||
};
|
||||
|
||||
function CommentActions({ onSave, isLoading }: CommentActionsProps) {
|
||||
function CommentActions({
|
||||
onSave,
|
||||
isLoading,
|
||||
onCancel,
|
||||
isCommentEditor,
|
||||
}: CommentActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Group justify="flex-end" pt={2} wrap="nowrap">
|
||||
<Button size="compact-sm" loading={isLoading} onClick={onSave}>Save</Button>
|
||||
<Group justify="flex-end" pt="sm" wrap="nowrap">
|
||||
{isCommentEditor && (
|
||||
<Button size="compact-sm" variant="default" onClick={onCancel}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button size="compact-sm" loading={isLoading} onClick={onSave}>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
@@ -59,6 +58,9 @@ function CommentListItem({ comment }: CommentListItemProps) {
|
||||
function handleEditToggle() {
|
||||
setIsEditing(true);
|
||||
}
|
||||
function cancelEdit() {
|
||||
setIsEditing(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box ref={ref} pb="xs">
|
||||
@@ -116,6 +118,8 @@ function CommentListItem({ comment }: CommentListItemProps) {
|
||||
<CommentActions
|
||||
onSave={handleUpdateComment}
|
||||
isLoading={isLoading}
|
||||
onCancel={cancelEdit}
|
||||
isCommentEditor={true}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -25,7 +26,6 @@ export function useCommentsQuery(
|
||||
params: ICommentParams,
|
||||
): UseQueryResult<IPagination<IComment>, Error> {
|
||||
return useQuery({
|
||||
// eslint-disable-next-line @tanstack/query/exhaustive-deps
|
||||
queryKey: RQ_KEY(params.pageId),
|
||||
queryFn: () => getPageComments(params),
|
||||
enabled: !!params.pageId,
|
||||
@@ -34,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),
|
||||
@@ -46,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),
|
||||
@@ -87,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),
|
||||
@@ -115,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",
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,6 +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 i18n from "i18next";
|
||||
|
||||
export const uploadAttachmentAction = handleAttachmentUpload({
|
||||
onUpload: async (file: File, pageId: string): Promise<any> => {
|
||||
@@ -18,10 +21,12 @@ export const uploadAttachmentAction = handleAttachmentUpload({
|
||||
if (file.type.includes("image/") || file.type.includes("video/")) {
|
||||
return false;
|
||||
}
|
||||
if (file.size / 1024 / 1024 > 50) {
|
||||
if (file.size > getFileUploadSizeLimit()) {
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: `File exceeds the 50 MB attachment limit`,
|
||||
message: i18n.t("File exceeds the {{limit}} attachment limit", {
|
||||
limit: formatBytes(getFileUploadSizeLimit()),
|
||||
}),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -23,9 +23,10 @@ import {
|
||||
showCommentPopupAtom,
|
||||
} from "@/features/comment/atoms/comment-atom";
|
||||
import { useAtom } from "jotai";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
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,10 +83,10 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
];
|
||||
|
||||
const commentItem: BubbleMenuItem = {
|
||||
name: "comment",
|
||||
name: "Comment",
|
||||
isActive: () => props.editor.isActive("comment"),
|
||||
command: () => {
|
||||
const commentId = uuidv4();
|
||||
const commentId = uuid7();
|
||||
|
||||
props.editor.chain().focus().setCommentDecoration().run();
|
||||
setDraftCommentId(commentId);
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,26 +1,36 @@
|
||||
import { NodeViewProps, NodeViewWrapper } from '@tiptap/react';
|
||||
import { ActionIcon, Card, Image, Modal, Text } from '@mantine/core';
|
||||
import { useRef, useState } from 'react';
|
||||
import { uploadFile } from '@/features/page/services/page-service.ts';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { 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();
|
||||
|
||||
const handleOpen = async () => {
|
||||
if (!editor.isEditable) {
|
||||
@@ -31,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 = () => {
|
||||
let base64data = (reader.result || '') as string;
|
||||
const base64data = (reader.result || "") as string;
|
||||
setInitialXML(base64data);
|
||||
};
|
||||
}
|
||||
@@ -53,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;
|
||||
@@ -80,14 +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: 'kennedy',
|
||||
ui: computedColorScheme === "light" ? "kennedy" : "dark",
|
||||
spin: true,
|
||||
libraries: true,
|
||||
saveAndExit: true,
|
||||
@@ -95,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);
|
||||
@@ -114,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"
|
||||
@@ -123,8 +134,8 @@ export default function DrawioView(props: NodeViewProps) {
|
||||
src={getFileUrl(src)}
|
||||
alt={title}
|
||||
className={clsx(
|
||||
selected ? 'ProseMirror-selectednode' : '',
|
||||
'alignCenter'
|
||||
selected ? "ProseMirror-selectednode" : "",
|
||||
"alignCenter",
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -135,7 +146,7 @@ export default function DrawioView(props: NodeViewProps) {
|
||||
color="gray"
|
||||
mx="xs"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
}}
|
||||
@@ -150,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>
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
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 { IconEdit } from "@tabler/icons-react";
|
||||
import { z } from "zod";
|
||||
import { useForm, zodResolver } from "@mantine/form";
|
||||
import {
|
||||
getEmbedProviderById,
|
||||
getEmbedUrlAndProvider,
|
||||
} from "@/features/editor/components/embed/providers.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18n from "i18next";
|
||||
|
||||
const schema = z.object({
|
||||
url: z
|
||||
.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;
|
||||
|
||||
const embedUrl = useMemo(() => {
|
||||
if (src) {
|
||||
return getEmbedUrlAndProvider(src).embedUrl;
|
||||
}
|
||||
return null;
|
||||
}, [src]);
|
||||
|
||||
const embedForm = useForm<{ url: string }>({
|
||||
initialValues: {
|
||||
url: "",
|
||||
},
|
||||
validate: zodResolver(schema),
|
||||
});
|
||||
|
||||
async function onSubmit(data: { url: string }) {
|
||||
if (provider) {
|
||||
const embedProvider = getEmbedProviderById(provider);
|
||||
if (embedProvider.regex.test(data.url)) {
|
||||
updateAttributes({ src: data.url });
|
||||
} else {
|
||||
notifications.show({
|
||||
message: t("Invalid {{provider}} embed link", {
|
||||
provider: embedProvider.name,
|
||||
}),
|
||||
position: "top-right",
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
{embedUrl ? (
|
||||
<>
|
||||
<AspectRatio ratio={16 / 9}>
|
||||
<iframe
|
||||
src={embedUrl}
|
||||
allow="encrypted-media"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
allowFullScreen
|
||||
frameBorder="0"
|
||||
></iframe>
|
||||
</AspectRatio>
|
||||
</>
|
||||
) : (
|
||||
<Popover width={300} position="bottom" withArrow shadow="md">
|
||||
<Popover.Target>
|
||||
<Card
|
||||
radius="md"
|
||||
p="xs"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
withBorder
|
||||
className={clsx(selected ? "ProseMirror-selectednode" : "")}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
<ActionIcon variant="transparent" color="gray">
|
||||
<IconEdit size={18} />
|
||||
</ActionIcon>
|
||||
|
||||
<Text component="span" size="lg" c="dimmed">
|
||||
{t("Embed {{provider}}", {
|
||||
provider: getEmbedProviderById(provider).name,
|
||||
})}
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown bg="var(--mantine-color-body)">
|
||||
<form onSubmit={embedForm.onSubmit(onSubmit)}>
|
||||
<FocusTrap active={true}>
|
||||
<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">{t("Embed link")}</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)}
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
export interface IEmbedProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
regex: RegExp;
|
||||
getEmbedUrl: (match: RegExpMatchArray, url?: string) => string;
|
||||
}
|
||||
|
||||
export const embedProviders: IEmbedProvider[] = [
|
||||
{
|
||||
id: 'loom',
|
||||
name: 'Loom',
|
||||
regex: /^https?:\/\/(?:www\.)?loom\.com\/(?:share|embed)\/([\da-zA-Z]+)\/?/,
|
||||
getEmbedUrl: (match, url) => {
|
||||
if(url.includes("/embed/")){
|
||||
return url;
|
||||
}
|
||||
return `https://loom.com/embed/${match[1]}`;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'airtable',
|
||||
name: 'Airtable',
|
||||
regex: /^https:\/\/(www.)?airtable.com\/([a-zA-Z0-9]{2,})\/.*/,
|
||||
getEmbedUrl: (match, url: string) => {
|
||||
const path = url.split('airtable.com/');
|
||||
if(url.includes("/embed/")){
|
||||
return url;
|
||||
}
|
||||
return `https://airtable.com/embed/${path[1]}`;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'figma',
|
||||
name: 'Figma',
|
||||
regex: /^https:\/\/[\w\.-]+\.?figma.com\/(file|proto|board|design|slides|deck)\/([0-9a-zA-Z]{22,128})/,
|
||||
getEmbedUrl: (match, url: string) => {
|
||||
return `https://www.figma.com/embed?url=${url}&embed_host=docmost`;
|
||||
}
|
||||
},
|
||||
{
|
||||
'id': 'typeform',
|
||||
name: 'Typeform',
|
||||
regex: /^(https?:)?(\/\/)?[\w\.]+\.typeform\.com\/to\/.+/,
|
||||
getEmbedUrl: (match, url: string) => {
|
||||
return url;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'miro',
|
||||
name: 'Miro',
|
||||
regex: /^https:\/\/(www\.)?miro\.com\/app\/board\/([\w-]+=)/,
|
||||
getEmbedUrl: (match, url) => {
|
||||
if(url.includes("/live-embed/")){
|
||||
return url;
|
||||
}
|
||||
return `https://miro.com/app/live-embed/${match[2]}?embedMode=view_only_without_ui&autoplay=true&embedSource=docmost`;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'youtube',
|
||||
name: 'YouTube',
|
||||
regex: /^((?:https?:)?\/\/)?((?:www|m|music)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$/,
|
||||
getEmbedUrl: (match, url) => {
|
||||
if (url.includes("/embed/")){
|
||||
return url;
|
||||
}
|
||||
return `https://www.youtube-nocookie.com/embed/${match[5]}`;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'vimeo',
|
||||
name: 'Vimeo',
|
||||
regex: /^(https:)?\/\/(?:www\.|player\.)?vimeo.com\/(?:channels\/(?:\w+\/)?|groups\/([^/]*)\/videos\/|album\/(\d+)\/video\/|video\/|)(\d+)/,
|
||||
getEmbedUrl: (match) => {
|
||||
return `https://player.vimeo.com/video/${match[4]}`;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'framer',
|
||||
name: 'Framer',
|
||||
regex: /^https:\/\/(www\.)?framer\.com\/embed\/([\w-]+)/,
|
||||
getEmbedUrl: (match, url: string) => {
|
||||
return url;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'gdrive',
|
||||
name: 'Google Drive',
|
||||
regex: /^((?:https?:)?\/\/)?((?:www|m)\.)?(drive\.google\.com)\/file\/d\/([a-zA-Z0-9_-]+)\/.*$/,
|
||||
getEmbedUrl: (match) => {
|
||||
return `https://drive.google.com/file/d/${match[4]}/preview`;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'gsheets',
|
||||
name: 'Google Sheets',
|
||||
regex: /^((?:https?:)?\/\/)?((?:www|m)\.)?(docs\.google\.com)\/spreadsheets\/d\/e\/([a-zA-Z0-9_-]+)\/.*$/,
|
||||
getEmbedUrl: (match, url: string) => {
|
||||
return url
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
export function getEmbedProviderById(id: string) {
|
||||
return embedProviders.find(provider => provider.id.toLowerCase() === id.toLowerCase());
|
||||
}
|
||||
|
||||
export interface IEmbedResult {
|
||||
embedUrl: string;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
export function getEmbedUrlAndProvider(url: string): IEmbedResult {
|
||||
for (const provider of embedProviders) {
|
||||
const match = url.match(provider.regex);
|
||||
if (match) {
|
||||
return {
|
||||
embedUrl: provider.getEmbedUrl(match, url),
|
||||
provider: provider.name.toLowerCase()
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
embedUrl: url,
|
||||
provider: 'iframe',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,13 +69,13 @@ 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(),
|
||||
appState: {
|
||||
exportEmbedScene: true,
|
||||
exportWithDarkMode: computedColorScheme == 'light' ? false : true,
|
||||
exportWithDarkMode: false,
|
||||
},
|
||||
files: excalidrawAPI?.getFiles(),
|
||||
});
|
||||
@@ -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)}
|
||||
@@ -147,13 +149,14 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
||||
...excalidrawData,
|
||||
scrollToContent: true,
|
||||
}}
|
||||
theme={computedColorScheme}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</ReactClearModal>
|
||||
|
||||
{src ? (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div style={{ position: "relative" }}>
|
||||
<Image
|
||||
onClick={(e) => e.detail === 2 && handleOpen()}
|
||||
radius="md"
|
||||
@@ -162,8 +165,8 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
||||
src={getFileUrl(src)}
|
||||
alt={title}
|
||||
className={clsx(
|
||||
selected ? 'ProseMirror-selectednode' : '',
|
||||
'alignCenter'
|
||||
selected ? "ProseMirror-selectednode" : "",
|
||||
"alignCenter",
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -174,7 +177,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
||||
color="gray"
|
||||
mx="xs"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
}}
|
||||
@@ -189,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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -1,6 +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 { formatBytes } from "@/lib";
|
||||
import i18n from "i18next";
|
||||
|
||||
export const uploadImageAction = handleImageUpload({
|
||||
onUpload: async (file: File, pageId: string): Promise<any> => {
|
||||
@@ -18,10 +21,12 @@ export const uploadImageAction = handleImageUpload({
|
||||
if (!file.type.includes("image/")) {
|
||||
return false;
|
||||
}
|
||||
if (file.size / 1024 / 1024 > 50) {
|
||||
if (file.size > getFileUploadSizeLimit()) {
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: `File exceeds the 50 MB attachment limit`,
|
||||
message: i18n.t("File exceeds the {{limit}} attachment limit", {
|
||||
limit: formatBytes(getFileUploadSizeLimit()),
|
||||
}),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
@@ -38,7 +40,7 @@ export default function MathInlineView(props: NodeViewProps) {
|
||||
renderMath(preview || "", mathPreviewContainer.current);
|
||||
} else if (preview !== null) {
|
||||
queueMicrotask(() => {
|
||||
updateAttributes({ text: preview });
|
||||
updateAttributes({ text: preview.trim() });
|
||||
});
|
||||
}
|
||||
}, [preview, isEditing]);
|
||||
@@ -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"}>
|
||||
@@ -97,7 +99,7 @@ export default function MathInlineView(props: NodeViewProps) {
|
||||
ref={textAreaRef}
|
||||
draggable={false}
|
||||
classNames={{ input: classes.textInput }}
|
||||
value={preview?.trim() ?? ""}
|
||||
value={preview ?? ""}
|
||||
placeholder={"E = mc^2"}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape" || (e.key === "Enter" && !e.shiftKey)) {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
transition: background-color 0.2s;
|
||||
padding: 0 0.25rem;
|
||||
margin: 0 0.1rem;
|
||||
user-select: none;
|
||||
|
||||
&.empty {
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-gray-4));
|
||||
@@ -17,10 +18,6 @@
|
||||
color: light-dark(var(--mantine-color-red-8), var(--mantine-color-red-7));
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-gray-8));
|
||||
}
|
||||
|
||||
&:not(.error, .empty) * {
|
||||
font-family: KaTeX_Main, Times New Roman, serif;
|
||||
}
|
||||
}
|
||||
|
||||
.mathBlock {
|
||||
@@ -34,6 +31,7 @@
|
||||
transition: background-color 0.2s;
|
||||
margin: 0 0.1rem;
|
||||
overflow-x: auto;
|
||||
user-select: none;
|
||||
|
||||
.textInput {
|
||||
width: 400px;
|
||||
@@ -52,10 +50,4 @@
|
||||
color: light-dark(var(--mantine-color-red-8), var(--mantine-color-red-7));
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-gray-8));
|
||||
}
|
||||
|
||||
&:not(.error, .empty) * {
|
||||
font-family: KaTeX_Main, Times New Roman, serif;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -30,6 +30,17 @@ import { uploadAttachmentAction } from "@/features/editor/components/attachment/
|
||||
import IconExcalidraw from "@/components/icons/icon-excalidraw";
|
||||
import IconMermaid from "@/components/icons/icon-mermaid";
|
||||
import IconDrawio from "@/components/icons/icon-drawio";
|
||||
import {
|
||||
AirtableIcon,
|
||||
FigmaIcon,
|
||||
FramerIcon,
|
||||
GoogleDriveIcon,
|
||||
GoogleSheetsIcon,
|
||||
LoomIcon,
|
||||
MiroIcon,
|
||||
TypeformIcon,
|
||||
VimeoIcon, YoutubeIcon
|
||||
} from "@/components/icons";
|
||||
|
||||
const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
basic: [
|
||||
@@ -358,7 +369,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
return editor
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
@@ -366,6 +377,96 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
.run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Airtable",
|
||||
description: "Embed Airtable",
|
||||
searchTerms: ["airtable"],
|
||||
icon: AirtableIcon,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).setEmbed({ provider: 'airtable' }).run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Loom",
|
||||
description: "Embed Loom video",
|
||||
searchTerms: ["loom"],
|
||||
icon: LoomIcon,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).setEmbed({ provider: 'loom' }).run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Figma",
|
||||
description: "Embed Figma files",
|
||||
searchTerms: ["figma"],
|
||||
icon: FigmaIcon,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).setEmbed({ provider: 'figma' }).run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Typeform",
|
||||
description: "Embed Typeform",
|
||||
searchTerms: ["typeform"],
|
||||
icon: TypeformIcon,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).setEmbed({ provider: 'typeform' }).run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Miro",
|
||||
description: "Embed Miro board",
|
||||
searchTerms: ["miro"],
|
||||
icon: MiroIcon,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).setEmbed({ provider: 'miro' }).run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "YouTube",
|
||||
description: "Embed YouTube video",
|
||||
searchTerms: ["youtube", "yt"],
|
||||
icon: YoutubeIcon,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).setEmbed({ provider: 'youtube' }).run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Vimeo",
|
||||
description: "Embed Vimeo video",
|
||||
searchTerms: ["vimeo"],
|
||||
icon: VimeoIcon,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).setEmbed({ provider: 'vimeo' }).run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Framer",
|
||||
description: "Embed Framer prototype",
|
||||
searchTerms: ["framer"],
|
||||
icon: FramerIcon,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).setEmbed({ provider: 'framer' }).run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Google Drive",
|
||||
description: "Embed Google Drive content",
|
||||
searchTerms: ["google drive", "gdrive"],
|
||||
icon: GoogleDriveIcon,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).setEmbed({ provider: 'gdrive' }).run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Google Sheets",
|
||||
description: "Embed Google Sheets content",
|
||||
searchTerms: ["google sheets", "gsheets"],
|
||||
icon: GoogleSheetsIcon,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).setEmbed({ provider: 'gsheets' }).run();
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -377,10 +478,10 @@ export const getSuggestionItems = ({
|
||||
const search = query.toLowerCase();
|
||||
const filteredGroups: SlashMenuGroupedItemsType = {};
|
||||
|
||||
const fuzzyMatch = (query, target) => {
|
||||
const fuzzyMatch = (query: string, target: string) => {
|
||||
let queryIndex = 0;
|
||||
target = target.toLowerCase();
|
||||
for (let char of target) {
|
||||
for (const char of target) {
|
||||
if (query[queryIndex] === char) queryIndex++;
|
||||
if (queryIndex === query.length) return true;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +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 i18n from "i18next";
|
||||
|
||||
export const uploadVideoAction = handleVideoUpload({
|
||||
onUpload: async (file: File, pageId: string): Promise<any> => {
|
||||
@@ -19,10 +22,12 @@ export const uploadVideoAction = handleVideoUpload({
|
||||
return false;
|
||||
}
|
||||
|
||||
if (file.size / 1024 / 1024 > 50) {
|
||||
if (file.size > getFileUploadSizeLimit()) {
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: `File exceeds the 50 MB attachment limit`,
|
||||
message: i18n.t("File exceeds the {{limit}} attachment limit", {
|
||||
limit: formatBytes(getFileUploadSizeLimit()),
|
||||
}),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
CustomCodeBlock,
|
||||
Drawio,
|
||||
Excalidraw,
|
||||
Embed,
|
||||
TableofContents,
|
||||
} from "@docmost/editor-ext";
|
||||
import {
|
||||
@@ -54,6 +55,7 @@ import AttachmentView from "@/features/editor/components/attachment/attachment-v
|
||||
import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx";
|
||||
import DrawioView from "../components/drawio/drawio-view";
|
||||
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx";
|
||||
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
||||
import plaintext from "highlight.js/lib/languages/plaintext";
|
||||
import powershell from "highlight.js/lib/languages/powershell";
|
||||
import elixir from "highlight.js/lib/languages/elixir";
|
||||
@@ -63,6 +65,8 @@ 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 { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts";
|
||||
import i18n from "i18next";
|
||||
import TableOfContentsView from "../components/table-of-contents/table-of-contents-view";
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
@@ -94,13 +98,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,
|
||||
@@ -131,7 +135,6 @@ export const mainExtensions = [
|
||||
class: "comment-mark",
|
||||
},
|
||||
}),
|
||||
|
||||
Table.configure({
|
||||
resizable: true,
|
||||
lastColumnResizable: false,
|
||||
@@ -140,7 +143,6 @@ export const mainExtensions = [
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
|
||||
MathInline.configure({
|
||||
view: MathInlineView,
|
||||
}),
|
||||
@@ -151,6 +153,7 @@ export const mainExtensions = [
|
||||
DetailsSummary,
|
||||
DetailsContent,
|
||||
Youtube.configure({
|
||||
addPasteHandler: false,
|
||||
controls: true,
|
||||
nocookie: true,
|
||||
}),
|
||||
@@ -181,6 +184,12 @@ export const mainExtensions = [
|
||||
Excalidraw.configure({
|
||||
view: ExcalidrawView,
|
||||
}),
|
||||
Embed.configure({
|
||||
view: EmbedView,
|
||||
}),
|
||||
MarkdownClipboard.configure({
|
||||
transformPastedText: true,
|
||||
}),
|
||||
TableofContents.configure({
|
||||
view: TableOfContentsView
|
||||
})
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// adapted from: https://github.com/aguingand/tiptap-markdown/blob/main/src/extensions/tiptap/clipboard.js - MIT
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { DOMParser } from "@tiptap/pm/model";
|
||||
import { find } from "linkifyjs";
|
||||
import { markdownToHtml } from "@docmost/editor-ext";
|
||||
|
||||
export const MarkdownClipboard = Extension.create({
|
||||
name: "markdownClipboard",
|
||||
priority: 50,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
transformPastedText: false,
|
||||
};
|
||||
},
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("markdownClipboard"),
|
||||
props: {
|
||||
clipboardTextParser: (text, context, plainText) => {
|
||||
const link = find(text, {
|
||||
defaultProtocol: "http",
|
||||
}).find((item) => item.isLink && item.value === text);
|
||||
|
||||
if (plainText || !this.options.transformPastedText || link) {
|
||||
// don't parse plaintext link to allow link paste handler to work
|
||||
// pasting with shift key prevents formatting
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = markdownToHtml(text);
|
||||
return DOMParser.fromSchema(this.editor.schema).parseSlice(
|
||||
elementFromString(parsed),
|
||||
{
|
||||
preserveWhitespace: true,
|
||||
context,
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
function elementFromString(value) {
|
||||
// add a wrapper to preserve leading and trailing whitespace
|
||||
const wrappedValue = `<body>${value}</body>`;
|
||||
|
||||
return new window.DOMParser().parseFromString(wrappedValue, "text/html").body;
|
||||
}
|
||||
@@ -30,8 +30,7 @@ export function FullEditor({
|
||||
return (
|
||||
<Container
|
||||
fluid={fullPageWidth}
|
||||
{...(fullPageWidth && { mx: 80 })}
|
||||
size={850}
|
||||
size={!fullPageWidth && 850}
|
||||
className={classes.editor}
|
||||
>
|
||||
<MemoizedTitleEditor
|
||||
|
||||
@@ -98,8 +98,8 @@ export default function PageEditor({ pageId, editable }: PageEditorProps) {
|
||||
}, [remoteProvider, localProvider]);
|
||||
|
||||
const extensions = [
|
||||
...mainExtensions,
|
||||
...collabExtensions(remoteProvider, currentUser.user),
|
||||
... mainExtensions,
|
||||
... collabExtensions(remoteProvider, currentUser.user),
|
||||
];
|
||||
|
||||
const editor = useEditor(
|
||||
@@ -186,6 +186,7 @@ export default function PageEditor({ pageId, editable }: PageEditorProps) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div onClick={() => editor.commands.focus('end')} style={{ paddingBottom: '20vh' }}></div>
|
||||
</div>
|
||||
) : (
|
||||
<EditorSkeleton />
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
background: none;
|
||||
font-size: inherit;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
}
|
||||
|
||||
/* Code styling */
|
||||
@@ -103,12 +103,12 @@
|
||||
|
||||
@mixin where-light {
|
||||
background-color: var(--code-bg, var(--mantine-color-gray-1));
|
||||
color: var(--mantine-color-black);
|
||||
color: var(--mantine-color-pink-7);
|
||||
}
|
||||
|
||||
@mixin where-dark {
|
||||
background-color: var(--mantine-color-dark-8);
|
||||
color: var(--mantine-color-gray-4);
|
||||
color: var(--mantine-color-pink-7);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,7 @@ import {
|
||||
pageEditorAtom,
|
||||
titleEditorAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms";
|
||||
import {
|
||||
usePageQuery,
|
||||
useUpdatePageMutation,
|
||||
} from "@/features/page/queries/page-query";
|
||||
import { useUpdatePageMutation } from "@/features/page/queries/page-query";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useAtom } from "jotai";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
|
||||
@@ -21,7 +18,8 @@ import { updateTreeNodeName } from "@/features/page/tree/utils";
|
||||
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, useParams } from "react-router-dom";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface TitleEditorProps {
|
||||
pageId: string;
|
||||
@@ -38,15 +36,20 @@ export function TitleEditor({
|
||||
spaceSlug,
|
||||
editable,
|
||||
}: TitleEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [debouncedTitleState, setDebouncedTitleState] = useState(null);
|
||||
const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 1000);
|
||||
const updatePageMutation = useUpdatePageMutation();
|
||||
const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 500);
|
||||
const {
|
||||
data: updatedPageData,
|
||||
mutate: updatePageMutation,
|
||||
status,
|
||||
} = useUpdatePageMutation();
|
||||
const pageEditor = useAtomValue(pageEditorAtom);
|
||||
const [, setTitleEditor] = useAtom(titleEditorAtom);
|
||||
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
||||
const emit = useQueryEmit();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const [activePageId, setActivePageId] = useState(pageId);
|
||||
|
||||
const titleEditor = useEditor({
|
||||
extensions: [
|
||||
@@ -58,7 +61,7 @@ export function TitleEditor({
|
||||
}),
|
||||
Text,
|
||||
Placeholder.configure({
|
||||
placeholder: "Untitled",
|
||||
placeholder: t("Untitled"),
|
||||
showOnlyWhenEditable: false,
|
||||
}),
|
||||
History.configure({
|
||||
@@ -74,6 +77,7 @@ export function TitleEditor({
|
||||
onUpdate({ editor }) {
|
||||
const currentTitle = editor.getText();
|
||||
setDebouncedTitleState(currentTitle);
|
||||
setActivePageId(pageId);
|
||||
},
|
||||
editable: editable,
|
||||
content: title,
|
||||
@@ -85,25 +89,30 @@ export function TitleEditor({
|
||||
}, [title]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedTitle !== null) {
|
||||
updatePageMutation.mutate({
|
||||
if (debouncedTitle !== null && activePageId === pageId) {
|
||||
updatePageMutation({
|
||||
pageId: pageId,
|
||||
title: debouncedTitle,
|
||||
});
|
||||
}
|
||||
}, [debouncedTitle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "success" && updatedPageData) {
|
||||
const newTreeData = updateTreeNodeName(treeData, pageId, debouncedTitle);
|
||||
setTreeData(newTreeData);
|
||||
|
||||
setTimeout(() => {
|
||||
emit({
|
||||
operation: "updateOne",
|
||||
spaceId: updatedPageData.spaceId,
|
||||
entity: ["pages"],
|
||||
id: pageId,
|
||||
payload: { title: debouncedTitle, slugId: slugId },
|
||||
});
|
||||
}, 50);
|
||||
|
||||
const newTreeData = updateTreeNodeName(treeData, pageId, debouncedTitle);
|
||||
setTreeData(newTreeData);
|
||||
}
|
||||
}, [debouncedTitle]);
|
||||
}, [updatedPageData, status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (titleEditor && title !== titleEditor.getText()) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -5,15 +5,17 @@ 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().min(2).max(50),
|
||||
name: z.string().trim().min(2).max(50),
|
||||
description: z.string().max(500),
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">Edit</Button>
|
||||
<Button type="submit">{t("Save")}</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</Box>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -3,67 +3,73 @@ 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 { useTranslation } from "react-i18next";
|
||||
import { formatMemberCount } from "@/lib";
|
||||
import { IGroup } from "@/features/group/types/group.types.ts";
|
||||
|
||||
export default function GroupList() {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading } = useGetGroupsQuery();
|
||||
|
||||
return (
|
||||
<>
|
||||
{data && (
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Group</Table.Th>
|
||||
<Table.Th>Members</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
|
||||
<Table.Tbody>
|
||||
{data?.items.map((group, index) => (
|
||||
<Table.Tr key={index}>
|
||||
<Table.Td>
|
||||
<Anchor
|
||||
size="sm"
|
||||
underline="never"
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
color: "var(--mantine-color-text)",
|
||||
}}
|
||||
component={Link}
|
||||
to={`/settings/groups/${group.id}`}
|
||||
>
|
||||
<Group gap="sm">
|
||||
<IconGroupCircle />
|
||||
<div>
|
||||
<Text fz="sm" fw={500}>
|
||||
{group.name}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{group.description}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Anchor>
|
||||
</Table.Td>
|
||||
|
||||
<Table.Td>
|
||||
<Anchor
|
||||
size="sm"
|
||||
underline="never"
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
color: "var(--mantine-color-text)",
|
||||
}}
|
||||
component={Link}
|
||||
to={`/settings/groups/${group.id}`}
|
||||
>
|
||||
{group.memberCount} members
|
||||
</Anchor>
|
||||
</Table.Td>
|
||||
<Table.ScrollContainer minWidth={400}>
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("Group")}</Table.Th>
|
||||
<Table.Th>{t("Members")}</Table.Th>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.Thead>
|
||||
|
||||
<Table.Tbody>
|
||||
{data?.items.map((group: IGroup, index: number) => (
|
||||
<Table.Tr key={index}>
|
||||
<Table.Td>
|
||||
<Anchor
|
||||
size="sm"
|
||||
underline="never"
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
color: "var(--mantine-color-text)",
|
||||
}}
|
||||
component={Link}
|
||||
to={`/settings/groups/${group.id}`}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<IconGroupCircle />
|
||||
<div>
|
||||
<Text fz="sm" fw={500} lineClamp={1}>
|
||||
{group.name}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed" lineClamp={2}>
|
||||
{group.description}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Anchor>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Anchor
|
||||
size="sm"
|
||||
underline="never"
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
color: "var(--mantine-color-text)",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
component={Link}
|
||||
to={`/settings/groups/${group.id}`}
|
||||
>
|
||||
{formatMemberCount(group.memberCount, t)}
|
||||
</Anchor>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -9,8 +9,11 @@ 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 { t } = useTranslation();
|
||||
const { groupId } = useParams();
|
||||
const { data, isLoading } = useGroupMembersQuery(groupId);
|
||||
const removeGroupMember = useRemoveGroupMemberMutation();
|
||||
@@ -26,15 +29,16 @@ 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" },
|
||||
labels: { confirm: t("Delete"), cancel: t("Cancel") },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: () => onRemove(userId),
|
||||
});
|
||||
@@ -42,64 +46,66 @@ export default function GroupMembersList() {
|
||||
return (
|
||||
<>
|
||||
{data && (
|
||||
<Table verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>User</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
|
||||
<Table.Tbody>
|
||||
{data?.items.map((user, index) => (
|
||||
<Table.Tr key={index}>
|
||||
<Table.Td>
|
||||
<Group gap="sm">
|
||||
<CustomAvatar avatarUrl={user.avatarUrl} name={user.name} />
|
||||
<div>
|
||||
<Text fz="sm" fw={500}>
|
||||
{user.name}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{user.email}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
|
||||
<Table.Td>
|
||||
<Badge variant="light">Active</Badge>
|
||||
</Table.Td>
|
||||
|
||||
<Table.Td>
|
||||
{isAdmin && (
|
||||
<Menu
|
||||
shadow="xl"
|
||||
position="bottom-end"
|
||||
offset={20}
|
||||
width={200}
|
||||
withArrow
|
||||
arrowPosition="center"
|
||||
>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" c="gray">
|
||||
<IconDots size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={() => openRemoveModal(user.id)}>
|
||||
Remove group member
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.ScrollContainer minWidth={500}>
|
||||
<Table verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("User")}</Table.Th>
|
||||
<Table.Th>{t("Status")}</Table.Th>
|
||||
<Table.Th></Table.Th>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.Thead>
|
||||
|
||||
<Table.Tbody>
|
||||
{data?.items.map((user: IUser, index: number) => (
|
||||
<Table.Tr key={index}>
|
||||
<Table.Td>
|
||||
<Group gap="sm">
|
||||
<CustomAvatar
|
||||
avatarUrl={user.avatarUrl}
|
||||
name={user.name}
|
||||
/>
|
||||
<div>
|
||||
<Text fz="sm" fw={500}>
|
||||
{user.name}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{user.email}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light">{t("Active")}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{isAdmin && (
|
||||
<Menu
|
||||
shadow="xl"
|
||||
position="bottom-end"
|
||||
offset={20}
|
||||
width={200}
|
||||
withArrow
|
||||
arrowPosition="center"
|
||||
>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" c="gray">
|
||||
<IconDots size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={() => openRemoveModal(user.id)}>
|
||||
{t("Remove group member")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
@@ -39,21 +41,23 @@ export function MultiGroupSelect({
|
||||
|
||||
useEffect(() => {
|
||||
if (groups) {
|
||||
const groupsData = groups?.items.map((group: IGroup) => {
|
||||
return {
|
||||
value: group.id,
|
||||
label: group.name,
|
||||
};
|
||||
});
|
||||
const groupsData = groups?.items
|
||||
.filter((group: IGroup) => group.name.toLowerCase() !== 'everyone')
|
||||
.map((group: IGroup) => {
|
||||
return {
|
||||
value: group.id,
|
||||
label: group.name,
|
||||
};
|
||||
});
|
||||
|
||||
// Filter out existing users by their ids
|
||||
// Filter out existing groups by their ids
|
||||
const filteredGroupData = groupsData.filter(
|
||||
(user) =>
|
||||
!data.find((existingUser) => existingUser.value === user.value),
|
||||
(group) =>
|
||||
!data.find((existingGroup) => existingGroup.value === group.value),
|
||||
);
|
||||
|
||||
// Combine existing data with new search data
|
||||
setData((prevData) => [...prevData, ...filteredGroupData]);
|
||||
setData((prevData) => [... prevData, ... filteredGroupData]);
|
||||
}
|
||||
}, [groups]);
|
||||
|
||||
@@ -64,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}
|
||||
@@ -73,7 +77,7 @@ export function MultiGroupSelect({
|
||||
clearable
|
||||
variant="filled"
|
||||
onChange={onChange}
|
||||
nothingFoundMessage="No group found"
|
||||
nothingFoundMessage={t("No group found")}
|
||||
maxValues={50}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -29,24 +29,22 @@ export function useGetGroupsQuery(
|
||||
|
||||
export function useGroupQuery(groupId: string): UseQueryResult<IGroup, Error> {
|
||||
return useQuery({
|
||||
queryKey: ['groups', groupId],
|
||||
queryKey: ['group', groupId],
|
||||
queryFn: () => getGroupById(groupId),
|
||||
enabled: !!groupId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useGroupMembersQuery(groupId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['groupMembers', groupId],
|
||||
queryFn: () => getGroupMembers(groupId),
|
||||
enabled: !!groupId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateGroupMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<IGroup, Error, Partial<IGroup>>({
|
||||
mutationFn: (data) => createGroup(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['groups'],
|
||||
});
|
||||
|
||||
notifications.show({ message: 'Group created successfully' });
|
||||
},
|
||||
onError: () => {
|
||||
@@ -96,6 +94,14 @@ export function useDeleteGroupMutation() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useGroupMembersQuery(groupId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['groupMembers', groupId],
|
||||
queryFn: () => getGroupMembers(groupId),
|
||||
enabled: !!groupId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddGroupMemberMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
.breadcrumbs {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
|
||||
a {
|
||||
color: var(--mantine-color-default-color);
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.mantine-Breadcrumbs-breadcrumb {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ActionIcon, Group, Menu, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconArrowsHorizontal,
|
||||
IconDots,
|
||||
IconDownload,
|
||||
IconFileExport,
|
||||
IconHistory,
|
||||
IconLink,
|
||||
IconMessage,
|
||||
@@ -24,6 +24,8 @@ 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 {
|
||||
readOnly?: boolean;
|
||||
@@ -52,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();
|
||||
@@ -68,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 = () => {
|
||||
@@ -106,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>
|
||||
|
||||
@@ -120,23 +123,23 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
leftSection={<IconHistory size={16} />}
|
||||
onClick={openHistoryModal}
|
||||
>
|
||||
Page history
|
||||
{t("Page history")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconDownload size={16} />}
|
||||
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 && (
|
||||
@@ -147,15 +150,16 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
leftSection={<IconTrash size={16} />}
|
||||
onClick={handleDeletePage}
|
||||
>
|
||||
Delete
|
||||
{t("Delete")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
<PageExportModal
|
||||
pageId={page.id}
|
||||
<ExportModal
|
||||
type="page"
|
||||
id={page.id}
|
||||
open={exportOpened}
|
||||
onClose={closeExportModal}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Modal, Button, Group, Text, Select } from "@mantine/core";
|
||||
import { Modal, Button, Group, Text, Select, Switch } from "@mantine/core";
|
||||
import { exportPage } from "@/features/page/services/page-service.ts";
|
||||
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,22 +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">{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>
|
||||
@@ -76,6 +85,8 @@ interface ExportFormatSelection {
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
function ExportFormatSelection({ format, onChange }: ExportFormatSelection) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Select
|
||||
data={[
|
||||
@@ -88,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")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -119,7 +123,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
||||
return (
|
||||
<>
|
||||
<SimpleGrid cols={2}>
|
||||
<FileButton onChange={handleFileUpload} accept="text/markdown" multiple>
|
||||
<FileButton onChange={handleFileUpload} accept=".md" multiple>
|
||||
{(props) => (
|
||||
<Button
|
||||
justify="start"
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
IconChevronRight,
|
||||
IconDotsVertical,
|
||||
IconFileDescription,
|
||||
IconFileExport,
|
||||
IconLink,
|
||||
IconPlus,
|
||||
IconPointFilled,
|
||||
@@ -39,7 +40,12 @@ import {
|
||||
import { IPage, SidebarPagesParams } from "@/features/page/types/page.types.ts";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { OpenMap } from "react-arborist/dist/main/state/open-slice";
|
||||
import { useClipboard, useElementSize, useMergedRef } from "@mantine/hooks";
|
||||
import {
|
||||
useClipboard,
|
||||
useDisclosure,
|
||||
useElementSize,
|
||||
useMergedRef,
|
||||
} from "@mantine/hooks";
|
||||
import { dfs } from "react-arborist/dist/module/utils";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
@@ -47,6 +53,8 @@ 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 {
|
||||
spaceId: string;
|
||||
@@ -191,13 +199,13 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
||||
<div ref={mergedRef} className={classes.treeContainer}>
|
||||
{rootElement.current && (
|
||||
<Tree
|
||||
data={data}
|
||||
data={data.filter((node) => node?.spaceId === spaceId)}
|
||||
disableDrag={readOnly}
|
||||
disableDrop={readOnly}
|
||||
disableEdit={readOnly}
|
||||
{...controllers}
|
||||
width={width}
|
||||
height={height}
|
||||
height={rootElement.current.clientHeight}
|
||||
ref={treeApiRef}
|
||||
openByDefault={false}
|
||||
disableMultiSelection={true}
|
||||
@@ -207,7 +215,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
||||
overscanCount={10}
|
||||
dndRootElement={rootElement.current}
|
||||
onToggle={() => {
|
||||
setOpenTreeNodes(treeApiRef.current.openState);
|
||||
setOpenTreeNodes(treeApiRef.current?.openState);
|
||||
}}
|
||||
initialOpenState={openTreeNodes}
|
||||
>
|
||||
@@ -279,6 +287,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
||||
setTimeout(() => {
|
||||
emit({
|
||||
operation: "updateOne",
|
||||
spaceId: node.data.spaceId,
|
||||
entity: ["pages"],
|
||||
id: node.id,
|
||||
payload: { icon: emoji.native },
|
||||
@@ -293,6 +302,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
||||
setTimeout(() => {
|
||||
emit({
|
||||
operation: "updateOne",
|
||||
spaceId: node.data.spaceId,
|
||||
entity: ["pages"],
|
||||
id: node.id,
|
||||
payload: { icon: null },
|
||||
@@ -397,68 +407,89 @@ interface NodeMenuProps {
|
||||
}
|
||||
|
||||
function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const clipboard = useClipboard({ timeout: 500 });
|
||||
const { spaceSlug } = useParams();
|
||||
const { openDeleteModal } = useDeletePageModal();
|
||||
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||
useDisclosure(false);
|
||||
|
||||
const handleCopyLink = () => {
|
||||
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 (
|
||||
<Menu shadow="md" width={200}>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
c="gray"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<IconDotsVertical
|
||||
style={{ width: rem(20), height: rem(20) }}
|
||||
stroke={2}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<>
|
||||
<Menu shadow="md" width={200}>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
c="gray"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<IconDotsVertical
|
||||
style={{ width: rem(20), height: rem(20) }}
|
||||
stroke={2}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconLink style={{ width: rem(14), height: rem(14) }} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleCopyLink();
|
||||
}}
|
||||
>
|
||||
Copy link
|
||||
</Menu.Item>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconLink size={16} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleCopyLink();
|
||||
}}
|
||||
>
|
||||
{t("Copy link")}
|
||||
</Menu.Item>
|
||||
|
||||
{!(treeApi.props.disableEdit as boolean) && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<IconFileExport size={16} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openExportModal();
|
||||
}}
|
||||
>
|
||||
{t("Export page")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
c="red"
|
||||
leftSection={
|
||||
<IconTrash style={{ width: rem(14), height: rem(14) }} />
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openDeleteModal({ onConfirm: () => treeApi?.delete(node) });
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
{!(treeApi.props.disableEdit as boolean) && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item
|
||||
c="red"
|
||||
leftSection={<IconTrash size={16} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openDeleteModal({ onConfirm: () => treeApi?.delete(node) });
|
||||
}}
|
||||
>
|
||||
{t("Delete")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
<ExportModal
|
||||
type="page"
|
||||
id={node.id}
|
||||
open={exportOpened}
|
||||
onClose={closeExportModal}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { getSpaceUrl } from "@/lib/config.ts";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
||||
|
||||
export function useTreeMutation<T>(spaceId: string) {
|
||||
const [data, setData] = useAtom(treeDataAtom);
|
||||
@@ -31,6 +32,8 @@ export function useTreeMutation<T>(spaceId: string) {
|
||||
const movePageMutation = useMovePageMutation();
|
||||
const navigate = useNavigate();
|
||||
const { spaceSlug } = useParams();
|
||||
const { pageSlug } = useParams();
|
||||
const emit = useQueryEmit();
|
||||
|
||||
const onCreate: CreateHandler<T> = async ({ parentId, index, type }) => {
|
||||
const payload: { spaceId: string; parentPageId?: string } = {
|
||||
@@ -69,10 +72,22 @@ export function useTreeMutation<T>(spaceId: string) {
|
||||
tree.create({ parentId, index, data });
|
||||
setData(tree.data);
|
||||
|
||||
setTimeout(() => {
|
||||
emit({
|
||||
operation: "addTreeNode",
|
||||
spaceId: spaceId,
|
||||
payload: {
|
||||
parentId,
|
||||
index,
|
||||
data,
|
||||
},
|
||||
});
|
||||
}, 50);
|
||||
|
||||
const pageUrl = buildPageUrl(
|
||||
spaceSlug,
|
||||
createdPage.slugId,
|
||||
createdPage.title,
|
||||
createdPage.title
|
||||
);
|
||||
navigate(pageUrl);
|
||||
return data;
|
||||
@@ -100,7 +115,7 @@ export function useTreeMutation<T>(spaceId: string) {
|
||||
: tree.data;
|
||||
|
||||
// if there is a parentId, tree.find(args.parentId).children returns a SimpleNode array
|
||||
// we have to access the node differently viq currentTreeData[args.index]?.data?.position
|
||||
// we have to access the node differently via currentTreeData[args.index]?.data?.position
|
||||
// this makes it possible to correctly sort children of a parent node that is not the root
|
||||
|
||||
const afterPosition =
|
||||
@@ -142,7 +157,7 @@ export function useTreeMutation<T>(spaceId: string) {
|
||||
// check if the previous still has children
|
||||
// if no children left, change 'hasChildren' to false, to make the page toggle arrows work properly
|
||||
const childrenCount = previousParent.children.filter(
|
||||
(child) => child.id !== draggedNodeId,
|
||||
(child) => child.id !== draggedNodeId
|
||||
).length;
|
||||
if (childrenCount === 0) {
|
||||
tree.update({
|
||||
@@ -162,6 +177,19 @@ export function useTreeMutation<T>(spaceId: string) {
|
||||
|
||||
try {
|
||||
movePageMutation.mutateAsync(payload);
|
||||
|
||||
setTimeout(() => {
|
||||
emit({
|
||||
operation: "moveTreeNode",
|
||||
spaceId: spaceId,
|
||||
payload: {
|
||||
id: draggedNodeId,
|
||||
parentId: args.parentId,
|
||||
index: args.index,
|
||||
position: newPosition,
|
||||
},
|
||||
});
|
||||
}, 50);
|
||||
} catch (error) {
|
||||
console.error("Error moving page:", error);
|
||||
}
|
||||
@@ -182,12 +210,26 @@ export function useTreeMutation<T>(spaceId: string) {
|
||||
try {
|
||||
await deletePageMutation.mutateAsync(args.ids[0]);
|
||||
|
||||
if (tree.find(args.ids[0])) {
|
||||
tree.drop({ id: args.ids[0] });
|
||||
setData(tree.data);
|
||||
const node = tree.find(args.ids[0]);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigate(getSpaceUrl(spaceSlug));
|
||||
tree.drop({ id: args.ids[0] });
|
||||
setData(tree.data);
|
||||
|
||||
// navigate only if the current url is same as the deleted page
|
||||
if (pageSlug && node.data.slugId === pageSlug.split("-")[1]) {
|
||||
navigate(getSpaceUrl(spaceSlug));
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
emit({
|
||||
operation: "deleteTreeNode",
|
||||
spaceId: spaceId,
|
||||
payload: { node: node.data },
|
||||
});
|
||||
}, 50);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete page:", error);
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
}
|
||||
|
||||
.treeContainer {
|
||||
display: flex;
|
||||
height: 68vh;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
|
||||
> div, > div > .tree {
|
||||
height: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
.node {
|
||||
|
||||
@@ -100,6 +100,28 @@ export const updateTreeNodeIcon = (
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteTreeNode = (
|
||||
nodes: SpaceTreeNode[],
|
||||
nodeId: string,
|
||||
): SpaceTreeNode[] => {
|
||||
return nodes
|
||||
.map((node) => {
|
||||
if (node.id === nodeId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (node.children && node.children.length > 0) {
|
||||
return {
|
||||
...node,
|
||||
children: deleteTreeNode(node.children, nodeId),
|
||||
};
|
||||
}
|
||||
return node;
|
||||
})
|
||||
.filter((node) => node !== null);
|
||||
};
|
||||
|
||||
|
||||
export function buildTreeWithChildren(items: SpaceTreeNode[]): SpaceTreeNode[] {
|
||||
const nodeMap = {};
|
||||
let result: SpaceTreeNode[] = [];
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface IPageInput {
|
||||
export interface IExportPageParams {
|
||||
pageId: string;
|
||||
format: ExportFormat;
|
||||
includeChildren?: boolean;
|
||||
}
|
||||
|
||||
export enum ExportFormat {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -6,11 +6,13 @@ 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().min(2).max(50),
|
||||
name: z.string().trim().min(2).max(50),
|
||||
slug: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(2)
|
||||
.max(50)
|
||||
.regex(
|
||||
@@ -22,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();
|
||||
|
||||
@@ -73,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")}
|
||||
/>
|
||||
@@ -82,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}
|
||||
@@ -101,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>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user