mirror of
https://github.com/docmost/docmost.git
synced 2025-11-15 13:01:13 +10:00
updates and fixes
* seo friendly urls * custom client serve-static module * database fixes * fix recent pages * other fixes
This commit is contained in:
@ -4,10 +4,13 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>docmost</title>
|
||||
<title>Docmost</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<!--window-config-->
|
||||
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
@ -31,6 +31,7 @@
|
||||
"react-arborist": "^3.4.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-error-boundary": "^4.0.13",
|
||||
"react-helmet-async": "^2.0.5",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"socket.io-client": "^4.7.5",
|
||||
"tippy.js": "^6.3.7",
|
||||
|
||||
@ -65,7 +65,7 @@ export default function App() {
|
||||
|
||||
<Route element={<DashboardLayout />}>
|
||||
<Route path={"/home"} element={<Home />} />
|
||||
<Route path={"/p/:pageId"} element={<Page />} />
|
||||
<Route path={"/p/:slugId/:slug?"} element={<Page />} />
|
||||
</Route>
|
||||
|
||||
<Route path={"/settings"} element={<SettingsLayout />}>
|
||||
|
||||
@ -14,6 +14,8 @@ import { IconDots } from "@tabler/icons-react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import classes from "./breadcrumb.module.css";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import { buildPageSlug } from "@/features/page/page.utils.ts";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
|
||||
function getTitle(name: string, icon: string) {
|
||||
if (icon) {
|
||||
@ -27,23 +29,17 @@ export default function Breadcrumb() {
|
||||
const [breadcrumbNodes, setBreadcrumbNodes] = useState<
|
||||
SpaceTreeNode[] | null
|
||||
>(null);
|
||||
const { pageId } = useParams();
|
||||
const { slugId } = useParams();
|
||||
const { data: currentPage } = usePageQuery(slugId);
|
||||
|
||||
useEffect(() => {
|
||||
if (treeData.length) {
|
||||
const breadcrumb = findBreadcrumbPath(treeData, pageId);
|
||||
if (treeData?.length > 0 && currentPage) {
|
||||
const breadcrumb = findBreadcrumbPath(treeData, currentPage.id);
|
||||
if (breadcrumb) {
|
||||
setBreadcrumbNodes(breadcrumb);
|
||||
}
|
||||
}
|
||||
}, [pageId, treeData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (treeData.length) {
|
||||
const breadcrumb = findBreadcrumbPath(treeData, pageId);
|
||||
if (breadcrumb) setBreadcrumbNodes(breadcrumb);
|
||||
}
|
||||
}, [pageId, treeData]);
|
||||
}, [currentPage?.id, treeData]);
|
||||
|
||||
const HiddenNodesTooltipContent = () =>
|
||||
breadcrumbNodes?.slice(1, -2).map((node) => (
|
||||
@ -51,7 +47,7 @@ export default function Breadcrumb() {
|
||||
<Button
|
||||
justify="start"
|
||||
component={Link}
|
||||
to={`/p/${node.id}`}
|
||||
to={buildPageSlug(node.slugId, node.name)}
|
||||
variant="default"
|
||||
style={{ border: "none" }}
|
||||
>
|
||||
@ -63,16 +59,14 @@ export default function Breadcrumb() {
|
||||
const getLastNthNode = (n: number) =>
|
||||
breadcrumbNodes && breadcrumbNodes[breadcrumbNodes.length - n];
|
||||
|
||||
// const getTitle = (title: string) => (title?.length > 0 ? title : "untitled");
|
||||
|
||||
const getBreadcrumbItems = () => {
|
||||
if (breadcrumbNodes?.length > 3) {
|
||||
return [
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={`/p/${breadcrumbNodes[0].id}`}
|
||||
to={buildPageSlug(breadcrumbNodes[0].slugId, breadcrumbNodes[0].name)}
|
||||
underline="never"
|
||||
key={breadcrumbNodes[0].id}
|
||||
key={breadcrumbNodes[0].slugId}
|
||||
>
|
||||
{getTitle(breadcrumbNodes[0].name, breadcrumbNodes[0].icon)}
|
||||
</Anchor>,
|
||||
@ -94,17 +88,17 @@ export default function Breadcrumb() {
|
||||
</Popover>,
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={`/p/${getLastNthNode(2)?.id}`}
|
||||
to={buildPageSlug(getLastNthNode(2)?.slugId, getLastNthNode(2)?.name)}
|
||||
underline="never"
|
||||
key={getLastNthNode(2)?.id}
|
||||
key={getLastNthNode(2)?.slugId}
|
||||
>
|
||||
{getTitle(getLastNthNode(2)?.name, getLastNthNode(2)?.icon)}
|
||||
</Anchor>,
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={`/p/${getLastNthNode(1)?.id}`}
|
||||
to={buildPageSlug(getLastNthNode(1)?.slugId, getLastNthNode(1)?.name)}
|
||||
underline="never"
|
||||
key={getLastNthNode(1)?.id}
|
||||
key={getLastNthNode(1)?.slugId}
|
||||
>
|
||||
{getTitle(getLastNthNode(1)?.name, getLastNthNode(1)?.icon)}
|
||||
</Anchor>,
|
||||
@ -115,7 +109,7 @@ export default function Breadcrumb() {
|
||||
return breadcrumbNodes.map((node) => (
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={`/p/${node.id}`}
|
||||
to={buildPageSlug(node.slugId, node.name)}
|
||||
underline="never"
|
||||
key={node.id}
|
||||
>
|
||||
|
||||
@ -1,11 +1,15 @@
|
||||
import { UserProvider } from "@/features/user/user-provider.tsx";
|
||||
import Shell from "./shell.tsx";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
|
||||
export default function DashboardLayout() {
|
||||
return (
|
||||
<UserProvider>
|
||||
<Shell>
|
||||
<Helmet>
|
||||
<title>Home</title>
|
||||
</Helmet>
|
||||
<Outlet />
|
||||
</Shell>
|
||||
</UserProvider>
|
||||
|
||||
@ -1,18 +1,19 @@
|
||||
import { ActionIcon, Menu, Button, Tooltip } from "@mantine/core";
|
||||
import { ActionIcon, Menu, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconDots,
|
||||
IconFileInfo,
|
||||
IconHistory,
|
||||
IconLink,
|
||||
IconLock,
|
||||
IconShare,
|
||||
IconTrash,
|
||||
IconMessage,
|
||||
} from "@tabler/icons-react";
|
||||
import React from "react";
|
||||
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
||||
import { useAtom } from "jotai";
|
||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||
import { useClipboard } from "@mantine/hooks";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { buildPageSlug } from "@/features/page/page.utils.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
|
||||
export default function Header() {
|
||||
const toggleAside = useToggleAside();
|
||||
@ -42,6 +43,16 @@ export default function Header() {
|
||||
|
||||
function PageActionMenu() {
|
||||
const [, setHistoryModalOpen] = useAtom(historyAtoms);
|
||||
const clipboard = useClipboard({ timeout: 500 });
|
||||
const { slugId } = useParams();
|
||||
const { data: page, isLoading, isError } = usePageQuery(slugId);
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const pageLink =
|
||||
window.location.host + buildPageSlug(page.slugId, page.title);
|
||||
clipboard.copy(pageLink);
|
||||
notifications.show({ message: "Link copied" });
|
||||
};
|
||||
|
||||
const openHistoryModal = () => {
|
||||
setHistoryModalOpen(true);
|
||||
@ -63,9 +74,13 @@ function PageActionMenu() {
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item leftSection={<IconLink size={16} stroke={2} />}>
|
||||
<Menu.Item
|
||||
leftSection={<IconLink size={16} stroke={2} />}
|
||||
onClick={handleCopyLink}
|
||||
>
|
||||
Copy link
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<IconHistory size={16} stroke={2} />}
|
||||
onClick={openHistoryModal}
|
||||
@ -73,10 +88,12 @@ function PageActionMenu() {
|
||||
Page history
|
||||
</Menu.Item>
|
||||
|
||||
{/*
|
||||
<Menu.Divider />
|
||||
<Menu.Item leftSection={<IconTrash size={16} stroke={2} />}>
|
||||
Delete
|
||||
</Menu.Item>
|
||||
*/}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
@ -1,11 +1,15 @@
|
||||
import { UserProvider } from "@/features/user/user-provider.tsx";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import SettingsShell from "@/components/layouts/settings/settings-shell.tsx";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
|
||||
export default function SettingsLayout() {
|
||||
return (
|
||||
<UserProvider>
|
||||
<SettingsShell>
|
||||
<Helmet>
|
||||
<title>Settings</title>
|
||||
</Helmet>
|
||||
<Outlet />
|
||||
</SettingsShell>
|
||||
</UserProvider>
|
||||
|
||||
@ -60,7 +60,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">
|
||||
Complete your signup
|
||||
Join the workspace
|
||||
</Title>
|
||||
|
||||
<Stack align="stretch" justify="center" gap="xl">
|
||||
|
||||
@ -0,0 +1,97 @@
|
||||
import * as React from "react";
|
||||
import * as z from "zod";
|
||||
|
||||
import { useForm, zodResolver } from "@mantine/form";
|
||||
import {
|
||||
Container,
|
||||
Title,
|
||||
TextInput,
|
||||
Button,
|
||||
PasswordInput,
|
||||
Box,
|
||||
} from "@mantine/core";
|
||||
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";
|
||||
|
||||
const formSchema = z.object({
|
||||
workspaceName: z.string().min(2).max(60),
|
||||
name: z.string().min(2).max(60),
|
||||
email: z
|
||||
.string()
|
||||
.min(1, { message: "email is required" })
|
||||
.email({ message: "Invalid email address" }),
|
||||
password: z.string().min(8),
|
||||
});
|
||||
|
||||
export function SetupWorkspaceForm() {
|
||||
const { setupWorkspace, isLoading } = useAuth();
|
||||
// useRedirectIfAuthenticated();
|
||||
|
||||
const form = useForm<ISetupWorkspace>({
|
||||
validate: zodResolver(formSchema),
|
||||
initialValues: {
|
||||
workspaceName: "",
|
||||
name: "",
|
||||
email: "",
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(data: ISetupWorkspace) {
|
||||
await setupWorkspace(data);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size={420} my={40} className={classes.container}>
|
||||
<Box p="xl" mt={200}>
|
||||
<Title order={2} ta="center" fw={500} mb="md">
|
||||
Create workspace
|
||||
</Title>
|
||||
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<TextInput
|
||||
id="workspaceName"
|
||||
type="text"
|
||||
label="Workspace Name"
|
||||
placeholder="e.g ACME Inc"
|
||||
variant="filled"
|
||||
mt="md"
|
||||
{...form.getInputProps("workspaceName")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
id="name"
|
||||
type="text"
|
||||
label="Your Name"
|
||||
placeholder="enter your full name"
|
||||
variant="filled"
|
||||
mt="md"
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
id="email"
|
||||
type="email"
|
||||
label="Your Email"
|
||||
placeholder="email@example.com"
|
||||
variant="filled"
|
||||
mt="md"
|
||||
{...form.getInputProps("email")}
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="Enter a strong password"
|
||||
variant="filled"
|
||||
mt="md"
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
|
||||
Setup workspace
|
||||
</Button>
|
||||
</form>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@ -1,10 +1,18 @@
|
||||
import { useState } from "react";
|
||||
import { login, register } from "@/features/auth/services/auth-service";
|
||||
import {
|
||||
login,
|
||||
register,
|
||||
setupWorkspace,
|
||||
} from "@/features/auth/services/auth-service";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAtom } from "jotai";
|
||||
import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||
import { ILogin, IRegister } from "@/features/auth/types/auth.types";
|
||||
import {
|
||||
ILogin,
|
||||
IRegister,
|
||||
ISetupWorkspace,
|
||||
} from "@/features/auth/types/auth.types";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IAcceptInvite } from "@/features/workspace/types/workspace.types.ts";
|
||||
import { acceptInvitation } from "@/features/workspace/services/workspace-service.ts";
|
||||
@ -76,6 +84,25 @@ export default function useAuth() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetupWorkspace = async (data: ISetupWorkspace) => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const res = await setupWorkspace(data);
|
||||
setIsLoading(false);
|
||||
|
||||
setAuthToken(res.tokens);
|
||||
|
||||
navigate("/home");
|
||||
} catch (err) {
|
||||
setIsLoading(false);
|
||||
notifications.show({
|
||||
message: err.response?.data.message,
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleIsAuthenticated = async () => {
|
||||
if (!authToken) {
|
||||
return false;
|
||||
@ -109,6 +136,7 @@ export default function useAuth() {
|
||||
signIn: handleSignIn,
|
||||
signUp: handleSignUp,
|
||||
invitationSignup: handleInvitationSignUp,
|
||||
setupWorkspace: handleSetupWorkspace,
|
||||
isAuthenticated: handleIsAuthenticated,
|
||||
logout: handleLogout,
|
||||
hasTokens,
|
||||
|
||||
@ -3,6 +3,7 @@ import {
|
||||
IChangePassword,
|
||||
ILogin,
|
||||
IRegister,
|
||||
ISetupWorkspace,
|
||||
ITokenResponse,
|
||||
} from "@/features/auth/types/auth.types";
|
||||
|
||||
@ -22,3 +23,10 @@ export async function changePassword(
|
||||
const req = await api.post<IChangePassword>("/auth/change-password", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function setupWorkspace(
|
||||
data: ISetupWorkspace,
|
||||
): Promise<ITokenResponse> {
|
||||
const req = await api.post<ITokenResponse>("/auth/setup", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
@ -9,6 +9,13 @@ export interface IRegister {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface ISetupWorkspace {
|
||||
workspaceName: string;
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface ITokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useState, useRef } from "react";
|
||||
import React, { useState, useRef, useCallback, memo } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Divider, Paper } from "@mantine/core";
|
||||
import CommentListItem from "@/features/comment/components/comment-list-item";
|
||||
@ -11,16 +11,67 @@ import CommentEditor from "@/features/comment/components/comment-editor";
|
||||
import CommentActions from "@/features/comment/components/comment-actions";
|
||||
import { useFocusWithin } from "@mantine/hooks";
|
||||
import { IComment } from "@/features/comment/types/comment.types.ts";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
|
||||
function CommentList() {
|
||||
const { pageId } = useParams();
|
||||
const { slugId } = useParams();
|
||||
const { data: page } = usePageQuery(slugId);
|
||||
const {
|
||||
data: comments,
|
||||
isLoading: isCommentsLoading,
|
||||
isError,
|
||||
} = useCommentsQuery({ pageId, limit: 100 });
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
} = useCommentsQuery({ pageId: page?.id, limit: 100 });
|
||||
const createCommentMutation = useCreateCommentMutation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleAddReply = useCallback(
|
||||
async (commentId: string, content: string) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const commentData = {
|
||||
pageId: page?.id,
|
||||
parentCommentId: commentId,
|
||||
content: JSON.stringify(content),
|
||||
};
|
||||
|
||||
await createCommentMutation.mutateAsync(commentData);
|
||||
} catch (error) {
|
||||
console.error("Failed to post comment:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[createCommentMutation, page?.id],
|
||||
);
|
||||
|
||||
const renderComments = useCallback(
|
||||
(comment: IComment) => (
|
||||
<Paper
|
||||
shadow="sm"
|
||||
radius="md"
|
||||
p="sm"
|
||||
mb="sm"
|
||||
withBorder
|
||||
key={comment.id}
|
||||
data-comment-id={comment.id}
|
||||
>
|
||||
<div>
|
||||
<CommentListItem comment={comment} />
|
||||
<MemoizedChildComments comments={comments} parentId={comment.id} />
|
||||
</div>
|
||||
|
||||
<Divider my={4} />
|
||||
|
||||
<CommentEditorWithActions
|
||||
commentId={comment.id}
|
||||
onSave={handleAddReply}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</Paper>
|
||||
),
|
||||
[comments, handleAddReply, isLoading],
|
||||
);
|
||||
|
||||
if (isCommentsLoading) {
|
||||
return <></>;
|
||||
@ -34,50 +85,6 @@ function CommentList() {
|
||||
return <>No comments yet.</>;
|
||||
}
|
||||
|
||||
const renderComments = (comment: IComment) => {
|
||||
const handleAddReply = async (commentId: string, content: string) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const commentData = {
|
||||
pageId: comment.pageId,
|
||||
parentCommentId: comment.id,
|
||||
content: JSON.stringify(content),
|
||||
};
|
||||
|
||||
await createCommentMutation.mutateAsync(commentData);
|
||||
} catch (error) {
|
||||
console.error("Failed to post comment:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
shadow="sm"
|
||||
radius="md"
|
||||
p="sm"
|
||||
mb="sm"
|
||||
withBorder
|
||||
key={comment.id}
|
||||
data-comment-id={comment.id}
|
||||
>
|
||||
<div>
|
||||
<CommentListItem comment={comment} />
|
||||
<ChildComments comments={comments} parentId={comment.id} />
|
||||
</div>
|
||||
|
||||
<Divider my={4} />
|
||||
|
||||
<CommentEditorWithActions
|
||||
commentId={comment.id}
|
||||
onSave={handleAddReply}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{comments.items
|
||||
@ -87,35 +94,46 @@ function CommentList() {
|
||||
);
|
||||
}
|
||||
|
||||
const ChildComments = ({ comments, parentId }) => {
|
||||
const getChildComments = (parentId: string) => {
|
||||
return comments.items.filter(
|
||||
(comment: IComment) => comment.parentCommentId === parentId,
|
||||
);
|
||||
};
|
||||
interface ChildCommentsProps {
|
||||
comments: IPagination<IComment>;
|
||||
parentId: string;
|
||||
}
|
||||
const ChildComments = ({ comments, parentId }: ChildCommentsProps) => {
|
||||
const getChildComments = useCallback(
|
||||
(parentId: string) =>
|
||||
comments.items.filter(
|
||||
(comment: IComment) => comment.parentCommentId === parentId,
|
||||
),
|
||||
[comments.items],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{getChildComments(parentId).map((childComment) => (
|
||||
<div key={childComment.id}>
|
||||
<CommentListItem comment={childComment} />
|
||||
<ChildComments comments={comments} parentId={childComment.id} />
|
||||
<MemoizedChildComments
|
||||
comments={comments}
|
||||
parentId={childComment.id}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MemoizedChildComments = memo(ChildComments);
|
||||
|
||||
const CommentEditorWithActions = ({ commentId, onSave, isLoading }) => {
|
||||
const [content, setContent] = useState("");
|
||||
const { ref, focused } = useFocusWithin();
|
||||
const commentEditorRef = useRef(null);
|
||||
|
||||
const handleSave = () => {
|
||||
const handleSave = useCallback(() => {
|
||||
onSave(commentId, content);
|
||||
setContent("");
|
||||
commentEditorRef.current?.clearContent();
|
||||
};
|
||||
}, [commentId, content, onSave]);
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
|
||||
@ -1,20 +1,22 @@
|
||||
import classes from '@/features/editor/styles/editor.module.css';
|
||||
import React from 'react';
|
||||
import { TitleEditor } from '@/features/editor/title-editor';
|
||||
import PageEditor from '@/features/editor/page-editor';
|
||||
import classes from "@/features/editor/styles/editor.module.css";
|
||||
import React from "react";
|
||||
import { TitleEditor } from "@/features/editor/title-editor";
|
||||
import PageEditor from "@/features/editor/page-editor";
|
||||
|
||||
const MemoizedTitleEditor = React.memo(TitleEditor);
|
||||
const MemoizedPageEditor = React.memo(PageEditor);
|
||||
|
||||
export interface FullEditorProps {
|
||||
pageId: string;
|
||||
title: any;
|
||||
slugId: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export function FullEditor({ pageId, title }: FullEditorProps) {
|
||||
|
||||
export function FullEditor({ pageId, title, slugId }: FullEditorProps) {
|
||||
return (
|
||||
<div className={classes.editor}>
|
||||
<TitleEditor pageId={pageId} title={title} />
|
||||
<PageEditor pageId={pageId} />
|
||||
<MemoizedTitleEditor pageId={pageId} slugId={slugId} title={title} />
|
||||
<MemoizedPageEditor pageId={pageId} />
|
||||
</div>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
@ -36,12 +36,9 @@ export default function PageEditor({
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const [, setEditor] = useAtom(pageEditorAtom);
|
||||
const [, setAsideState] = useAtom(asideStateAtom);
|
||||
|
||||
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||
|
||||
const ydoc = useMemo(() => new Y.Doc(), [pageId]);
|
||||
|
||||
const [isLocalSynced, setLocalSynced] = useState(false);
|
||||
const [isRemoteSynced, setRemoteSynced] = useState(false);
|
||||
const documentName = `page.${pageId}`;
|
||||
|
||||
@ -10,27 +10,34 @@ import {
|
||||
pageEditorAtom,
|
||||
titleEditorAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms";
|
||||
import { useUpdatePageMutation } from "@/features/page/queries/page-query";
|
||||
import {
|
||||
usePageQuery,
|
||||
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";
|
||||
import { updateTreeNodeName } from "@/features/page/tree/utils";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
||||
import { History } from "@tiptap/extension-history";
|
||||
import { buildPageSlug } from "@/features/page/page.utils.ts";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export interface TitleEditorProps {
|
||||
pageId: string;
|
||||
slugId: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export function TitleEditor({ pageId, title }: TitleEditorProps) {
|
||||
const [debouncedTitleState, setDebouncedTitleState] = useState("");
|
||||
export function TitleEditor({ pageId, slugId, title }: TitleEditorProps) {
|
||||
const [debouncedTitleState, setDebouncedTitleState] = useState(null);
|
||||
const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 1000);
|
||||
const updatePageMutation = useUpdatePageMutation();
|
||||
const pageEditor = useAtomValue(pageEditorAtom);
|
||||
const [, setTitleEditor] = useAtom(titleEditorAtom);
|
||||
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
||||
const emit = useQueryEmit();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const titleEditor = useEditor({
|
||||
extensions: [
|
||||
@ -62,15 +69,23 @@ export function TitleEditor({ pageId, title }: TitleEditorProps) {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedTitle !== "") {
|
||||
updatePageMutation.mutate({ pageId, title: debouncedTitle });
|
||||
const pageSlug = buildPageSlug(slugId, title);
|
||||
navigate(pageSlug, { replace: true });
|
||||
}, [title]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedTitle !== null) {
|
||||
updatePageMutation.mutate({
|
||||
pageId: pageId,
|
||||
title: debouncedTitle,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
emit({
|
||||
operation: "updateOne",
|
||||
entity: ["pages"],
|
||||
id: pageId,
|
||||
payload: { title: debouncedTitle },
|
||||
payload: { title: debouncedTitle, slugId: slugId },
|
||||
});
|
||||
}, 50);
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Text, Tabs, Space } from "@mantine/core";
|
||||
import { IconClockHour3, IconStar } from "@tabler/icons-react";
|
||||
import { IconClockHour3 } from "@tabler/icons-react";
|
||||
import RecentChanges from "@/features/home/components/recent-changes";
|
||||
|
||||
export default function HomeTabs() {
|
||||
@ -16,7 +16,7 @@ export default function HomeTabs() {
|
||||
<Space my="md" />
|
||||
|
||||
<Tabs.Panel value="recent">
|
||||
<div>Recent</div>
|
||||
<RecentChanges />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { Text, Group, Stack, UnstyledButton, Divider } from '@mantine/core';
|
||||
import { format } from 'date-fns';
|
||||
import classes from './home.module.css';
|
||||
import { Link } from 'react-router-dom';
|
||||
import PageListSkeleton from '@/features/home/components/page-list-skeleton';
|
||||
import { useRecentChangesQuery } from '@/features/page/queries/page-query';
|
||||
import { Text, Group, Stack, UnstyledButton, Divider } from "@mantine/core";
|
||||
import { format } from "date-fns";
|
||||
import classes from "./home.module.css";
|
||||
import { Link } from "react-router-dom";
|
||||
import PageListSkeleton from "@/features/home/components/page-list-skeleton";
|
||||
import { useRecentChangesQuery } from "@/features/page/queries/page-query";
|
||||
import { buildPageSlug } from "@/features/page/page.utils.ts";
|
||||
|
||||
function RecentChanges() {
|
||||
const { data, isLoading, isError } = useRecentChangesQuery();
|
||||
@ -18,21 +19,23 @@ function RecentChanges() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{data
|
||||
.map((page) => (
|
||||
{data.items.map((page) => (
|
||||
<div key={page.id}>
|
||||
<UnstyledButton component={Link} to={`/p/${page.id}`}
|
||||
className={classes.page} p="xs">
|
||||
<UnstyledButton
|
||||
component={Link}
|
||||
to={buildPageSlug(page.slugId, page.title)}
|
||||
className={classes.page}
|
||||
p="xs"
|
||||
>
|
||||
<Group wrap="nowrap">
|
||||
|
||||
<Stack gap="xs" style={{ flex: 1 }}>
|
||||
<Text fw={500} size="md" lineClamp={1}>
|
||||
{page.title || 'Untitled'}
|
||||
{page.title || "Untitled"}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Text c="dimmed" size="xs" fw={500}>
|
||||
{format(new Date(page.updatedAt), 'PP')}
|
||||
{format(new Date(page.updatedAt), "PP")}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
|
||||
@ -18,9 +18,12 @@ import {
|
||||
import { modals } from "@mantine/modals";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
|
||||
function HistoryList() {
|
||||
interface Props {
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
function HistoryList({ pageId }: Props) {
|
||||
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
|
||||
const { pageId } = useParams();
|
||||
const {
|
||||
data: pageHistoryList,
|
||||
isLoading,
|
||||
|
||||
@ -1,18 +1,22 @@
|
||||
import { ScrollArea } from '@mantine/core';
|
||||
import HistoryList from '@/features/page-history/components/history-list';
|
||||
import classes from './history.module.css';
|
||||
import { useAtom } from 'jotai';
|
||||
import { activeHistoryIdAtom } from '@/features/page-history/atoms/history-atoms';
|
||||
import HistoryView from '@/features/page-history/components/history-view';
|
||||
import { ScrollArea } from "@mantine/core";
|
||||
import HistoryList from "@/features/page-history/components/history-list";
|
||||
import classes from "./history.module.css";
|
||||
import { useAtom } from "jotai";
|
||||
import { activeHistoryIdAtom } from "@/features/page-history/atoms/history-atoms";
|
||||
import HistoryView from "@/features/page-history/components/history-view";
|
||||
|
||||
export default function HistoryModalBody() {
|
||||
interface Props {
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
export default function HistoryModalBody({ pageId }: Props) {
|
||||
const [activeHistoryId] = useAtom(activeHistoryIdAtom);
|
||||
|
||||
return (
|
||||
<div className={classes.sidebarFlex}>
|
||||
<nav className={classes.sidebar}>
|
||||
<div className={classes.sidebarMain}>
|
||||
<HistoryList />
|
||||
<HistoryList pageId={pageId} />
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@ -21,7 +25,6 @@ export default function HistoryModalBody() {
|
||||
{activeHistoryId && <HistoryView historyId={activeHistoryId} />}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,24 +1,33 @@
|
||||
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 { 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";
|
||||
|
||||
export default function HistoryModal() {
|
||||
interface Props {
|
||||
pageId: string;
|
||||
}
|
||||
export default function HistoryModal({ pageId }: Props) {
|
||||
const [isModalOpen, setModalOpen] = useAtom(historyAtoms);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal.Root size={1200} opened={isModalOpen} onClose={() => setModalOpen(false)}>
|
||||
<Modal.Root
|
||||
size={1200}
|
||||
opened={isModalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
>
|
||||
<Modal.Overlay />
|
||||
<Modal.Content style={{ overflow: 'hidden' }}>
|
||||
<Modal.Content style={{ overflow: "hidden" }}>
|
||||
<Modal.Header>
|
||||
<Modal.Title>
|
||||
<Text size="md" fw={500}>Page history</Text>
|
||||
<Text size="md" fw={500}>
|
||||
Page history
|
||||
</Text>
|
||||
</Modal.Title>
|
||||
<Modal.CloseButton />
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<HistoryModalBody />
|
||||
<HistoryModalBody pageId={pageId} />
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
|
||||
15
apps/client/src/features/page/page.utils.ts
Normal file
15
apps/client/src/features/page/page.utils.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import slugify from "@sindresorhus/slugify";
|
||||
|
||||
export const buildPageSlug = (
|
||||
pageShortId: string,
|
||||
pageTitle?: string,
|
||||
): string => {
|
||||
const titleSlug = slugify(pageTitle?.substring(0, 99) || "untitled", {
|
||||
customReplacements: [
|
||||
["♥", ""],
|
||||
["🦄", ""],
|
||||
],
|
||||
});
|
||||
|
||||
return `/p/${pageShortId}/${titleSlug}`;
|
||||
};
|
||||
@ -27,16 +27,21 @@ import { buildTree } from "@/features/page/tree/utils";
|
||||
|
||||
const RECENT_CHANGES_KEY = ["recentChanges"];
|
||||
|
||||
export function usePageQuery(pageId: string): UseQueryResult<IPage, Error> {
|
||||
export function usePageQuery(
|
||||
pageIdOrSlugId: string,
|
||||
): UseQueryResult<IPage, Error> {
|
||||
return useQuery({
|
||||
queryKey: ["pages", pageId],
|
||||
queryFn: () => getPageById(pageId),
|
||||
enabled: !!pageId,
|
||||
queryKey: ["pages", pageIdOrSlugId],
|
||||
queryFn: () => getPageById(pageIdOrSlugId),
|
||||
enabled: !!pageIdOrSlugId,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRecentChangesQuery(): UseQueryResult<IPage[], Error> {
|
||||
export function useRecentChangesQuery(): UseQueryResult<
|
||||
IPagination<IPage>,
|
||||
Error
|
||||
> {
|
||||
return useQuery({
|
||||
queryKey: RECENT_CHANGES_KEY,
|
||||
queryFn: () => getRecentChanges(),
|
||||
@ -60,7 +65,7 @@ export function useUpdatePageMutation() {
|
||||
mutationFn: (data) => updatePage(data),
|
||||
onSuccess: (data) => {
|
||||
// update page in cache
|
||||
queryClient.setQueryData(["pages", data.id], data);
|
||||
queryClient.setQueryData(["pages", data.slugId], data);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -29,8 +29,8 @@ export async function movePage(data: IMovePage): Promise<void> {
|
||||
await api.post<void>("/pages/move", data);
|
||||
}
|
||||
|
||||
export async function getRecentChanges(): Promise<IPage[]> {
|
||||
const req = await api.post<IPage[]>("/pages/recent");
|
||||
export async function getRecentChanges(): Promise<IPagination<IPage>> {
|
||||
const req = await api.post("/pages/recent");
|
||||
return req.data;
|
||||
}
|
||||
|
||||
|
||||
@ -4,12 +4,13 @@ import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
||||
import {
|
||||
fetchAncestorChildren,
|
||||
useGetRootSidebarPagesQuery,
|
||||
usePageQuery,
|
||||
useUpdatePageMutation,
|
||||
} from "@/features/page/queries/page-query.ts";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import classes from "@/features/page/tree/styles/tree.module.css";
|
||||
import { ActionIcon, Menu, rem } from "@mantine/core";
|
||||
import { ActionIcon, Menu, rem, Text } from "@mantine/core";
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
@ -18,7 +19,6 @@ import {
|
||||
IconLink,
|
||||
IconPlus,
|
||||
IconPointFilled,
|
||||
IconStar,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
@ -39,9 +39,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 { useElementSize, useMergedRef } from "@mantine/hooks";
|
||||
import { useClipboard, useElementSize, useMergedRef } from "@mantine/hooks";
|
||||
import { dfs } from "react-arborist/dist/module/utils";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
||||
import { buildPageSlug } from "@/features/page/page.utils.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { modals } from "@mantine/modals";
|
||||
|
||||
interface SpaceTreeProps {
|
||||
spaceId: string;
|
||||
@ -50,7 +53,7 @@ interface SpaceTreeProps {
|
||||
const openTreeNodesAtom = atom<OpenMap>({});
|
||||
|
||||
export default function SpaceTree({ spaceId }: SpaceTreeProps) {
|
||||
const { pageId } = useParams();
|
||||
const { slugId } = useParams();
|
||||
const { data, setData, controllers } =
|
||||
useTreeMutation<TreeApi<SpaceTreeNode>>(spaceId);
|
||||
const {
|
||||
@ -68,6 +71,7 @@ export default function SpaceTree({ spaceId }: SpaceTreeProps) {
|
||||
const { ref: sizeRef, width, height } = useElementSize();
|
||||
const mergedRef = useMergedRef(rootElement, sizeRef);
|
||||
const isDataLoaded = useRef(false);
|
||||
const { data: currentPage } = usePageQuery(slugId);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasNextPage && !isFetching) {
|
||||
@ -94,24 +98,24 @@ export default function SpaceTree({ spaceId }: SpaceTreeProps) {
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (isDataLoaded.current) {
|
||||
if (isDataLoaded.current && currentPage) {
|
||||
// check if pageId node is present in the tree
|
||||
const node = dfs(treeApiRef.current.root, pageId);
|
||||
const node = dfs(treeApiRef.current.root, currentPage.id);
|
||||
if (node) {
|
||||
// if node is found, no need to traverse its ancestors
|
||||
return;
|
||||
}
|
||||
|
||||
// if not found, fetch and build its ancestors and their children
|
||||
if (!pageId) return;
|
||||
const ancestors = await getPageBreadcrumbs(pageId);
|
||||
if (!currentPage.id) return;
|
||||
const ancestors = await getPageBreadcrumbs(currentPage.id);
|
||||
|
||||
if (ancestors && ancestors?.length > 1) {
|
||||
let flatTreeItems = [...buildTree(ancestors)];
|
||||
|
||||
const fetchAndUpdateChildren = async (ancestor: IPage) => {
|
||||
// we don't want to fetch the children of the opened page
|
||||
if (ancestor.id === pageId) {
|
||||
if (ancestor.id === currentPage.id) {
|
||||
return;
|
||||
}
|
||||
const children = await fetchAncestorChildren({
|
||||
@ -148,7 +152,7 @@ export default function SpaceTree({ spaceId }: SpaceTreeProps) {
|
||||
|
||||
setTimeout(() => {
|
||||
// focus on node and open all parents
|
||||
treeApiRef.current.select(pageId);
|
||||
treeApiRef.current.select(currentPage.id);
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
@ -156,13 +160,15 @@ export default function SpaceTree({ spaceId }: SpaceTreeProps) {
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [isDataLoaded.current, pageId]);
|
||||
}, [isDataLoaded.current, currentPage?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
treeApiRef.current?.select(pageId, { align: "auto" });
|
||||
}, 200);
|
||||
}, [pageId]);
|
||||
if (currentPage) {
|
||||
setTimeout(() => {
|
||||
treeApiRef.current?.select(currentPage.id, { align: "auto" });
|
||||
}, 200);
|
||||
}
|
||||
}, [currentPage?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (treeApiRef.current) {
|
||||
@ -241,7 +247,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
navigate(`/p/${node.id}`);
|
||||
navigate(buildPageSlug(node.data.slugId, node.data.name));
|
||||
};
|
||||
|
||||
const handleUpdateNodeIcon = (nodeId: string, newIcon: string) => {
|
||||
@ -333,6 +339,7 @@ interface CreateNodeProps {
|
||||
treeApi: TreeApi<SpaceTreeNode>;
|
||||
onExpandTree?: () => void;
|
||||
}
|
||||
|
||||
function CreateNode({ node, treeApi, onExpandTree }: CreateNodeProps) {
|
||||
function handleCreate() {
|
||||
if (node.data.hasChildren && node.children.length === 0) {
|
||||
@ -366,7 +373,32 @@ interface NodeMenuProps {
|
||||
node: NodeApi<SpaceTreeNode>;
|
||||
treeApi: TreeApi<SpaceTreeNode>;
|
||||
}
|
||||
|
||||
function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
||||
const clipboard = useClipboard({ timeout: 500 });
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const pageLink =
|
||||
window.location.host + buildPageSlug(node.data.id, node.data.name);
|
||||
clipboard.copy(pageLink);
|
||||
notifications.show({ message: "Link copied" });
|
||||
};
|
||||
|
||||
const openDeleteModal = () =>
|
||||
modals.openConfirmModal({
|
||||
title: "Are you sure you want to delete this page?",
|
||||
children: (
|
||||
<Text size="sm">
|
||||
Are you sure you want to delete this page? This action is
|
||||
irreversible.
|
||||
</Text>
|
||||
),
|
||||
centered: true,
|
||||
labels: { confirm: "Delete", cancel: "Cancel" },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: () => treeApi?.delete(node),
|
||||
});
|
||||
|
||||
return (
|
||||
<Menu shadow="md" width={200}>
|
||||
<Menu.Target>
|
||||
@ -386,13 +418,12 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconLink style={{ width: rem(14), height: rem(14) }} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleCopyLink();
|
||||
}}
|
||||
>
|
||||
Copy link
|
||||
@ -404,7 +435,7 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
||||
leftSection={
|
||||
<IconTrash style={{ width: rem(14), height: rem(14) }} />
|
||||
}
|
||||
onClick={() => treeApi?.delete(node)}
|
||||
onClick={openDeleteModal}
|
||||
>
|
||||
Delete
|
||||
</Menu.Item>
|
||||
@ -417,6 +448,7 @@ interface PageArrowProps {
|
||||
node: NodeApi<SpaceTreeNode>;
|
||||
onExpandTree?: () => void;
|
||||
}
|
||||
|
||||
function PageArrow({ node, onExpandTree }: PageArrowProps) {
|
||||
return (
|
||||
<ActionIcon
|
||||
|
||||
@ -10,7 +10,7 @@ import {
|
||||
import { IconChevronRight } from "@tabler/icons-react";
|
||||
import classes from "./tree-collapse.module.css";
|
||||
|
||||
interface LinksGroupProps {
|
||||
interface TreeCollapseProps {
|
||||
icon?: React.FC<any>;
|
||||
label: string;
|
||||
initiallyOpened?: boolean;
|
||||
@ -22,7 +22,7 @@ export function TreeCollapse({
|
||||
label,
|
||||
initiallyOpened,
|
||||
children,
|
||||
}: LinksGroupProps) {
|
||||
}: TreeCollapseProps) {
|
||||
const [opened, setOpened] = useState(initiallyOpened || false);
|
||||
|
||||
return (
|
||||
|
||||
@ -19,6 +19,7 @@ import {
|
||||
} from "@/features/page/queries/page-query.ts";
|
||||
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import { buildPageSlug } from "@/features/page/page.utils.ts";
|
||||
|
||||
export function useTreeMutation<T>(spaceId: string) {
|
||||
const [data, setData] = useAtom(treeDataAtom);
|
||||
@ -46,6 +47,7 @@ export function useTreeMutation<T>(spaceId: string) {
|
||||
|
||||
const data = {
|
||||
id: createdPage.id,
|
||||
slugId: createdPage.slugId,
|
||||
name: "",
|
||||
position: createdPage.position,
|
||||
children: [],
|
||||
@ -63,7 +65,7 @@ export function useTreeMutation<T>(spaceId: string) {
|
||||
tree.create({ parentId, index, data });
|
||||
setData(tree.data);
|
||||
|
||||
navigate(`/p/${createdPage.id}`);
|
||||
navigate(buildPageSlug(createdPage.slugId, createdPage.title));
|
||||
return data;
|
||||
};
|
||||
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
export type SpaceTreeNode = {
|
||||
id: string;
|
||||
slugId: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
position: string;
|
||||
slug?: string;
|
||||
spaceId: string;
|
||||
parentPageId: string;
|
||||
hasChildren: boolean;
|
||||
|
||||
@ -17,6 +17,7 @@ export function buildTree(pages: IPage[]): SpaceTreeNode[] {
|
||||
pages.forEach((page) => {
|
||||
pageMap[page.id] = {
|
||||
id: page.id,
|
||||
slugId: page.slugId,
|
||||
name: page.title,
|
||||
icon: page.icon,
|
||||
position: page.position,
|
||||
|
||||
@ -1,28 +1,23 @@
|
||||
export interface IPage {
|
||||
pageId: string;
|
||||
id: string;
|
||||
slugId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
html: string;
|
||||
slug: string;
|
||||
icon: string;
|
||||
coverPhoto: string;
|
||||
editor: string;
|
||||
shareId: string;
|
||||
parentPageId: string;
|
||||
creatorId: string;
|
||||
spaceId: string;
|
||||
workspaceId: string;
|
||||
children: [];
|
||||
childrenIds: [];
|
||||
isLocked: boolean;
|
||||
status: string;
|
||||
publishedAt: Date;
|
||||
isPublic: boolean;
|
||||
lastModifiedById: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
deletedAt: Date;
|
||||
position: string;
|
||||
hasChildren: boolean;
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
export interface IMovePage {
|
||||
|
||||
@ -57,7 +57,6 @@ export default function SpaceMembersList({ spaceId }: SpaceMembersProps) {
|
||||
};
|
||||
|
||||
const onRemove = async (memberId: string, type: MemberType) => {
|
||||
console.log("remove", spaceId);
|
||||
const memberToRemove: IRemoveSpaceMember = {
|
||||
spaceId: spaceId,
|
||||
};
|
||||
|
||||
@ -8,14 +8,18 @@ export interface IUser {
|
||||
avatarUrl: string;
|
||||
timezone: string;
|
||||
settings: any;
|
||||
invitedById: string;
|
||||
lastLoginAt: string;
|
||||
lastActiveAt: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
role: string;
|
||||
workspaceId: string;
|
||||
deactivatedAt: Date;
|
||||
deletedAt: Date;
|
||||
}
|
||||
|
||||
export interface ICurrentUser {
|
||||
user: IUser,
|
||||
workspace: IWorkspace
|
||||
user: IUser;
|
||||
workspace: IWorkspace;
|
||||
}
|
||||
|
||||
@ -12,6 +12,9 @@ export const useQuerySubscription = () => {
|
||||
socket?.on("message", (event) => {
|
||||
const data: WebSocketEvent = event;
|
||||
|
||||
let entity = null;
|
||||
let queryKeyId = null;
|
||||
|
||||
switch (data.operation) {
|
||||
case "invalidate":
|
||||
queryClient.invalidateQueries({
|
||||
@ -19,8 +22,16 @@ export const useQuerySubscription = () => {
|
||||
});
|
||||
break;
|
||||
case "updateOne":
|
||||
queryClient.setQueryData([...data.entity, data.id], {
|
||||
...queryClient.getQueryData([...data.entity, data.id]),
|
||||
entity = data.entity[0];
|
||||
if (entity === "pages") {
|
||||
// we have to do this because the usePageQuery cache key is the slugId.
|
||||
queryKeyId = data.payload.slugId;
|
||||
} else {
|
||||
queryKeyId = data.id;
|
||||
}
|
||||
|
||||
queryClient.setQueryData([...data.entity, queryKeyId], {
|
||||
...queryClient.getQueryData([...data.entity, queryKeyId]),
|
||||
...data.payload,
|
||||
});
|
||||
|
||||
|
||||
@ -8,11 +8,12 @@ import {
|
||||
changeMemberRole,
|
||||
getInvitationById,
|
||||
getPendingInvitations,
|
||||
getWorkspace,
|
||||
getWorkspaceMembers,
|
||||
createInvitation,
|
||||
resendInvitation,
|
||||
revokeInvitation,
|
||||
getWorkspace,
|
||||
getWorkspacePublicData,
|
||||
} from "@/features/workspace/services/workspace-service";
|
||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
@ -22,13 +23,23 @@ import {
|
||||
IWorkspace,
|
||||
} from "@/features/workspace/types/workspace.types.ts";
|
||||
|
||||
export function useWorkspace(): UseQueryResult<IWorkspace, Error> {
|
||||
export function useWorkspaceQuery(): UseQueryResult<IWorkspace, Error> {
|
||||
return useQuery({
|
||||
queryKey: ["workspace"],
|
||||
queryFn: () => getWorkspace(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useWorkspacePublicDataQuery(): UseQueryResult<
|
||||
IWorkspace,
|
||||
Error
|
||||
> {
|
||||
return useQuery({
|
||||
queryKey: ["workspace-public"],
|
||||
queryFn: () => getWorkspacePublicData(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useWorkspaceMembersQuery(params?: QueryParams) {
|
||||
return useQuery({
|
||||
queryKey: ["workspaceMembers", params],
|
||||
@ -69,7 +80,7 @@ export function useCreateInvitationMutation() {
|
||||
return useMutation<void, Error, ICreateInvite>({
|
||||
mutationFn: (data) => createInvitation(data),
|
||||
onSuccess: (data, variables) => {
|
||||
notifications.show({ message: "Invitation successfully" });
|
||||
notifications.show({ message: "Invitation sent" });
|
||||
// TODO: mutate cache
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["invitations"],
|
||||
@ -92,7 +103,7 @@ export function useResendInvitationMutation() {
|
||||
>({
|
||||
mutationFn: (data) => resendInvitation(data),
|
||||
onSuccess: (data, variables) => {
|
||||
notifications.show({ message: "Invitation mail sent" });
|
||||
notifications.show({ message: "Invitation resent" });
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
|
||||
@ -14,6 +14,11 @@ export async function getWorkspace(): Promise<IWorkspace> {
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getWorkspacePublicData(): Promise<IWorkspace> {
|
||||
const req = await api.post<IWorkspace>("/workspace/public");
|
||||
return req.data;
|
||||
}
|
||||
|
||||
// Todo: fix all paginated types
|
||||
export async function getWorkspaceMembers(
|
||||
params?: QueryParams,
|
||||
|
||||
@ -38,15 +38,22 @@ api.interceptors.response.use(
|
||||
switch (error.response.status) {
|
||||
case 401:
|
||||
// Handle unauthorized error
|
||||
if (window.location.pathname != Routes.AUTH.LOGIN) {
|
||||
window.location.href = Routes.AUTH.LOGIN;
|
||||
}
|
||||
Cookies.remove("authTokens");
|
||||
redirectToLogin();
|
||||
break;
|
||||
case 403:
|
||||
// Handle forbidden error
|
||||
break;
|
||||
case 404:
|
||||
// Handle not found error
|
||||
if (
|
||||
error.response.data.message
|
||||
.toLowerCase()
|
||||
.includes("workspace not found")
|
||||
) {
|
||||
Cookies.remove("authTokens");
|
||||
redirectToLogin();
|
||||
}
|
||||
break;
|
||||
case 500:
|
||||
// Handle internal server error
|
||||
@ -59,4 +66,13 @@ api.interceptors.response.use(
|
||||
},
|
||||
);
|
||||
|
||||
function redirectToLogin() {
|
||||
if (
|
||||
window.location.pathname != Routes.AUTH.LOGIN &&
|
||||
window.location.pathname != Routes.AUTH.SIGNUP
|
||||
) {
|
||||
window.location.href = Routes.AUTH.LOGIN;
|
||||
}
|
||||
}
|
||||
|
||||
export default api;
|
||||
|
||||
@ -10,6 +10,7 @@ import { BrowserRouter } from "react-router-dom";
|
||||
import { ModalsProvider } from "@mantine/modals";
|
||||
import { Notifications } from "@mantine/notifications";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { HelmetProvider } from "react-helmet-async";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@ -30,8 +31,10 @@ root.render(
|
||||
<MantineProvider theme={theme}>
|
||||
<ModalsProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Notifications position="top-right" limit={3} />
|
||||
<App />
|
||||
<Notifications position="bottom-center" limit={3} />
|
||||
<HelmetProvider>
|
||||
<App />
|
||||
</HelmetProvider>
|
||||
</QueryClientProvider>
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
|
||||
@ -1,5 +1,13 @@
|
||||
import { LoginForm } from '@/features/auth/components/login-form';
|
||||
import { LoginForm } from "@/features/auth/components/login-form";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
|
||||
export default function LoginPage() {
|
||||
return <LoginForm />;
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>Login</title>
|
||||
</Helmet>
|
||||
<LoginForm />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,42 @@
|
||||
import { SignUpForm } from '@/features/auth/components/sign-up-form';
|
||||
import { SignUpForm } from "@/features/auth/components/sign-up-form";
|
||||
import { useWorkspacePublicDataQuery } from "@/features/workspace/queries/workspace-query.ts";
|
||||
import { SetupWorkspaceForm } from "@/features/auth/components/setup-workspace-form.tsx";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import React from "react";
|
||||
|
||||
export default function SignUpPage() {
|
||||
return <SignUpForm />;
|
||||
const {
|
||||
data: workspace,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = useWorkspacePublicDataQuery();
|
||||
|
||||
if (isLoading) {
|
||||
return <div></div>;
|
||||
}
|
||||
|
||||
if (
|
||||
isError &&
|
||||
error?.["response"]?.status === 404 &&
|
||||
error?.["response"]?.data.message.includes("Workspace not found")
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>Setup workspace</title>
|
||||
</Helmet>
|
||||
<SetupWorkspaceForm />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return workspace ? (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>Signup</title>
|
||||
</Helmet>
|
||||
<SignUpForm />
|
||||
</>
|
||||
) : null;
|
||||
}
|
||||
|
||||
@ -1,27 +1,31 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { usePageQuery } from '@/features/page/queries/page-query';
|
||||
import { FullEditor } from '@/features/editor/full-editor';
|
||||
import HistoryModal from '@/features/page-history/components/history-modal';
|
||||
import { useParams } from "react-router-dom";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query";
|
||||
import { FullEditor } from "@/features/editor/full-editor";
|
||||
import HistoryModal from "@/features/page-history/components/history-modal";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
|
||||
export default function Page() {
|
||||
const { pageId } = useParams();
|
||||
const { data, isLoading, isError } = usePageQuery(pageId);
|
||||
const { slugId } = useParams();
|
||||
const { data: page, isLoading, isError } = usePageQuery(slugId);
|
||||
|
||||
if (isLoading) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
if (isError || !data) { // TODO: fix this
|
||||
if (isError || !page) {
|
||||
// TODO: fix this
|
||||
return <div>Error fetching page data.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
data && (
|
||||
page && (
|
||||
<div>
|
||||
<FullEditor pageId={pageId} title={data.title} />
|
||||
<HistoryModal />
|
||||
<Helmet>
|
||||
<title>{page.title}</title>
|
||||
</Helmet>
|
||||
<FullEditor pageId={page.id} title={page.title} slugId={page.slugId} />
|
||||
<HistoryModal pageId={page.id} />
|
||||
</div>
|
||||
)
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user