mirror of
https://github.com/docmost/docmost.git
synced 2025-11-12 18:22:37 +10:00
updates and fixes
* seo friendly urls * custom client serve-static module * database fixes * fix recent pages * other fixes
This commit is contained in:
@ -4,10 +4,13 @@
|
|||||||
<meta charset="UTF-8" />
|
<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>
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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 />}>
|
||||||
|
|||||||
@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -0,0 +1,97 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as z from "zod";
|
||||||
|
|
||||||
|
import { useForm, zodResolver } from "@mantine/form";
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
Title,
|
||||||
|
TextInput,
|
||||||
|
Button,
|
||||||
|
PasswordInput,
|
||||||
|
Box,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { ISetupWorkspace } from "@/features/auth/types/auth.types";
|
||||||
|
import useAuth from "@/features/auth/hooks/use-auth";
|
||||||
|
import classes from "@/features/auth/components/auth.module.css";
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
workspaceName: z.string().min(2).max(60),
|
||||||
|
name: z.string().min(2).max(60),
|
||||||
|
email: z
|
||||||
|
.string()
|
||||||
|
.min(1, { message: "email is required" })
|
||||||
|
.email({ message: "Invalid email address" }),
|
||||||
|
password: z.string().min(8),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function SetupWorkspaceForm() {
|
||||||
|
const { setupWorkspace, isLoading } = useAuth();
|
||||||
|
// useRedirectIfAuthenticated();
|
||||||
|
|
||||||
|
const form = useForm<ISetupWorkspace>({
|
||||||
|
validate: zodResolver(formSchema),
|
||||||
|
initialValues: {
|
||||||
|
workspaceName: "",
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onSubmit(data: ISetupWorkspace) {
|
||||||
|
await setupWorkspace(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size={420} my={40} className={classes.container}>
|
||||||
|
<Box p="xl" mt={200}>
|
||||||
|
<Title order={2} ta="center" fw={500} mb="md">
|
||||||
|
Create workspace
|
||||||
|
</Title>
|
||||||
|
|
||||||
|
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||||
|
<TextInput
|
||||||
|
id="workspaceName"
|
||||||
|
type="text"
|
||||||
|
label="Workspace Name"
|
||||||
|
placeholder="e.g ACME Inc"
|
||||||
|
variant="filled"
|
||||||
|
mt="md"
|
||||||
|
{...form.getInputProps("workspaceName")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
label="Your Name"
|
||||||
|
placeholder="enter your full name"
|
||||||
|
variant="filled"
|
||||||
|
mt="md"
|
||||||
|
{...form.getInputProps("name")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
label="Your Email"
|
||||||
|
placeholder="email@example.com"
|
||||||
|
variant="filled"
|
||||||
|
mt="md"
|
||||||
|
{...form.getInputProps("email")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PasswordInput
|
||||||
|
label="Password"
|
||||||
|
placeholder="Enter a strong password"
|
||||||
|
variant="filled"
|
||||||
|
mt="md"
|
||||||
|
{...form.getInputProps("password")}
|
||||||
|
/>
|
||||||
|
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
|
||||||
|
Setup workspace
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,10 +1,18 @@
|
|||||||
import { useState } from "react";
|
import { 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,
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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}>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}`;
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
15
apps/client/src/features/page/page.utils.ts
Normal file
15
apps/client/src/features/page/page.utils.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import slugify from "@sindresorhus/slugify";
|
||||||
|
|
||||||
|
export const buildPageSlug = (
|
||||||
|
pageShortId: string,
|
||||||
|
pageTitle?: string,
|
||||||
|
): string => {
|
||||||
|
const titleSlug = slugify(pageTitle?.substring(0, 99) || "untitled", {
|
||||||
|
customReplacements: [
|
||||||
|
["♥", ""],
|
||||||
|
["🦄", ""],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return `/p/${pageShortId}/${titleSlug}`;
|
||||||
|
};
|
||||||
@ -27,16 +27,21 @@ import { buildTree } from "@/features/page/tree/utils";
|
|||||||
|
|
||||||
const RECENT_CHANGES_KEY = ["recentChanges"];
|
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);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
1
apps/server/.gitignore
vendored
1
apps/server/.gitignore
vendored
@ -1,3 +1,4 @@
|
|||||||
|
/storage
|
||||||
.env
|
.env
|
||||||
package-lock.json
|
package-lock.json
|
||||||
# compiled output
|
# compiled output
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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],
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
@ -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',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -10,7 +10,7 @@ export class CreatePageDto {
|
|||||||
icon?: string;
|
icon?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsString()
|
||||||
parentPageId?: string;
|
parentPageId?: string;
|
||||||
|
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -1,3 +0,0 @@
|
|||||||
import { Page } from '@docmost/db/types/entity.types';
|
|
||||||
|
|
||||||
export type PageWithOrderingDto = Page & { childrenIds?: string[] };
|
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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> {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -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',
|
||||||
@ -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()`),
|
||||||
)
|
)
|
||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -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();
|
||||||
}
|
}
|
||||||
@ -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'),
|
||||||
)
|
)
|
||||||
@ -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,
|
||||||
@ -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)
|
||||||
@ -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(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -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;
|
||||||
@ -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);
|
||||||
|
|||||||
57
apps/server/src/integrations/static/static.module.ts
Normal file
57
apps/server/src/integrations/static/static.module.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
@ -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({
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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
Reference in New Issue
Block a user