updates and fixes

* seo friendly urls
* custom client serve-static module
* database fixes
* fix recent pages
* other fixes
This commit is contained in:
Philipinho
2024-05-18 03:19:42 +01:00
parent eefe63d1cd
commit 9c7c2f1163
102 changed files with 921 additions and 536 deletions

View File

@ -4,10 +4,13 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>docmost</title> <title>Docmost</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<!--window-config-->
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

View File

@ -1,7 +1,7 @@
{ {
"name": "client", "name": "client",
"private": true, "private": true,
"version": "0.0.0", "version": "0.1.0",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
@ -31,6 +31,7 @@
"react-arborist": "^3.4.0", "react-arborist": "^3.4.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-error-boundary": "^4.0.13", "react-error-boundary": "^4.0.13",
"react-helmet-async": "^2.0.5",
"react-router-dom": "^6.22.3", "react-router-dom": "^6.22.3",
"socket.io-client": "^4.7.5", "socket.io-client": "^4.7.5",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",

View File

@ -65,7 +65,7 @@ export default function App() {
<Route element={<DashboardLayout />}> <Route element={<DashboardLayout />}>
<Route path={"/home"} element={<Home />} /> <Route path={"/home"} element={<Home />} />
<Route path={"/p/:pageId"} element={<Page />} /> <Route path={"/p/:slugId/:slug?"} element={<Page />} />
</Route> </Route>
<Route path={"/settings"} element={<SettingsLayout />}> <Route path={"/settings"} element={<SettingsLayout />}>

View File

@ -14,6 +14,8 @@ import { IconDots } from "@tabler/icons-react";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import classes from "./breadcrumb.module.css"; import classes from "./breadcrumb.module.css";
import { SpaceTreeNode } from "@/features/page/tree/types.ts"; 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) { function getTitle(name: string, icon: string) {
if (icon) { if (icon) {
@ -27,23 +29,17 @@ export default function Breadcrumb() {
const [breadcrumbNodes, setBreadcrumbNodes] = useState< const [breadcrumbNodes, setBreadcrumbNodes] = useState<
SpaceTreeNode[] | null SpaceTreeNode[] | null
>(null); >(null);
const { pageId } = useParams(); const { slugId } = useParams();
const { data: currentPage } = usePageQuery(slugId);
useEffect(() => { useEffect(() => {
if (treeData.length) { if (treeData?.length > 0 && currentPage) {
const breadcrumb = findBreadcrumbPath(treeData, pageId); const breadcrumb = findBreadcrumbPath(treeData, currentPage.id);
if (breadcrumb) { if (breadcrumb) {
setBreadcrumbNodes(breadcrumb); setBreadcrumbNodes(breadcrumb);
} }
} }
}, [pageId, treeData]); }, [currentPage?.id, treeData]);
useEffect(() => {
if (treeData.length) {
const breadcrumb = findBreadcrumbPath(treeData, pageId);
if (breadcrumb) setBreadcrumbNodes(breadcrumb);
}
}, [pageId, treeData]);
const HiddenNodesTooltipContent = () => const HiddenNodesTooltipContent = () =>
breadcrumbNodes?.slice(1, -2).map((node) => ( breadcrumbNodes?.slice(1, -2).map((node) => (
@ -51,7 +47,7 @@ export default function Breadcrumb() {
<Button <Button
justify="start" justify="start"
component={Link} component={Link}
to={`/p/${node.id}`} to={buildPageSlug(node.slugId, node.name)}
variant="default" variant="default"
style={{ border: "none" }} style={{ border: "none" }}
> >
@ -63,16 +59,14 @@ export default function Breadcrumb() {
const getLastNthNode = (n: number) => const getLastNthNode = (n: number) =>
breadcrumbNodes && breadcrumbNodes[breadcrumbNodes.length - n]; breadcrumbNodes && breadcrumbNodes[breadcrumbNodes.length - n];
// const getTitle = (title: string) => (title?.length > 0 ? title : "untitled");
const getBreadcrumbItems = () => { const getBreadcrumbItems = () => {
if (breadcrumbNodes?.length > 3) { if (breadcrumbNodes?.length > 3) {
return [ return [
<Anchor <Anchor
component={Link} component={Link}
to={`/p/${breadcrumbNodes[0].id}`} to={buildPageSlug(breadcrumbNodes[0].slugId, breadcrumbNodes[0].name)}
underline="never" underline="never"
key={breadcrumbNodes[0].id} key={breadcrumbNodes[0].slugId}
> >
{getTitle(breadcrumbNodes[0].name, breadcrumbNodes[0].icon)} {getTitle(breadcrumbNodes[0].name, breadcrumbNodes[0].icon)}
</Anchor>, </Anchor>,
@ -94,17 +88,17 @@ export default function Breadcrumb() {
</Popover>, </Popover>,
<Anchor <Anchor
component={Link} component={Link}
to={`/p/${getLastNthNode(2)?.id}`} to={buildPageSlug(getLastNthNode(2)?.slugId, getLastNthNode(2)?.name)}
underline="never" underline="never"
key={getLastNthNode(2)?.id} key={getLastNthNode(2)?.slugId}
> >
{getTitle(getLastNthNode(2)?.name, getLastNthNode(2)?.icon)} {getTitle(getLastNthNode(2)?.name, getLastNthNode(2)?.icon)}
</Anchor>, </Anchor>,
<Anchor <Anchor
component={Link} component={Link}
to={`/p/${getLastNthNode(1)?.id}`} to={buildPageSlug(getLastNthNode(1)?.slugId, getLastNthNode(1)?.name)}
underline="never" underline="never"
key={getLastNthNode(1)?.id} key={getLastNthNode(1)?.slugId}
> >
{getTitle(getLastNthNode(1)?.name, getLastNthNode(1)?.icon)} {getTitle(getLastNthNode(1)?.name, getLastNthNode(1)?.icon)}
</Anchor>, </Anchor>,
@ -115,7 +109,7 @@ export default function Breadcrumb() {
return breadcrumbNodes.map((node) => ( return breadcrumbNodes.map((node) => (
<Anchor <Anchor
component={Link} component={Link}
to={`/p/${node.id}`} to={buildPageSlug(node.slugId, node.name)}
underline="never" underline="never"
key={node.id} key={node.id}
> >

View File

@ -1,11 +1,15 @@
import { UserProvider } from "@/features/user/user-provider.tsx"; import { UserProvider } from "@/features/user/user-provider.tsx";
import Shell from "./shell.tsx"; import Shell from "./shell.tsx";
import { Outlet } from "react-router-dom"; import { Outlet } from "react-router-dom";
import { Helmet } from "react-helmet-async";
export default function DashboardLayout() { export default function DashboardLayout() {
return ( return (
<UserProvider> <UserProvider>
<Shell> <Shell>
<Helmet>
<title>Home</title>
</Helmet>
<Outlet /> <Outlet />
</Shell> </Shell>
</UserProvider> </UserProvider>

View File

@ -1,18 +1,19 @@
import { ActionIcon, Menu, Button, Tooltip } from "@mantine/core"; import { ActionIcon, Menu, Tooltip } from "@mantine/core";
import { import {
IconDots, IconDots,
IconFileInfo,
IconHistory, IconHistory,
IconLink, IconLink,
IconLock,
IconShare,
IconTrash,
IconMessage, IconMessage,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import React from "react"; import React from "react";
import useToggleAside from "@/hooks/use-toggle-aside.tsx"; import useToggleAside from "@/hooks/use-toggle-aside.tsx";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts"; 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() { export default function Header() {
const toggleAside = useToggleAside(); const toggleAside = useToggleAside();
@ -42,6 +43,16 @@ export default function Header() {
function PageActionMenu() { function PageActionMenu() {
const [, setHistoryModalOpen] = useAtom(historyAtoms); 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 = () => { const openHistoryModal = () => {
setHistoryModalOpen(true); setHistoryModalOpen(true);
@ -63,9 +74,13 @@ function PageActionMenu() {
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Item leftSection={<IconLink size={16} stroke={2} />}> <Menu.Item
leftSection={<IconLink size={16} stroke={2} />}
onClick={handleCopyLink}
>
Copy link Copy link
</Menu.Item> </Menu.Item>
<Menu.Divider />
<Menu.Item <Menu.Item
leftSection={<IconHistory size={16} stroke={2} />} leftSection={<IconHistory size={16} stroke={2} />}
onClick={openHistoryModal} onClick={openHistoryModal}
@ -73,10 +88,12 @@ function PageActionMenu() {
Page history Page history
</Menu.Item> </Menu.Item>
{/*
<Menu.Divider /> <Menu.Divider />
<Menu.Item leftSection={<IconTrash size={16} stroke={2} />}> <Menu.Item leftSection={<IconTrash size={16} stroke={2} />}>
Delete Delete
</Menu.Item> </Menu.Item>
*/}
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
); );

View File

@ -1,11 +1,15 @@
import { UserProvider } from "@/features/user/user-provider.tsx"; import { UserProvider } from "@/features/user/user-provider.tsx";
import { Outlet } from "react-router-dom"; import { Outlet } from "react-router-dom";
import SettingsShell from "@/components/layouts/settings/settings-shell.tsx"; import SettingsShell from "@/components/layouts/settings/settings-shell.tsx";
import { Helmet } from "react-helmet-async";
export default function SettingsLayout() { export default function SettingsLayout() {
return ( return (
<UserProvider> <UserProvider>
<SettingsShell> <SettingsShell>
<Helmet>
<title>Settings</title>
</Helmet>
<Outlet /> <Outlet />
</SettingsShell> </SettingsShell>
</UserProvider> </UserProvider>

View File

@ -60,7 +60,7 @@ export function InviteSignUpForm() {
<Container size={420} my={40} className={classes.container}> <Container size={420} my={40} className={classes.container}>
<Box p="xl" mt={200}> <Box p="xl" mt={200}>
<Title order={2} ta="center" fw={500} mb="md"> <Title order={2} ta="center" fw={500} mb="md">
Complete your signup Join the workspace
</Title> </Title>
<Stack align="stretch" justify="center" gap="xl"> <Stack align="stretch" justify="center" gap="xl">

View File

@ -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>
);
}

View File

@ -1,10 +1,18 @@
import { useState } from "react"; 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 { useNavigate } from "react-router-dom";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom"; import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom";
import { currentUserAtom } from "@/features/user/atoms/current-user-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 { notifications } from "@mantine/notifications";
import { IAcceptInvite } from "@/features/workspace/types/workspace.types.ts"; import { IAcceptInvite } from "@/features/workspace/types/workspace.types.ts";
import { acceptInvitation } from "@/features/workspace/services/workspace-service.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 () => { const handleIsAuthenticated = async () => {
if (!authToken) { if (!authToken) {
return false; return false;
@ -109,6 +136,7 @@ export default function useAuth() {
signIn: handleSignIn, signIn: handleSignIn,
signUp: handleSignUp, signUp: handleSignUp,
invitationSignup: handleInvitationSignUp, invitationSignup: handleInvitationSignUp,
setupWorkspace: handleSetupWorkspace,
isAuthenticated: handleIsAuthenticated, isAuthenticated: handleIsAuthenticated,
logout: handleLogout, logout: handleLogout,
hasTokens, hasTokens,

View File

@ -3,6 +3,7 @@ import {
IChangePassword, IChangePassword,
ILogin, ILogin,
IRegister, IRegister,
ISetupWorkspace,
ITokenResponse, ITokenResponse,
} from "@/features/auth/types/auth.types"; } from "@/features/auth/types/auth.types";
@ -22,3 +23,10 @@ export async function changePassword(
const req = await api.post<IChangePassword>("/auth/change-password", data); const req = await api.post<IChangePassword>("/auth/change-password", data);
return req.data; return req.data;
} }
export async function setupWorkspace(
data: ISetupWorkspace,
): Promise<ITokenResponse> {
const req = await api.post<ITokenResponse>("/auth/setup", data);
return req.data;
}

View File

@ -9,6 +9,13 @@ export interface IRegister {
password: string; password: string;
} }
export interface ISetupWorkspace {
workspaceName: string;
name: string;
email: string;
password: string;
}
export interface ITokens { export interface ITokens {
accessToken: string; accessToken: string;
refreshToken: string; refreshToken: string;

View File

@ -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 { useParams } from "react-router-dom";
import { Divider, Paper } from "@mantine/core"; import { Divider, Paper } from "@mantine/core";
import CommentListItem from "@/features/comment/components/comment-list-item"; 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 CommentActions from "@/features/comment/components/comment-actions";
import { useFocusWithin } from "@mantine/hooks"; import { useFocusWithin } from "@mantine/hooks";
import { IComment } from "@/features/comment/types/comment.types.ts"; 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() { function CommentList() {
const { pageId } = useParams(); const { slugId } = useParams();
const { data: page } = usePageQuery(slugId);
const { const {
data: comments, data: comments,
isLoading: isCommentsLoading, isLoading: isCommentsLoading,
isError, isError,
} = useCommentsQuery({ pageId, limit: 100 }); } = useCommentsQuery({ pageId: page?.id, limit: 100 });
const [isLoading, setIsLoading] = useState(false);
const createCommentMutation = useCreateCommentMutation(); 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) { if (isCommentsLoading) {
return <></>; return <></>;
@ -34,50 +85,6 @@ function CommentList() {
return <>No comments yet.</>; 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 ( return (
<> <>
{comments.items {comments.items
@ -87,35 +94,46 @@ function CommentList() {
); );
} }
const ChildComments = ({ comments, parentId }) => { interface ChildCommentsProps {
const getChildComments = (parentId: string) => { comments: IPagination<IComment>;
return comments.items.filter( parentId: string;
(comment: IComment) => comment.parentCommentId === parentId, }
); const ChildComments = ({ comments, parentId }: ChildCommentsProps) => {
}; const getChildComments = useCallback(
(parentId: string) =>
comments.items.filter(
(comment: IComment) => comment.parentCommentId === parentId,
),
[comments.items],
);
return ( return (
<div> <div>
{getChildComments(parentId).map((childComment) => ( {getChildComments(parentId).map((childComment) => (
<div key={childComment.id}> <div key={childComment.id}>
<CommentListItem comment={childComment} /> <CommentListItem comment={childComment} />
<ChildComments comments={comments} parentId={childComment.id} /> <MemoizedChildComments
comments={comments}
parentId={childComment.id}
/>
</div> </div>
))} ))}
</div> </div>
); );
}; };
const MemoizedChildComments = memo(ChildComments);
const CommentEditorWithActions = ({ commentId, onSave, isLoading }) => { const CommentEditorWithActions = ({ commentId, onSave, isLoading }) => {
const [content, setContent] = useState(""); const [content, setContent] = useState("");
const { ref, focused } = useFocusWithin(); const { ref, focused } = useFocusWithin();
const commentEditorRef = useRef(null); const commentEditorRef = useRef(null);
const handleSave = () => { const handleSave = useCallback(() => {
onSave(commentId, content); onSave(commentId, content);
setContent(""); setContent("");
commentEditorRef.current?.clearContent(); commentEditorRef.current?.clearContent();
}; }, [commentId, content, onSave]);
return ( return (
<div ref={ref}> <div ref={ref}>

View File

@ -1,20 +1,22 @@
import classes from '@/features/editor/styles/editor.module.css'; import classes from "@/features/editor/styles/editor.module.css";
import React from 'react'; import React from "react";
import { TitleEditor } from '@/features/editor/title-editor'; import { TitleEditor } from "@/features/editor/title-editor";
import PageEditor from '@/features/editor/page-editor'; import PageEditor from "@/features/editor/page-editor";
const MemoizedTitleEditor = React.memo(TitleEditor);
const MemoizedPageEditor = React.memo(PageEditor);
export interface FullEditorProps { export interface FullEditorProps {
pageId: string; pageId: string;
title: any; slugId: string;
title: string;
} }
export function FullEditor({ pageId, title }: FullEditorProps) { export function FullEditor({ pageId, title, slugId }: FullEditorProps) {
return ( return (
<div className={classes.editor}> <div className={classes.editor}>
<TitleEditor pageId={pageId} title={title} /> <MemoizedTitleEditor pageId={pageId} slugId={slugId} title={title} />
<PageEditor pageId={pageId} /> <MemoizedPageEditor pageId={pageId} />
</div> </div>
); );
} }

View File

@ -36,12 +36,9 @@ export default function PageEditor({
const [currentUser] = useAtom(currentUserAtom); const [currentUser] = useAtom(currentUserAtom);
const [, setEditor] = useAtom(pageEditorAtom); const [, setEditor] = useAtom(pageEditorAtom);
const [, setAsideState] = useAtom(asideStateAtom); const [, setAsideState] = useAtom(asideStateAtom);
const [, setActiveCommentId] = useAtom(activeCommentIdAtom); const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom); const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const ydoc = useMemo(() => new Y.Doc(), [pageId]); const ydoc = useMemo(() => new Y.Doc(), [pageId]);
const [isLocalSynced, setLocalSynced] = useState(false); const [isLocalSynced, setLocalSynced] = useState(false);
const [isRemoteSynced, setRemoteSynced] = useState(false); const [isRemoteSynced, setRemoteSynced] = useState(false);
const documentName = `page.${pageId}`; const documentName = `page.${pageId}`;

View File

@ -10,27 +10,34 @@ import {
pageEditorAtom, pageEditorAtom,
titleEditorAtom, titleEditorAtom,
} from "@/features/editor/atoms/editor-atoms"; } 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 { useDebouncedValue } from "@mantine/hooks";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom"; import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
import { updateTreeNodeName } from "@/features/page/tree/utils"; import { updateTreeNodeName } from "@/features/page/tree/utils";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts"; import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
import { History } from "@tiptap/extension-history"; import { History } from "@tiptap/extension-history";
import { buildPageSlug } from "@/features/page/page.utils.ts";
import { useNavigate } from "react-router-dom";
export interface TitleEditorProps { export interface TitleEditorProps {
pageId: string; pageId: string;
slugId: string;
title: string; title: string;
} }
export function TitleEditor({ pageId, title }: TitleEditorProps) { export function TitleEditor({ pageId, slugId, title }: TitleEditorProps) {
const [debouncedTitleState, setDebouncedTitleState] = useState(""); const [debouncedTitleState, setDebouncedTitleState] = useState(null);
const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 1000); const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 1000);
const updatePageMutation = useUpdatePageMutation(); const updatePageMutation = useUpdatePageMutation();
const pageEditor = useAtomValue(pageEditorAtom); const pageEditor = useAtomValue(pageEditorAtom);
const [, setTitleEditor] = useAtom(titleEditorAtom); const [, setTitleEditor] = useAtom(titleEditorAtom);
const [treeData, setTreeData] = useAtom(treeDataAtom); const [treeData, setTreeData] = useAtom(treeDataAtom);
const emit = useQueryEmit(); const emit = useQueryEmit();
const navigate = useNavigate();
const titleEditor = useEditor({ const titleEditor = useEditor({
extensions: [ extensions: [
@ -62,15 +69,23 @@ export function TitleEditor({ pageId, title }: TitleEditorProps) {
}); });
useEffect(() => { useEffect(() => {
if (debouncedTitle !== "") { const pageSlug = buildPageSlug(slugId, title);
updatePageMutation.mutate({ pageId, title: debouncedTitle }); navigate(pageSlug, { replace: true });
}, [title]);
useEffect(() => {
if (debouncedTitle !== null) {
updatePageMutation.mutate({
pageId: pageId,
title: debouncedTitle,
});
setTimeout(() => { setTimeout(() => {
emit({ emit({
operation: "updateOne", operation: "updateOne",
entity: ["pages"], entity: ["pages"],
id: pageId, id: pageId,
payload: { title: debouncedTitle }, payload: { title: debouncedTitle, slugId: slugId },
}); });
}, 50); }, 50);

View File

@ -1,5 +1,5 @@
import { Text, Tabs, Space } from "@mantine/core"; 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"; import RecentChanges from "@/features/home/components/recent-changes";
export default function HomeTabs() { export default function HomeTabs() {
@ -16,7 +16,7 @@ export default function HomeTabs() {
<Space my="md" /> <Space my="md" />
<Tabs.Panel value="recent"> <Tabs.Panel value="recent">
<div>Recent</div> <RecentChanges />
</Tabs.Panel> </Tabs.Panel>
</Tabs> </Tabs>
); );

View File

@ -1,9 +1,10 @@
import { Text, Group, Stack, UnstyledButton, Divider } from '@mantine/core'; import { Text, Group, Stack, UnstyledButton, Divider } from "@mantine/core";
import { format } from 'date-fns'; import { format } from "date-fns";
import classes from './home.module.css'; import classes from "./home.module.css";
import { Link } from 'react-router-dom'; import { Link } from "react-router-dom";
import PageListSkeleton from '@/features/home/components/page-list-skeleton'; import PageListSkeleton from "@/features/home/components/page-list-skeleton";
import { useRecentChangesQuery } from '@/features/page/queries/page-query'; import { useRecentChangesQuery } from "@/features/page/queries/page-query";
import { buildPageSlug } from "@/features/page/page.utils.ts";
function RecentChanges() { function RecentChanges() {
const { data, isLoading, isError } = useRecentChangesQuery(); const { data, isLoading, isError } = useRecentChangesQuery();
@ -18,21 +19,23 @@ function RecentChanges() {
return ( return (
<div> <div>
{data {data.items.map((page) => (
.map((page) => (
<div key={page.id}> <div key={page.id}>
<UnstyledButton component={Link} to={`/p/${page.id}`} <UnstyledButton
className={classes.page} p="xs"> component={Link}
to={buildPageSlug(page.slugId, page.title)}
className={classes.page}
p="xs"
>
<Group wrap="nowrap"> <Group wrap="nowrap">
<Stack gap="xs" style={{ flex: 1 }}> <Stack gap="xs" style={{ flex: 1 }}>
<Text fw={500} size="md" lineClamp={1}> <Text fw={500} size="md" lineClamp={1}>
{page.title || 'Untitled'} {page.title || "Untitled"}
</Text> </Text>
</Stack> </Stack>
<Text c="dimmed" size="xs" fw={500}> <Text c="dimmed" size="xs" fw={500}>
{format(new Date(page.updatedAt), 'PP')} {format(new Date(page.updatedAt), "PP")}
</Text> </Text>
</Group> </Group>
</UnstyledButton> </UnstyledButton>

View File

@ -18,9 +18,12 @@ import {
import { modals } from "@mantine/modals"; import { modals } from "@mantine/modals";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
function HistoryList() { interface Props {
pageId: string;
}
function HistoryList({ pageId }: Props) {
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom); const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
const { pageId } = useParams();
const { const {
data: pageHistoryList, data: pageHistoryList,
isLoading, isLoading,

View File

@ -1,18 +1,22 @@
import { ScrollArea } from '@mantine/core'; import { ScrollArea } from "@mantine/core";
import HistoryList from '@/features/page-history/components/history-list'; import HistoryList from "@/features/page-history/components/history-list";
import classes from './history.module.css'; import classes from "./history.module.css";
import { useAtom } from 'jotai'; import { useAtom } from "jotai";
import { activeHistoryIdAtom } from '@/features/page-history/atoms/history-atoms'; import { activeHistoryIdAtom } from "@/features/page-history/atoms/history-atoms";
import HistoryView from '@/features/page-history/components/history-view'; 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); const [activeHistoryId] = useAtom(activeHistoryIdAtom);
return ( return (
<div className={classes.sidebarFlex}> <div className={classes.sidebarFlex}>
<nav className={classes.sidebar}> <nav className={classes.sidebar}>
<div className={classes.sidebarMain}> <div className={classes.sidebarMain}>
<HistoryList /> <HistoryList pageId={pageId} />
</div> </div>
</nav> </nav>
@ -21,7 +25,6 @@ export default function HistoryModalBody() {
{activeHistoryId && <HistoryView historyId={activeHistoryId} />} {activeHistoryId && <HistoryView historyId={activeHistoryId} />}
</div> </div>
</ScrollArea> </ScrollArea>
</div> </div>
); );
} }

View File

@ -1,24 +1,33 @@
import { Modal, Text } from '@mantine/core'; import { Modal, Text } from "@mantine/core";
import { useAtom } from 'jotai'; import { useAtom } from "jotai";
import { historyAtoms } from '@/features/page-history/atoms/history-atoms'; import { historyAtoms } from "@/features/page-history/atoms/history-atoms";
import HistoryModalBody from '@/features/page-history/components/history-modal-body'; 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); const [isModalOpen, setModalOpen] = useAtom(historyAtoms);
return ( return (
<> <>
<Modal.Root size={1200} opened={isModalOpen} onClose={() => setModalOpen(false)}> <Modal.Root
size={1200}
opened={isModalOpen}
onClose={() => setModalOpen(false)}
>
<Modal.Overlay /> <Modal.Overlay />
<Modal.Content style={{ overflow: 'hidden' }}> <Modal.Content style={{ overflow: "hidden" }}>
<Modal.Header> <Modal.Header>
<Modal.Title> <Modal.Title>
<Text size="md" fw={500}>Page history</Text> <Text size="md" fw={500}>
Page history
</Text>
</Modal.Title> </Modal.Title>
<Modal.CloseButton /> <Modal.CloseButton />
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body>
<HistoryModalBody /> <HistoryModalBody pageId={pageId} />
</Modal.Body> </Modal.Body>
</Modal.Content> </Modal.Content>
</Modal.Root> </Modal.Root>

View 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}`;
};

View File

@ -27,16 +27,21 @@ import { buildTree } from "@/features/page/tree/utils";
const RECENT_CHANGES_KEY = ["recentChanges"]; const RECENT_CHANGES_KEY = ["recentChanges"];
export function usePageQuery(pageId: string): UseQueryResult<IPage, Error> { export function usePageQuery(
pageIdOrSlugId: string,
): UseQueryResult<IPage, Error> {
return useQuery({ return useQuery({
queryKey: ["pages", pageId], queryKey: ["pages", pageIdOrSlugId],
queryFn: () => getPageById(pageId), queryFn: () => getPageById(pageIdOrSlugId),
enabled: !!pageId, enabled: !!pageIdOrSlugId,
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
}); });
} }
export function useRecentChangesQuery(): UseQueryResult<IPage[], Error> { export function useRecentChangesQuery(): UseQueryResult<
IPagination<IPage>,
Error
> {
return useQuery({ return useQuery({
queryKey: RECENT_CHANGES_KEY, queryKey: RECENT_CHANGES_KEY,
queryFn: () => getRecentChanges(), queryFn: () => getRecentChanges(),
@ -60,7 +65,7 @@ export function useUpdatePageMutation() {
mutationFn: (data) => updatePage(data), mutationFn: (data) => updatePage(data),
onSuccess: (data) => { onSuccess: (data) => {
// update page in cache // update page in cache
queryClient.setQueryData(["pages", data.id], data); queryClient.setQueryData(["pages", data.slugId], data);
}, },
}); });
} }

View File

@ -29,8 +29,8 @@ export async function movePage(data: IMovePage): Promise<void> {
await api.post<void>("/pages/move", data); await api.post<void>("/pages/move", data);
} }
export async function getRecentChanges(): Promise<IPage[]> { export async function getRecentChanges(): Promise<IPagination<IPage>> {
const req = await api.post<IPage[]>("/pages/recent"); const req = await api.post("/pages/recent");
return req.data; return req.data;
} }

View File

@ -4,12 +4,13 @@ import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
import { import {
fetchAncestorChildren, fetchAncestorChildren,
useGetRootSidebarPagesQuery, useGetRootSidebarPagesQuery,
usePageQuery,
useUpdatePageMutation, useUpdatePageMutation,
} from "@/features/page/queries/page-query.ts"; } from "@/features/page/queries/page-query.ts";
import React, { useEffect, useRef } from "react"; import React, { useEffect, useRef } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import classes from "@/features/page/tree/styles/tree.module.css"; 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 { import {
IconChevronDown, IconChevronDown,
IconChevronRight, IconChevronRight,
@ -18,7 +19,6 @@ import {
IconLink, IconLink,
IconPlus, IconPlus,
IconPointFilled, IconPointFilled,
IconStar,
IconTrash, IconTrash,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts"; 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 { IPage, SidebarPagesParams } from "@/features/page/types/page.types.ts";
import { queryClient } from "@/main.tsx"; import { queryClient } from "@/main.tsx";
import { OpenMap } from "react-arborist/dist/main/state/open-slice"; 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 { dfs } from "react-arborist/dist/module/utils";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts"; 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 { interface SpaceTreeProps {
spaceId: string; spaceId: string;
@ -50,7 +53,7 @@ interface SpaceTreeProps {
const openTreeNodesAtom = atom<OpenMap>({}); const openTreeNodesAtom = atom<OpenMap>({});
export default function SpaceTree({ spaceId }: SpaceTreeProps) { export default function SpaceTree({ spaceId }: SpaceTreeProps) {
const { pageId } = useParams(); const { slugId } = useParams();
const { data, setData, controllers } = const { data, setData, controllers } =
useTreeMutation<TreeApi<SpaceTreeNode>>(spaceId); useTreeMutation<TreeApi<SpaceTreeNode>>(spaceId);
const { const {
@ -68,6 +71,7 @@ export default function SpaceTree({ spaceId }: SpaceTreeProps) {
const { ref: sizeRef, width, height } = useElementSize(); const { ref: sizeRef, width, height } = useElementSize();
const mergedRef = useMergedRef(rootElement, sizeRef); const mergedRef = useMergedRef(rootElement, sizeRef);
const isDataLoaded = useRef(false); const isDataLoaded = useRef(false);
const { data: currentPage } = usePageQuery(slugId);
useEffect(() => { useEffect(() => {
if (hasNextPage && !isFetching) { if (hasNextPage && !isFetching) {
@ -94,24 +98,24 @@ export default function SpaceTree({ spaceId }: SpaceTreeProps) {
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
if (isDataLoaded.current) { if (isDataLoaded.current && currentPage) {
// check if pageId node is present in the tree // 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) {
// if node is found, no need to traverse its ancestors // if node is found, no need to traverse its ancestors
return; return;
} }
// if not found, fetch and build its ancestors and their children // if not found, fetch and build its ancestors and their children
if (!pageId) return; if (!currentPage.id) return;
const ancestors = await getPageBreadcrumbs(pageId); const ancestors = await getPageBreadcrumbs(currentPage.id);
if (ancestors && ancestors?.length > 1) { if (ancestors && ancestors?.length > 1) {
let flatTreeItems = [...buildTree(ancestors)]; let flatTreeItems = [...buildTree(ancestors)];
const fetchAndUpdateChildren = async (ancestor: IPage) => { const fetchAndUpdateChildren = async (ancestor: IPage) => {
// we don't want to fetch the children of the opened page // we don't want to fetch the children of the opened page
if (ancestor.id === pageId) { if (ancestor.id === currentPage.id) {
return; return;
} }
const children = await fetchAncestorChildren({ const children = await fetchAncestorChildren({
@ -148,7 +152,7 @@ export default function SpaceTree({ spaceId }: SpaceTreeProps) {
setTimeout(() => { setTimeout(() => {
// focus on node and open all parents // focus on node and open all parents
treeApiRef.current.select(pageId); treeApiRef.current.select(currentPage.id);
}, 100); }, 100);
}); });
} }
@ -156,13 +160,15 @@ export default function SpaceTree({ spaceId }: SpaceTreeProps) {
}; };
fetchData(); fetchData();
}, [isDataLoaded.current, pageId]); }, [isDataLoaded.current, currentPage?.id]);
useEffect(() => { useEffect(() => {
setTimeout(() => { if (currentPage) {
treeApiRef.current?.select(pageId, { align: "auto" }); setTimeout(() => {
}, 200); treeApiRef.current?.select(currentPage.id, { align: "auto" });
}, [pageId]); }, 200);
}
}, [currentPage?.id]);
useEffect(() => { useEffect(() => {
if (treeApiRef.current) { if (treeApiRef.current) {
@ -241,7 +247,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
} }
const handleClick = () => { const handleClick = () => {
navigate(`/p/${node.id}`); navigate(buildPageSlug(node.data.slugId, node.data.name));
}; };
const handleUpdateNodeIcon = (nodeId: string, newIcon: string) => { const handleUpdateNodeIcon = (nodeId: string, newIcon: string) => {
@ -333,6 +339,7 @@ interface CreateNodeProps {
treeApi: TreeApi<SpaceTreeNode>; treeApi: TreeApi<SpaceTreeNode>;
onExpandTree?: () => void; onExpandTree?: () => void;
} }
function CreateNode({ node, treeApi, onExpandTree }: CreateNodeProps) { function CreateNode({ node, treeApi, onExpandTree }: CreateNodeProps) {
function handleCreate() { function handleCreate() {
if (node.data.hasChildren && node.children.length === 0) { if (node.data.hasChildren && node.children.length === 0) {
@ -366,7 +373,32 @@ interface NodeMenuProps {
node: NodeApi<SpaceTreeNode>; node: NodeApi<SpaceTreeNode>;
treeApi: TreeApi<SpaceTreeNode>; treeApi: TreeApi<SpaceTreeNode>;
} }
function NodeMenu({ node, treeApi }: NodeMenuProps) { 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 ( return (
<Menu shadow="md" width={200}> <Menu shadow="md" width={200}>
<Menu.Target> <Menu.Target>
@ -386,13 +418,12 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Divider />
<Menu.Item <Menu.Item
leftSection={<IconLink style={{ width: rem(14), height: rem(14) }} />} leftSection={<IconLink style={{ width: rem(14), height: rem(14) }} />}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
handleCopyLink();
}} }}
> >
Copy link Copy link
@ -404,7 +435,7 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
leftSection={ leftSection={
<IconTrash style={{ width: rem(14), height: rem(14) }} /> <IconTrash style={{ width: rem(14), height: rem(14) }} />
} }
onClick={() => treeApi?.delete(node)} onClick={openDeleteModal}
> >
Delete Delete
</Menu.Item> </Menu.Item>
@ -417,6 +448,7 @@ interface PageArrowProps {
node: NodeApi<SpaceTreeNode>; node: NodeApi<SpaceTreeNode>;
onExpandTree?: () => void; onExpandTree?: () => void;
} }
function PageArrow({ node, onExpandTree }: PageArrowProps) { function PageArrow({ node, onExpandTree }: PageArrowProps) {
return ( return (
<ActionIcon <ActionIcon

View File

@ -10,7 +10,7 @@ import {
import { IconChevronRight } from "@tabler/icons-react"; import { IconChevronRight } from "@tabler/icons-react";
import classes from "./tree-collapse.module.css"; import classes from "./tree-collapse.module.css";
interface LinksGroupProps { interface TreeCollapseProps {
icon?: React.FC<any>; icon?: React.FC<any>;
label: string; label: string;
initiallyOpened?: boolean; initiallyOpened?: boolean;
@ -22,7 +22,7 @@ export function TreeCollapse({
label, label,
initiallyOpened, initiallyOpened,
children, children,
}: LinksGroupProps) { }: TreeCollapseProps) {
const [opened, setOpened] = useState(initiallyOpened || false); const [opened, setOpened] = useState(initiallyOpened || false);
return ( return (

View File

@ -19,6 +19,7 @@ import {
} from "@/features/page/queries/page-query.ts"; } from "@/features/page/queries/page-query.ts";
import { generateJitteredKeyBetween } from "fractional-indexing-jittered"; import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
import { SpaceTreeNode } from "@/features/page/tree/types.ts"; import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { buildPageSlug } from "@/features/page/page.utils.ts";
export function useTreeMutation<T>(spaceId: string) { export function useTreeMutation<T>(spaceId: string) {
const [data, setData] = useAtom(treeDataAtom); const [data, setData] = useAtom(treeDataAtom);
@ -46,6 +47,7 @@ export function useTreeMutation<T>(spaceId: string) {
const data = { const data = {
id: createdPage.id, id: createdPage.id,
slugId: createdPage.slugId,
name: "", name: "",
position: createdPage.position, position: createdPage.position,
children: [], children: [],
@ -63,7 +65,7 @@ export function useTreeMutation<T>(spaceId: string) {
tree.create({ parentId, index, data }); tree.create({ parentId, index, data });
setData(tree.data); setData(tree.data);
navigate(`/p/${createdPage.id}`); navigate(buildPageSlug(createdPage.slugId, createdPage.title));
return data; return data;
}; };

View File

@ -1,9 +1,9 @@
export type SpaceTreeNode = { export type SpaceTreeNode = {
id: string; id: string;
slugId: string;
name: string; name: string;
icon?: string; icon?: string;
position: string; position: string;
slug?: string;
spaceId: string; spaceId: string;
parentPageId: string; parentPageId: string;
hasChildren: boolean; hasChildren: boolean;

View File

@ -17,6 +17,7 @@ export function buildTree(pages: IPage[]): SpaceTreeNode[] {
pages.forEach((page) => { pages.forEach((page) => {
pageMap[page.id] = { pageMap[page.id] = {
id: page.id, id: page.id,
slugId: page.slugId,
name: page.title, name: page.title,
icon: page.icon, icon: page.icon,
position: page.position, position: page.position,

View File

@ -1,28 +1,23 @@
export interface IPage { export interface IPage {
pageId: string;
id: string; id: string;
slugId: string;
title: string; title: string;
content: string; content: string;
html: string;
slug: string;
icon: string; icon: string;
coverPhoto: string; coverPhoto: string;
editor: string;
shareId: string;
parentPageId: string; parentPageId: string;
creatorId: string; creatorId: string;
spaceId: string; spaceId: string;
workspaceId: string; workspaceId: string;
children: [];
childrenIds: [];
isLocked: boolean; isLocked: boolean;
status: string; isPublic: boolean;
publishedAt: Date; lastModifiedById: Date;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
deletedAt: Date; deletedAt: Date;
position: string; position: string;
hasChildren: boolean; hasChildren: boolean;
pageId: string;
} }
export interface IMovePage { export interface IMovePage {

View File

@ -57,7 +57,6 @@ export default function SpaceMembersList({ spaceId }: SpaceMembersProps) {
}; };
const onRemove = async (memberId: string, type: MemberType) => { const onRemove = async (memberId: string, type: MemberType) => {
console.log("remove", spaceId);
const memberToRemove: IRemoveSpaceMember = { const memberToRemove: IRemoveSpaceMember = {
spaceId: spaceId, spaceId: spaceId,
}; };

View File

@ -8,14 +8,18 @@ export interface IUser {
avatarUrl: string; avatarUrl: string;
timezone: string; timezone: string;
settings: any; settings: any;
invitedById: string;
lastLoginAt: string; lastLoginAt: string;
lastActiveAt: Date;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
role: string; role: string;
workspaceId: string; workspaceId: string;
deactivatedAt: Date;
deletedAt: Date;
} }
export interface ICurrentUser { export interface ICurrentUser {
user: IUser, user: IUser;
workspace: IWorkspace workspace: IWorkspace;
} }

View File

@ -12,6 +12,9 @@ export const useQuerySubscription = () => {
socket?.on("message", (event) => { socket?.on("message", (event) => {
const data: WebSocketEvent = event; const data: WebSocketEvent = event;
let entity = null;
let queryKeyId = null;
switch (data.operation) { switch (data.operation) {
case "invalidate": case "invalidate":
queryClient.invalidateQueries({ queryClient.invalidateQueries({
@ -19,8 +22,16 @@ export const useQuerySubscription = () => {
}); });
break; break;
case "updateOne": case "updateOne":
queryClient.setQueryData([...data.entity, data.id], { entity = data.entity[0];
...queryClient.getQueryData([...data.entity, data.id]), 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, ...data.payload,
}); });

View File

@ -8,11 +8,12 @@ import {
changeMemberRole, changeMemberRole,
getInvitationById, getInvitationById,
getPendingInvitations, getPendingInvitations,
getWorkspace,
getWorkspaceMembers, getWorkspaceMembers,
createInvitation, createInvitation,
resendInvitation, resendInvitation,
revokeInvitation, revokeInvitation,
getWorkspace,
getWorkspacePublicData,
} from "@/features/workspace/services/workspace-service"; } from "@/features/workspace/services/workspace-service";
import { IPagination, QueryParams } from "@/lib/types.ts"; import { IPagination, QueryParams } from "@/lib/types.ts";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
@ -22,13 +23,23 @@ import {
IWorkspace, IWorkspace,
} from "@/features/workspace/types/workspace.types.ts"; } from "@/features/workspace/types/workspace.types.ts";
export function useWorkspace(): UseQueryResult<IWorkspace, Error> { export function useWorkspaceQuery(): UseQueryResult<IWorkspace, Error> {
return useQuery({ return useQuery({
queryKey: ["workspace"], queryKey: ["workspace"],
queryFn: () => getWorkspace(), queryFn: () => getWorkspace(),
}); });
} }
export function useWorkspacePublicDataQuery(): UseQueryResult<
IWorkspace,
Error
> {
return useQuery({
queryKey: ["workspace-public"],
queryFn: () => getWorkspacePublicData(),
});
}
export function useWorkspaceMembersQuery(params?: QueryParams) { export function useWorkspaceMembersQuery(params?: QueryParams) {
return useQuery({ return useQuery({
queryKey: ["workspaceMembers", params], queryKey: ["workspaceMembers", params],
@ -69,7 +80,7 @@ export function useCreateInvitationMutation() {
return useMutation<void, Error, ICreateInvite>({ return useMutation<void, Error, ICreateInvite>({
mutationFn: (data) => createInvitation(data), mutationFn: (data) => createInvitation(data),
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
notifications.show({ message: "Invitation successfully" }); notifications.show({ message: "Invitation sent" });
// TODO: mutate cache // TODO: mutate cache
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["invitations"], queryKey: ["invitations"],
@ -92,7 +103,7 @@ export function useResendInvitationMutation() {
>({ >({
mutationFn: (data) => resendInvitation(data), mutationFn: (data) => resendInvitation(data),
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
notifications.show({ message: "Invitation mail sent" }); notifications.show({ message: "Invitation resent" });
}, },
onError: (error) => { onError: (error) => {
const errorMessage = error["response"]?.data?.message; const errorMessage = error["response"]?.data?.message;

View File

@ -14,6 +14,11 @@ export async function getWorkspace(): Promise<IWorkspace> {
return req.data; 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 // Todo: fix all paginated types
export async function getWorkspaceMembers( export async function getWorkspaceMembers(
params?: QueryParams, params?: QueryParams,

View File

@ -38,15 +38,22 @@ api.interceptors.response.use(
switch (error.response.status) { switch (error.response.status) {
case 401: case 401:
// Handle unauthorized error // Handle unauthorized error
if (window.location.pathname != Routes.AUTH.LOGIN) { Cookies.remove("authTokens");
window.location.href = Routes.AUTH.LOGIN; redirectToLogin();
}
break; break;
case 403: case 403:
// Handle forbidden error // Handle forbidden error
break; break;
case 404: case 404:
// Handle not found error // Handle not found error
if (
error.response.data.message
.toLowerCase()
.includes("workspace not found")
) {
Cookies.remove("authTokens");
redirectToLogin();
}
break; break;
case 500: case 500:
// Handle internal server error // 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; export default api;

View File

@ -10,6 +10,7 @@ import { BrowserRouter } from "react-router-dom";
import { ModalsProvider } from "@mantine/modals"; import { ModalsProvider } from "@mantine/modals";
import { Notifications } from "@mantine/notifications"; import { Notifications } from "@mantine/notifications";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { HelmetProvider } from "react-helmet-async";
export const queryClient = new QueryClient({ export const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@ -30,8 +31,10 @@ root.render(
<MantineProvider theme={theme}> <MantineProvider theme={theme}>
<ModalsProvider> <ModalsProvider>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<Notifications position="top-right" limit={3} /> <Notifications position="bottom-center" limit={3} />
<App /> <HelmetProvider>
<App />
</HelmetProvider>
</QueryClientProvider> </QueryClientProvider>
</ModalsProvider> </ModalsProvider>
</MantineProvider> </MantineProvider>

View File

@ -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() { export default function LoginPage() {
return <LoginForm />; return (
<>
<Helmet>
<title>Login</title>
</Helmet>
<LoginForm />
</>
);
} }

View File

@ -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() { 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;
} }

View File

@ -1,27 +1,31 @@
import { useParams } from 'react-router-dom'; import { useParams } from "react-router-dom";
import { usePageQuery } from '@/features/page/queries/page-query'; import { usePageQuery } from "@/features/page/queries/page-query";
import { FullEditor } from '@/features/editor/full-editor'; import { FullEditor } from "@/features/editor/full-editor";
import HistoryModal from '@/features/page-history/components/history-modal'; import HistoryModal from "@/features/page-history/components/history-modal";
import { Helmet } from "react-helmet-async";
export default function Page() { export default function Page() {
const { pageId } = useParams(); const { slugId } = useParams();
const { data, isLoading, isError } = usePageQuery(pageId); const { data: page, isLoading, isError } = usePageQuery(slugId);
if (isLoading) { if (isLoading) {
return <></>; return <></>;
} }
if (isError || !data) { // TODO: fix this if (isError || !page) {
// TODO: fix this
return <div>Error fetching page data.</div>; return <div>Error fetching page data.</div>;
} }
return ( return (
data && ( page && (
<div> <div>
<FullEditor pageId={pageId} title={data.title} /> <Helmet>
<HistoryModal /> <title>{page.title}</title>
</Helmet>
<FullEditor pageId={page.id} title={page.title} slugId={page.slugId} />
<HistoryModal pageId={page.id} />
</div> </div>
) )
); );
} }

View File

@ -1,3 +1,4 @@
/storage
.env .env
package-lock.json package-lock.json
# compiled output # compiled output

View File

@ -1,6 +1,6 @@
{ {
"name": "server", "name": "server",
"version": "0.0.1", "version": "0.1.0",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@ -13,13 +13,13 @@
"start:debug": "nest start --debug --watch", "start:debug": "nest start --debug --watch",
"start:prod": "node dist/main", "start:prod": "node dist/main",
"email:dev": "email dev -p 5019 -d ./src/integrations/transactional/emails", "email:dev": "email dev -p 5019 -d ./src/integrations/transactional/emails",
"migration:create": "tsx ./src/kysely/migrate.ts create", "migration:create": "tsx src/database/migrate.ts create",
"migration:up": "tsx ./src/kysely/migrate.ts up", "migration:up": "tsx src/database/migrate.ts up",
"migration:down": "tsx ./src/kysely/migrate.ts down", "migration:down": "tsx src/database/migrate.ts down",
"migration:latest": "tsx ./src/kysely/migrate.ts latest", "migration:latest": "tsx src/database/migrate.ts latest",
"migration:redo": "tsx ./src/kysely/migrate.ts redo", "migration:redo": "tsx src/database/migrate.ts redo",
"migration:reset": "tsx ./src/kysely/migrate.ts down-to NO_MIGRATIONS", "migration:reset": "tsx src/database/migrate.ts down-to NO_MIGRATIONS",
"migration:codegen": "kysely-codegen --dialect=postgres --camel-case --env-file=../../.env --out-file=./src/kysely/types/db.d.ts", "migration:codegen": "kysely-codegen --dialect=postgres --camel-case --env-file=../../.env --out-file=./src/database/types/db.d.ts",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest", "test": "jest",
"test:watch": "jest --watch", "test:watch": "jest --watch",
@ -42,7 +42,6 @@
"@nestjs/passport": "^10.0.3", "@nestjs/passport": "^10.0.3",
"@nestjs/platform-fastify": "^10.3.8", "@nestjs/platform-fastify": "^10.3.8",
"@nestjs/platform-socket.io": "^10.3.8", "@nestjs/platform-socket.io": "^10.3.8",
"@nestjs/serve-static": "^4.0.2",
"@nestjs/websockets": "^10.3.8", "@nestjs/websockets": "^10.3.8",
"@react-email/components": "0.0.17", "@react-email/components": "0.0.17",
"@react-email/render": "^0.0.13", "@react-email/render": "^0.0.13",
@ -68,7 +67,6 @@
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"sanitize-filename-ts": "^1.0.2", "sanitize-filename-ts": "^1.0.2",
"slugify": "^1.6.6",
"socket.io": "^4.7.5", "socket.io": "^4.7.5",
"tsx": "^4.8.2", "tsx": "^4.8.2",
"uuid": "^9.0.1", "uuid": "^9.0.1",

View File

@ -5,26 +5,11 @@ import { CoreModule } from './core/core.module';
import { EnvironmentModule } from './integrations/environment/environment.module'; import { EnvironmentModule } from './integrations/environment/environment.module';
import { CollaborationModule } from './collaboration/collaboration.module'; import { CollaborationModule } from './collaboration/collaboration.module';
import { WsModule } from './ws/ws.module'; import { WsModule } from './ws/ws.module';
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';
import { DatabaseModule } from '@docmost/db/database.module'; import { DatabaseModule } from '@docmost/db/database.module';
import * as fs from 'fs';
import { StorageModule } from './integrations/storage/storage.module'; import { StorageModule } from './integrations/storage/storage.module';
import { MailModule } from './integrations/mail/mail.module'; import { MailModule } from './integrations/mail/mail.module';
import { QueueModule } from './integrations/queue/queue.module'; import { QueueModule } from './integrations/queue/queue.module';
import { StaticModule } from './integrations/static/static.module';
const clientDistPath = join(__dirname, '..', '..', 'client/dist');
function getServeStaticModule() {
if (fs.existsSync(clientDistPath)) {
return [
ServeStaticModule.forRoot({
rootPath: clientDistPath,
}),
];
}
return [];
}
@Module({ @Module({
imports: [ imports: [
@ -34,7 +19,7 @@ function getServeStaticModule() {
CollaborationModule, CollaborationModule,
WsModule, WsModule,
QueueModule, QueueModule,
...getServeStaticModule(), StaticModule,
StorageModule.forRootAsync({ StorageModule.forRootAsync({
imports: [EnvironmentModule], imports: [EnvironmentModule],
}), }),

View File

@ -78,6 +78,7 @@ export class PersistenceExtension implements Extension {
textContent: textContent, textContent: textContent,
ydoc: ydocState, ydoc: ydocState,
lastUpdatedById: context.user.id, lastUpdatedById: context.user.id,
updatedAt: new Date(),
}, },
pageId, pageId,
); );

View File

@ -9,6 +9,7 @@ import { executeTx } from '@docmost/db/utils';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { User } from '@docmost/db/types/entity.types'; import { User } from '@docmost/db/types/entity.types';
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo'; import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
import { UserRole } from '../../../helpers/types/permission';
@Injectable() @Injectable()
export class SignupService { export class SignupService {
@ -75,7 +76,11 @@ export class SignupService {
this.db, this.db,
async (trx) => { async (trx) => {
// create user // create user
const user = await this.userRepo.insertUser(createAdminUserDto, trx);
const user = await this.userRepo.insertUser(
{ ...createAdminUserDto, role: UserRole.OWNER },
trx,
);
// create workspace with full setup // create workspace with full setup
const workspaceData: CreateWorkspaceDto = { const workspaceData: CreateWorkspaceDto = {

View File

@ -1,4 +1,8 @@
import { ForbiddenException, Injectable } from '@nestjs/common'; import {
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { import {
AbilityBuilder, AbilityBuilder,
createMongoAbility, createMongoAbility,
@ -33,9 +37,7 @@ export default class SpaceAbilityFactory {
case SpaceRole.READER: case SpaceRole.READER:
return buildSpaceReaderAbility(); return buildSpaceReaderAbility();
default: default:
throw new ForbiddenException( throw new NotFoundException('Space permissions not found');
'You do not have permission to access this space',
);
} }
} }
} }

View File

@ -52,7 +52,12 @@ export class CommentController {
throw new ForbiddenException(); throw new ForbiddenException();
} }
return this.commentService.create(user.id, workspace.id, createCommentDto); return this.commentService.create(
user.id,
page.id,
workspace.id,
createCommentDto,
);
} }
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ -73,7 +78,7 @@ export class CommentController {
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) { if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException(); throw new ForbiddenException();
} }
return this.commentService.findByPageId(input.pageId, pagination); return this.commentService.findByPageId(page.id, pagination);
} }
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ -84,7 +89,6 @@ export class CommentController {
throw new NotFoundException('Comment not found'); throw new NotFoundException('Comment not found');
} }
// TODO: add spaceId to comment entity.
const page = await this.pageRepo.findById(comment.pageId); const page = await this.pageRepo.findById(comment.pageId);
if (!page) { if (!page) {
throw new NotFoundException('Page not found'); throw new NotFoundException('Page not found');
@ -104,6 +108,7 @@ export class CommentController {
return this.commentService.update( return this.commentService.update(
updateCommentDto.commentId, updateCommentDto.commentId,
updateCommentDto, updateCommentDto,
user,
); );
} }
@ -111,6 +116,6 @@ export class CommentController {
@Post('delete') @Post('delete')
remove(@Body() input: CommentIdDto, @AuthUser() user: User) { remove(@Body() input: CommentIdDto, @AuthUser() user: User) {
// TODO: only comment creators and admins can delete their comments // TODO: only comment creators and admins can delete their comments
return this.commentService.remove(input.commentId); return this.commentService.remove(input.commentId, user);
} }
} }

View File

@ -1,12 +1,13 @@
import { import {
BadRequestException, BadRequestException,
ForbiddenException,
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { CreateCommentDto } from './dto/create-comment.dto'; import { CreateCommentDto } from './dto/create-comment.dto';
import { UpdateCommentDto } from './dto/update-comment.dto'; import { UpdateCommentDto } from './dto/update-comment.dto';
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo'; import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
import { Comment } from '@docmost/db/types/entity.types'; import { Comment, User } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { PaginationResult } from '@docmost/db/pagination/pagination'; import { PaginationResult } from '@docmost/db/pagination/pagination';
import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { PageRepo } from '@docmost/db/repos/page/page.repo';
@ -30,24 +31,18 @@ export class CommentService {
async create( async create(
userId: string, userId: string,
pageId: string,
workspaceId: string, workspaceId: string,
createCommentDto: CreateCommentDto, createCommentDto: CreateCommentDto,
) { ) {
const commentContent = JSON.parse(createCommentDto.content); const commentContent = JSON.parse(createCommentDto.content);
const page = await this.pageRepo.findById(createCommentDto.pageId);
// const spaceId = null; // todo, get from page
if (!page) {
throw new BadRequestException('Page not found');
}
if (createCommentDto.parentCommentId) { if (createCommentDto.parentCommentId) {
const parentComment = await this.commentRepo.findById( const parentComment = await this.commentRepo.findById(
createCommentDto.parentCommentId, createCommentDto.parentCommentId,
); );
if (!parentComment) { if (!parentComment || parentComment.pageId !== pageId) {
throw new BadRequestException('Parent comment not found'); throw new BadRequestException('Parent comment not found');
} }
@ -57,10 +52,10 @@ export class CommentService {
} }
const createdComment = await this.commentRepo.insertComment({ const createdComment = await this.commentRepo.insertComment({
pageId: createCommentDto.pageId, pageId: pageId,
content: commentContent, content: commentContent,
selection: createCommentDto?.selection?.substring(0, 250), selection: createCommentDto?.selection?.substring(0, 250),
type: 'inline', // for now type: 'inline',
parentCommentId: createCommentDto?.parentCommentId, parentCommentId: createCommentDto?.parentCommentId,
creatorId: userId, creatorId: userId,
workspaceId: workspaceId, workspaceId: workspaceId,
@ -90,6 +85,7 @@ export class CommentService {
async update( async update(
commentId: string, commentId: string,
updateCommentDto: UpdateCommentDto, updateCommentDto: UpdateCommentDto,
authUser: User,
): Promise<Comment> { ): Promise<Comment> {
const commentContent = JSON.parse(updateCommentDto.content); const commentContent = JSON.parse(updateCommentDto.content);
@ -98,6 +94,10 @@ export class CommentService {
throw new NotFoundException('Comment not found'); throw new NotFoundException('Comment not found');
} }
if (comment.creatorId !== authUser.id) {
throw new ForbiddenException('You can only edit your own comments');
}
const editedAt = new Date(); const editedAt = new Date();
await this.commentRepo.updateComment( await this.commentRepo.updateComment(
@ -113,12 +113,17 @@ export class CommentService {
return comment; return comment;
} }
async remove(commentId: string): Promise<void> { async remove(commentId: string, authUser: User): Promise<void> {
const comment = await this.commentRepo.findById(commentId); const comment = await this.commentRepo.findById(commentId);
if (!comment) { if (!comment) {
throw new NotFoundException('Comment not found'); throw new NotFoundException('Comment not found');
} }
if (comment.creatorId !== authUser.id) {
throw new ForbiddenException('You can only delete your own comments');
}
await this.commentRepo.deleteComment(commentId); await this.commentRepo.deleteComment(commentId);
} }
} }

View File

@ -1,7 +1,7 @@
import { IsUUID } from 'class-validator'; import { IsString, IsUUID } from 'class-validator';
export class PageIdDto { export class PageIdDto {
@IsUUID() @IsString()
pageId: string; pageId: string;
} }

View File

@ -1,7 +1,7 @@
import { IsJSON, IsOptional, IsString, IsUUID } from 'class-validator'; import { IsJSON, IsOptional, IsString, IsUUID } from 'class-validator';
export class CreateCommentDto { export class CreateCommentDto {
@IsUUID() @IsString()
pageId: string; pageId: string;
@IsJSON() @IsJSON()

View File

@ -10,7 +10,7 @@ export class CreatePageDto {
icon?: string; icon?: string;
@IsOptional() @IsOptional()
@IsUUID() @IsString()
parentPageId?: string; parentPageId?: string;
@IsUUID() @IsUUID()

View File

@ -1,13 +1,7 @@
import { import { IsString, IsOptional, MinLength, MaxLength } from 'class-validator';
IsString,
IsUUID,
IsOptional,
MinLength,
MaxLength,
} from 'class-validator';
export class MovePageDto { export class MovePageDto {
@IsUUID() @IsString()
pageId: string; pageId: string;
@IsString() @IsString()

View File

@ -1,3 +0,0 @@
import { Page } from '@docmost/db/types/entity.types';
export type PageWithOrderingDto = Page & { childrenIds?: string[] };

View File

@ -1,7 +1,7 @@
import { IsUUID } from 'class-validator'; import { IsString, IsUUID } from 'class-validator';
export class PageIdDto { export class PageIdDto {
@IsUUID() @IsString()
pageId: string; pageId: string;
} }

View File

@ -1,8 +1,8 @@
import { IsOptional, IsUUID } from 'class-validator'; import { IsOptional, IsString } from 'class-validator';
import { SpaceIdDto } from './page.dto'; import { SpaceIdDto } from './page.dto';
export class SidebarPageDto extends SpaceIdDto { export class SidebarPageDto extends SpaceIdDto {
@IsOptional() @IsOptional()
@IsUUID() @IsString()
pageId: string; pageId: string;
} }

View File

@ -1,8 +1,8 @@
import { PartialType } from '@nestjs/mapped-types'; import { PartialType } from '@nestjs/mapped-types';
import { CreatePageDto } from './create-page.dto'; import { CreatePageDto } from './create-page.dto';
import { IsUUID } from 'class-validator'; import { IsString } from 'class-validator';
export class UpdatePageDto extends PartialType(CreatePageDto) { export class UpdatePageDto extends PartialType(CreatePageDto) {
@IsUUID() @IsString()
pageId: string; pageId: string;
} }

View File

@ -12,7 +12,7 @@ import { PageService } from './services/page.service';
import { CreatePageDto } from './dto/create-page.dto'; import { CreatePageDto } from './dto/create-page.dto';
import { UpdatePageDto } from './dto/update-page.dto'; import { UpdatePageDto } from './dto/update-page.dto';
import { MovePageDto } from './dto/move-page.dto'; import { MovePageDto } from './dto/move-page.dto';
import { PageHistoryIdDto, PageIdDto, SpaceIdDto } from './dto/page.dto'; import { PageHistoryIdDto, PageIdDto } from './dto/page.dto';
import { PageHistoryService } from './services/page-history.service'; import { PageHistoryService } from './services/page-history.service';
import { AuthUser } from '../../decorators/auth-user.decorator'; import { AuthUser } from '../../decorators/auth-user.decorator';
import { AuthWorkspace } from '../../decorators/auth-workspace.decorator'; import { AuthWorkspace } from '../../decorators/auth-workspace.decorator';
@ -118,18 +118,23 @@ export class PageController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('recent') @Post('recent')
async getRecentSpacePages( async getRecentSpacePages(
@Body() spaceIdDto: SpaceIdDto,
@Body() pagination: PaginationOptions, @Body() pagination: PaginationOptions,
@AuthUser() user: User, @AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) { ) {
const ability = await this.spaceAbility.createForUser( const ability = await this.spaceAbility.createForUser(
user, user,
spaceIdDto.spaceId, workspace.defaultSpaceId,
); );
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) { if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException(); throw new ForbiddenException();
} }
return this.pageService.getRecentSpacePages(spaceIdDto.spaceId, pagination);
return this.pageService.getRecentSpacePages(
workspace.defaultSpaceId,
pagination,
);
} }
// TODO: scope to workspaces // TODO: scope to workspaces
@ -146,7 +151,7 @@ export class PageController {
throw new ForbiddenException(); throw new ForbiddenException();
} }
return this.pageHistoryService.findHistoryByPageId(dto.pageId, pagination); return this.pageHistoryService.findHistoryByPageId(page.id, pagination);
} }
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ -181,7 +186,17 @@ export class PageController {
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) { if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException(); throw new ForbiddenException();
} }
return this.pageService.getSidebarPages(dto, pagination);
let pageId = null;
if (dto.pageId) {
const page = await this.pageRepo.findById(dto.pageId);
if (page.spaceId !== dto.spaceId) {
throw new ForbiddenException();
}
pageId = page.id;
}
return this.pageService.getSidebarPages(dto.spaceId, pagination, pageId);
} }
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ -207,10 +222,14 @@ export class PageController {
@Post('/breadcrumbs') @Post('/breadcrumbs')
async getPageBreadcrumbs(@Body() dto: PageIdDto, @AuthUser() user: User) { async getPageBreadcrumbs(@Body() dto: PageIdDto, @AuthUser() user: User) {
const page = await this.pageRepo.findById(dto.pageId); const page = await this.pageRepo.findById(dto.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId); const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) { if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException(); throw new ForbiddenException();
} }
return this.pageService.getPageBreadCrumbs(dto.pageId); return this.pageService.getPageBreadCrumbs(page.id);
} }
} }

View File

@ -18,7 +18,7 @@ import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
import { MovePageDto } from '../dto/move-page.dto'; import { MovePageDto } from '../dto/move-page.dto';
import { ExpressionBuilder } from 'kysely'; import { ExpressionBuilder } from 'kysely';
import { DB } from '@docmost/db/types/db'; import { DB } from '@docmost/db/types/db';
import { SidebarPageDto } from '../dto/sidebar-page.dto'; import { genPageShortId } from '../../../helpers/nanoid.utils';
@Injectable() @Injectable()
export class PageService { export class PageService {
@ -40,14 +40,19 @@ export class PageService {
workspaceId: string, workspaceId: string,
createPageDto: CreatePageDto, createPageDto: CreatePageDto,
): Promise<Page> { ): Promise<Page> {
let parentPageId = undefined;
// check if parent page exists // check if parent page exists
if (createPageDto.parentPageId) { if (createPageDto.parentPageId) {
const parentPage = await this.pageRepo.findById( const parentPage = await this.pageRepo.findById(
createPageDto.parentPageId, createPageDto.parentPageId,
); );
if (!parentPage || parentPage.spaceId !== createPageDto.spaceId) if (!parentPage || parentPage.spaceId !== createPageDto.spaceId) {
throw new NotFoundException('Parent page not found'); throw new NotFoundException('Parent page not found');
}
parentPageId = parentPage.id;
} }
let pagePosition: string; let pagePosition: string;
@ -59,10 +64,10 @@ export class PageService {
.orderBy('position', 'desc') .orderBy('position', 'desc')
.limit(1); .limit(1);
if (createPageDto.parentPageId) { if (parentPageId) {
// check for children of this page // check for children of this page
const lastPage = await lastPageQuery const lastPage = await lastPageQuery
.where('parentPageId', '=', createPageDto.parentPageId) .where('parentPageId', '=', parentPageId)
.executeTakeFirst(); .executeTakeFirst();
if (!lastPage) { if (!lastPage) {
@ -87,10 +92,11 @@ export class PageService {
} }
const createdPage = await this.pageRepo.insertPage({ const createdPage = await this.pageRepo.insertPage({
slugId: genPageShortId(),
title: createPageDto.title, title: createPageDto.title,
position: pagePosition, position: pagePosition,
icon: createPageDto.icon, icon: createPageDto.icon,
parentPageId: createPageDto.parentPageId, parentPageId: parentPageId,
spaceId: createPageDto.spaceId, spaceId: createPageDto.spaceId,
creatorId: userId, creatorId: userId,
workspaceId: workspaceId, workspaceId: workspaceId,
@ -110,6 +116,7 @@ export class PageService {
title: updatePageDto.title, title: updatePageDto.title,
icon: updatePageDto.icon, icon: updatePageDto.icon,
lastUpdatedById: userId, lastUpdatedById: userId,
updatedAt: new Date(),
}, },
pageId, pageId,
); );
@ -135,13 +142,15 @@ export class PageService {
} }
async getSidebarPages( async getSidebarPages(
dto: SidebarPageDto, spaceId: string,
pagination: PaginationOptions, pagination: PaginationOptions,
pageId?: string,
): Promise<any> { ): Promise<any> {
let query = this.db let query = this.db
.selectFrom('pages') .selectFrom('pages')
.select([ .select([
'id', 'id',
'slugId',
'title', 'title',
'icon', 'icon',
'position', 'position',
@ -151,10 +160,10 @@ export class PageService {
]) ])
.select((eb) => this.withHasChildren(eb)) .select((eb) => this.withHasChildren(eb))
.orderBy('position', 'asc') .orderBy('position', 'asc')
.where('spaceId', '=', dto.spaceId); .where('spaceId', '=', spaceId);
if (dto.pageId) { if (pageId) {
query = query.where('parentPageId', '=', dto.pageId); query = query.where('parentPageId', '=', pageId);
} else { } else {
query = query.where('parentPageId', 'is', null); query = query.where('parentPageId', 'is', null);
} }
@ -185,8 +194,8 @@ export class PageService {
if (!parentPage || parentPage.spaceId !== movedPage.spaceId) { if (!parentPage || parentPage.spaceId !== movedPage.spaceId) {
throw new NotFoundException('Parent page not found'); throw new NotFoundException('Parent page not found');
} }
parentPageId = parentPage.id;
} }
parentPageId = dto.parentPageId;
} }
await this.pageRepo.updatePage( await this.pageRepo.updatePage(
@ -205,6 +214,7 @@ export class PageService {
.selectFrom('pages') .selectFrom('pages')
.select([ .select([
'id', 'id',
'slugId',
'title', 'title',
'icon', 'icon',
'position', 'position',
@ -218,6 +228,7 @@ export class PageService {
.selectFrom('pages as p') .selectFrom('pages as p')
.select([ .select([
'p.id', 'p.id',
'p.slugId',
'p.title', 'p.title',
'p.icon', 'p.icon',
'p.position', 'p.position',
@ -255,10 +266,7 @@ export class PageService {
spaceId: string, spaceId: string,
pagination: PaginationOptions, pagination: PaginationOptions,
): Promise<PaginationResult<Page>> { ): Promise<PaginationResult<Page>> {
const pages = await this.pageRepo.getRecentPagesInSpace( const pages = await this.pageRepo.getRecentPageUpdates(spaceId, pagination);
spaceId,
pagination,
);
return pages; return pages;
} }
@ -267,6 +275,7 @@ export class PageService {
await this.pageRepo.deletePage(pageId); await this.pageRepo.deletePage(pageId);
} }
} }
/* /*
// TODO: page deletion and restoration // TODO: page deletion and restoration
async delete(pageId: string): Promise<void> { async delete(pageId: string): Promise<void> {

View File

@ -5,12 +5,13 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { CreateSpaceDto } from '../dto/create-space.dto'; import { CreateSpaceDto } from '../dto/create-space.dto';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import slugify from 'slugify';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo'; import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import { KyselyTransaction } from '@docmost/db/types/kysely.types'; import { KyselyTransaction } from '@docmost/db/types/kysely.types';
import { Space } from '@docmost/db/types/entity.types'; import { Space } from '@docmost/db/types/entity.types';
import { PaginationResult } from '@docmost/db/pagination/pagination'; import { PaginationResult } from '@docmost/db/pagination/pagination';
import { UpdateSpaceDto } from '../dto/update-space.dto'; import { UpdateSpaceDto } from '../dto/update-space.dto';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { slugify } = require('fix-esm').require('@sindresorhus/slugify');
@Injectable() @Injectable()
export class SpaceService { export class SpaceService {

View File

@ -36,12 +36,16 @@ export class WorkspaceController {
private readonly workspaceInvitationService: WorkspaceInvitationService, private readonly workspaceInvitationService: WorkspaceInvitationService,
) {} ) {}
@Public()
@HttpCode(HttpStatus.OK)
@Post('/public')
async getWorkspacePublicInfo(@Req() req) {
return this.workspaceService.getWorkspacePublicData(req.raw.workspaceId);
}
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('/info') @Post('/info')
async getWorkspace( async getWorkspace(@AuthWorkspace() workspace: Workspace) {
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
return this.workspaceService.getWorkspaceInfo(workspace.id); return this.workspaceService.getWorkspaceInfo(workspace.id);
} }

View File

@ -46,6 +46,19 @@ export class WorkspaceService {
return workspace; return workspace;
} }
async getWorkspacePublicData(workspaceId: string) {
const workspace = await this.db
.selectFrom('workspaces')
.select(['id'])
.where('id', '=', workspaceId)
.executeTakeFirst();
if (!workspace) {
throw new NotFoundException('Workspace not found');
}
return workspace;
}
async create( async create(
user: User, user: User,
createWorkspaceDto: CreateWorkspaceDto, createWorkspaceDto: CreateWorkspaceDto,

View File

@ -8,20 +8,17 @@ export async function up(db: Kysely<any>): Promise<void> {
col.primaryKey().defaultTo(sql`gen_random_uuid()`), col.primaryKey().defaultTo(sql`gen_random_uuid()`),
) )
.addColumn('name', 'varchar', (col) => col) .addColumn('name', 'varchar', (col) => col)
.addColumn('description', 'text', (col) => col) .addColumn('description', 'varchar', (col) => col)
.addColumn('logo', 'varchar', (col) => col) .addColumn('logo', 'varchar', (col) => col)
.addColumn('hostname', 'varchar', (col) => col) .addColumn('hostname', 'varchar', (col) => col)
.addColumn('custom_domain', 'varchar', (col) => col) .addColumn('custom_domain', 'varchar', (col) => col)
.addColumn('enable_invite', 'boolean', (col) =>
col.defaultTo(true).notNull(),
)
.addColumn('invite_code', 'varchar', (col) =>
col.defaultTo(sql`gen_random_uuid()`),
)
.addColumn('settings', 'jsonb', (col) => col) .addColumn('settings', 'jsonb', (col) => col)
.addColumn('default_role', 'varchar', (col) => .addColumn('default_role', 'varchar', (col) =>
col.defaultTo(UserRole.MEMBER).notNull(), col.defaultTo(UserRole.MEMBER).notNull(),
) )
.addColumn('allowed_email_domains', sql`varchar[]`, (col) =>
col.defaultTo('{}'),
)
.addColumn('default_space_id', 'uuid', (col) => col) .addColumn('default_space_id', 'uuid', (col) => col)
.addColumn('created_at', 'timestamptz', (col) => .addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`), col.notNull().defaultTo(sql`now()`),
@ -31,7 +28,7 @@ export async function up(db: Kysely<any>): Promise<void> {
) )
.addColumn('deleted_at', 'timestamptz', (col) => col) .addColumn('deleted_at', 'timestamptz', (col) => col)
.addUniqueConstraint('workspaces_hostname_unique', ['hostname']) .addUniqueConstraint('workspaces_hostname_unique', ['hostname'])
.addUniqueConstraint('workspaces_invite_code_unique', ['invite_code']) .addUniqueConstraint('workspaces_custom_domain_unique', ['custom_domain'])
.execute(); .execute();
} }

View File

@ -9,10 +9,12 @@ export async function up(db: Kysely<any>): Promise<void> {
.addColumn('name', 'varchar', (col) => col) .addColumn('name', 'varchar', (col) => col)
.addColumn('email', 'varchar', (col) => col.notNull()) .addColumn('email', 'varchar', (col) => col.notNull())
.addColumn('email_verified_at', 'timestamptz', (col) => col) .addColumn('email_verified_at', 'timestamptz', (col) => col)
.addColumn('password', 'varchar', (col) => col.notNull()) .addColumn('password', 'varchar', (col) => col)
.addColumn('avatar_url', 'varchar', (col) => col) .addColumn('avatar_url', 'varchar', (col) => col)
.addColumn('role', 'varchar', (col) => col) .addColumn('role', 'varchar', (col) => col.notNull())
.addColumn('status', 'varchar', (col) => col) .addColumn('invited_by_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.addColumn('workspace_id', 'uuid', (col) => .addColumn('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').onDelete('cascade'), col.references('workspaces.id').onDelete('cascade'),
) )
@ -21,12 +23,14 @@ export async function up(db: Kysely<any>): Promise<void> {
.addColumn('settings', 'jsonb', (col) => col) .addColumn('settings', 'jsonb', (col) => col)
.addColumn('last_active_at', 'timestamptz', (col) => col) .addColumn('last_active_at', 'timestamptz', (col) => col)
.addColumn('last_login_at', 'timestamptz', (col) => col) .addColumn('last_login_at', 'timestamptz', (col) => col)
.addColumn('deactivated_at', 'timestamptz', (col) => col)
.addColumn('created_at', 'timestamptz', (col) => .addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`), col.notNull().defaultTo(sql`now()`),
) )
.addColumn('updated_at', 'timestamptz', (col) => .addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`), col.notNull().defaultTo(sql`now()`),
) )
.addColumn('deleted_at', 'timestamptz', (col) => col)
.addUniqueConstraint('users_email_workspace_id_unique', [ .addUniqueConstraint('users_email_workspace_id_unique', [
'email', 'email',
'workspace_id', 'workspace_id',

View File

@ -49,7 +49,7 @@ export async function up(db: Kysely<any>): Promise<void> {
col.references('spaces.id').onDelete('cascade').notNull(), col.references('spaces.id').onDelete('cascade').notNull(),
) )
.addColumn('role', 'varchar', (col) => col.notNull()) .addColumn('role', 'varchar', (col) => col.notNull())
.addColumn('addedById', 'uuid', (col) => col.references('users.id')) .addColumn('added_by_id', 'uuid', (col) => col.references('users.id'))
.addColumn('created_at', 'timestamptz', (col) => .addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`), col.notNull().defaultTo(sql`now()`),
) )

View File

@ -6,19 +6,24 @@ export async function up(db: Kysely<any>): Promise<void> {
.addColumn('id', 'uuid', (col) => .addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_random_uuid()`), col.primaryKey().defaultTo(sql`gen_random_uuid()`),
) )
.addColumn('email', 'varchar', (col) => col)
.addColumn('role', 'varchar', (col) => col.notNull())
.addColumn('token', 'varchar', (col) => col.notNull())
.addColumn('group_ids', sql`uuid[]`, (col) => col)
.addColumn('invited_by_id', 'uuid', (col) => col.references('users.id'))
.addColumn('workspace_id', 'uuid', (col) => .addColumn('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').onDelete('cascade').notNull(), col.references('workspaces.id').onDelete('cascade').notNull(),
) )
.addColumn('invited_by_id', 'uuid', (col) => col.references('users.id'))
.addColumn('email', 'varchar', (col) => col.notNull())
.addColumn('role', 'varchar', (col) => col.notNull())
.addColumn('status', 'varchar', (col) => col)
.addColumn('created_at', 'timestamptz', (col) => .addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`), col.notNull().defaultTo(sql`now()`),
) )
.addColumn('updated_at', 'timestamptz', (col) => .addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`), col.notNull().defaultTo(sql`now()`),
) )
.addUniqueConstraint('invitations_email_workspace_id_unique', [
'email',
'workspace_id',
])
.execute(); .execute();
} }

View File

@ -6,17 +6,15 @@ export async function up(db: Kysely<any>): Promise<void> {
.addColumn('id', 'uuid', (col) => .addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_random_uuid()`), col.primaryKey().defaultTo(sql`gen_random_uuid()`),
) )
.addColumn('slug_id', 'varchar', (col) => col.notNull())
.addColumn('title', 'varchar', (col) => col) .addColumn('title', 'varchar', (col) => col)
.addColumn('icon', 'varchar', (col) => col) .addColumn('icon', 'varchar', (col) => col)
.addColumn('key', 'varchar', (col) => col) .addColumn('cover_photo', 'varchar', (col) => col)
.addColumn('position', 'varchar', (col) => col)
.addColumn('content', 'jsonb', (col) => col) .addColumn('content', 'jsonb', (col) => col)
.addColumn('html', 'text', (col) => col) .addColumn('ydoc', 'bytea', (col) => col)
.addColumn('text_content', 'text', (col) => col) .addColumn('text_content', 'text', (col) => col)
.addColumn('tsv', sql`tsvector`, (col) => col) .addColumn('tsv', sql`tsvector`, (col) => col)
.addColumn('ydoc', 'bytea', (col) => col)
.addColumn('slug', 'varchar', (col) => col)
.addColumn('cover_photo', 'varchar', (col) => col)
.addColumn('editor', 'varchar', (col) => col)
.addColumn('parent_page_id', 'uuid', (col) => .addColumn('parent_page_id', 'uuid', (col) =>
col.references('pages.id').onDelete('cascade'), col.references('pages.id').onDelete('cascade'),
) )
@ -32,8 +30,6 @@ export async function up(db: Kysely<any>): Promise<void> {
col.references('workspaces.id').onDelete('cascade').notNull(), col.references('workspaces.id').onDelete('cascade').notNull(),
) )
.addColumn('is_locked', 'boolean', (col) => col.defaultTo(false).notNull()) .addColumn('is_locked', 'boolean', (col) => col.defaultTo(false).notNull())
.addColumn('status', 'varchar', (col) => col)
.addColumn('published_at', 'date', (col) => col)
.addColumn('created_at', 'timestamptz', (col) => .addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`), col.notNull().defaultTo(sql`now()`),
) )
@ -41,6 +37,7 @@ export async function up(db: Kysely<any>): Promise<void> {
col.notNull().defaultTo(sql`now()`), col.notNull().defaultTo(sql`now()`),
) )
.addColumn('deleted_at', 'timestamptz', (col) => col) .addColumn('deleted_at', 'timestamptz', (col) => col)
.addUniqueConstraint('pages_slug_id_unique', ['slug_id'])
.execute(); .execute();
await db.schema await db.schema
@ -49,38 +46,14 @@ export async function up(db: Kysely<any>): Promise<void> {
.using('GIN') .using('GIN')
.column('tsv') .column('tsv')
.execute(); .execute();
await db.schema
.createIndex('pages_slug_id_idx')
.on('pages')
.column('slug_id')
.execute();
} }
export async function down(db: Kysely<any>): Promise<void> { export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('pages')
.dropConstraint('pages_creator_id_fkey')
.execute();
await db.schema
.alterTable('pages')
.dropConstraint('pages_last_updated_by_id_fkey')
.execute();
await db.schema
.alterTable('pages')
.dropConstraint('pages_deleted_by_id_fkey')
.execute();
await db.schema
.alterTable('pages')
.dropConstraint('pages_space_id_fkey')
.execute();
await db.schema
.alterTable('pages')
.dropConstraint('pages_workspace_id_fkey')
.execute();
await db.schema
.alterTable('pages')
.dropConstraint('pages_parent_page_id_fkey')
.execute();
await db.schema.dropTable('pages').execute(); await db.schema.dropTable('pages').execute();
} }

View File

@ -9,12 +9,13 @@ export async function up(db: Kysely<any>): Promise<void> {
.addColumn('page_id', 'uuid', (col) => .addColumn('page_id', 'uuid', (col) =>
col.references('pages.id').onDelete('cascade').notNull(), col.references('pages.id').onDelete('cascade').notNull(),
) )
.addColumn('slug_id', 'varchar', (col) => col)
.addColumn('title', 'varchar', (col) => col) .addColumn('title', 'varchar', (col) => col)
.addColumn('content', 'jsonb', (col) => col) .addColumn('content', 'jsonb', (col) => col)
.addColumn('slug', 'varchar', (col) => col) .addColumn('slug', 'varchar', (col) => col)
.addColumn('icon', 'varchar', (col) => col) .addColumn('icon', 'varchar', (col) => col)
.addColumn('cover_photo', 'varchar', (col) => col) .addColumn('cover_photo', 'varchar', (col) => col)
.addColumn('version', 'int4', (col) => col.notNull()) .addColumn('version', 'int4', (col) => col)
.addColumn('last_updated_by_id', 'uuid', (col) => .addColumn('last_updated_by_id', 'uuid', (col) =>
col.references('users.id'), col.references('users.id'),
) )

View File

@ -6,7 +6,6 @@ import {
InsertablePageHistory, InsertablePageHistory,
Page, Page,
PageHistory, PageHistory,
UpdatablePageHistory,
} from '@docmost/db/types/entity.types'; } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination'; import { executeWithPagination } from '@docmost/db/pagination/pagination';
@ -27,19 +26,6 @@ export class PageHistoryRepo {
.executeTakeFirst(); .executeTakeFirst();
} }
async updatePageHistory(
updatablePageHistory: UpdatablePageHistory,
pageHistoryId: string,
trx?: KyselyTransaction,
) {
const db = dbOrTx(this.db, trx);
return db
.updateTable('pageHistory')
.set(updatablePageHistory)
.where('id', '=', pageHistoryId)
.execute();
}
async insertPageHistory( async insertPageHistory(
insertablePageHistory: InsertablePageHistory, insertablePageHistory: InsertablePageHistory,
trx?: KyselyTransaction, trx?: KyselyTransaction,
@ -55,11 +41,10 @@ export class PageHistoryRepo {
async saveHistory(page: Page): Promise<void> { async saveHistory(page: Page): Promise<void> {
await this.insertPageHistory({ await this.insertPageHistory({
pageId: page.id, pageId: page.id,
slugId: page.slugId,
title: page.title, title: page.title,
content: page.content, content: page.content,
slug: page.slug,
icon: page.icon, icon: page.icon,
version: 1, // TODO: make incremental
coverPhoto: page.coverPhoto, coverPhoto: page.coverPhoto,
lastUpdatedById: page.lastUpdatedById ?? page.creatorId, lastUpdatedById: page.lastUpdatedById ?? page.creatorId,
spaceId: page.spaceId, spaceId: page.spaceId,

View File

@ -7,22 +7,20 @@ import {
Page, Page,
UpdatablePage, UpdatablePage,
} from '@docmost/db/types/entity.types'; } from '@docmost/db/types/entity.types';
import { sql } from 'kysely';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination'; import { executeWithPagination } from '@docmost/db/pagination/pagination';
import { validate as isValidUUID } from 'uuid';
// TODO: scope to space/workspace
@Injectable() @Injectable()
export class PageRepo { export class PageRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {} constructor(@InjectKysely() private readonly db: KyselyDB) {}
private baseFields: Array<keyof Page> = [ private baseFields: Array<keyof Page> = [
'id', 'id',
'slugId',
'title', 'title',
'slug',
'icon', 'icon',
'coverPhoto', 'coverPhoto',
'key',
'position', 'position',
'parentPageId', 'parentPageId',
'creatorId', 'creatorId',
@ -30,8 +28,6 @@ export class PageRepo {
'spaceId', 'spaceId',
'workspaceId', 'workspaceId',
'isLocked', 'isLocked',
'status',
'publishedAt',
'createdAt', 'createdAt',
'updatedAt', 'updatedAt',
'deletedAt', 'deletedAt',
@ -44,21 +40,19 @@ export class PageRepo {
includeYdoc?: boolean; includeYdoc?: boolean;
}, },
): Promise<Page> { ): Promise<Page> {
return await this.db let query = this.db
.selectFrom('pages') .selectFrom('pages')
.select(this.baseFields) .select(this.baseFields)
.where('id', '=', pageId)
.$if(opts?.includeContent, (qb) => qb.select('content')) .$if(opts?.includeContent, (qb) => qb.select('content'))
.$if(opts?.includeYdoc, (qb) => qb.select('ydoc')) .$if(opts?.includeYdoc, (qb) => qb.select('ydoc'));
.executeTakeFirst();
}
async slug(slug: string): Promise<Page> { if (isValidUUID(pageId)) {
return await this.db query = query.where('id', '=', pageId);
.selectFrom('pages') } else {
.selectAll() query = query.where('slugId', '=', pageId);
.where(sql`LOWER(slug)`, '=', sql`LOWER(${slug})`) }
.executeTakeFirst();
return query.executeTakeFirst();
} }
async updatePage( async updatePage(
@ -67,11 +61,15 @@ export class PageRepo {
trx?: KyselyTransaction, trx?: KyselyTransaction,
) { ) {
const db = dbOrTx(this.db, trx); const db = dbOrTx(this.db, trx);
return db let query = db.updateTable('pages').set(updatablePage);
.updateTable('pages')
.set(updatablePage) if (isValidUUID(pageId)) {
.where('id', '=', pageId) query = query.where('id', '=', pageId);
.executeTakeFirst(); } else {
query = query.where('slugId', '=', pageId);
}
return query.executeTakeFirst();
} }
async insertPage( async insertPage(
@ -87,10 +85,20 @@ export class PageRepo {
} }
async deletePage(pageId: string): Promise<void> { async deletePage(pageId: string): Promise<void> {
await this.db.deleteFrom('pages').where('id', '=', pageId).execute(); let query = this.db.deleteFrom('pages');
if (isValidUUID(pageId)) {
query = query.where('id', '=', pageId);
} else {
query = query.where('slugId', '=', pageId);
}
await query.execute();
} }
async getRecentPagesInSpace(spaceId: string, pagination: PaginationOptions) { async getRecentPageUpdates(spaceId: string, pagination: PaginationOptions) {
//TODO: should fetch pages from all spaces the user is member of
// for now, fetch from default space
const query = this.db const query = this.db
.selectFrom('pages') .selectFrom('pages')
.select(this.baseFields) .select(this.baseFields)

View File

@ -28,8 +28,10 @@ export class UserRepo {
'timezone', 'timezone',
'settings', 'settings',
'lastLoginAt', 'lastLoginAt',
'deactivatedAt',
'createdAt', 'createdAt',
'updatedAt', 'updatedAt',
'deletedAt',
]; ];
async findById( async findById(
@ -97,6 +99,7 @@ export class UserRepo {
email: insertableUser.email.toLowerCase(), email: insertableUser.email.toLowerCase(),
password: await hashPassword(insertableUser.password), password: await hashPassword(insertableUser.password),
locale: 'en', locale: 'en',
role: insertableUser?.role,
lastLoginAt: new Date(), lastLoginAt: new Date(),
}; };

View File

@ -79,10 +79,11 @@ export interface PageHistory {
lastUpdatedById: string | null; lastUpdatedById: string | null;
pageId: string; pageId: string;
slug: string | null; slug: string | null;
slugId: string | null;
spaceId: string; spaceId: string;
title: string | null; title: string | null;
updatedAt: Generated<Timestamp>; updatedAt: Generated<Timestamp>;
version: number; version: number | null;
workspaceId: string; workspaceId: string;
} }
@ -93,19 +94,14 @@ export interface Pages {
creatorId: string | null; creatorId: string | null;
deletedAt: Timestamp | null; deletedAt: Timestamp | null;
deletedById: string | null; deletedById: string | null;
editor: string | null;
html: string | null;
icon: string | null; icon: string | null;
id: Generated<string>; id: Generated<string>;
isLocked: Generated<boolean>; isLocked: Generated<boolean>;
key: string | null;
lastUpdatedById: string | null; lastUpdatedById: string | null;
parentPageId: string | null; parentPageId: string | null;
position: string | null; position: string | null;
publishedAt: Timestamp | null; slugId: string;
slug: string | null;
spaceId: string; spaceId: string;
status: string | null;
textContent: string | null; textContent: string | null;
title: string | null; title: string | null;
tsv: string | null; tsv: string | null;
@ -115,8 +111,8 @@ export interface Pages {
} }
export interface SpaceMembers { export interface SpaceMembers {
addedById: string | null;
createdAt: Generated<Timestamp>; createdAt: Generated<Timestamp>;
creatorId: string | null;
groupId: string | null; groupId: string | null;
id: Generated<string>; id: Generated<string>;
role: string; role: string;
@ -143,6 +139,8 @@ export interface Spaces {
export interface Users { export interface Users {
avatarUrl: string | null; avatarUrl: string | null;
createdAt: Generated<Timestamp>; createdAt: Generated<Timestamp>;
deactivatedAt: Timestamp | null;
deletedAt: Timestamp | null;
email: string; email: string;
emailVerifiedAt: Timestamp | null; emailVerifiedAt: Timestamp | null;
id: Generated<string>; id: Generated<string>;
@ -151,10 +149,9 @@ export interface Users {
lastLoginAt: Timestamp | null; lastLoginAt: Timestamp | null;
locale: string | null; locale: string | null;
name: string | null; name: string | null;
password: string; password: string | null;
role: string | null; role: string;
settings: Json | null; settings: Json | null;
status: string | null;
timezone: string | null; timezone: string | null;
updatedAt: Generated<Timestamp>; updatedAt: Generated<Timestamp>;
workspaceId: string | null; workspaceId: string | null;
@ -162,27 +159,26 @@ export interface Users {
export interface WorkspaceInvitations { export interface WorkspaceInvitations {
createdAt: Generated<Timestamp>; createdAt: Generated<Timestamp>;
email: string; email: string | null;
groupIds: string[] | null; groupIds: string[] | null;
id: Generated<string>; id: Generated<string>;
invitedById: string | null; invitedById: string | null;
role: string; role: string;
token: string | null; token: string;
updatedAt: Generated<Timestamp>; updatedAt: Generated<Timestamp>;
workspaceId: string; workspaceId: string;
} }
export interface Workspaces { export interface Workspaces {
allowedEmailDomains: Generated<string[] | null>;
createdAt: Generated<Timestamp>; createdAt: Generated<Timestamp>;
customDomain: string | null; customDomain: string | null;
defaultRole: Generated<string>; defaultRole: Generated<string>;
defaultSpaceId: string | null; defaultSpaceId: string | null;
deletedAt: Timestamp | null; deletedAt: Timestamp | null;
description: string | null; description: string | null;
enableInvite: Generated<boolean>;
hostname: string | null; hostname: string | null;
id: Generated<string>; id: Generated<string>;
inviteCode: Generated<string | null>;
logo: string | null; logo: string | null;
name: string | null; name: string | null;
settings: Json | null; settings: Json | null;

View File

@ -3,3 +3,7 @@ const { customAlphabet } = require('fix-esm').require('nanoid');
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz'; const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
export const nanoIdGen = customAlphabet(alphabet, 10); export const nanoIdGen = customAlphabet(alphabet, 10);
const slugIdAlphabet =
'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
export const genPageShortId = customAlphabet(slugIdAlphabet, 12);

View File

@ -0,0 +1,57 @@
import { Module, OnModuleInit } from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import { join } from 'path';
import * as fs from 'node:fs';
import fastifyStatic from '@fastify/static';
import { EnvironmentService } from '../environment/environment.service';
@Module({})
export class StaticModule implements OnModuleInit {
constructor(
private readonly httpAdapterHost: HttpAdapterHost,
private readonly environmentService: EnvironmentService,
) {}
public async onModuleInit() {
const httpAdapter = this.httpAdapterHost.httpAdapter;
const app = httpAdapter.getInstance();
const clientDistPath = join(
__dirname,
'..',
'..',
'..',
'..',
'client/dist',
);
if (fs.existsSync(clientDistPath)) {
const indexFilePath = join(clientDistPath, 'index.html');
const windowVar = '<!--window-config-->';
const configString = {
env: this.environmentService.getEnv(),
appUrl: this.environmentService.getAppUrl(),
isCloud: this.environmentService.isCloud(),
};
const windowScriptContent = `<script>window.CONFIG=${JSON.stringify(configString)};</script>`;
const html = fs.readFileSync(indexFilePath, 'utf8');
const transformedHtml = html.replace(windowVar, windowScriptContent);
fs.writeFileSync(indexFilePath, transformedHtml);
const RENDER_PATH = '*';
await app.register(fastifyStatic, {
root: clientDistPath,
wildcard: false,
});
app.get(RENDER_PATH, (req: any, res: any) => {
const stream = fs.createReadStream(indexFilePath);
res.type('text/html').send(stream);
});
}
}
}

View File

@ -1,12 +0,0 @@
import { type Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('pages')
.addColumn('position', 'varchar', (col) => col)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.alterTable('pages').dropColumn('position').execute();
}

View File

@ -1,43 +0,0 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('workspace_invitations')
.addColumn('token', 'varchar', (col) => col)
.addColumn('group_ids', sql`uuid[]`, (col) => col)
.execute();
await db.schema
.alterTable('workspace_invitations')
.dropColumn('status')
.execute();
await db.schema
.alterTable('workspace_invitations')
.addUniqueConstraint('invitation_email_workspace_id_unique', [
'email',
'workspace_id',
])
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('workspace_invitations')
.dropColumn('token')
.execute();
await db.schema
.alterTable('workspace_invitations')
.dropColumn('group_ids')
.execute();
await db.schema
.alterTable('workspace_invitations')
.addColumn('status', 'varchar', (col) => col)
.execute();
await db.schema
.alterTable('workspace_invitations')
.dropConstraint('invitation_email_workspace_id_unique')
.execute();
}

View File

@ -1,14 +0,0 @@
import { type Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('users')
.addColumn('invited_by_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.alterTable('users').dropColumn('invited_by_id').execute();
}

View File

@ -4,7 +4,7 @@ import {
FastifyAdapter, FastifyAdapter,
NestFastifyApplication, NestFastifyApplication,
} from '@nestjs/platform-fastify'; } from '@nestjs/platform-fastify';
import { ValidationPipe } from '@nestjs/common'; import { NotFoundException, ValidationPipe } from '@nestjs/common';
import { TransformHttpResponseInterceptor } from './interceptors/http-response.interceptor'; import { TransformHttpResponseInterceptor } from './interceptors/http-response.interceptor';
import fastifyMultipart from '@fastify/multipart'; import fastifyMultipart from '@fastify/multipart';
@ -14,12 +14,29 @@ async function bootstrap() {
new FastifyAdapter({ new FastifyAdapter({
ignoreTrailingSlash: true, ignoreTrailingSlash: true,
ignoreDuplicateSlashes: true, ignoreDuplicateSlashes: true,
} as any), }),
); );
app.setGlobalPrefix('api'); app.setGlobalPrefix('api');
await app.register(fastifyMultipart as any); await app.register(fastifyMultipart);
app
.getHttpAdapter()
.getInstance()
.addHook('preHandler', function (req, reply, done) {
if (
req.originalUrl.startsWith('/api') &&
!req.originalUrl.startsWith('/api/auth/setup')
) {
if (!req.raw?.['workspaceId']) {
throw new NotFoundException('Workspace not found');
}
done();
} else {
done();
}
});
app.useGlobalPipes( app.useGlobalPipes(
new ValidationPipe({ new ValidationPipe({

View File

@ -17,7 +17,9 @@ export class DomainMiddleware implements NestMiddleware {
if (this.environmentService.isSelfHosted()) { if (this.environmentService.isSelfHosted()) {
const workspace = await this.workspaceRepo.findFirst(); const workspace = await this.workspaceRepo.findFirst();
if (!workspace) { if (!workspace) {
throw new NotFoundException('Workspace not found'); //throw new NotFoundException('Workspace not found');
(req as any).workspaceId = null;
return next();
} }
(req as any).workspaceId = workspace.id; (req as any).workspaceId = workspace.id;

View File

@ -20,8 +20,7 @@
"strict": true, "strict": true,
"jsx": "react", "jsx": "react",
"paths": { "paths": {
"@docmost/db": ["./src/kysely"], "@docmost/db/*": ["./src/database/*"],
"@docmost/db/*": ["./src/kysely/*"],
"@docmost/transactional/*": ["./src/integrations/transactional/*"] "@docmost/transactional/*": ["./src/integrations/transactional/*"]
} }
} }

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