mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| af6d9672f5 | |||
| 2e4e5ab47f | |||
| cd5fa445be | |||
| a8565e57fb | |||
| 0fb89f52f0 | |||
| 9226014fe9 | |||
| c9531acf04 | |||
| b3ab12d732 | |||
| 74118e11d7 |
@@ -7,6 +7,7 @@
|
||||
"Add members": "Add members",
|
||||
"Add to groups": "Add to groups",
|
||||
"Add space members": "Add space members",
|
||||
"Add to favorites": "Add to favorites",
|
||||
"Admin": "Admin",
|
||||
"Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "Are you sure you want to delete this group? Members will lose access to resources this group has access to.",
|
||||
"Are you sure you want to delete this page?": "Are you sure you want to delete this page?",
|
||||
@@ -74,6 +75,9 @@
|
||||
"Failed to import pages": "Failed to import pages",
|
||||
"Failed to load page. An error occurred.": "Failed to load page. An error occurred.",
|
||||
"Failed to update data": "Failed to update data",
|
||||
"Favorite spaces": "Favorite spaces",
|
||||
"Favorite spaces appear here": "Favorite spaces appear here",
|
||||
"Favorites": "Favorites",
|
||||
"Full access": "Full access",
|
||||
"Full page width": "Full page width",
|
||||
"Full width": "Full width",
|
||||
@@ -92,6 +96,7 @@
|
||||
"Invite by email": "Invite by email",
|
||||
"Invite members": "Invite members",
|
||||
"Invite new members": "Invite new members",
|
||||
"Invite People": "Invite People",
|
||||
"Invited members who are yet to accept their invitation will appear here.": "Invited members who are yet to accept their invitation will appear here.",
|
||||
"Invited members will be granted access to spaces the groups can access": "Invited members will be granted access to spaces the groups can access",
|
||||
"Join the workspace": "Join the workspace",
|
||||
@@ -139,6 +144,7 @@
|
||||
"Profile": "Profile",
|
||||
"Recently updated": "Recently updated",
|
||||
"Remove": "Remove",
|
||||
"Remove from favorites": "Remove from favorites",
|
||||
"Remove group member": "Remove group member",
|
||||
"Remove space member": "Remove space member",
|
||||
"Restore": "Restore",
|
||||
@@ -175,6 +181,7 @@
|
||||
"Successfully imported": "Successfully imported",
|
||||
"Successfully restored": "Successfully restored",
|
||||
"System settings": "System settings",
|
||||
"Templates": "Templates",
|
||||
"Theme": "Theme",
|
||||
"To change your email, you have to enter your password and new email.": "To change your email, you have to enter your password and new email.",
|
||||
"Toggle full page width": "Toggle full page width",
|
||||
@@ -478,6 +485,7 @@
|
||||
"Replace (Enter)": "Replace (Enter)",
|
||||
"Replace all (Ctrl+Alt+Enter)": "Replace all (Ctrl+Alt+Enter)",
|
||||
"Replace all": "Replace all",
|
||||
"View all": "View all",
|
||||
"View all spaces": "View all spaces",
|
||||
"Error": "Error",
|
||||
"Failed to disable MFA": "Failed to disable MFA",
|
||||
|
||||
@@ -38,6 +38,9 @@ import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
|
||||
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
|
||||
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
|
||||
import AuditLogs from "@/ee/audit/pages/audit-logs.tsx";
|
||||
import TemplateList from "@/ee/template/pages/template-list";
|
||||
import TemplateEditor from "@/ee/template/pages/template-editor";
|
||||
import FavoritesPage from "@/pages/favorites/favorites-page";
|
||||
import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx";
|
||||
import VerifyEmail from "@/ee/pages/verify-email.tsx";
|
||||
|
||||
@@ -85,6 +88,12 @@ export default function App() {
|
||||
<Route path={"/ai"} element={<AiChat />} />
|
||||
<Route path={"/ai/chat/:chatId"} element={<AiChat />} />
|
||||
<Route path={"/spaces"} element={<SpacesPage />} />
|
||||
<Route path={"/favorites"} element={<FavoritesPage />} />
|
||||
<Route path={"/templates"} element={<TemplateList />} />
|
||||
<Route
|
||||
path={"/templates/:templateId"}
|
||||
element={<TemplateEditor />}
|
||||
/>
|
||||
<Route path={"/s/:spaceSlug"} element={<SpaceHome />} />
|
||||
<Route path={"/s/:spaceSlug/trash"} element={<SpaceTrash />} />
|
||||
<Route
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Badge,
|
||||
Table,
|
||||
ActionIcon,
|
||||
Button,
|
||||
} from "@mantine/core";
|
||||
import { Link } from "react-router-dom";
|
||||
import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx";
|
||||
@@ -23,7 +24,8 @@ interface Props {
|
||||
|
||||
export default function RecentChanges({ spaceId }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { data: pages, isLoading, isError } = useRecentChangesQuery(spaceId);
|
||||
const { data, isLoading, isError, hasNextPage, fetchNextPage, isFetchingNextPage } = useRecentChangesQuery(spaceId);
|
||||
const pages = data?.pages.flatMap((p) => p.items) ?? [];
|
||||
|
||||
if (isLoading) {
|
||||
return <PageListSkeleton />;
|
||||
@@ -33,58 +35,72 @@ export default function RecentChanges({ spaceId }: Props) {
|
||||
return <Text>{t("Failed to fetch recent pages")}</Text>;
|
||||
}
|
||||
|
||||
return pages && pages.items.length > 0 ? (
|
||||
<Table.ScrollContainer minWidth={500}>
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Tbody>
|
||||
{pages.items.map((page) => (
|
||||
<Table.Tr key={page.id}>
|
||||
<Table.Td>
|
||||
<UnstyledButton
|
||||
component={Link}
|
||||
to={buildPageUrl(page?.space.slug, page.slugId, page.title)}
|
||||
>
|
||||
<Group wrap="nowrap">
|
||||
{page.icon || (
|
||||
<ActionIcon variant="transparent" color="gray" size={18}>
|
||||
<IconFileDescription size={18} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
|
||||
<Text fw={500} size="md" lineClamp={1}>
|
||||
{page.title || t("Untitled")}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Table.Td>
|
||||
{!spaceId && (
|
||||
return pages.length > 0 ? (
|
||||
<>
|
||||
<Table.ScrollContainer minWidth={500}>
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Tbody>
|
||||
{pages.map((page) => (
|
||||
<Table.Tr key={page.id}>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
color={getInitialsColor(page?.space.name)}
|
||||
variant="light"
|
||||
<UnstyledButton
|
||||
component={Link}
|
||||
to={getSpaceUrl(page?.space.slug)}
|
||||
style={{ cursor: "pointer" }}
|
||||
to={buildPageUrl(page?.space.slug, page.slugId, page.title)}
|
||||
>
|
||||
{page?.space.name}
|
||||
</Badge>
|
||||
<Group wrap="nowrap">
|
||||
{page.icon || (
|
||||
<ActionIcon variant="transparent" color="gray" size={18}>
|
||||
<IconFileDescription size={18} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
|
||||
<Text fw={500} size="md" lineClamp={1}>
|
||||
{page.title || t("Untitled")}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Table.Td>
|
||||
)}
|
||||
<Table.Td>
|
||||
<Text
|
||||
c="dimmed"
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
size="xs"
|
||||
fw={500}
|
||||
>
|
||||
{formattedDate(page.updatedAt)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
{!spaceId && (
|
||||
<Table.Td>
|
||||
<Badge
|
||||
color={getInitialsColor(page?.space.name)}
|
||||
variant="light"
|
||||
component={Link}
|
||||
to={getSpaceUrl(page?.space.slug)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{page?.space.name}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
)}
|
||||
<Table.Td>
|
||||
<Text
|
||||
c="dimmed"
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
size="xs"
|
||||
fw={500}
|
||||
>
|
||||
{formattedDate(page.updatedAt)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
{hasNextPage && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
fullWidth
|
||||
mt="sm"
|
||||
mb="xl"
|
||||
onClick={() => fetchNextPage()}
|
||||
loading={isFetchingNextPage}
|
||||
>
|
||||
{t("Load more")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={IconFiles}
|
||||
|
||||
@@ -52,10 +52,7 @@ export function AppHeader() {
|
||||
const [workspace] = useAtom(workspaceAtom);
|
||||
const aiChatEnabled = workspace?.settings?.ai?.chat === true;
|
||||
|
||||
const isHomeRoute = location.pathname.startsWith("/home");
|
||||
const isSpacesRoute = location.pathname === "/spaces";
|
||||
const isPageRoute = location.pathname.includes("/p/");
|
||||
const hideSidebar = isHomeRoute || isSpacesRoute;
|
||||
|
||||
const items = links.map((link) => (
|
||||
<Link key={link.label} to={link.link} className={classes.link}>
|
||||
@@ -67,29 +64,25 @@ export function AppHeader() {
|
||||
<>
|
||||
<Group h="100%" px="md" justify="space-between" wrap={"nowrap"}>
|
||||
<Group wrap="nowrap">
|
||||
{!hideSidebar && (
|
||||
<>
|
||||
<Tooltip label={t("Sidebar toggle")}>
|
||||
<SidebarToggle
|
||||
aria-label={t("Sidebar toggle")}
|
||||
opened={mobileOpened}
|
||||
onClick={toggleMobile}
|
||||
hiddenFrom="sm"
|
||||
size="sm"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("Sidebar toggle")}>
|
||||
<SidebarToggle
|
||||
aria-label={t("Sidebar toggle")}
|
||||
opened={mobileOpened}
|
||||
onClick={toggleMobile}
|
||||
hiddenFrom="sm"
|
||||
size="sm"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label={t("Sidebar toggle")}>
|
||||
<SidebarToggle
|
||||
aria-label={t("Sidebar toggle")}
|
||||
opened={desktopOpened}
|
||||
onClick={toggleDesktop}
|
||||
visibleFrom="sm"
|
||||
size="sm"
|
||||
/>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
<Tooltip label={t("Sidebar toggle")}>
|
||||
<SidebarToggle
|
||||
aria-label={t("Sidebar toggle")}
|
||||
opened={desktopOpened}
|
||||
onClick={toggleDesktop}
|
||||
visibleFrom="sm"
|
||||
size="sm"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Link to="/home" className={classes.brand} aria-label="Docmost">
|
||||
<Box hiddenFrom="sm" className={classes.brandIcon}>
|
||||
|
||||
@@ -16,6 +16,7 @@ import Aside from "@/components/layouts/global/aside.tsx";
|
||||
import classes from "./app-shell.module.css";
|
||||
import { useTrialEndAction } from "@/ee/hooks/use-trial-end-action.tsx";
|
||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||
import GlobalSidebar from "@/components/layouts/global/global-sidebar.tsx";
|
||||
|
||||
export default function GlobalAppShell({
|
||||
children,
|
||||
@@ -74,24 +75,20 @@ export default function GlobalAppShell({
|
||||
const isSettingsRoute = location.pathname.startsWith("/settings");
|
||||
const isSpaceRoute = location.pathname.startsWith("/s/");
|
||||
const isAiRoute = location.pathname.startsWith("/ai");
|
||||
const isHomeRoute = location.pathname.startsWith("/home");
|
||||
const isSpacesRoute = location.pathname === "/spaces";
|
||||
const isPageRoute = location.pathname.includes("/p/");
|
||||
const hideSidebar = isHomeRoute || isSpacesRoute;
|
||||
const showGlobalSidebar = !isSpaceRoute && !isSettingsRoute && !isAiRoute;
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
header={{ height: 45 }}
|
||||
navbar={
|
||||
!hideSidebar && {
|
||||
width: isSpaceRoute ? sidebarWidth : 300,
|
||||
breakpoint: "sm",
|
||||
collapsed: {
|
||||
mobile: !mobileOpened,
|
||||
desktop: !desktopOpened,
|
||||
},
|
||||
}
|
||||
}
|
||||
navbar={{
|
||||
width: isSpaceRoute ? sidebarWidth : 300,
|
||||
breakpoint: "sm",
|
||||
collapsed: {
|
||||
mobile: !mobileOpened,
|
||||
desktop: !desktopOpened,
|
||||
},
|
||||
}}
|
||||
aside={
|
||||
isPageRoute && {
|
||||
width: 350,
|
||||
@@ -104,21 +101,22 @@ export default function GlobalAppShell({
|
||||
<AppShell.Header px="md" className={classes.header}>
|
||||
<AppHeader />
|
||||
</AppShell.Header>
|
||||
{!hideSidebar && (
|
||||
<AppShell.Navbar
|
||||
className={classes.navbar}
|
||||
withBorder={false}
|
||||
ref={sidebarRef}
|
||||
>
|
||||
{!isAiRoute && <div className={classes.resizeHandle} onMouseDown={startResizing} />}
|
||||
{isSpaceRoute && <SpaceSidebar />}
|
||||
{isSettingsRoute && <SettingsSidebar />}
|
||||
{isAiRoute && <AiChatSidebar />}
|
||||
</AppShell.Navbar>
|
||||
)}
|
||||
<AppShell.Navbar
|
||||
className={classes.navbar}
|
||||
withBorder={false}
|
||||
ref={sidebarRef}
|
||||
>
|
||||
{isSpaceRoute && (
|
||||
<div className={classes.resizeHandle} onMouseDown={startResizing} />
|
||||
)}
|
||||
{isSpaceRoute && <SpaceSidebar />}
|
||||
{isSettingsRoute && <SettingsSidebar />}
|
||||
{isAiRoute && <AiChatSidebar />}
|
||||
{showGlobalSidebar && <GlobalSidebar />}
|
||||
</AppShell.Navbar>
|
||||
<AppShell.Main>
|
||||
{isSettingsRoute ? (
|
||||
<Container size={850}>{children}</Container>
|
||||
<Container size={900}>{children}</Container>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
.navbar {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: var(--mantine-spacing-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding-bottom: var(--mantine-spacing-xs);
|
||||
}
|
||||
|
||||
.link {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
padding-left: var(--mantine-spacing-xs);
|
||||
min-height: 30px;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
|
||||
@mixin hover {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
|
||||
}
|
||||
|
||||
&[data-active] {
|
||||
&,
|
||||
& :hover {
|
||||
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6));
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.linkIcon {
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
margin-right: var(--mantine-spacing-sm);
|
||||
width: rem(16px);
|
||||
height: rem(16px);
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-3));
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bottomSection {
|
||||
padding-top: var(--mantine-spacing-xs);
|
||||
border-top: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
}
|
||||
|
||||
.spaceItem {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--mantine-spacing-sm);
|
||||
text-decoration: none;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
padding-left: var(--mantine-spacing-xs);
|
||||
min-height: 30px;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
|
||||
@mixin hover {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ScrollArea, Text, Divider, Modal } from "@mantine/core";
|
||||
import {
|
||||
IconHome,
|
||||
IconClock,
|
||||
IconStar,
|
||||
IconLayoutGrid,
|
||||
IconSettings,
|
||||
IconUserPlus,
|
||||
} from "@tabler/icons-react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import classes from "./global-sidebar.module.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar";
|
||||
import { useFavoritesQuery } from "@/features/favorite/queries/favorite-query";
|
||||
import { getSpaceUrl } from "@/lib/config";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { WorkspaceInviteForm } from "@/features/workspace/components/members/components/workspace-invite-form";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types";
|
||||
|
||||
const mainNavItems = [
|
||||
{ label: "Home", icon: IconHome, path: "/home" },
|
||||
{ label: "Favorites", icon: IconStar, path: "/favorites" },
|
||||
{ label: "Spaces", icon: IconLayoutGrid, path: "/spaces" },
|
||||
];
|
||||
|
||||
export default function GlobalSidebar() {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const [active, setActive] = useState(location.pathname);
|
||||
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
|
||||
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
|
||||
const { data: favoriteSpacesData } = useFavoritesQuery("space");
|
||||
const favoriteSpaces = favoriteSpacesData?.pages.flatMap((p) => p.items) ?? [];
|
||||
const sortedFavoriteSpaces = [...favoriteSpaces]
|
||||
.filter((fav) => fav.space)
|
||||
.sort((a, b) => {
|
||||
const cmp = (a.space!.name ?? "").localeCompare(b.space!.name ?? "", undefined, { sensitivity: "base" });
|
||||
return cmp !== 0 ? cmp : a.id.localeCompare(b.id);
|
||||
});
|
||||
const [inviteOpened, { open: openInvite, close: closeInvite }] =
|
||||
useDisclosure(false);
|
||||
|
||||
useEffect(() => {
|
||||
setActive(location.pathname);
|
||||
}, [location.pathname]);
|
||||
|
||||
const handleNavClick = () => {
|
||||
if (mobileSidebarOpened) {
|
||||
toggleMobileSidebar();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.navbar}>
|
||||
<ScrollArea w="100%" style={{ flex: 1 }}>
|
||||
<div className={classes.section}>
|
||||
{mainNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.label}
|
||||
className={classes.link}
|
||||
data-active={active === item.path || undefined}
|
||||
to={item.path}
|
||||
onClick={handleNavClick}
|
||||
>
|
||||
<item.icon className={classes.linkIcon} stroke={2} />
|
||||
<span>{t(item.label)}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Divider my="xs" />
|
||||
<div className={classes.section}>
|
||||
<Text className={classes.sectionHeader}>{t("Favorite spaces")}</Text>
|
||||
{sortedFavoriteSpaces.length === 0 ? (
|
||||
<Text size="xs" c="dimmed" pl="xs" py={4}>
|
||||
{t("Favorite spaces appear here")}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
{sortedFavoriteSpaces.slice(0, 10).map((fav) => (
|
||||
<Link
|
||||
key={fav.id}
|
||||
className={classes.spaceItem}
|
||||
to={getSpaceUrl(fav.space!.slug)}
|
||||
onClick={handleNavClick}
|
||||
>
|
||||
<CustomAvatar
|
||||
name={fav.space!.name}
|
||||
avatarUrl={fav.space!.logo}
|
||||
type={AvatarIconType.SPACE_ICON}
|
||||
color="initials"
|
||||
variant="filled"
|
||||
size={20}
|
||||
/>
|
||||
<Text size="sm" fw={500} lineClamp={1}>
|
||||
{fav.space!.name}
|
||||
</Text>
|
||||
</Link>
|
||||
))}
|
||||
{sortedFavoriteSpaces.length > 10 && (
|
||||
<Link
|
||||
className={classes.spaceItem}
|
||||
to="/spaces"
|
||||
onClick={handleNavClick}
|
||||
>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("View all")}
|
||||
</Text>
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</ScrollArea>
|
||||
|
||||
<div className={classes.bottomSection}>
|
||||
<a
|
||||
className={classes.link}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
openInvite();
|
||||
}}
|
||||
href="#"
|
||||
>
|
||||
<IconUserPlus className={classes.linkIcon} stroke={2} />
|
||||
<span>{t("Invite People")}</span>
|
||||
</a>
|
||||
<Link
|
||||
className={classes.link}
|
||||
data-active={active.startsWith("/settings") || undefined}
|
||||
to="/settings/account/profile"
|
||||
onClick={handleNavClick}
|
||||
>
|
||||
<IconSettings className={classes.linkIcon} stroke={2} />
|
||||
<span>{t("Settings")}</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
size="550"
|
||||
opened={inviteOpened}
|
||||
onClose={closeInvite}
|
||||
title={t("Invite new members")}
|
||||
centered
|
||||
>
|
||||
<Divider size="xs" mb="xs" />
|
||||
<ScrollArea h="80%">
|
||||
<WorkspaceInviteForm onClose={closeInvite} />
|
||||
</ScrollArea>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Modal, Button, Group } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DestinationPicker } from "./destination-picker";
|
||||
import {
|
||||
DestinationPickerModalProps,
|
||||
DestinationSelection,
|
||||
} from "./destination-picker.types";
|
||||
|
||||
export function DestinationPickerModal({
|
||||
opened,
|
||||
onClose,
|
||||
title,
|
||||
actionLabel,
|
||||
onSelect,
|
||||
loading,
|
||||
excludePageId,
|
||||
pageLimit,
|
||||
}: DestinationPickerModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [selection, setSelection] = useState<DestinationSelection | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) {
|
||||
setSelection(null);
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
return (
|
||||
<Modal.Root
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
size={550}
|
||||
padding="lg"
|
||||
yOffset="10vh"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Modal.Overlay />
|
||||
<Modal.Content>
|
||||
<Modal.Header py={0}>
|
||||
<Modal.Title fw={500}>{title}</Modal.Title>
|
||||
<Modal.CloseButton />
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<DestinationPicker
|
||||
onSelectionChange={setSelection}
|
||||
excludePageId={excludePageId}
|
||||
pageLimit={pageLimit}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
{t("Close")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => selection && onSelect(selection)}
|
||||
disabled={!selection}
|
||||
loading={loading}
|
||||
>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
.searchInput {
|
||||
margin-bottom: var(--mantine-spacing-sm);
|
||||
}
|
||||
|
||||
.scrollArea {
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
.row {
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: background-color 150ms ease;
|
||||
user-select: none;
|
||||
|
||||
@mixin hover {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-0),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.selected {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-blue-0),
|
||||
var(--mantine-color-dark-5)
|
||||
);
|
||||
border-left: 2px solid var(--mantine-primary-color-filled);
|
||||
}
|
||||
|
||||
.spaceRow {
|
||||
composes: row;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pageRow {
|
||||
composes: row;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
flex-shrink: 0;
|
||||
transition: transform 150ms ease;
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
|
||||
@mixin hover {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-5)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.chevronExpanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.loadMore {
|
||||
text-align: center;
|
||||
padding: 6px;
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
cursor: pointer;
|
||||
|
||||
@mixin hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.selectedIndicator {
|
||||
padding: 8px 12px;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||
margin-top: var(--mantine-spacing-xs);
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
}
|
||||
|
||||
.searchResult {
|
||||
composes: row;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
}
|
||||
|
||||
.spaceName {
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.iconWrapper {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { TextInput, ScrollArea, Loader } from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { IconSearch, IconFile } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
|
||||
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query";
|
||||
import { ISpace } from "@/features/space/types/space.types";
|
||||
import { IPage } from "@/features/page/types/page.types";
|
||||
import { DestinationSelection } from "./destination-picker.types";
|
||||
import { SpaceRow } from "./space-row";
|
||||
import classes from "./destination-picker.module.css";
|
||||
|
||||
type DestinationPickerProps = {
|
||||
onSelectionChange: (selection: DestinationSelection | null) => void;
|
||||
excludePageId?: string;
|
||||
pageLimit?: number;
|
||||
};
|
||||
|
||||
export function DestinationPicker({
|
||||
onSelectionChange,
|
||||
excludePageId,
|
||||
pageLimit = 15,
|
||||
}: DestinationPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selection, setSelection] = useState<DestinationSelection | null>(null);
|
||||
const [debouncedQuery] = useDebouncedValue(searchQuery, 300);
|
||||
|
||||
const { data: spacesData, isLoading: spacesLoading } = useGetSpacesQuery({
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
const searchEnabled = debouncedQuery && debouncedQuery.length >= 2;
|
||||
|
||||
const { data: searchData, isLoading: searchLoading } =
|
||||
useSearchSuggestionsQuery({
|
||||
query: searchEnabled ? debouncedQuery : "",
|
||||
includePages: true,
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
const isSearching = !!searchEnabled;
|
||||
|
||||
const selectedId =
|
||||
selection?.type === "space" ? selection.spaceId : selection?.pageId ?? null;
|
||||
|
||||
const updateSelection = useCallback(
|
||||
(next: DestinationSelection | null) => {
|
||||
setSelection(next);
|
||||
onSelectionChange(next);
|
||||
},
|
||||
[onSelectionChange],
|
||||
);
|
||||
|
||||
const handleSearchResultClick = (page: Partial<IPage>) => {
|
||||
if (!page.space || !page.id) return;
|
||||
|
||||
updateSelection({
|
||||
type: "page",
|
||||
spaceId: page.space.id,
|
||||
pageId: page.id,
|
||||
page,
|
||||
space: page.space,
|
||||
});
|
||||
setSearchQuery("");
|
||||
};
|
||||
|
||||
const handleSelectSpace = useCallback(
|
||||
(space: ISpace) => {
|
||||
updateSelection({ type: "space", spaceId: space.id, space });
|
||||
},
|
||||
[updateSelection],
|
||||
);
|
||||
|
||||
const handleSelectPage = useCallback(
|
||||
(page: Partial<IPage>, space: ISpace) => {
|
||||
if (!page.id) return;
|
||||
updateSelection({
|
||||
type: "page",
|
||||
spaceId: page.spaceId ?? space.id,
|
||||
pageId: page.id,
|
||||
page,
|
||||
space,
|
||||
});
|
||||
},
|
||||
[updateSelection],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextInput
|
||||
leftSection={<IconSearch size={16} />}
|
||||
placeholder={t("Search pages and spaces...")}
|
||||
variant="filled"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.currentTarget.value)}
|
||||
className={classes.searchInput}
|
||||
/>
|
||||
|
||||
<ScrollArea h="50vh" offsetScrollbars className={classes.scrollArea}>
|
||||
{isSearching ? (
|
||||
searchLoading ? (
|
||||
<div className={classes.emptyState}>
|
||||
<Loader size="xs" />
|
||||
</div>
|
||||
) : searchData?.pages && searchData.pages.length > 0 ? (
|
||||
searchData.pages.map(
|
||||
(page) =>
|
||||
page && (
|
||||
<div
|
||||
key={page.id}
|
||||
className={classes.searchResult}
|
||||
onClick={() => handleSearchResultClick(page)}
|
||||
>
|
||||
<div className={classes.iconWrapper}>
|
||||
{page.icon ? (
|
||||
page.icon
|
||||
) : (
|
||||
<IconFile
|
||||
size={16}
|
||||
color="var(--mantine-color-gray-5)"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={classes.pageTitle}>
|
||||
{page.title || t("Untitled")}
|
||||
</div>
|
||||
{page.space && (
|
||||
<div className={classes.spaceName}>
|
||||
{page.space.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
)
|
||||
) : (
|
||||
<div className={classes.emptyState}>{t("No results found")}</div>
|
||||
)
|
||||
) : spacesLoading ? (
|
||||
<div className={classes.emptyState}>
|
||||
<Loader size="xs" />
|
||||
</div>
|
||||
) : (
|
||||
spacesData?.items?.map((space) => (
|
||||
<SpaceRow
|
||||
key={space.id}
|
||||
space={space}
|
||||
limit={pageLimit}
|
||||
selectedId={selectedId}
|
||||
excludePageId={excludePageId}
|
||||
onSelectSpace={handleSelectSpace}
|
||||
onSelectPage={handleSelectPage}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{selection && (
|
||||
<div className={classes.selectedIndicator}>
|
||||
{selection.type === "space"
|
||||
? selection.space.name
|
||||
: `${selection.space.name} / ${selection.page.title || t("Untitled")}`}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ISpace } from "@/features/space/types/space.types";
|
||||
import { IPage } from "@/features/page/types/page.types";
|
||||
|
||||
export type DestinationSelection =
|
||||
| { type: "space"; spaceId: string; space: ISpace }
|
||||
| {
|
||||
type: "page";
|
||||
spaceId: string;
|
||||
pageId: string;
|
||||
page: Partial<IPage>;
|
||||
space: Partial<ISpace>;
|
||||
};
|
||||
|
||||
export type DestinationPickerModalProps = {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
actionLabel: string;
|
||||
onSelect: (selection: DestinationSelection) => void | Promise<void>;
|
||||
loading?: boolean;
|
||||
excludePageId?: string;
|
||||
pageLimit?: number;
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Loader } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getSidebarPages } from "@/features/page/services/page-service";
|
||||
import { IPage } from "@/features/page/types/page.types";
|
||||
import { IPagination } from "@/lib/types";
|
||||
import { PageRow } from "./page-row";
|
||||
import classes from "./destination-picker.module.css";
|
||||
|
||||
type PageChildrenProps = {
|
||||
spaceId: string;
|
||||
pageId?: string;
|
||||
depth: number;
|
||||
limit: number;
|
||||
selectedId: string | null;
|
||||
excludePageId?: string;
|
||||
onSelectPage: (page: Partial<IPage>) => void;
|
||||
};
|
||||
|
||||
export function PageChildren({
|
||||
spaceId,
|
||||
pageId,
|
||||
depth,
|
||||
limit,
|
||||
selectedId,
|
||||
excludePageId,
|
||||
onSelectPage,
|
||||
}: PageChildrenProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data, isLoading, hasNextPage, fetchNextPage } = useInfiniteQuery({
|
||||
queryKey: ["destination-pages", spaceId, pageId ?? "root"],
|
||||
queryFn: ({ pageParam }) =>
|
||||
getSidebarPages({
|
||||
spaceId,
|
||||
pageId,
|
||||
limit,
|
||||
cursor: pageParam,
|
||||
}),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage: IPagination<IPage>) =>
|
||||
lastPage.meta?.nextCursor ?? undefined,
|
||||
});
|
||||
|
||||
const pages = data?.pages.flatMap((page) => page.items) ?? [];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={classes.emptyState}>
|
||||
<Loader size="xs" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (pages.length === 0) {
|
||||
return (
|
||||
<div className={classes.emptyState}>
|
||||
{pageId ? t("No pages inside") : t("No pages in this space")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{pages.map((page) => (
|
||||
<PageRow
|
||||
key={page.id}
|
||||
page={page}
|
||||
depth={depth}
|
||||
limit={limit}
|
||||
selectedId={selectedId}
|
||||
excludePageId={excludePageId}
|
||||
onSelect={onSelectPage}
|
||||
/>
|
||||
))}
|
||||
{hasNextPage && (
|
||||
<div className={classes.loadMore} onClick={() => fetchNextPage()}>
|
||||
{t("Load more")}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useState } from "react";
|
||||
import { IconChevronRight, IconFile } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IPage } from "@/features/page/types/page.types";
|
||||
import { PageChildren } from "./page-children";
|
||||
import classes from "./destination-picker.module.css";
|
||||
|
||||
type PageRowProps = {
|
||||
page: Partial<IPage>;
|
||||
depth: number;
|
||||
limit: number;
|
||||
selectedId: string | null;
|
||||
excludePageId?: string;
|
||||
onSelect: (page: Partial<IPage>) => void;
|
||||
};
|
||||
|
||||
export function PageRow({
|
||||
page,
|
||||
depth,
|
||||
limit,
|
||||
selectedId,
|
||||
excludePageId,
|
||||
onSelect,
|
||||
}: PageRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const isExcluded = page.id === excludePageId;
|
||||
const isSelected = page.id === selectedId;
|
||||
|
||||
const rowClasses = [
|
||||
classes.pageRow,
|
||||
isSelected && classes.selected,
|
||||
isExcluded && classes.disabled,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={rowClasses}
|
||||
style={{ paddingLeft: depth * 20 + 12 }}
|
||||
onClick={() => !isExcluded && onSelect(page)}
|
||||
>
|
||||
{page.hasChildren ? (
|
||||
<div
|
||||
className={`${classes.chevron} ${expanded ? classes.chevronExpanded : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpanded(!expanded);
|
||||
}}
|
||||
>
|
||||
<IconChevronRight size={14} />
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ width: 20, flexShrink: 0 }} />
|
||||
)}
|
||||
|
||||
<div className={classes.iconWrapper}>
|
||||
{page.icon ? (
|
||||
page.icon
|
||||
) : (
|
||||
<IconFile
|
||||
size={16}
|
||||
color="var(--mantine-color-gray-5)"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={classes.pageTitle}>
|
||||
{page.title || t("Untitled")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && page.hasChildren && (
|
||||
<PageChildren
|
||||
spaceId={page.spaceId}
|
||||
pageId={page.id}
|
||||
depth={depth + 1}
|
||||
limit={limit}
|
||||
selectedId={selectedId}
|
||||
excludePageId={excludePageId}
|
||||
onSelectPage={onSelect}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useState } from "react";
|
||||
import { Tooltip } from "@mantine/core";
|
||||
import { IconChevronRight, IconLock } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ISpace } from "@/features/space/types/space.types";
|
||||
import { IPage } from "@/features/page/types/page.types";
|
||||
import { SpaceRole } from "@/lib/types";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types";
|
||||
import { PageChildren } from "./page-children";
|
||||
import classes from "./destination-picker.module.css";
|
||||
|
||||
type SpaceRowProps = {
|
||||
space: ISpace;
|
||||
limit: number;
|
||||
selectedId: string | null;
|
||||
excludePageId?: string;
|
||||
onSelectSpace: (space: ISpace) => void;
|
||||
onSelectPage: (page: Partial<IPage>, space: ISpace) => void;
|
||||
};
|
||||
|
||||
export function SpaceRow({
|
||||
space,
|
||||
limit,
|
||||
selectedId,
|
||||
excludePageId,
|
||||
onSelectSpace,
|
||||
onSelectPage,
|
||||
}: SpaceRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const writable =
|
||||
!!space.membership?.role && space.membership.role !== SpaceRole.READER;
|
||||
const isSelected = space.id === selectedId;
|
||||
|
||||
const rowClasses = [
|
||||
classes.spaceRow,
|
||||
isSelected && classes.selected,
|
||||
!writable && classes.disabled,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
const rowContent = (
|
||||
<div
|
||||
className={rowClasses}
|
||||
onClick={() => writable && onSelectSpace(space)}
|
||||
>
|
||||
{writable ? (
|
||||
<div
|
||||
className={`${classes.chevron} ${expanded ? classes.chevronExpanded : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpanded(!expanded);
|
||||
}}
|
||||
>
|
||||
<IconChevronRight size={14} />
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ width: 20, flexShrink: 0 }} />
|
||||
)}
|
||||
|
||||
<CustomAvatar
|
||||
name={space.name}
|
||||
avatarUrl={space.logo}
|
||||
type={AvatarIconType.SPACE_ICON}
|
||||
size={22}
|
||||
/>
|
||||
|
||||
<div className={classes.pageTitle}>{space.name}</div>
|
||||
|
||||
{!writable && (
|
||||
<IconLock
|
||||
size={14}
|
||||
color="var(--mantine-color-gray-5)"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{writable ? (
|
||||
rowContent
|
||||
) : (
|
||||
<Tooltip
|
||||
label={t("You don't have permission to create pages here")}
|
||||
position="right"
|
||||
withArrow
|
||||
>
|
||||
<div>{rowContent}</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{expanded && writable && (
|
||||
<PageChildren
|
||||
spaceId={space.id}
|
||||
depth={1}
|
||||
limit={limit}
|
||||
selectedId={selectedId}
|
||||
excludePageId={excludePageId}
|
||||
onSelectPage={(page) => onSelectPage(page, space)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -11,53 +11,9 @@ import {
|
||||
useSearchChatsQuery,
|
||||
} from "../queries/ai-chat-query";
|
||||
import AiChatSidebarItem from "./ai-chat-sidebar-item";
|
||||
import type { AiChat } from "../types/ai-chat.types";
|
||||
import { groupChatsByAge } from "../utils/group-chats-by-age";
|
||||
import classes from "../styles/chat-sidebar.module.css";
|
||||
|
||||
type ChatGroup = { key: string; label: string; chats: AiChat[] };
|
||||
|
||||
function groupChatsByAge(
|
||||
chats: AiChat[],
|
||||
t: (key: string) => string,
|
||||
): ChatGroup[] {
|
||||
if (chats.length === 0) return [];
|
||||
|
||||
const now = new Date();
|
||||
const startOfToday = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
).getTime();
|
||||
const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;
|
||||
const startOfLast7 = startOfToday - 7 * 24 * 60 * 60 * 1000;
|
||||
const startOfLast30 = startOfToday - 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const buckets: Record<string, ChatGroup> = {
|
||||
today: { key: "today", label: t("Today"), chats: [] },
|
||||
yesterday: { key: "yesterday", label: t("Yesterday"), chats: [] },
|
||||
last7: { key: "last7", label: t("Previous 7 days"), chats: [] },
|
||||
last30: { key: "last30", label: t("Previous 30 days"), chats: [] },
|
||||
older: { key: "older", label: t("Older"), chats: [] },
|
||||
};
|
||||
|
||||
for (const chat of chats) {
|
||||
const ts = new Date(chat.updatedAt).getTime();
|
||||
if (ts >= startOfToday) buckets.today.chats.push(chat);
|
||||
else if (ts >= startOfYesterday) buckets.yesterday.chats.push(chat);
|
||||
else if (ts >= startOfLast7) buckets.last7.chats.push(chat);
|
||||
else if (ts >= startOfLast30) buckets.last30.chats.push(chat);
|
||||
else buckets.older.chats.push(chat);
|
||||
}
|
||||
|
||||
return [
|
||||
buckets.today,
|
||||
buckets.yesterday,
|
||||
buckets.last7,
|
||||
buckets.last30,
|
||||
buckets.older,
|
||||
].filter((b) => b.chats.length > 0);
|
||||
}
|
||||
|
||||
export default function AiChatSidebar() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { AiChat } from "../types/ai-chat.types";
|
||||
|
||||
export type ChatGroup = { key: string; label: string; chats: AiChat[] };
|
||||
|
||||
export function groupChatsByAge(
|
||||
chats: AiChat[],
|
||||
t: (key: string) => string,
|
||||
): ChatGroup[] {
|
||||
if (chats.length === 0) return [];
|
||||
|
||||
const now = new Date();
|
||||
const startOfToday = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
).getTime();
|
||||
const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;
|
||||
const startOfLast7 = startOfToday - 7 * 24 * 60 * 60 * 1000;
|
||||
const startOfLast30 = startOfToday - 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const buckets: Record<string, ChatGroup> = {
|
||||
today: { key: "today", label: t("Today"), chats: [] },
|
||||
yesterday: { key: "yesterday", label: t("Yesterday"), chats: [] },
|
||||
last7: { key: "last7", label: t("Previous 7 days"), chats: [] },
|
||||
last30: { key: "last30", label: t("Previous 30 days"), chats: [] },
|
||||
older: { key: "older", label: t("Older"), chats: [] },
|
||||
};
|
||||
|
||||
for (const chat of chats) {
|
||||
const ts = new Date(chat.updatedAt).getTime();
|
||||
if (ts >= startOfToday) buckets.today.chats.push(chat);
|
||||
else if (ts >= startOfYesterday) buckets.yesterday.chats.push(chat);
|
||||
else if (ts >= startOfLast7) buckets.last7.chats.push(chat);
|
||||
else if (ts >= startOfLast30) buckets.last30.chats.push(chat);
|
||||
else buckets.older.chats.push(chat);
|
||||
}
|
||||
|
||||
return [
|
||||
buckets.today,
|
||||
buckets.yesterday,
|
||||
buckets.last7,
|
||||
buckets.last30,
|
||||
buckets.older,
|
||||
].filter((b) => b.chats.length > 0);
|
||||
}
|
||||
@@ -16,5 +16,6 @@ export const Feature = {
|
||||
AUDIT_LOGS: 'audit:logs',
|
||||
RETENTION: 'retention',
|
||||
SHARING_CONTROLS: 'sharing:controls',
|
||||
TEMPLATES: 'templates',
|
||||
VIEWER_COMMENTS: 'comment:viewer',
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Group, Text, Switch, Tooltip } from "@mantine/core";
|
||||
import { useAtom } from "jotai";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { Feature } from "@/ee/features";
|
||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
|
||||
|
||||
export default function AllowMemberTemplates() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||
<div>
|
||||
<Text size="md">{t("Allow members to create templates")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"Allow non-admin members to create and manage templates in their spaces.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<AllowMemberTemplatesToggle />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function AllowMemberTemplatesToggle() {
|
||||
const { t } = useTranslation();
|
||||
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
||||
const [checked, setChecked] = useState(
|
||||
workspace?.settings?.templates?.allowMemberTemplates === true,
|
||||
);
|
||||
const hasSecuritySettings = useHasFeature(Feature.SECURITY_SETTINGS);
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
|
||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.currentTarget.checked;
|
||||
try {
|
||||
const updatedWorkspace = await updateWorkspace({
|
||||
allowMemberTemplates: value,
|
||||
});
|
||||
setChecked(value);
|
||||
setWorkspace(updatedWorkspace);
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
message: err?.response?.data?.message,
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label={upgradeLabel}
|
||||
disabled={hasSecuritySettings}
|
||||
refProp="rootRef"
|
||||
>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={handleChange}
|
||||
disabled={!hasSecuritySettings}
|
||||
aria-label={t("Toggle allow members to create templates")}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { useTranslation } from "react-i18next";
|
||||
import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx";
|
||||
import DisablePublicSharing from "@/ee/security/components/disable-public-sharing.tsx";
|
||||
import TrashRetention from "@/ee/security/components/trash-retention.tsx";
|
||||
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { Feature } from "@/ee/features";
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Modal,
|
||||
TextInput,
|
||||
Select,
|
||||
Button,
|
||||
Stack,
|
||||
Group,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useCreateTemplateMutation } from "../queries/template-query";
|
||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
|
||||
import useUserRole from "@/hooks/use-user-role";
|
||||
|
||||
type CreateTemplateModalProps = {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function CreateTemplateModal({
|
||||
opened,
|
||||
onClose,
|
||||
}: CreateTemplateModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { isAdmin: isWorkspaceAdmin } = useUserRole();
|
||||
const createMutation = useCreateTemplateMutation();
|
||||
const { data: spaces } = useGetSpacesQuery({ limit: 100 });
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [spaceId, setSpaceId] = useState<string | null>(null);
|
||||
|
||||
const scopeOptions = [
|
||||
...(isWorkspaceAdmin
|
||||
? [
|
||||
{ group: t("Workspace"), items: [{ value: "", label: t("Global") }] },
|
||||
]
|
||||
: []),
|
||||
...(spaces?.items?.length
|
||||
? [
|
||||
{
|
||||
group: t("Spaces"),
|
||||
items: spaces.items.map((s) => ({ value: s.id, label: s.name })),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!title.trim()) return;
|
||||
|
||||
try {
|
||||
const result = await createMutation.mutateAsync({
|
||||
title: title.trim(),
|
||||
spaceId: spaceId || undefined,
|
||||
});
|
||||
|
||||
handleClose();
|
||||
navigate(`/templates/${result.id}`);
|
||||
} catch {
|
||||
// error notification handled by mutation's onError
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setTitle("");
|
||||
setSpaceId(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
title={t("New template")}
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label={t("Title")}
|
||||
placeholder={t("Untitled")}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.currentTarget.value)}
|
||||
data-autofocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && title.trim() && !createMutation.isPending) {
|
||||
handleCreate();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label={t("Scope")}
|
||||
description={t("Choose which space this template belongs to")}
|
||||
data={scopeOptions}
|
||||
value={spaceId || ""}
|
||||
onChange={(val) => setSpaceId(val || null)}
|
||||
searchable
|
||||
placeholder={t("Select scope")}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={handleClose}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
loading={createMutation.isPending}
|
||||
disabled={!title.trim()}
|
||||
>
|
||||
{t("Create")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import "@/features/editor/styles/index.css";
|
||||
import { useMemo } from "react";
|
||||
import { Title } from "@mantine/core";
|
||||
import { EditorProvider } from "@tiptap/react";
|
||||
import { mainExtensions } from "@/features/editor/extensions/extensions";
|
||||
import { UniqueID } from "@docmost/editor-ext";
|
||||
import { ITemplate } from "@/ee/template/types/template.types";
|
||||
import TemplateMeta from "@/ee/template/components/template-meta";
|
||||
|
||||
type ReadonlyTemplateEditorProps = {
|
||||
template: ITemplate;
|
||||
};
|
||||
|
||||
export default function ReadonlyTemplateEditor({
|
||||
template,
|
||||
}: ReadonlyTemplateEditorProps) {
|
||||
const extensions = useMemo(() => {
|
||||
const filteredExtensions = mainExtensions.filter(
|
||||
(ext) => ext.name !== "uniqueID",
|
||||
);
|
||||
|
||||
return [
|
||||
...filteredExtensions,
|
||||
UniqueID.configure({
|
||||
types: ["heading", "paragraph"],
|
||||
updateDocument: false,
|
||||
}),
|
||||
];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ padding: "0 3rem" }}>
|
||||
<Title order={1} size="2.5rem" lh={1.2}>
|
||||
{template.title || "Untitled"}
|
||||
</Title>
|
||||
|
||||
<TemplateMeta template={template} />
|
||||
</div>
|
||||
|
||||
<EditorProvider
|
||||
editable={false}
|
||||
immediatelyRender={true}
|
||||
extensions={extensions}
|
||||
content={template.content}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
|
||||
transition: transform 150ms ease;
|
||||
box-shadow: light-dark(rgba(0, 0, 0, 0.07) 0px 2px 45px 4px, none);
|
||||
|
||||
@mixin hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
.cardBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.iconFallback {
|
||||
composes: icon;
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: var(--mantine-font-size-md);
|
||||
line-height: 1.35;
|
||||
color: light-dark(var(--mantine-color-gray-9), var(--mantine-color-gray-0));
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--mantine-spacing-xs);
|
||||
padding-top: var(--mantine-spacing-sm);
|
||||
margin-top: var(--mantine-spacing-lg);
|
||||
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
}
|
||||
|
||||
.menuTarget {
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease;
|
||||
|
||||
.card:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Card, Text, ActionIcon, Menu, Group } from "@mantine/core";
|
||||
import {
|
||||
IconDots,
|
||||
IconEdit,
|
||||
IconTrash,
|
||||
IconFileText,
|
||||
} from "@tabler/icons-react";
|
||||
import { ITemplate } from "@/ee/template/types/template.types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classes from "./template-card.module.css";
|
||||
|
||||
type TemplateCardProps = {
|
||||
template: ITemplate;
|
||||
spaceName?: string;
|
||||
onUse: (template: ITemplate) => void;
|
||||
onEdit?: (template: ITemplate) => void;
|
||||
onDelete?: (template: ITemplate) => void;
|
||||
canManage?: boolean;
|
||||
};
|
||||
|
||||
export default function TemplateCard({
|
||||
template,
|
||||
spaceName,
|
||||
onUse,
|
||||
onEdit,
|
||||
onDelete,
|
||||
canManage,
|
||||
}: TemplateCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Card
|
||||
radius="md"
|
||||
padding="lg"
|
||||
className={classes.card}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => onUse(template)}
|
||||
>
|
||||
<div className={classes.cardBody}>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap" mb="md">
|
||||
{template.icon ? (
|
||||
<div className={classes.icon}>{template.icon}</div>
|
||||
) : (
|
||||
<div className={classes.iconFallback}>
|
||||
<IconFileText size={20} stroke={1.5} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{canManage && (
|
||||
<Menu width={150} shadow="md" withArrow>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
color="gray"
|
||||
className={classes.menuTarget}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<IconDots size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconEdit size={14} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit?.(template);
|
||||
}}
|
||||
>
|
||||
{t("Edit")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconTrash size={14} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete?.(template);
|
||||
}}
|
||||
>
|
||||
{t("Delete")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<div className={classes.title}>{template.title}</div>
|
||||
|
||||
<div className={classes.footer}>
|
||||
<Text size="sm" fw={500} c="dimmed">
|
||||
{template.spaceId ? (spaceName || t("Space")) : t("Global")}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Group, Text } from "@mantine/core";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
import { ITemplate } from "@/ee/template/types/template.types";
|
||||
|
||||
type TemplateMetaProps = {
|
||||
template: ITemplate;
|
||||
};
|
||||
|
||||
export default function TemplateMeta({ template }: TemplateMetaProps) {
|
||||
const { t } = useTranslation();
|
||||
const updatedAtAgo = useTimeAgo(template.updatedAt);
|
||||
|
||||
return (
|
||||
<Group gap={8} mt="xs" wrap="nowrap" style={{ cursor: "default" }}>
|
||||
{template.creator?.name && (
|
||||
<>
|
||||
<CustomAvatar
|
||||
size={24}
|
||||
radius="xl"
|
||||
name={template.creator.name}
|
||||
avatarUrl={template.creator.avatarUrl}
|
||||
/>
|
||||
<Text size="sm" c="dimmed" fw={500}>
|
||||
{t("By {{name}}", { name: template.creator.name })}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{updatedAtAgo && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("Updated {{time}}", { time: updatedAtAgo })}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Modal, Text, ScrollArea, Button, Group, Center, Loader } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useGetTemplateByIdQuery } from "@/ee/template/queries/template-query";
|
||||
import ReadonlyTemplateEditor from "@/ee/template/components/readonly-template-editor";
|
||||
|
||||
type TemplatePreviewModalProps = {
|
||||
templateId: string;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onUse: () => void;
|
||||
onEdit?: () => void;
|
||||
};
|
||||
|
||||
export default function TemplatePreviewModal({
|
||||
templateId,
|
||||
opened,
|
||||
onClose,
|
||||
onUse,
|
||||
onEdit,
|
||||
}: TemplatePreviewModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: template, isLoading } = useGetTemplateByIdQuery(templateId);
|
||||
|
||||
const title = template?.title || t("Untitled");
|
||||
|
||||
return (
|
||||
<Modal.Root size={1200} opened={opened} onClose={onClose}>
|
||||
<Modal.Overlay />
|
||||
<Modal.Content style={{ overflow: "hidden" }}>
|
||||
<Modal.Header>
|
||||
<Modal.Title>
|
||||
<Group gap="xs">
|
||||
{template?.icon && <Text size="lg">{template.icon}</Text>}
|
||||
<Text size="md" fw={500}>
|
||||
{title}
|
||||
</Text>
|
||||
</Group>
|
||||
</Modal.Title>
|
||||
<Group gap="sm">
|
||||
{onEdit && (
|
||||
<Button size="xs" variant="default" onClick={onEdit}>
|
||||
{t("Edit")}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="xs" onClick={onUse}>
|
||||
{t("Use template")}
|
||||
</Button>
|
||||
<Modal.CloseButton />
|
||||
</Group>
|
||||
</Modal.Header>
|
||||
<Modal.Body p={0}>
|
||||
{isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
) : (
|
||||
<ScrollArea h="80vh" w="100%" scrollbarSize={5}>
|
||||
{template && <ReadonlyTemplateEditor template={template} />}
|
||||
</ScrollArea>
|
||||
)}
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ITemplate } from "@/ee/template/types/template.types";
|
||||
import { useUseTemplateMutation } from "@/ee/template/queries/template-query";
|
||||
import { buildPageUrl } from "@/features/page/page.utils";
|
||||
import { DestinationPickerModal } from "@/components/ui/destination-picker/destination-picker-modal";
|
||||
import { DestinationSelection } from "@/components/ui/destination-picker/destination-picker.types";
|
||||
|
||||
type UseTemplateModalProps = {
|
||||
template: ITemplate;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function UseTemplateModal({
|
||||
template,
|
||||
opened,
|
||||
onClose,
|
||||
}: UseTemplateModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const useTemplateMutation = useUseTemplateMutation();
|
||||
|
||||
const handleSelect = async (selection: DestinationSelection) => {
|
||||
const spaceId = selection.spaceId;
|
||||
const parentPageId =
|
||||
selection.type === "page" ? selection.pageId : undefined;
|
||||
|
||||
try {
|
||||
const page = await useTemplateMutation.mutateAsync({
|
||||
templateId: template.id,
|
||||
spaceId,
|
||||
parentPageId,
|
||||
});
|
||||
|
||||
onClose();
|
||||
|
||||
if (page?.slugId) {
|
||||
const space = selection.space;
|
||||
if (space?.slug) {
|
||||
navigate(buildPageUrl(space.slug, page.slugId, page.title));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// error notification handled by mutation's onError
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DestinationPickerModal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={t("Choose destination")}
|
||||
actionLabel={t("Create page")}
|
||||
onSelect={handleSelect}
|
||||
loading={useTemplateMutation.isPending}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
.header {
|
||||
height: 45px;
|
||||
background-color: var(--mantine-color-body);
|
||||
padding-left: var(--mantine-spacing-md);
|
||||
padding-right: var(--mantine-spacing-md);
|
||||
position: fixed;
|
||||
z-index: 99;
|
||||
top: var(--app-shell-header-offset, 0rem);
|
||||
inset-inline-start: var(--app-shell-navbar-offset, 0rem);
|
||||
inset-inline-end: var(--app-shell-aside-offset, 0rem);
|
||||
|
||||
@media (max-width: $mantine-breakpoint-sm) {
|
||||
padding-left: var(--mantine-spacing-xs);
|
||||
padding-right: var(--mantine-spacing-xs);
|
||||
}
|
||||
}
|
||||
|
||||
.editor {
|
||||
height: 100%;
|
||||
padding: 8px 0;
|
||||
margin: 48px auto;
|
||||
}
|
||||
|
||||
.titleArea {
|
||||
padding: 0 3rem;
|
||||
margin-bottom: 1.5em;
|
||||
}
|
||||
|
||||
.emojiButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
.titleInput {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
border: none;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
|
||||
&::placeholder {
|
||||
color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3));
|
||||
}
|
||||
}
|
||||
|
||||
.emojiIcon {
|
||||
font-size: 3rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.backLink {
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
text-decoration: none;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
@mixin hover {
|
||||
color: light-dark(var(--mantine-color-gray-9), var(--mantine-color-gray-0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
import "@/features/editor/styles/index.css";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Group,
|
||||
Select,
|
||||
Popover,
|
||||
Stack,
|
||||
ActionIcon,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconArrowLeft,
|
||||
IconSettings,
|
||||
IconMoodSmile,
|
||||
IconCheck,
|
||||
} from "@tabler/icons-react";
|
||||
import EmojiPicker from "@/components/ui/emoji-picker";
|
||||
import TemplateMeta from "@/ee/template/components/template-meta";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDisclosure, useWindowEvent } from "@mantine/hooks";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { getAppName } from "@/lib/config";
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import { templateExtensions } from "@/features/editor/extensions/extensions";
|
||||
import {
|
||||
useUpdateTemplateMutation,
|
||||
useGetTemplateByIdQuery,
|
||||
} from "../queries/template-query";
|
||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
|
||||
import useUserRole from "@/hooks/use-user-role";
|
||||
|
||||
import classes from "./template-editor.module.css";
|
||||
|
||||
export default function TemplateEditor() {
|
||||
const { t } = useTranslation();
|
||||
const { templateId } = useParams<{ templateId: string }>();
|
||||
const { isAdmin: isWorkspaceAdmin } = useUserRole();
|
||||
|
||||
const { data: existingTemplate } = useGetTemplateByIdQuery(templateId || "");
|
||||
const { data: spaces } = useGetSpacesQuery({ limit: 100 });
|
||||
const updateMutation = useUpdateTemplateMutation();
|
||||
const updateMutationRef = useRef(updateMutation.mutateAsync);
|
||||
updateMutationRef.current = updateMutation.mutateAsync;
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [icon, setIcon] = useState<string | null>(null);
|
||||
const [spaceId, setSpaceId] = useState<string | null>(null);
|
||||
const [draftSpaceId, setDraftSpaceId] = useState<string | null>(null);
|
||||
const [settingsOpened, { open: openSettings, close: closeSettings }] =
|
||||
useDisclosure(false);
|
||||
|
||||
useWindowEvent("keydown", (event) => {
|
||||
if (settingsOpened && event.key === "Escape") {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
closeSettings();
|
||||
}
|
||||
});
|
||||
|
||||
const [saveStatus, setSaveStatus] = useState<
|
||||
"idle" | "saving" | "saved" | "error"
|
||||
>("idle");
|
||||
const titleRef = useRef(title);
|
||||
const iconRef = useRef(icon);
|
||||
const spaceIdRef = useRef(spaceId);
|
||||
const loadedRef = useRef(false);
|
||||
const isDirtyRef = useRef(false);
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const savedFadeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: templateExtensions,
|
||||
content: "",
|
||||
onUpdate() {
|
||||
if (loadedRef.current) {
|
||||
markDirty();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Load template data into editor
|
||||
useEffect(() => {
|
||||
if (existingTemplate && editor) {
|
||||
loadedRef.current = false;
|
||||
setTitle(existingTemplate.title || "");
|
||||
setIcon(existingTemplate.icon || null);
|
||||
setSpaceId(existingTemplate.spaceId || null);
|
||||
titleRef.current = existingTemplate.title || "";
|
||||
iconRef.current = existingTemplate.icon || null;
|
||||
spaceIdRef.current = existingTemplate.spaceId || null;
|
||||
if (existingTemplate.content) {
|
||||
editor.commands.setContent(existingTemplate.content);
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
loadedRef.current = true;
|
||||
});
|
||||
}
|
||||
}, [existingTemplate, editor]);
|
||||
|
||||
const spaceOptions = [
|
||||
...(isWorkspaceAdmin
|
||||
? [
|
||||
{ group: t("Workspace"), items: [{ value: "", label: t("Global") }] },
|
||||
]
|
||||
: []),
|
||||
...(spaces?.items?.length
|
||||
? [
|
||||
{
|
||||
group: t("Spaces"),
|
||||
items: spaces.items.map((s) => ({ value: s.id, label: s.name })),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
// Save function
|
||||
const save = useCallback(async () => {
|
||||
if (!editor || !templateId || !titleRef.current.trim()) return;
|
||||
if (!isDirtyRef.current) return;
|
||||
|
||||
setSaveStatus("saving");
|
||||
try {
|
||||
await updateMutationRef.current({
|
||||
templateId,
|
||||
title: titleRef.current,
|
||||
icon: iconRef.current || undefined,
|
||||
content: editor.getJSON(),
|
||||
spaceId: spaceIdRef.current,
|
||||
});
|
||||
isDirtyRef.current = false;
|
||||
setSaveStatus("saved");
|
||||
|
||||
if (savedFadeTimerRef.current) clearTimeout(savedFadeTimerRef.current);
|
||||
savedFadeTimerRef.current = setTimeout(() => {
|
||||
setSaveStatus((prev) => (prev === "saved" ? "idle" : prev));
|
||||
}, 3000);
|
||||
} catch {
|
||||
setSaveStatus("error");
|
||||
}
|
||||
}, [editor, templateId]);
|
||||
|
||||
// Schedule save 30s after last change
|
||||
const scheduleSave = useCallback(() => {
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
save();
|
||||
}, 30000);
|
||||
}, [save]);
|
||||
|
||||
// Mark content as dirty and schedule save
|
||||
const markDirty = useCallback(() => {
|
||||
isDirtyRef.current = true;
|
||||
setSaveStatus("idle");
|
||||
scheduleSave();
|
||||
}, [scheduleSave]);
|
||||
|
||||
const handleTitleChange = useCallback(
|
||||
(value: string) => {
|
||||
setTitle(value);
|
||||
titleRef.current = value;
|
||||
if (loadedRef.current) markDirty();
|
||||
},
|
||||
[markDirty],
|
||||
);
|
||||
|
||||
const handleIconChange = useCallback(
|
||||
(value: string | null) => {
|
||||
setIcon(value);
|
||||
iconRef.current = value;
|
||||
if (loadedRef.current) markDirty();
|
||||
},
|
||||
[markDirty],
|
||||
);
|
||||
|
||||
const handleSpaceIdChange = useCallback(
|
||||
(value: string | null) => {
|
||||
setSpaceId(value);
|
||||
spaceIdRef.current = value;
|
||||
if (loadedRef.current) markDirty();
|
||||
},
|
||||
[markDirty],
|
||||
);
|
||||
|
||||
// beforeunload warning for unsaved changes
|
||||
// If user cancels (stays on page), the save fires and completes.
|
||||
// If user leaves, the save is fire-and-forget.
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (isDirtyRef.current) {
|
||||
e.preventDefault();
|
||||
e.returnValue = "";
|
||||
save();
|
||||
}
|
||||
};
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => {
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
};
|
||||
}, [save]);
|
||||
|
||||
// Save on unmount if dirty
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
if (savedFadeTimerRef.current) clearTimeout(savedFadeTimerRef.current);
|
||||
if (isDirtyRef.current) {
|
||||
save();
|
||||
}
|
||||
};
|
||||
}, [save]);
|
||||
|
||||
// Manual retry for error state
|
||||
const handleRetry = useCallback(() => {
|
||||
save();
|
||||
}, [save]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>
|
||||
{t("Edit template")} - {getAppName()}
|
||||
</title>
|
||||
</Helmet>
|
||||
|
||||
<div className={classes.header}>
|
||||
<Container size={900} h="100%" px={0}>
|
||||
<Group justify="space-between" h="100%" wrap="nowrap">
|
||||
<Link to="/templates" className={classes.backLink}>
|
||||
<IconArrowLeft size={16} />
|
||||
{t("Templates")}
|
||||
</Link>
|
||||
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{saveStatus === "saving" && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("Saving...")}
|
||||
</Text>
|
||||
)}
|
||||
{saveStatus === "saved" && (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconCheck size={14} color="var(--mantine-color-green-6)" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("Saved")}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{saveStatus === "error" && (
|
||||
<Text
|
||||
size="xs"
|
||||
c="red"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={handleRetry}
|
||||
>
|
||||
{t("Save failed. Retry")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Popover
|
||||
width={300}
|
||||
position="bottom"
|
||||
shadow="md"
|
||||
opened={settingsOpened}
|
||||
onDismiss={closeSettings}
|
||||
>
|
||||
<Popover.Target>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="md"
|
||||
onClick={() => {
|
||||
setDraftSpaceId(spaceId);
|
||||
openSettings();
|
||||
}}
|
||||
>
|
||||
<IconSettings size={18} />
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Stack gap="sm">
|
||||
<Select
|
||||
label={t("Scope")}
|
||||
description={t("Choose which space this template belongs to")}
|
||||
data={spaceOptions}
|
||||
value={draftSpaceId || ""}
|
||||
onChange={(val) =>
|
||||
setDraftSpaceId(val || null)
|
||||
}
|
||||
searchable
|
||||
size="sm"
|
||||
comboboxProps={{ withinPortal: false }}
|
||||
/>
|
||||
<Group justify="flex-end" mt="xs">
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
onClick={closeSettings}
|
||||
>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
const scopeChanged = draftSpaceId !== spaceId;
|
||||
handleSpaceIdChange(draftSpaceId);
|
||||
closeSettings();
|
||||
if (scopeChanged) {
|
||||
notifications.show({
|
||||
message: t("Template scope updated"),
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Group>
|
||||
</Group>
|
||||
</Container>
|
||||
</div>
|
||||
|
||||
<Container size={900} className={classes.editor}>
|
||||
<div className={classes.titleArea}>
|
||||
<div className={classes.emojiButton}>
|
||||
<EmojiPicker
|
||||
onEmojiSelect={(emoji: { native: string }) =>
|
||||
handleIconChange(emoji.native)
|
||||
}
|
||||
icon={
|
||||
icon ? (
|
||||
<span className={classes.emojiIcon}>{icon}</span>
|
||||
) : (
|
||||
<IconMoodSmile size={20} stroke={1.5} />
|
||||
)
|
||||
}
|
||||
removeEmojiAction={() =>
|
||||
handleIconChange(null)
|
||||
}
|
||||
readOnly={false}
|
||||
actionIconProps={icon ? { size: "3rem", variant: "transparent" } : undefined}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
className={classes.titleInput}
|
||||
placeholder={t("Untitled")}
|
||||
autoFocus
|
||||
value={title}
|
||||
onChange={(e) =>
|
||||
handleTitleChange(e.currentTarget.value)
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
editor?.commands.focus("start");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{existingTemplate && (
|
||||
<TemplateMeta template={existingTemplate} />
|
||||
)}
|
||||
</div>
|
||||
<EditorContent editor={editor} />
|
||||
<div style={{ paddingBottom: "20vh" }} />
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Container,
|
||||
Title,
|
||||
Group,
|
||||
Button,
|
||||
SimpleGrid,
|
||||
Select,
|
||||
Text,
|
||||
Center,
|
||||
Skeleton,
|
||||
Card,
|
||||
} from "@mantine/core";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { getAppName } from "@/lib/config";
|
||||
import {
|
||||
useGetTemplatesQuery,
|
||||
useDeleteTemplateMutation,
|
||||
} from "@/ee/template/queries/template-query";
|
||||
import TemplateCard from "@/ee/template/components/template-card";
|
||||
import { ITemplate } from "@/ee/template/types/template.types";
|
||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
|
||||
import UseTemplateModal from "@/ee/template/components/use-template-modal";
|
||||
import TemplatePreviewModal from "@/ee/template/components/template-preview-modal";
|
||||
import useUserRole from "@/hooks/use-user-role";
|
||||
import CreateTemplateModal from "@/ee/template/components/create-template-modal";
|
||||
|
||||
export default function TemplateList() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { isAdmin: isWorkspaceAdmin } = useUserRole();
|
||||
const [spaceFilter, setSpaceFilter] = useState<string | null>(null);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<ITemplate | null>(
|
||||
null,
|
||||
);
|
||||
const [useModalOpened, { open: openUseModal, close: closeUseModal }] =
|
||||
useDisclosure(false);
|
||||
const [previewOpened, { open: openPreview, close: closePreview }] =
|
||||
useDisclosure(false);
|
||||
const [createModalOpened, { open: openCreateModal, close: closeCreateModal }] =
|
||||
useDisclosure(false);
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useGetTemplatesQuery({
|
||||
spaceId: spaceFilter || undefined,
|
||||
});
|
||||
|
||||
const templates = data?.pages.flatMap((p) => p.items) ?? [];
|
||||
|
||||
const { data: spaces } = useGetSpacesQuery({ limit: 100 });
|
||||
const deleteTemplateMutation = useDeleteTemplateMutation();
|
||||
|
||||
const spaceOptions = [
|
||||
{ value: "", label: t("All templates") },
|
||||
...(spaces?.items?.map((s) => ({ value: s.id, label: s.name })) || []),
|
||||
];
|
||||
|
||||
const spaceNameMap = new Map(
|
||||
spaces?.items?.map((s) => [s.id, s.name]) || [],
|
||||
);
|
||||
|
||||
const handlePreview = (template: ITemplate) => {
|
||||
setSelectedTemplate(template);
|
||||
openPreview();
|
||||
};
|
||||
|
||||
const handleUse = (template: ITemplate) => {
|
||||
setSelectedTemplate(template);
|
||||
closePreview();
|
||||
openUseModal();
|
||||
};
|
||||
|
||||
const handleEdit = (template: ITemplate) => {
|
||||
navigate(`/templates/${template.id}`);
|
||||
};
|
||||
|
||||
const handleDelete = (template: ITemplate) => {
|
||||
modals.openConfirmModal({
|
||||
title: t("Are you sure you want to delete this template?"),
|
||||
centered: true,
|
||||
labels: { confirm: t("Delete"), cancel: t("Cancel") },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: () => deleteTemplateMutation.mutate(template.id),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>
|
||||
{t("Templates")} - {getAppName()}
|
||||
</title>
|
||||
</Helmet>
|
||||
|
||||
<Container size="900" pt="xl">
|
||||
<Group justify="space-between" mb="xl">
|
||||
<Title order={3}>{t("Templates")}</Title>
|
||||
{isWorkspaceAdmin && (
|
||||
<Button
|
||||
leftSection={<IconPlus size={16} />}
|
||||
onClick={openCreateModal}
|
||||
>
|
||||
{t("New template")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Group mb="lg">
|
||||
<Select
|
||||
data={spaceOptions}
|
||||
value={spaceFilter || ""}
|
||||
onChange={(val) => setSpaceFilter(val || null)}
|
||||
placeholder={t("Filter by space")}
|
||||
clearable={false}
|
||||
searchable
|
||||
size="sm"
|
||||
w={220}
|
||||
comboboxProps={{ width: "target" }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3 }}>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Card key={i} radius="md" padding="lg" style={{ boxShadow: "rgba(0, 0, 0, 0.07) 0px 2px 45px 4px" }}>
|
||||
<Group justify="space-between" align="flex-start" mb="md">
|
||||
<Skeleton width={36} height={36} radius="md" />
|
||||
</Group>
|
||||
<Skeleton height={14} width="70%" mb={8} />
|
||||
<Skeleton height={10} width="50%" mb="sm" />
|
||||
<Group justify="space-between" pt="sm" style={{ borderTop: "1px solid var(--mantine-color-gray-2)", marginTop: "auto" }}>
|
||||
<Skeleton height={20} width={60} radius="xl" />
|
||||
<Group gap={6}>
|
||||
<Skeleton height={18} circle />
|
||||
<Skeleton height={10} width={80} />
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : templates.length ? (
|
||||
<>
|
||||
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3 }}>
|
||||
{templates.map((template) => (
|
||||
<TemplateCard
|
||||
key={template.id}
|
||||
template={template}
|
||||
spaceName={
|
||||
template.spaceId
|
||||
? spaceNameMap.get(template.spaceId)
|
||||
: undefined
|
||||
}
|
||||
onUse={handlePreview}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
canManage={isWorkspaceAdmin}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
{hasNextPage && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
fullWidth
|
||||
mt="sm"
|
||||
mb="xl"
|
||||
onClick={() => fetchNextPage()}
|
||||
loading={isFetchingNextPage}
|
||||
>
|
||||
{t("Load more")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Center py="xl">
|
||||
<Text c="dimmed">{t("No templates found")}</Text>
|
||||
</Center>
|
||||
)}
|
||||
</Container>
|
||||
|
||||
<CreateTemplateModal
|
||||
opened={createModalOpened}
|
||||
onClose={closeCreateModal}
|
||||
/>
|
||||
|
||||
{selectedTemplate && (
|
||||
<>
|
||||
<TemplatePreviewModal
|
||||
templateId={selectedTemplate.id}
|
||||
opened={previewOpened}
|
||||
onClose={closePreview}
|
||||
onUse={() => handleUse(selectedTemplate)}
|
||||
onEdit={isWorkspaceAdmin ? () => handleEdit(selectedTemplate) : undefined}
|
||||
/>
|
||||
<UseTemplateModal
|
||||
template={selectedTemplate}
|
||||
opened={useModalOpened}
|
||||
onClose={closeUseModal}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
UseQueryResult,
|
||||
InfiniteData,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
getTemplates,
|
||||
getTemplateById,
|
||||
createTemplate,
|
||||
updateTemplate,
|
||||
deleteTemplate,
|
||||
useTemplate,
|
||||
} from "@/ee/template/services/template-service.ts";
|
||||
import { ITemplate } from "@/ee/template/types/template.types";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function useGetTemplatesQuery(params?: { spaceId?: string }) {
|
||||
const { spaceId } = params ?? {};
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["templates", { spaceId }],
|
||||
queryFn: ({ pageParam }) =>
|
||||
getTemplates({ spaceId, cursor: pageParam, limit: 30 }),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function useGetTemplateByIdQuery(
|
||||
templateId: string,
|
||||
): UseQueryResult<ITemplate, Error> {
|
||||
return useQuery({
|
||||
queryKey: ["template", templateId],
|
||||
queryFn: () => getTemplateById(templateId),
|
||||
enabled: !!templateId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateTemplateMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<ITemplate, Error, Partial<ITemplate>>({
|
||||
mutationFn: (data) => createTemplate(data),
|
||||
onSuccess: (newTemplate) => {
|
||||
queryClient.setQueriesData<InfiniteData<IPagination<ITemplate>>>(
|
||||
{ queryKey: ["templates"] },
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
const firstPage = old.pages[0];
|
||||
return {
|
||||
...old,
|
||||
pages: [
|
||||
{ ...firstPage, items: [newTemplate, ...firstPage.items] },
|
||||
...old.pages.slice(1),
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
notifications.show({ message: t("Template created successfully") });
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({
|
||||
message: errorMessage || t("Failed to create template"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateTemplateMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<
|
||||
ITemplate,
|
||||
Error,
|
||||
Partial<ITemplate> & { templateId: string }
|
||||
>({
|
||||
mutationFn: (data) => updateTemplate(data),
|
||||
onSuccess: (updatedTemplate) => {
|
||||
queryClient.setQueriesData<InfiniteData<IPagination<ITemplate>>>(
|
||||
{ queryKey: ["templates"] },
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.map((item) =>
|
||||
item.id === updatedTemplate.id ? updatedTemplate : item,
|
||||
),
|
||||
})),
|
||||
};
|
||||
},
|
||||
);
|
||||
queryClient.setQueryData(
|
||||
["template", updatedTemplate.id],
|
||||
updatedTemplate,
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({
|
||||
message: errorMessage || t("Failed to update template"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteTemplateMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<void, Error, string>({
|
||||
mutationFn: (templateId) => deleteTemplate(templateId),
|
||||
onSuccess: (_data, templateId) => {
|
||||
queryClient.setQueriesData<InfiniteData<IPagination<ITemplate>>>(
|
||||
{ queryKey: ["templates"] },
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.filter((item) => item.id !== templateId),
|
||||
})),
|
||||
};
|
||||
},
|
||||
);
|
||||
notifications.show({ message: t("Template deleted") });
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({
|
||||
message: errorMessage || t("Failed to delete template"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUseTemplateMutation() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: {
|
||||
templateId: string;
|
||||
spaceId: string;
|
||||
parentPageId?: string;
|
||||
}) => useTemplate(data),
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({
|
||||
message: errorMessage || t("Failed to create page from template"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import api from "@/lib/api-client";
|
||||
import { ITemplate } from "@/ee/template/types/template.types";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
|
||||
export async function getTemplates(params?: {
|
||||
spaceId?: string;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
}): Promise<IPagination<ITemplate>> {
|
||||
const req = await api.post("/templates", params);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getTemplateById(
|
||||
templateId: string,
|
||||
): Promise<ITemplate> {
|
||||
const req = await api.post<ITemplate>("/templates/info", { templateId });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function createTemplate(
|
||||
data: Partial<ITemplate>,
|
||||
): Promise<ITemplate> {
|
||||
const req = await api.post<ITemplate>("/templates/create", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function updateTemplate(
|
||||
data: Partial<ITemplate> & { templateId: string },
|
||||
): Promise<ITemplate> {
|
||||
const req = await api.post<ITemplate>("/templates/update", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function deleteTemplate(templateId: string): Promise<void> {
|
||||
await api.post<void>("/templates/delete", { templateId });
|
||||
}
|
||||
|
||||
export async function useTemplate(data: {
|
||||
templateId: string;
|
||||
spaceId: string;
|
||||
parentPageId?: string;
|
||||
}): Promise<any> {
|
||||
const req = await api.post("/templates/use", data);
|
||||
return req.data;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export interface ITemplate {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
content?: any;
|
||||
icon?: string;
|
||||
spaceId?: string;
|
||||
workspaceId: string;
|
||||
creatorId: string;
|
||||
lastUpdatedById?: string;
|
||||
creator?: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
};
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -419,7 +419,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
.run(),
|
||||
},
|
||||
{
|
||||
title: "Draw.io (diagrams.net) ",
|
||||
title: "Draw.io (diagrams.net)",
|
||||
description: "Insert and design Drawio diagrams",
|
||||
searchTerms: ["drawio", "diagrams", "charts", "uml", "whiteboard"],
|
||||
icon: IconDrawio,
|
||||
@@ -688,8 +688,10 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
|
||||
export const getSuggestionItems = ({
|
||||
query,
|
||||
excludeItems,
|
||||
}: {
|
||||
query: string;
|
||||
excludeItems?: Set<string>;
|
||||
}): SlashMenuGroupedItemsType => {
|
||||
const search = query.toLowerCase();
|
||||
const filteredGroups: SlashMenuGroupedItemsType = {};
|
||||
@@ -706,6 +708,7 @@ export const getSuggestionItems = ({
|
||||
|
||||
for (const [group, items] of Object.entries(CommandGroups)) {
|
||||
const filteredItems = items.filter((item) => {
|
||||
if (excludeItems?.has(item.title)) return false;
|
||||
return (
|
||||
fuzzyMatch(search, item.title) ||
|
||||
item.description.toLowerCase().includes(search) ||
|
||||
|
||||
@@ -11,7 +11,9 @@ import { TextStyle } from "@tiptap/extension-text-style";
|
||||
import { Color } from "@tiptap/extension-color";
|
||||
import GlobalDragHandle from "tiptap-extension-global-drag-handle";
|
||||
import { Youtube } from "@tiptap/extension-youtube";
|
||||
import SlashCommand from "@/features/editor/extensions/slash-command";
|
||||
import SlashCommand, { SlashCommandExtension as Command } from "@/features/editor/extensions/slash-command";
|
||||
import renderItems from "@/features/editor/components/slash-menu/render-items";
|
||||
import getSuggestionItems from "@/features/editor/components/slash-menu/menu-items";
|
||||
import { Collaboration, isChangeOrigin } from "@tiptap/extension-collaboration";
|
||||
import { CollaborationCaret } from "@tiptap/extension-collaboration-caret";
|
||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
@@ -380,6 +382,27 @@ export const mainExtensions = [
|
||||
|
||||
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
||||
|
||||
const TEMPLATE_EXCLUDED_SLASH_ITEMS = new Set([
|
||||
"Image",
|
||||
"Video",
|
||||
"File attachment",
|
||||
"Draw.io (diagrams.net)",
|
||||
"Excalidraw diagram",
|
||||
]);
|
||||
|
||||
const TemplateSlashCommand = Command.configure({
|
||||
suggestion: {
|
||||
items: ({ query }: { query: string }) =>
|
||||
getSuggestionItems({ query, excludeItems: TEMPLATE_EXCLUDED_SLASH_ITEMS }),
|
||||
render: renderItems,
|
||||
},
|
||||
});
|
||||
|
||||
export const templateExtensions = [
|
||||
...mainExtensions.filter((ext: any) => ext !== SlashCommand),
|
||||
TemplateSlashCommand,
|
||||
] as any;
|
||||
|
||||
export const collabExtensions: CollabExtensions = (provider, user) => [
|
||||
Collaboration.configure({
|
||||
document: provider.document,
|
||||
|
||||
@@ -47,4 +47,5 @@ const SlashCommand = Command.configure({
|
||||
},
|
||||
});
|
||||
|
||||
export { Command as SlashCommandExtension };
|
||||
export default SlashCommand;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { IconStar, IconStarFilled } from "@tabler/icons-react";
|
||||
import {
|
||||
useFavoriteIds,
|
||||
useAddFavoriteMutation,
|
||||
useRemoveFavoriteMutation,
|
||||
} from "../queries/favorite-query";
|
||||
import { FavoriteType } from "../types/favorite.types";
|
||||
import { ToggleFavoriteParams } from "../services/favorite-service";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type StarButtonProps = {
|
||||
type: FavoriteType;
|
||||
pageId?: string;
|
||||
spaceId?: string;
|
||||
templateId?: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
function getEntityId(props: StarButtonProps): string | undefined {
|
||||
if (props.type === "page") return props.pageId;
|
||||
if (props.type === "space") return props.spaceId;
|
||||
if (props.type === "template") return props.templateId;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export default function StarButton(props: StarButtonProps) {
|
||||
const { type, size = 18 } = props;
|
||||
const { t } = useTranslation();
|
||||
const favoriteIds = useFavoriteIds(type);
|
||||
const addMutation = useAddFavoriteMutation();
|
||||
const removeMutation = useRemoveFavoriteMutation();
|
||||
|
||||
const entityId = getEntityId(props);
|
||||
const isFavorited = entityId ? favoriteIds.has(entityId) : false;
|
||||
const isPending = addMutation.isPending || removeMutation.isPending;
|
||||
|
||||
const handleToggle = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
const params: ToggleFavoriteParams = {
|
||||
type,
|
||||
pageId: props.pageId,
|
||||
spaceId: props.spaceId,
|
||||
templateId: props.templateId,
|
||||
};
|
||||
|
||||
if (isFavorited) {
|
||||
removeMutation.mutate(params);
|
||||
} else {
|
||||
addMutation.mutate(params);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label={isFavorited ? t("Remove from favorites") : t("Add to favorites")}
|
||||
openDelay={250}
|
||||
withArrow
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color={isFavorited ? "yellow" : "gray"}
|
||||
onClick={handleToggle}
|
||||
loading={isPending}
|
||||
>
|
||||
{isFavorited ? (
|
||||
<IconStarFilled size={size} />
|
||||
) : (
|
||||
<IconStar size={size} stroke={2} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
useQuery,
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
addFavorite,
|
||||
removeFavorite,
|
||||
getFavorites,
|
||||
ToggleFavoriteParams,
|
||||
} from "../services/favorite-service";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
import { IFavorite, FavoriteType } from "../types/favorite.types";
|
||||
|
||||
export function useFavoritesQuery(type?: FavoriteType) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["favorites", type],
|
||||
queryFn: ({ pageParam }) =>
|
||||
getFavorites({ type, cursor: pageParam, limit: 15 }),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
|
||||
refetchOnMount: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function useFavoriteIds(type: FavoriteType): Set<string> {
|
||||
const { data } = useQuery<IPagination<IFavorite>>({
|
||||
queryKey: ["favorite-ids", type],
|
||||
queryFn: () => getFavorites({ type, limit: 50 }),
|
||||
refetchOnMount: true,
|
||||
});
|
||||
|
||||
const ids = new Set<string>();
|
||||
if (data?.items) {
|
||||
for (const fav of data.items) {
|
||||
let id: string | undefined;
|
||||
if (type === "page") id = fav.pageId;
|
||||
else if (type === "space") id = fav.spaceId;
|
||||
else if (type === "template") id = fav.templateId;
|
||||
if (id) ids.add(id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function useAddFavoriteMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<void, Error, ToggleFavoriteParams>({
|
||||
mutationFn: (data) => addFavorite(data),
|
||||
onSuccess: (_result, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["favorite-ids", variables.type],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["favorites", variables.type],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveFavoriteMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<void, Error, ToggleFavoriteParams>({
|
||||
mutationFn: (data) => removeFavorite(data),
|
||||
onSuccess: (_result, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["favorite-ids", variables.type],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["favorites", variables.type],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import api from "@/lib/api-client";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
import { IFavorite, FavoriteType } from "../types/favorite.types";
|
||||
|
||||
export type ToggleFavoriteParams = {
|
||||
type: FavoriteType;
|
||||
pageId?: string;
|
||||
spaceId?: string;
|
||||
templateId?: string;
|
||||
};
|
||||
|
||||
export async function addFavorite(
|
||||
params: ToggleFavoriteParams,
|
||||
): Promise<void> {
|
||||
await api.post("/favorites/add", params);
|
||||
}
|
||||
|
||||
export async function removeFavorite(
|
||||
params: ToggleFavoriteParams,
|
||||
): Promise<void> {
|
||||
await api.post("/favorites/remove", params);
|
||||
}
|
||||
|
||||
export async function getFavorites(params?: {
|
||||
type?: FavoriteType;
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
}): Promise<IPagination<IFavorite>> {
|
||||
const req = await api.post("/favorites", params);
|
||||
return req.data;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export type FavoriteType = "page" | "space" | "template";
|
||||
|
||||
export type IFavorite = {
|
||||
id: string;
|
||||
userId: string;
|
||||
pageId: string | null;
|
||||
spaceId: string | null;
|
||||
templateId: string | null;
|
||||
type: FavoriteType;
|
||||
workspaceId: string;
|
||||
createdAt: string;
|
||||
page?: {
|
||||
id: string;
|
||||
slugId: string;
|
||||
title: string;
|
||||
icon: string | null;
|
||||
spaceId: string;
|
||||
};
|
||||
space?: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
logo: string | null;
|
||||
};
|
||||
template?: {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
spaceId: string | null;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { atomWithStorage } from "jotai/utils";
|
||||
|
||||
export const homeTabAtom = atomWithStorage<string>("home-tab", "recent");
|
||||
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
Text,
|
||||
Group,
|
||||
UnstyledButton,
|
||||
Badge,
|
||||
Table,
|
||||
ActionIcon,
|
||||
Button,
|
||||
} from "@mantine/core";
|
||||
import { Link } from "react-router-dom";
|
||||
import PageListSkeleton from "@/components/ui/page-list-skeleton";
|
||||
import { buildPageUrl } from "@/features/page/page.utils";
|
||||
import { formattedDate } from "@/lib/time";
|
||||
import { useCreatedByQuery } from "@/features/page/queries/page-query";
|
||||
import { IconFileDescription, IconFiles } from "@tabler/icons-react";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { getSpaceUrl } from "@/lib/config";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getInitialsColor } from "@/lib/get-initials-color";
|
||||
|
||||
type Props = {
|
||||
spaceId?: string;
|
||||
};
|
||||
|
||||
export default function CreatedByMe({ spaceId }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useCreatedByQuery({ spaceId });
|
||||
|
||||
const pages = data?.pages.flatMap((p) => p.items) ?? [];
|
||||
|
||||
if (isLoading) {
|
||||
return <PageListSkeleton />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <Text>{t("Failed to fetch pages")}</Text>;
|
||||
}
|
||||
|
||||
return pages.length > 0 ? (
|
||||
<>
|
||||
<Table.ScrollContainer minWidth={500}>
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Tbody>
|
||||
{pages.map((page) => (
|
||||
<Table.Tr key={page.id}>
|
||||
<Table.Td>
|
||||
<UnstyledButton
|
||||
component={Link}
|
||||
to={buildPageUrl(
|
||||
page?.space.slug,
|
||||
page.slugId,
|
||||
page.title,
|
||||
)}
|
||||
>
|
||||
<Group wrap="nowrap">
|
||||
{page.icon || (
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color="gray"
|
||||
size={18}
|
||||
>
|
||||
<IconFileDescription size={18} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<Text fw={500} size="md" lineClamp={1}>
|
||||
{page.title || t("Untitled")}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Table.Td>
|
||||
{!spaceId && (
|
||||
<Table.Td>
|
||||
<Badge
|
||||
color={getInitialsColor(page?.space.name)}
|
||||
variant="light"
|
||||
component={Link}
|
||||
to={getSpaceUrl(page?.space.slug)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{page?.space.name}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
)}
|
||||
<Table.Td>
|
||||
<Text
|
||||
c="dimmed"
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
size="xs"
|
||||
fw={500}
|
||||
>
|
||||
{formattedDate(page.createdAt)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
{hasNextPage && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
fullWidth
|
||||
mt="sm"
|
||||
mb="xl"
|
||||
onClick={() => fetchNextPage()}
|
||||
loading={isFetchingNextPage}
|
||||
>
|
||||
{t("Load more")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={IconFiles}
|
||||
title={t("No pages yet")}
|
||||
description={t("Pages you create will show up here.")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
Text,
|
||||
Group,
|
||||
UnstyledButton,
|
||||
Badge,
|
||||
Table,
|
||||
ActionIcon,
|
||||
Button,
|
||||
} from "@mantine/core";
|
||||
import { Link } from "react-router-dom";
|
||||
import PageListSkeleton from "@/components/ui/page-list-skeleton";
|
||||
import { buildPageUrl } from "@/features/page/page.utils";
|
||||
import { formattedDate } from "@/lib/time";
|
||||
import { useFavoritesQuery } from "@/features/favorite/queries/favorite-query";
|
||||
import { IconFileDescription, IconStar } from "@tabler/icons-react";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { getSpaceUrl } from "@/lib/config";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getInitialsColor } from "@/lib/get-initials-color";
|
||||
|
||||
export default function FavoritesPages() {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useFavoritesQuery("page");
|
||||
|
||||
const favorites = data?.pages.flatMap((p) => p.items) ?? [];
|
||||
|
||||
if (isLoading) {
|
||||
return <PageListSkeleton />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <Text>{t("Failed to fetch starred pages")}</Text>;
|
||||
}
|
||||
|
||||
return favorites.length > 0 ? (
|
||||
<>
|
||||
<Table.ScrollContainer minWidth={500}>
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Tbody>
|
||||
{favorites.map((fav) =>
|
||||
fav.page ? (
|
||||
<Table.Tr key={fav.id}>
|
||||
<Table.Td>
|
||||
<UnstyledButton
|
||||
component={Link}
|
||||
to={buildPageUrl(
|
||||
fav.space?.slug,
|
||||
fav.page.slugId,
|
||||
fav.page.title,
|
||||
)}
|
||||
>
|
||||
<Group wrap="nowrap">
|
||||
{fav.page.icon || (
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color="gray"
|
||||
size={18}
|
||||
>
|
||||
<IconFileDescription size={18} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<Text fw={500} size="md" lineClamp={1}>
|
||||
{fav.page.title || t("Untitled")}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{fav.space && (
|
||||
<Badge
|
||||
color={getInitialsColor(fav.space.name)}
|
||||
variant="light"
|
||||
component={Link}
|
||||
to={getSpaceUrl(fav.space.slug)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{fav.space.name}
|
||||
</Badge>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text
|
||||
c="dimmed"
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
size="xs"
|
||||
fw={500}
|
||||
>
|
||||
{formattedDate(new Date(fav.createdAt))}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : null,
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
{hasNextPage && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
fullWidth
|
||||
mt="sm"
|
||||
mb="xl"
|
||||
onClick={() => fetchNextPage()}
|
||||
loading={isFetchingNextPage}
|
||||
>
|
||||
{t("Load more")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={IconStar}
|
||||
title={t("No favorites yet")}
|
||||
description={t("Pages you star will show up here.")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,40 @@
|
||||
import { Text, Tabs, Space } from "@mantine/core";
|
||||
import { IconClockHour3 } from "@tabler/icons-react";
|
||||
import RecentChanges from "@/components/common/recent-changes.tsx";
|
||||
import { IconClockHour3, IconStar, IconUser } from "@tabler/icons-react";
|
||||
import RecentChanges from "@/components/common/recent-changes";
|
||||
import FavoritesPages from "./favorites-pages";
|
||||
import CreatedByMe from "./created-by-me";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import { homeTabAtom } from "@/features/home/atoms/home-tab-atom";
|
||||
|
||||
export default function HomeTabs() {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useAtom(homeTabAtom);
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="recent">
|
||||
<Tabs.List>
|
||||
<Tabs
|
||||
color="dark"
|
||||
value={activeTab}
|
||||
onChange={(value) => {
|
||||
if (value) setActiveTab(value);
|
||||
}}
|
||||
>
|
||||
<Tabs.List style={{ flexWrap: "nowrap", overflowX: "auto" }}>
|
||||
<Tabs.Tab value="recent" leftSection={<IconClockHour3 size={18} />}>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("Recently updated")}
|
||||
</Text>
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="favorites" leftSection={<IconStar size={18} />}>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("Favorites")}
|
||||
</Text>
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="created" leftSection={<IconUser size={18} />}>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("Created by me")}
|
||||
</Text>
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Space my="md" />
|
||||
@@ -21,6 +42,12 @@ export default function HomeTabs() {
|
||||
<Tabs.Panel value="recent">
|
||||
<RecentChanges />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="favorites">
|
||||
<FavoritesPages />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="created">
|
||||
<CreatedByMe />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
IconMarkdown,
|
||||
IconMessage,
|
||||
IconPrinter,
|
||||
IconStar,
|
||||
IconStarFilled,
|
||||
IconTrash,
|
||||
IconWifiOff,
|
||||
} from "@tabler/icons-react";
|
||||
@@ -42,6 +44,11 @@ import { PageStateSegmentedControl } from "@/features/user/components/page-state
|
||||
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||
import { PageShareModal } from "@/ee/page-permission";
|
||||
import {
|
||||
useFavoriteIds,
|
||||
useAddFavoriteMutation,
|
||||
useRemoveFavoriteMutation,
|
||||
} from "@/features/favorite/queries/favorite-query";
|
||||
import {
|
||||
useWatchStatusQuery,
|
||||
useWatchPageMutation,
|
||||
@@ -130,6 +137,10 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
] = useDisclosure(false);
|
||||
const [pageEditor] = useAtom(pageEditorAtom);
|
||||
const pageUpdatedAt = useTimeAgo(page?.updatedAt);
|
||||
const favoriteIds = useFavoriteIds("page");
|
||||
const addFavoriteMutation = useAddFavoriteMutation();
|
||||
const removeFavoriteMutation = useRemoveFavoriteMutation();
|
||||
const isFavorited = page?.id ? favoriteIds.has(page.id) : false;
|
||||
const { data: watchStatus } = useWatchStatusQuery(page?.id);
|
||||
const watchPage = useWatchPageMutation();
|
||||
const unwatchPage = useUnwatchPageMutation();
|
||||
@@ -165,6 +176,16 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
openDeleteModal({ onConfirm: () => tree?.delete(page.id) });
|
||||
};
|
||||
|
||||
const handleToggleFavorite = () => {
|
||||
if (!page?.id) return;
|
||||
const params = { type: "page" as const, pageId: page.id };
|
||||
if (isFavorited) {
|
||||
removeFavoriteMutation.mutate(params);
|
||||
} else {
|
||||
addFavoriteMutation.mutate(params);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu
|
||||
@@ -196,6 +217,19 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
{t("Copy as Markdown")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
isFavorited ? (
|
||||
<IconStarFilled size={16} color="var(--mantine-color-yellow-5)" />
|
||||
) : (
|
||||
<IconStar size={16} />
|
||||
)
|
||||
}
|
||||
onClick={handleToggleFavorite}
|
||||
>
|
||||
{isFavorited ? t("Remove from favorites") : t("Add to favorites")}
|
||||
</Menu.Item>
|
||||
|
||||
{watchStatus?.watching ? (
|
||||
<Menu.Item
|
||||
leftSection={<IconEyeOff size={16} />}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
movePage,
|
||||
getPageBreadcrumbs,
|
||||
getRecentChanges,
|
||||
getCreatedByPages,
|
||||
getAllSidebarPages,
|
||||
getDeletedPages,
|
||||
restorePage,
|
||||
@@ -244,7 +245,7 @@ export function useGetSidebarPagesQuery(
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["sidebar-pages", data],
|
||||
enabled: !!data?.pageId || !!data?.spaceId,
|
||||
queryFn: ({ pageParam }) => getSidebarPages({ ...data, cursor: pageParam }),
|
||||
queryFn: ({ pageParam }) => getSidebarPages({ ...data, cursor: pageParam, limit: 100 }),
|
||||
initialPageParam: undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.meta?.nextCursor ?? undefined,
|
||||
@@ -255,7 +256,7 @@ export function useGetRootSidebarPagesQuery(data: SidebarPagesParams) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["root-sidebar-pages", data.spaceId],
|
||||
queryFn: async ({ pageParam }) => {
|
||||
return getSidebarPages({ spaceId: data.spaceId, cursor: pageParam });
|
||||
return getSidebarPages({ spaceId: data.spaceId, cursor: pageParam, limit: 100 });
|
||||
},
|
||||
initialPageParam: undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
@@ -285,12 +286,26 @@ export async function fetchAllAncestorChildren(params: SidebarPagesParams) {
|
||||
return buildTree(allItems);
|
||||
}
|
||||
|
||||
export function useRecentChangesQuery(
|
||||
spaceId?: string,
|
||||
): UseQueryResult<IPagination<IPage>, Error> {
|
||||
return useQuery({
|
||||
export function useRecentChangesQuery(spaceId?: string) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["recent-changes", spaceId],
|
||||
queryFn: () => getRecentChanges(spaceId),
|
||||
queryFn: ({ pageParam }) =>
|
||||
getRecentChanges({ spaceId, cursor: pageParam, limit: 15 }),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
|
||||
refetchOnMount: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreatedByQuery(params?: { userId?: string; spaceId?: string }) {
|
||||
const { userId, spaceId } = params ?? {};
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["pages-created-by-user", { userId, spaceId }],
|
||||
queryFn: ({ pageParam }) => getCreatedByPages({ userId, spaceId, cursor: pageParam, limit: 15 }),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
|
||||
refetchOnMount: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ export async function getAllSidebarPages(
|
||||
const pageParams: (string | undefined)[] = [];
|
||||
|
||||
do {
|
||||
const req = await api.post("/pages/sidebar-pages", { ...params, cursor });
|
||||
const req = await api.post("/pages/sidebar-pages", { ...params, cursor, limit: 100 });
|
||||
|
||||
const data: IPagination<IPage> = req.data;
|
||||
pages.push(data);
|
||||
@@ -100,9 +100,16 @@ export async function getPageBreadcrumbs(
|
||||
}
|
||||
|
||||
export async function getRecentChanges(
|
||||
spaceId?: string,
|
||||
params?: QueryParams & { spaceId?: string },
|
||||
): Promise<IPagination<IPage>> {
|
||||
const req = await api.post("/pages/recent", { spaceId });
|
||||
const req = await api.post("/pages/recent", params);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getCreatedByPages(
|
||||
params?: QueryParams & { userId?: string; spaceId?: string },
|
||||
): Promise<IPagination<IPage>> {
|
||||
const req = await api.post("/pages/created-by-user", params);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
IconLink,
|
||||
IconPlus,
|
||||
IconPointFilled,
|
||||
IconStar,
|
||||
IconStarFilled,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
@@ -69,6 +71,7 @@ import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sideb
|
||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||
import CopyPageModal from "../../components/copy-page-modal.tsx";
|
||||
import { duplicatePage } from "../../services/page-service.ts";
|
||||
import { useFavoriteIds, useAddFavoriteMutation, useRemoveFavoriteMutation } from "@/features/favorite/queries/favorite-query";
|
||||
|
||||
interface SpaceTreeProps {
|
||||
spaceId: string;
|
||||
@@ -506,6 +509,10 @@ function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) {
|
||||
copyPageModalOpened,
|
||||
{ open: openCopyPageModal, close: closeCopySpaceModal },
|
||||
] = useDisclosure(false);
|
||||
const favoriteIds = useFavoriteIds("page");
|
||||
const addFavorite = useAddFavoriteMutation();
|
||||
const removeFavorite = useRemoveFavoriteMutation();
|
||||
const isFavorited = favoriteIds.has(node.data.id);
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const pageUrl =
|
||||
@@ -608,6 +615,21 @@ function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) {
|
||||
{t("Copy link")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
leftSection={isFavorited ? <IconStarFilled size={16} /> : <IconStar size={16} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (isFavorited) {
|
||||
removeFavorite.mutate({ type: "page", pageId: node.data.id });
|
||||
} else {
|
||||
addFavorite.mutate({ type: "page", pageId: node.data.id });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isFavorited ? t("Remove from favorites") : t("Add to favorites")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconFileExport size={16} />}
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -68,6 +68,7 @@ export interface SidebarPagesParams {
|
||||
spaceId?: string;
|
||||
pageId?: string;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface IPageInput {
|
||||
|
||||
@@ -56,6 +56,7 @@ export function SpaceSidebar() {
|
||||
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
|
||||
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
|
||||
|
||||
|
||||
const { spaceSlug } = useParams();
|
||||
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
||||
|
||||
@@ -81,11 +82,13 @@ export function SpaceSidebar() {
|
||||
marginBottom: 3,
|
||||
}}
|
||||
>
|
||||
<SwitchSpace
|
||||
spaceName={space?.name}
|
||||
spaceSlug={space?.slug}
|
||||
spaceIcon={space?.logo}
|
||||
/>
|
||||
<Group gap={4} wrap="nowrap" justify="space-between" style={{ width: "100%" }}>
|
||||
<SwitchSpace
|
||||
spaceName={space?.name}
|
||||
spaceSlug={space?.slug}
|
||||
spaceIcon={space?.logo}
|
||||
/>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div className={classes.section}>
|
||||
|
||||
@@ -7,9 +7,26 @@
|
||||
}
|
||||
|
||||
.cardSection {
|
||||
position: relative;
|
||||
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
|
||||
}
|
||||
|
||||
.starButton {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.starButton[data-favorited="true"] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.card:hover .starButton {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family:
|
||||
Greycliff CF,
|
||||
|
||||
@@ -12,12 +12,15 @@ import { useTranslation } from "react-i18next";
|
||||
import { IconArrowRight } from "@tabler/icons-react";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||
import StarButton from "@/features/favorite/components/star-button";
|
||||
import { useFavoriteIds } from "@/features/favorite/queries/favorite-query";
|
||||
|
||||
export default function SpaceGrid() {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading } = useGetSpacesQuery({ limit: 10 });
|
||||
const spaceFavoriteIds = useFavoriteIds("space");
|
||||
|
||||
const cards = data?.items.slice(0, 9).map((space, index) => (
|
||||
const cards = data?.items.slice(0, 6).map((space, index) => (
|
||||
<Card
|
||||
key={space.id}
|
||||
p="xs"
|
||||
@@ -28,7 +31,11 @@ export default function SpaceGrid() {
|
||||
className={classes.card}
|
||||
withBorder
|
||||
>
|
||||
<Card.Section className={classes.cardSection} h={40}></Card.Section>
|
||||
<Card.Section className={classes.cardSection} h={40}>
|
||||
<div className={classes.starButton} data-favorited={spaceFavoriteIds.has(space.id)}>
|
||||
<StarButton type="space" spaceId={space.id} size={16} />
|
||||
</div>
|
||||
</Card.Section>
|
||||
<CustomAvatar
|
||||
name={space.name}
|
||||
avatarUrl={space.logo}
|
||||
@@ -59,7 +66,7 @@ export default function SpaceGrid() {
|
||||
|
||||
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3 }}>{cards}</SimpleGrid>
|
||||
|
||||
{data?.items && data.items.length > 9 && (
|
||||
{data?.items && data.items.length > 6 && (
|
||||
<Group justify="flex-end" mt="lg">
|
||||
<Button
|
||||
component={Link}
|
||||
|
||||
@@ -1,23 +1,44 @@
|
||||
import { Text, Tabs, Space } from "@mantine/core";
|
||||
import { IconClockHour3 } from "@tabler/icons-react";
|
||||
import RecentChanges from "@/components/common/recent-changes.tsx";
|
||||
import { IconClockHour3, IconStar, IconUser } from "@tabler/icons-react";
|
||||
import RecentChanges from "@/components/common/recent-changes";
|
||||
import FavoritesPages from "@/features/home/components/favorites-pages";
|
||||
import CreatedByMe from "@/features/home/components/created-by-me";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import { homeTabAtom } from "@/features/home/atoms/home-tab-atom";
|
||||
|
||||
export default function SpaceHomeTabs() {
|
||||
const { t } = useTranslation();
|
||||
const { spaceSlug } = useParams();
|
||||
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
||||
const [activeTab, setActiveTab] = useAtom(homeTabAtom);
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="recent">
|
||||
<Tabs.List>
|
||||
<Tabs
|
||||
color="dark"
|
||||
value={activeTab}
|
||||
onChange={(value) => {
|
||||
if (value) setActiveTab(value);
|
||||
}}
|
||||
>
|
||||
<Tabs.List style={{ flexWrap: "nowrap", overflowX: "auto" }}>
|
||||
<Tabs.Tab value="recent" leftSection={<IconClockHour3 size={18} />}>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("Recently updated")}
|
||||
</Text>
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="favorites" leftSection={<IconStar size={18} />}>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("Favorites")}
|
||||
</Text>
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="created" leftSection={<IconUser size={18} />}>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("Created by me")}
|
||||
</Text>
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Space my="md" />
|
||||
@@ -25,6 +46,12 @@ export default function SpaceHomeTabs() {
|
||||
<Tabs.Panel value="recent">
|
||||
{space?.id && <RecentChanges spaceId={space.id} />}
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="favorites">
|
||||
<FavoritesPages />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="created">
|
||||
{space?.id && <CreatedByMe spaceId={space.id} />}
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Anchor,
|
||||
} from "@mantine/core";
|
||||
import { IconDots, IconSettings } from "@tabler/icons-react";
|
||||
import StarButton from "@/features/favorite/components/star-button";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import React, { useState } from "react";
|
||||
@@ -117,6 +118,7 @@ export default function AllSpacesList({
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end">
|
||||
<StarButton type="space" spaceId={space.id} size={16} />
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray">
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Text, SimpleGrid, Card, rem, Group, Box, Button } from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFavoritesQuery } from "@/features/favorite/queries/favorite-query";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types";
|
||||
import { getSpaceUrl } from "@/lib/config";
|
||||
import { prefetchSpace } from "@/features/space/queries/space-query";
|
||||
import StarButton from "@/features/favorite/components/star-button";
|
||||
import { IconChevronDown } from "@tabler/icons-react";
|
||||
import spaceClasses from "../space-grid.module.css";
|
||||
|
||||
const INITIAL_COUNT = 8;
|
||||
|
||||
export default function FavoriteSpacesGrid() {
|
||||
const { t } = useTranslation();
|
||||
const { data } = useFavoritesQuery("space");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const allSpaces = (data?.pages.flatMap((p) => p.items) ?? [])
|
||||
.filter((fav) => fav.space)
|
||||
.sort((a, b) => a.space!.name.localeCompare(b.space!.name));
|
||||
|
||||
if (allSpaces.length === 0) return null;
|
||||
|
||||
const visibleSpaces = expanded
|
||||
? allSpaces
|
||||
: allSpaces.slice(0, INITIAL_COUNT);
|
||||
|
||||
return (
|
||||
<Box mb="xl">
|
||||
<Text size="sm" fw={500} mb="md">
|
||||
{t("Favorite spaces")}
|
||||
</Text>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, xs: 2, sm: 4 }}>
|
||||
{visibleSpaces.map((fav) => (
|
||||
<Card
|
||||
key={fav.id}
|
||||
p="xs"
|
||||
radius="md"
|
||||
component={Link}
|
||||
to={getSpaceUrl(fav.space!.slug)}
|
||||
onMouseEnter={() =>
|
||||
prefetchSpace(fav.space!.slug, fav.space!.id)
|
||||
}
|
||||
className={spaceClasses.card}
|
||||
withBorder
|
||||
>
|
||||
<Card.Section className={spaceClasses.cardSection} h={40}>
|
||||
<div className={spaceClasses.starButton} data-favorited="true">
|
||||
<StarButton
|
||||
type="space"
|
||||
spaceId={fav.space!.id}
|
||||
size={16}
|
||||
/>
|
||||
</div>
|
||||
</Card.Section>
|
||||
<CustomAvatar
|
||||
name={fav.space!.name}
|
||||
avatarUrl={fav.space!.logo}
|
||||
type={AvatarIconType.SPACE_ICON}
|
||||
color="initials"
|
||||
variant="filled"
|
||||
size="md"
|
||||
mt={rem(-20)}
|
||||
/>
|
||||
<Text fz="md" fw={500} mt="xs" className={spaceClasses.title}>
|
||||
{fav.space!.name}
|
||||
</Text>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{!expanded && allSpaces.length > INITIAL_COUNT && (
|
||||
<Group justify="center" mt="sm">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
rightSection={<IconChevronDown size={14} />}
|
||||
onClick={() => setExpanded(true)}
|
||||
>
|
||||
{t("Show more")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -69,9 +69,10 @@ export const prefetchSpace = (spaceSlug: string, spaceId?: string) => {
|
||||
|
||||
if (spaceId) {
|
||||
// this endpoint only accepts uuid for now
|
||||
queryClient.prefetchQuery({
|
||||
queryClient.prefetchInfiniteQuery({
|
||||
queryKey: ["recent-changes", spaceId],
|
||||
queryFn: () => getRecentChanges(spaceId),
|
||||
queryFn: () => getRecentChanges({ spaceId }),
|
||||
initialPageParam: undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -27,12 +27,14 @@ export interface IWorkspace {
|
||||
mcpEnabled?: boolean;
|
||||
trashRetentionDays?: number;
|
||||
restrictApiToAdmins?: boolean;
|
||||
allowMemberTemplates?: boolean;
|
||||
}
|
||||
|
||||
export interface IWorkspaceSettings {
|
||||
ai?: IWorkspaceAiSettings;
|
||||
sharing?: IWorkspaceSharingSettings;
|
||||
api?: IWorkspaceApiSettings;
|
||||
templates?: IWorkspaceTemplateSettings;
|
||||
}
|
||||
|
||||
export interface IWorkspaceApiSettings {
|
||||
@@ -50,6 +52,10 @@ export interface IWorkspaceSharingSettings {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface IWorkspaceTemplateSettings {
|
||||
allowMemberTemplates?: boolean;
|
||||
}
|
||||
|
||||
export interface ICreateInvite {
|
||||
role: string;
|
||||
emails: string[];
|
||||
|
||||
@@ -26,7 +26,7 @@ function getSnapshot() {
|
||||
return tick;
|
||||
}
|
||||
|
||||
export function useTimeAgo(date: Date | string) {
|
||||
export function useTimeAgo(date: Date | string | undefined) {
|
||||
const currentTick = useSyncExternalStore(subscribe, getSnapshot);
|
||||
return useMemo(() => timeAgo(new Date(date)), [date, currentTick]);
|
||||
return useMemo(() => (date ? timeAgo(new Date(date)) : ""), [date, currentTick]);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const APP_ROUTE = {
|
||||
HOME: "/home",
|
||||
SPACES: "/spaces",
|
||||
FAVORITES: "/favorites",
|
||||
SEARCH: "/search",
|
||||
AUTH: {
|
||||
LOGIN: "/login",
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function Home() {
|
||||
{t("Home")} - {getAppName()}
|
||||
</title>
|
||||
</Helmet>
|
||||
<Container size={"800"} pt="xl">
|
||||
<Container size={"900"} pt="xl">
|
||||
<HomeAiPrompt />
|
||||
|
||||
<Space h="xl" />
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
Text,
|
||||
Group,
|
||||
UnstyledButton,
|
||||
Badge,
|
||||
Table,
|
||||
Container,
|
||||
Title,
|
||||
ActionIcon,
|
||||
Button,
|
||||
} from "@mantine/core";
|
||||
import { Link } from "react-router-dom";
|
||||
import { buildPageUrl } from "@/features/page/page.utils";
|
||||
import { formattedDate } from "@/lib/time";
|
||||
import { useFavoritesQuery } from "@/features/favorite/queries/favorite-query";
|
||||
import { IconFileDescription, IconStar } from "@tabler/icons-react";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { getSpaceUrl } from "@/lib/config";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getInitialsColor } from "@/lib/get-initials-color";
|
||||
import PageListSkeleton from "@/components/ui/page-list-skeleton";
|
||||
|
||||
export default function FavoritesPage() {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isError, hasNextPage, fetchNextPage, isFetchingNextPage } = useFavoritesQuery("page");
|
||||
const favorites = data?.pages.flatMap((p) => p.items) ?? [];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Container size={800} py="xl">
|
||||
<Title order={3} mb="lg">
|
||||
{t("Favorites")}
|
||||
</Title>
|
||||
<PageListSkeleton />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Container size={800} py="xl">
|
||||
<Title order={3} mb="lg">
|
||||
{t("Favorites")}
|
||||
</Title>
|
||||
<Text>{t("Failed to fetch favorite pages")}</Text>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size={800} py="xl">
|
||||
<Title order={3} mb="lg">
|
||||
{t("Favorites")}
|
||||
</Title>
|
||||
{favorites.length > 0 ? (
|
||||
<>
|
||||
<Table.ScrollContainer minWidth={500}>
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Tbody>
|
||||
{favorites.map((fav) =>
|
||||
fav.page ? (
|
||||
<Table.Tr key={fav.id}>
|
||||
<Table.Td>
|
||||
<UnstyledButton
|
||||
component={Link}
|
||||
to={buildPageUrl(
|
||||
fav.space?.slug,
|
||||
fav.page.slugId,
|
||||
fav.page.title,
|
||||
)}
|
||||
>
|
||||
<Group wrap="nowrap">
|
||||
{fav.page.icon || (
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color="gray"
|
||||
size={18}
|
||||
>
|
||||
<IconFileDescription size={18} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<Text fw={500} size="md" lineClamp={1}>
|
||||
{fav.page.title || t("Untitled")}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{fav.space && (
|
||||
<Badge
|
||||
color={getInitialsColor(fav.space.name)}
|
||||
variant="light"
|
||||
component={Link}
|
||||
to={getSpaceUrl(fav.space.slug)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{fav.space.name}
|
||||
</Badge>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text
|
||||
c="dimmed"
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
size="xs"
|
||||
fw={500}
|
||||
>
|
||||
{formattedDate(new Date(fav.createdAt))}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : null,
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
{hasNextPage && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
fullWidth
|
||||
mt="sm"
|
||||
mb="xl"
|
||||
onClick={() => fetchNextPage()}
|
||||
loading={isFetchingNextPage}
|
||||
>
|
||||
{t("Load more")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={IconStar}
|
||||
title={t("No favorite pages")}
|
||||
description={t("Pages you favorite will show up here.")}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export default function SpaceHome() {
|
||||
<Helmet>
|
||||
<title>{space?.name || 'Overview'} - {getAppName()}</title>
|
||||
</Helmet>
|
||||
<Container size={"800"} pt="xl">
|
||||
<Container size={"900"} pt="xl">
|
||||
{space && <SpaceHomeTabs/>}
|
||||
</Container>
|
||||
</>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getAppName } from "@/lib/config";
|
||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
|
||||
import CreateSpaceModal from "@/features/space/components/create-space-modal";
|
||||
import { AllSpacesList } from "@/features/space/components/spaces-page";
|
||||
import FavoriteSpacesGrid from "@/features/space/components/spaces-page/favorite-spaces-grid";
|
||||
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
|
||||
import useUserRole from "@/hooks/use-user-role";
|
||||
|
||||
@@ -33,9 +34,11 @@ export default function Spaces() {
|
||||
{isAdmin && <CreateSpaceModal />}
|
||||
</Group>
|
||||
|
||||
<FavoriteSpacesGrid />
|
||||
|
||||
<Box>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
{t("Spaces you belong to")}
|
||||
{t("All spaces")}
|
||||
</Text>
|
||||
|
||||
<AllSpacesList
|
||||
|
||||
@@ -17,6 +17,7 @@ export const Feature = {
|
||||
RETENTION: 'retention',
|
||||
SHARING_CONTROLS: 'sharing:controls',
|
||||
VIEWER_COMMENTS: 'comment:viewer',
|
||||
TEMPLATES: 'templates',
|
||||
} as const;
|
||||
|
||||
export type FeatureKey = (typeof Feature)[keyof typeof Feature];
|
||||
|
||||
@@ -20,6 +20,7 @@ import { AuditContextMiddleware } from '../common/middlewares/audit-context.midd
|
||||
import { ShareModule } from './share/share.module';
|
||||
import { NotificationModule } from './notification/notification.module';
|
||||
import { WatcherModule } from './watcher/watcher.module';
|
||||
import { FavoriteModule } from './favorite/favorite.module';
|
||||
import { SessionModule } from './session/session.module';
|
||||
import { ClsMiddleware } from 'nestjs-cls';
|
||||
|
||||
@@ -31,6 +32,7 @@ import { ClsMiddleware } from 'nestjs-cls';
|
||||
PageModule,
|
||||
AttachmentModule,
|
||||
CommentModule,
|
||||
FavoriteModule,
|
||||
SearchModule,
|
||||
SpaceModule,
|
||||
GroupModule,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
|
||||
export class AddFavoriteDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsIn(['page', 'space', 'template'])
|
||||
type: 'page' | 'space' | 'template';
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
pageId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
spaceId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
templateId?: string;
|
||||
}
|
||||
|
||||
export class RemoveFavoriteDto extends AddFavoriteDto {}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { IsIn, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class ListFavoritesDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsIn(['page', 'space', 'template'])
|
||||
type?: 'page' | 'space' | 'template';
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { FavoriteService } from './services/favorite.service';
|
||||
import { AddFavoriteDto, RemoveFavoriteDto } from './dto/favorite.dto';
|
||||
import { ListFavoritesDto } from './dto/list-favorites.dto';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||
import { Page, User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { PageAccessService } from '../page/page-access/page-access.service';
|
||||
import { TemplateRepo } from '@docmost/db/repos/template/template.repo';
|
||||
import { FavoriteType } from '@docmost/db/repos/favorite/favorite.repo';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('favorites')
|
||||
export class FavoriteController {
|
||||
constructor(
|
||||
private readonly favoriteService: FavoriteService,
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly spaceRepo: SpaceRepo,
|
||||
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||
private readonly pageAccessService: PageAccessService,
|
||||
private readonly templateRepo: TemplateRepo,
|
||||
) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('add')
|
||||
async addFavorite(
|
||||
@Body() dto: AddFavoriteDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const resolved = await this.resolveAndValidate(dto, user, workspace.id);
|
||||
|
||||
await this.favoriteService.addFavorite(user.id, workspace.id, {
|
||||
type: dto.type,
|
||||
pageId: dto.pageId,
|
||||
spaceId: dto.type === 'space' ? resolved.spaceId : undefined,
|
||||
templateId: dto.templateId,
|
||||
});
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('remove')
|
||||
async removeFavorite(
|
||||
@Body() dto: RemoveFavoriteDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
await this.resolveAndValidate(dto, user, workspace.id);
|
||||
|
||||
await this.favoriteService.removeFavorite(user.id, {
|
||||
type: dto.type,
|
||||
pageId: dto.pageId,
|
||||
spaceId: dto.spaceId,
|
||||
templateId: dto.templateId,
|
||||
});
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post()
|
||||
async getUserFavorites(
|
||||
@Body() dto: ListFavoritesDto,
|
||||
@Body() pagination: PaginationOptions,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return this.favoriteService.getUserFavorites(
|
||||
user.id,
|
||||
workspace.id,
|
||||
pagination,
|
||||
dto.type as FavoriteType | undefined,
|
||||
);
|
||||
}
|
||||
|
||||
private async resolveAndValidate(
|
||||
dto: AddFavoriteDto | RemoveFavoriteDto,
|
||||
user: User,
|
||||
workspaceId: string,
|
||||
): Promise<{ spaceId: string; page?: Page }> {
|
||||
if (dto.type === 'page') {
|
||||
if (!dto.pageId) throw new BadRequestException('pageId is required');
|
||||
const page = await this.pageRepo.findById(dto.pageId);
|
||||
if (!page) throw new NotFoundException('Page not found');
|
||||
await this.pageAccessService.validateCanView(page, user);
|
||||
return { spaceId: page.spaceId, page };
|
||||
}
|
||||
|
||||
if (dto.type === 'space') {
|
||||
if (!dto.spaceId) throw new BadRequestException('spaceId is required');
|
||||
const space = await this.spaceRepo.findById(dto.spaceId, workspaceId);
|
||||
if (!space) throw new NotFoundException('Space not found');
|
||||
await this.validateSpaceAccess(user.id, space.id);
|
||||
return { spaceId: space.id };
|
||||
}
|
||||
|
||||
if (dto.type === 'template') {
|
||||
if (!dto.templateId)
|
||||
throw new BadRequestException('templateId is required');
|
||||
const template = await this.templateRepo.findById(
|
||||
dto.templateId,
|
||||
workspaceId,
|
||||
);
|
||||
if (!template) throw new NotFoundException('Template not found');
|
||||
if (template.spaceId) {
|
||||
await this.validateSpaceAccess(user.id, template.spaceId);
|
||||
}
|
||||
return { spaceId: template.spaceId };
|
||||
}
|
||||
|
||||
throw new BadRequestException('Invalid favorite type');
|
||||
}
|
||||
|
||||
private async validateSpaceAccess(
|
||||
userId: string,
|
||||
spaceId: string,
|
||||
): Promise<void> {
|
||||
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
|
||||
if (!userSpaceIds.includes(spaceId)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { FavoriteService } from './services/favorite.service';
|
||||
import { FavoriteController } from './favorite.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [FavoriteController],
|
||||
providers: [FavoriteService],
|
||||
exports: [FavoriteService],
|
||||
})
|
||||
export class FavoriteModule {}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
FavoriteRepo,
|
||||
FavoriteType,
|
||||
} from '@docmost/db/repos/favorite/favorite.repo';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { InsertableFavorite } from '@docmost/db/types/entity.types';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
|
||||
@Injectable()
|
||||
export class FavoriteService {
|
||||
constructor(
|
||||
private readonly favoriteRepo: FavoriteRepo,
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||
) {}
|
||||
|
||||
async addFavorite(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
opts: {
|
||||
type: FavoriteType;
|
||||
pageId?: string;
|
||||
spaceId?: string;
|
||||
templateId?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const favorite: InsertableFavorite = {
|
||||
userId,
|
||||
pageId: opts.pageId ?? null,
|
||||
spaceId: opts.spaceId ?? null,
|
||||
templateId: opts.templateId ?? null,
|
||||
type: opts.type,
|
||||
workspaceId,
|
||||
};
|
||||
|
||||
await this.favoriteRepo.insert(favorite);
|
||||
}
|
||||
|
||||
async removeFavorite(
|
||||
userId: string,
|
||||
opts: {
|
||||
type: FavoriteType;
|
||||
pageId?: string;
|
||||
spaceId?: string;
|
||||
templateId?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (opts.type === FavoriteType.PAGE && opts.pageId) {
|
||||
await this.favoriteRepo.deleteByUserAndPage(userId, opts.pageId);
|
||||
} else if (opts.type === FavoriteType.SPACE && opts.spaceId) {
|
||||
await this.favoriteRepo.deleteByUserAndSpace(userId, opts.spaceId);
|
||||
} else if (opts.type === FavoriteType.TEMPLATE && opts.templateId) {
|
||||
await this.favoriteRepo.deleteByUserAndTemplate(userId, opts.templateId);
|
||||
}
|
||||
}
|
||||
|
||||
async getUserFavorites(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
pagination: PaginationOptions,
|
||||
type?: FavoriteType,
|
||||
) {
|
||||
const result = await this.favoriteRepo.findUserFavorites(
|
||||
userId,
|
||||
workspaceId,
|
||||
pagination,
|
||||
type,
|
||||
);
|
||||
|
||||
if (result.items.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
|
||||
const spaceSet = new Set(userSpaceIds);
|
||||
|
||||
const pageFavorites = result.items.filter(
|
||||
(f) => f.type === FavoriteType.PAGE && f.pageId,
|
||||
);
|
||||
|
||||
let accessiblePageSet: Set<string> | undefined;
|
||||
if (pageFavorites.length > 0) {
|
||||
const pageIds = pageFavorites.map((f) => f.pageId as string);
|
||||
const accessibleIds =
|
||||
await this.pagePermissionRepo.filterAccessiblePageIds({
|
||||
pageIds,
|
||||
userId,
|
||||
});
|
||||
accessiblePageSet = new Set(accessibleIds);
|
||||
}
|
||||
|
||||
result.items = result.items.filter((f) => {
|
||||
if (f.type === FavoriteType.PAGE) {
|
||||
return f.pageId && accessiblePageSet?.has(f.pageId);
|
||||
}
|
||||
if (f.type === FavoriteType.SPACE) {
|
||||
return f.spaceId && spaceSet.has(f.spaceId);
|
||||
}
|
||||
if (f.type === FavoriteType.TEMPLATE) {
|
||||
const templateSpaceId = (f as any).template?.spaceId;
|
||||
return !templateSpaceId || spaceSet.has(templateSpaceId);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||
import { FavoriteRepo } from '@docmost/db/repos/favorite/favorite.repo';
|
||||
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
@@ -29,6 +30,7 @@ export class GroupUserService {
|
||||
@Inject(forwardRef(() => GroupService))
|
||||
private groupService: GroupService,
|
||||
private readonly watcherRepo: WatcherRepo,
|
||||
private readonly favoriteRepo: FavoriteRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
@@ -137,6 +139,12 @@ export class GroupUserService {
|
||||
spaceId,
|
||||
{ trx },
|
||||
);
|
||||
|
||||
await this.favoriteRepo.deleteByUsersWithoutSpaceAccess(
|
||||
[userId],
|
||||
spaceId,
|
||||
{ trx },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Group, InsertableGroup, User } from '@docmost/db/types/entity.types';
|
||||
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
||||
import { GroupUserService } from './group-user.service';
|
||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||
import { FavoriteRepo } from '@docmost/db/repos/favorite/favorite.repo';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
|
||||
@@ -34,6 +35,7 @@ export class GroupService {
|
||||
@Inject(forwardRef(() => GroupUserService))
|
||||
private groupUserService: GroupUserService,
|
||||
private readonly watcherRepo: WatcherRepo,
|
||||
private readonly favoriteRepo: FavoriteRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
@@ -189,6 +191,12 @@ export class GroupService {
|
||||
spaceId,
|
||||
{ trx },
|
||||
);
|
||||
|
||||
await this.favoriteRepo.deleteByUsersWithoutSpaceAccess(
|
||||
userIds,
|
||||
spaceId,
|
||||
{ trx },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
export class CreatedByUserDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
userId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
spaceId?: string;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
import { SpaceIdDto } from './page.dto';
|
||||
|
||||
export class SidebarPageDto {
|
||||
@IsOptional()
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { RecentPageDto } from './dto/recent-page.dto';
|
||||
import { CreatedByUserDto } from './dto/created-by-user.dto';
|
||||
import { DuplicatePageDto } from './dto/duplicate-page.dto';
|
||||
import { DeletedPageDto } from './dto/deleted-page.dto';
|
||||
import {
|
||||
@@ -336,6 +337,29 @@ export class PageController {
|
||||
return this.pageService.getRecentPages(user.id, pagination);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('created-by-user')
|
||||
async getCreatedByPages(
|
||||
@Body() dto: CreatedByUserDto,
|
||||
@Body() pagination: PaginationOptions,
|
||||
@AuthUser() user: User,
|
||||
) {
|
||||
const targetUserId = dto.userId ?? user.id;
|
||||
|
||||
if (dto.spaceId) {
|
||||
const ability = await this.spaceAbility.createForUser(
|
||||
user,
|
||||
dto.spaceId,
|
||||
);
|
||||
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
|
||||
return this.pageService.getCreatedByPages(targetUserId, user.id, pagination, dto.spaceId);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('trash')
|
||||
async getDeletedPages(
|
||||
|
||||
@@ -300,7 +300,7 @@ export class PageService {
|
||||
}
|
||||
|
||||
const result = await executeWithCursorPagination(query, {
|
||||
perPage: 200,
|
||||
perPage: pagination.limit,
|
||||
cursor: pagination.cursor,
|
||||
beforeCursor: pagination.beforeCursor,
|
||||
fields: [
|
||||
@@ -856,6 +856,33 @@ export class PageService {
|
||||
return result;
|
||||
}
|
||||
|
||||
async getCreatedByPages(
|
||||
creatorId: string,
|
||||
requestingUserId: string,
|
||||
pagination: PaginationOptions,
|
||||
spaceId?: string,
|
||||
): Promise<CursorPaginationResult<Page>> {
|
||||
const result = await this.pageRepo.getCreatedByPages(
|
||||
creatorId,
|
||||
requestingUserId,
|
||||
pagination,
|
||||
spaceId,
|
||||
);
|
||||
|
||||
if (result.items.length > 0) {
|
||||
const pageIds = result.items.map((p) => p.id);
|
||||
const accessibleIds =
|
||||
await this.pagePermissionRepo.filterAccessiblePageIds({
|
||||
pageIds,
|
||||
userId: requestingUserId,
|
||||
});
|
||||
const accessibleSet = new Set(accessibleIds);
|
||||
result.items = result.items.filter((p) => accessibleSet.has(p.id));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async getDeletedSpacePages(
|
||||
spaceId: string,
|
||||
userId: string,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { UpdateSpaceMemberRoleDto } from '../dto/update-space-member-role.dto';
|
||||
import { SpaceRole } from '../../../common/helpers/types/permission';
|
||||
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||
import { FavoriteRepo } from '@docmost/db/repos/favorite/favorite.repo';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
|
||||
import {
|
||||
@@ -31,6 +32,7 @@ export class SpaceMemberService {
|
||||
private groupUserRepo: GroupUserRepo,
|
||||
private spaceRepo: SpaceRepo,
|
||||
private watcherRepo: WatcherRepo,
|
||||
private favoriteRepo: FavoriteRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
@@ -272,6 +274,12 @@ export class SpaceMemberService {
|
||||
dto.spaceId,
|
||||
{ trx },
|
||||
);
|
||||
|
||||
await this.favoriteRepo.deleteByUsersWithoutSpaceAccess(
|
||||
affectedUserIds,
|
||||
dto.spaceId,
|
||||
{ trx },
|
||||
);
|
||||
});
|
||||
|
||||
this.auditService.log({
|
||||
|
||||
@@ -53,7 +53,41 @@ export class SpaceController {
|
||||
pagination: PaginationOptions,
|
||||
@AuthUser() user: User,
|
||||
) {
|
||||
return this.spaceMemberService.getUserSpaces(user.id, pagination);
|
||||
const result = await this.spaceMemberService.getUserSpaces(
|
||||
user.id,
|
||||
pagination,
|
||||
);
|
||||
|
||||
if (result.items.length > 0) {
|
||||
const spaceIds = result.items.map((s) => s.id);
|
||||
const roles = await this.spaceMemberRepo.getUserRolesForSpaces(
|
||||
user.id,
|
||||
spaceIds,
|
||||
);
|
||||
|
||||
const roleMap = new Map<string, string[]>();
|
||||
for (const row of roles) {
|
||||
const existing = roleMap.get(row.spaceId) || [];
|
||||
existing.push(row.role);
|
||||
roleMap.set(row.spaceId, existing);
|
||||
}
|
||||
|
||||
result.items = result.items.map((space) => {
|
||||
const spaceRoles = roleMap.get(space.id);
|
||||
const role = spaceRoles
|
||||
? findHighestUserSpaceRole(
|
||||
spaceRoles.map((r) => ({ userId: user.id, role: r })),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...space,
|
||||
membership: { userId: user.id, role },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
@@ -54,4 +54,8 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
trashRetentionDays: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowMemberTemplates: boolean;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ import { isPageEmbeddingsTableExists } from '@docmost/db/helpers/helpers';
|
||||
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||
import { FavoriteRepo } from '@docmost/db/repos/favorite/favorite.repo';
|
||||
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
@@ -64,6 +65,7 @@ export class WorkspaceService {
|
||||
private licenseCheckService: LicenseCheckService,
|
||||
private shareRepo: ShareRepo,
|
||||
private watcherRepo: WatcherRepo,
|
||||
private favoriteRepo: FavoriteRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
||||
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
|
||||
@@ -328,7 +330,8 @@ export class WorkspaceService {
|
||||
typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.mcpEnabled !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined'
|
||||
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined'
|
||||
) {
|
||||
const ws = await this.db
|
||||
.selectFrom('workspaces')
|
||||
@@ -351,7 +354,8 @@ export class WorkspaceService {
|
||||
if (
|
||||
typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined'
|
||||
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined'
|
||||
) {
|
||||
if (!this.licenseCheckService.hasFeature(ws.licenseKey, Feature.SECURITY_SETTINGS, ws.plan)) {
|
||||
throw new ForbiddenException(
|
||||
@@ -458,6 +462,20 @@ export class WorkspaceService {
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined') {
|
||||
const prev = settingsBefore?.templates?.allowMemberTemplates ?? false;
|
||||
if (prev !== updateWorkspaceDto.allowMemberTemplates) {
|
||||
before.allowMemberTemplates = prev;
|
||||
after.allowMemberTemplates = updateWorkspaceDto.allowMemberTemplates;
|
||||
}
|
||||
await this.workspaceRepo.updateTemplateSettings(
|
||||
workspaceId,
|
||||
'allowMemberTemplates',
|
||||
updateWorkspaceDto.allowMemberTemplates,
|
||||
trx,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof updateWorkspaceDto.aiChat !== 'undefined') {
|
||||
const prev = settingsBefore?.ai?.chat ?? false;
|
||||
if (prev !== updateWorkspaceDto.aiChat) {
|
||||
@@ -477,6 +495,7 @@ export class WorkspaceService {
|
||||
delete updateWorkspaceDto.generativeAi;
|
||||
delete updateWorkspaceDto.disablePublicSharing;
|
||||
delete updateWorkspaceDto.mcpEnabled;
|
||||
delete updateWorkspaceDto.allowMemberTemplates;
|
||||
delete updateWorkspaceDto.aiChat;
|
||||
|
||||
await this.workspaceRepo.updateWorkspace(
|
||||
@@ -808,6 +827,10 @@ export class WorkspaceService {
|
||||
trx,
|
||||
});
|
||||
|
||||
await this.favoriteRepo.deleteByUserAndWorkspace(userId, workspaceId, {
|
||||
trx,
|
||||
});
|
||||
|
||||
await this.userSessionRepo.revokeByUserId(userId, workspaceId, trx);
|
||||
});
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
|
||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
|
||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||
import { FavoriteRepo } from '@docmost/db/repos/favorite/favorite.repo';
|
||||
import { TemplateRepo } from '@docmost/db/repos/template/template.repo';
|
||||
import { PageListener } from '@docmost/db/listeners/page.listener';
|
||||
import { PostgresJSDialect } from 'kysely-postgres-js';
|
||||
import * as postgres from 'postgres';
|
||||
@@ -75,6 +77,7 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
PagePermissionRepo,
|
||||
PageHistoryRepo,
|
||||
CommentRepo,
|
||||
FavoriteRepo,
|
||||
AttachmentRepo,
|
||||
UserTokenRepo,
|
||||
UserSessionRepo,
|
||||
@@ -82,6 +85,7 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
ShareRepo,
|
||||
NotificationRepo,
|
||||
WatcherRepo,
|
||||
TemplateRepo,
|
||||
PageListener,
|
||||
],
|
||||
exports: [
|
||||
@@ -95,6 +99,7 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
PagePermissionRepo,
|
||||
PageHistoryRepo,
|
||||
CommentRepo,
|
||||
FavoriteRepo,
|
||||
AttachmentRepo,
|
||||
UserTokenRepo,
|
||||
UserSessionRepo,
|
||||
@@ -102,6 +107,7 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
ShareRepo,
|
||||
NotificationRepo,
|
||||
WatcherRepo,
|
||||
TemplateRepo,
|
||||
],
|
||||
})
|
||||
export class DatabaseModule implements OnApplicationBootstrap {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable('templates')
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('title', 'varchar')
|
||||
.addColumn('description', 'text')
|
||||
.addColumn('content', 'jsonb')
|
||||
.addColumn('ydoc', 'bytea')
|
||||
.addColumn('icon', 'varchar')
|
||||
.addColumn('space_id', 'uuid', (col) =>
|
||||
col.references('spaces.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.notNull().references('workspaces.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('creator_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('set null'),
|
||||
)
|
||||
.addColumn('last_updated_by_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('set null'),
|
||||
)
|
||||
.addColumn('collaborator_ids', sql`uuid[]`)
|
||||
.addColumn('text_content', 'text', (col) => col)
|
||||
.addColumn('tsv', sql`tsvector`, (col) => col)
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('deleted_at', 'timestamptz')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_templates_workspace_id')
|
||||
.on('templates')
|
||||
.columns(['workspace_id'])
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_templates_space_id')
|
||||
.on('templates')
|
||||
.columns(['space_id'])
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('templates_tsv_idx')
|
||||
.on('templates')
|
||||
.using('GIN')
|
||||
.column('tsv')
|
||||
.execute();
|
||||
|
||||
await sql`
|
||||
CREATE OR REPLACE FUNCTION templates_tsvector_trigger() RETURNS trigger AS $$
|
||||
begin
|
||||
new.tsv :=
|
||||
setweight(to_tsvector('english', f_unaccent(coalesce(new.title, ''))), 'A') ||
|
||||
setweight(to_tsvector('english', f_unaccent(substring(coalesce(new.text_content, ''), 1, 1000000))), 'B');
|
||||
return new;
|
||||
end;
|
||||
$$ LANGUAGE plpgsql;
|
||||
`.execute(db);
|
||||
|
||||
await sql`CREATE OR REPLACE TRIGGER templates_tsvector_update BEFORE INSERT OR UPDATE
|
||||
ON templates FOR EACH ROW EXECUTE FUNCTION templates_tsvector_trigger();`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`DROP TRIGGER IF EXISTS templates_tsvector_update ON templates`.execute(db);
|
||||
await sql`DROP FUNCTION IF EXISTS templates_tsvector_trigger`.execute(db);
|
||||
await db.schema.dropTable('templates').execute();
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { type Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable('favorites')
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('user_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('page_id', 'uuid', (col) =>
|
||||
col.references('pages.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('space_id', 'uuid', (col) =>
|
||||
col.references('spaces.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('template_id', 'uuid', (col) =>
|
||||
col.references('templates.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('type', 'varchar', (col) => col.notNull())
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.defaultTo(sql`now()`).notNull(),
|
||||
)
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_favorites_user_page')
|
||||
.on('favorites')
|
||||
.columns(['user_id', 'page_id'])
|
||||
.unique()
|
||||
.where('page_id', 'is not', null)
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_favorites_user_space')
|
||||
.on('favorites')
|
||||
.columns(['user_id', 'space_id'])
|
||||
.unique()
|
||||
.where('space_id', 'is not', null)
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_favorites_user_template')
|
||||
.on('favorites')
|
||||
.columns(['user_id', 'template_id'])
|
||||
.unique()
|
||||
.where('template_id', 'is not', null)
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_favorites_user_workspace_type')
|
||||
.on('favorites')
|
||||
.columns(['user_id', 'workspace_id', 'type'])
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropTable('favorites').execute();
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import { InsertableFavorite, Favorite } from '@docmost/db/types/entity.types';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
||||
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { ExpressionBuilder, sql } from 'kysely';
|
||||
import { DB } from '@docmost/db/types/db';
|
||||
import { dbOrTx } from '@docmost/db/utils';
|
||||
|
||||
export const FavoriteType = {
|
||||
PAGE: 'page',
|
||||
SPACE: 'space',
|
||||
TEMPLATE: 'template',
|
||||
} as const;
|
||||
|
||||
export type FavoriteType = (typeof FavoriteType)[keyof typeof FavoriteType];
|
||||
|
||||
@Injectable()
|
||||
export class FavoriteRepo {
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
|
||||
async insert(favorite: InsertableFavorite): Promise<Favorite | undefined> {
|
||||
try {
|
||||
return await this.db
|
||||
.insertInto('favorites')
|
||||
.values(favorite)
|
||||
.returningAll()
|
||||
.executeTakeFirst();
|
||||
} catch (err: any) {
|
||||
if (err?.code === '23505') return undefined;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteByUserAndPage(userId: string, pageId: string): Promise<void> {
|
||||
await this.db
|
||||
.deleteFrom('favorites')
|
||||
.where('userId', '=', userId)
|
||||
.where('pageId', '=', pageId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async deleteByUserAndSpace(userId: string, spaceId: string): Promise<void> {
|
||||
await this.db
|
||||
.deleteFrom('favorites')
|
||||
.where('userId', '=', userId)
|
||||
.where('spaceId', '=', spaceId)
|
||||
.where('type', '=', FavoriteType.SPACE)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async deleteByUserAndTemplate(
|
||||
userId: string,
|
||||
templateId: string,
|
||||
): Promise<void> {
|
||||
await this.db
|
||||
.deleteFrom('favorites')
|
||||
.where('userId', '=', userId)
|
||||
.where('templateId', '=', templateId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async findUserFavorites(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
pagination: PaginationOptions,
|
||||
type?: FavoriteType,
|
||||
) {
|
||||
let query = this.db
|
||||
.selectFrom('favorites')
|
||||
.selectAll('favorites')
|
||||
.where('favorites.userId', '=', userId)
|
||||
.where('favorites.workspaceId', '=', workspaceId);
|
||||
|
||||
if (type) {
|
||||
query = query.where('favorites.type', '=', type);
|
||||
}
|
||||
|
||||
if (type === FavoriteType.PAGE || !type) {
|
||||
query = query.select((eb) => this.withPage(eb));
|
||||
}
|
||||
|
||||
if (type === FavoriteType.PAGE) {
|
||||
query = query.select((eb) => this.withPageSpace(eb));
|
||||
} else if (type === FavoriteType.SPACE) {
|
||||
query = query.select((eb) => this.withSpace(eb));
|
||||
} else {
|
||||
query = query.select((eb) => this.withSpaceResolved(eb));
|
||||
}
|
||||
|
||||
if (type === FavoriteType.TEMPLATE || !type) {
|
||||
query = query.select((eb) => this.withTemplate(eb));
|
||||
}
|
||||
|
||||
return executeWithCursorPagination(query, {
|
||||
perPage: pagination.limit,
|
||||
cursor: pagination.cursor,
|
||||
beforeCursor: pagination.beforeCursor,
|
||||
fields: [{ expression: 'favorites.id', direction: 'desc' }],
|
||||
parseCursor: (cursor) => ({
|
||||
id: cursor.id,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteByUsersWithoutSpaceAccess(
|
||||
userIds: string[],
|
||||
spaceId: string,
|
||||
opts?: { trx?: KyselyTransaction },
|
||||
): Promise<void> {
|
||||
if (userIds.length === 0) return;
|
||||
|
||||
const { trx } = opts;
|
||||
const db = dbOrTx(this.db, trx);
|
||||
|
||||
const usersWithAccess = db
|
||||
.selectFrom('spaceMembers')
|
||||
.select('userId')
|
||||
.where('spaceId', '=', spaceId)
|
||||
.where('userId', 'is not', null)
|
||||
.union(
|
||||
db
|
||||
.selectFrom('spaceMembers')
|
||||
.innerJoin('groupUsers', 'groupUsers.groupId', 'spaceMembers.groupId')
|
||||
.select('groupUsers.userId')
|
||||
.where('spaceMembers.spaceId', '=', spaceId),
|
||||
);
|
||||
|
||||
await db
|
||||
.deleteFrom('favorites')
|
||||
.where('userId', 'in', userIds)
|
||||
.where('spaceId', '=', spaceId)
|
||||
.where('userId', 'not in', usersWithAccess)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async deleteByUserAndWorkspace(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
opts?: { trx?: KyselyTransaction },
|
||||
): Promise<void> {
|
||||
const { trx } = opts;
|
||||
const db = dbOrTx(this.db, trx);
|
||||
|
||||
await db
|
||||
.deleteFrom('favorites')
|
||||
.where('userId', '=', userId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
private withPage(eb: ExpressionBuilder<DB, 'favorites'>) {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom('pages')
|
||||
.select([
|
||||
'pages.id',
|
||||
'pages.slugId',
|
||||
'pages.title',
|
||||
'pages.icon',
|
||||
'pages.spaceId',
|
||||
])
|
||||
.whereRef('pages.id', '=', 'favorites.pageId'),
|
||||
).as('page');
|
||||
}
|
||||
|
||||
private withSpace(eb: ExpressionBuilder<DB, 'favorites'>) {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom('spaces')
|
||||
.select(['spaces.id', 'spaces.name', 'spaces.slug', 'spaces.logo'])
|
||||
.whereRef('spaces.id', '=', 'favorites.spaceId'),
|
||||
).as('space');
|
||||
}
|
||||
|
||||
private withPageSpace(eb: ExpressionBuilder<DB, 'favorites'>) {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom('spaces')
|
||||
.innerJoin('pages', 'pages.spaceId', 'spaces.id')
|
||||
.select(['spaces.id', 'spaces.name', 'spaces.slug', 'spaces.logo'])
|
||||
.whereRef('pages.id', '=', 'favorites.pageId'),
|
||||
).as('space');
|
||||
}
|
||||
|
||||
private withSpaceResolved(eb: ExpressionBuilder<DB, 'favorites'>) {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom('spaces')
|
||||
.select(['spaces.id', 'spaces.name', 'spaces.slug', 'spaces.logo'])
|
||||
.where(({ or, ref }) =>
|
||||
or([
|
||||
sql<boolean>`${ref('spaces.id')} = ${ref('favorites.spaceId')}`,
|
||||
sql<boolean>`${ref('spaces.id')} = (SELECT pages.space_id FROM pages WHERE pages.id = ${ref('favorites.pageId')})`,
|
||||
]),
|
||||
),
|
||||
).as('space');
|
||||
}
|
||||
|
||||
private withTemplate(eb: ExpressionBuilder<DB, 'favorites'>) {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom('templates')
|
||||
.select([
|
||||
'templates.id',
|
||||
'templates.title',
|
||||
'templates.description',
|
||||
'templates.icon',
|
||||
'templates.spaceId',
|
||||
])
|
||||
.whereRef('templates.id', '=', 'favorites.templateId'),
|
||||
).as('template');
|
||||
}
|
||||
}
|
||||
@@ -324,6 +324,35 @@ export class PageRepo {
|
||||
});
|
||||
}
|
||||
|
||||
async getCreatedByPages(creatorId: string, requestingUserId: string, pagination: PaginationOptions, spaceId?: string) {
|
||||
let query = this.db
|
||||
.selectFrom('pages')
|
||||
.select(this.baseFields)
|
||||
.select((eb) => this.withSpace(eb))
|
||||
.where('creatorId', '=', creatorId)
|
||||
.where('deletedAt', 'is', null);
|
||||
|
||||
if (spaceId) {
|
||||
query = query.where('spaceId', '=', spaceId);
|
||||
} else {
|
||||
query = query.where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(requestingUserId));
|
||||
}
|
||||
|
||||
return executeWithCursorPagination(query, {
|
||||
perPage: pagination.limit,
|
||||
cursor: pagination.cursor,
|
||||
beforeCursor: pagination.beforeCursor,
|
||||
fields: [
|
||||
{ expression: 'updatedAt', direction: 'desc' },
|
||||
{ expression: 'id', direction: 'desc' },
|
||||
],
|
||||
parseCursor: (cursor) => ({
|
||||
updatedAt: new Date(cursor.updatedAt),
|
||||
id: cursor.id,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async getDeletedPagesInSpace(spaceId: string, pagination: PaginationOptions) {
|
||||
const query = this.db
|
||||
.selectFrom('pages')
|
||||
|
||||
@@ -290,6 +290,32 @@ export class SpaceMemberRepo {
|
||||
return membership.map((space) => space.id);
|
||||
}
|
||||
|
||||
async getUserRolesForSpaces(
|
||||
userId: string,
|
||||
spaceIds: string[],
|
||||
): Promise<{ spaceId: string; role: string }[]> {
|
||||
if (spaceIds.length === 0) return [];
|
||||
|
||||
return this.db
|
||||
.selectFrom('spaceMembers')
|
||||
.select(['spaceId', 'role'])
|
||||
.where('userId', '=', userId)
|
||||
.where('spaceId', 'in', spaceIds)
|
||||
.unionAll(
|
||||
this.db
|
||||
.selectFrom('spaceMembers')
|
||||
.innerJoin(
|
||||
'groupUsers',
|
||||
'groupUsers.groupId',
|
||||
'spaceMembers.groupId',
|
||||
)
|
||||
.select(['spaceMembers.spaceId', 'spaceMembers.role'])
|
||||
.where('groupUsers.userId', '=', userId)
|
||||
.where('spaceMembers.spaceId', 'in', spaceIds),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async getUserSpaces(userId: string, pagination: PaginationOptions) {
|
||||
let query = this.db
|
||||
.selectFrom('spaces')
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import { dbOrTx } from '@docmost/db/utils';
|
||||
import {
|
||||
InsertableTemplate,
|
||||
Page,
|
||||
Template,
|
||||
UpdatableTemplate,
|
||||
} from '@docmost/db/types/entity.types';
|
||||
import { PaginationOptions } from '../../pagination/pagination-options';
|
||||
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
||||
import { ExpressionBuilder, sql } from 'kysely';
|
||||
import { DB } from '@docmost/db/types/db';
|
||||
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
|
||||
@Injectable()
|
||||
export class TemplateRepo {
|
||||
private baseFields: Array<keyof Template> = [
|
||||
'id',
|
||||
'title',
|
||||
'description',
|
||||
'icon',
|
||||
'spaceId',
|
||||
'workspaceId',
|
||||
'creatorId',
|
||||
'lastUpdatedById',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
];
|
||||
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
|
||||
async findById(
|
||||
templateId: string,
|
||||
workspaceId: string,
|
||||
opts?: { includeContent?: boolean; trx?: KyselyTransaction },
|
||||
): Promise<Template> {
|
||||
const db = dbOrTx(this.db, opts?.trx);
|
||||
|
||||
const query = db
|
||||
.selectFrom('templates')
|
||||
.select(this.baseFields)
|
||||
.$if(opts?.includeContent ?? false, (qb) => qb.select('content'))
|
||||
.select((eb) => [this.withCreator(eb)])
|
||||
.where('id', '=', templateId)
|
||||
.where('workspaceId', '=', workspaceId);
|
||||
|
||||
return query.executeTakeFirst() as Promise<Template>;
|
||||
}
|
||||
|
||||
async findTemplates(
|
||||
workspaceId: string,
|
||||
accessibleSpaceIds: string[],
|
||||
pagination: PaginationOptions,
|
||||
opts?: { spaceId?: string },
|
||||
) {
|
||||
let query = this.db
|
||||
.selectFrom('templates')
|
||||
.select(this.baseFields)
|
||||
.select((eb) => [this.withCreator(eb)])
|
||||
.where('workspaceId', '=', workspaceId);
|
||||
|
||||
if (opts?.spaceId) {
|
||||
if (!accessibleSpaceIds.includes(opts.spaceId)) {
|
||||
query = query.where('spaceId', 'is', null);
|
||||
} else {
|
||||
query = query.where((eb) =>
|
||||
eb.or([eb('spaceId', '=', opts.spaceId), eb('spaceId', 'is', null)]),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
query = query.where((eb) =>
|
||||
eb.or([
|
||||
eb('spaceId', 'is', null),
|
||||
...(accessibleSpaceIds.length > 0
|
||||
? [eb('spaceId', 'in', accessibleSpaceIds)]
|
||||
: []),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
if (pagination.query) {
|
||||
const searchTerm = `%${pagination.query}%`;
|
||||
query = query.where((eb) =>
|
||||
eb.or([
|
||||
eb(sql`f_unaccent(title)`, 'ilike', sql`f_unaccent(${searchTerm})`),
|
||||
eb(
|
||||
sql`f_unaccent(description)`,
|
||||
'ilike',
|
||||
sql`f_unaccent(${searchTerm})`,
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
return executeWithCursorPagination(query, {
|
||||
perPage: pagination.limit,
|
||||
cursor: pagination.cursor,
|
||||
beforeCursor: pagination.beforeCursor,
|
||||
fields: [
|
||||
{ expression: 'title', direction: 'asc' },
|
||||
{ expression: 'id', direction: 'asc' },
|
||||
],
|
||||
parseCursor: (cursor) => ({
|
||||
title: cursor.title,
|
||||
id: cursor.id,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async insertTemplate(
|
||||
insertableTemplate: InsertableTemplate,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<{ id: string }> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.insertInto('templates')
|
||||
.values(insertableTemplate)
|
||||
.returning('id')
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async updateTemplate(
|
||||
updatableTemplate: UpdatableTemplate,
|
||||
templateId: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.updateTable('templates')
|
||||
.set({ ...updatableTemplate, updatedAt: new Date() })
|
||||
.where('id', '=', templateId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async deleteTemplate(
|
||||
templateId: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.deleteFrom('templates')
|
||||
.where('id', '=', templateId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
withCreator(eb: ExpressionBuilder<DB, 'templates'>) {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom('users')
|
||||
.select(['users.id', 'users.name', 'users.avatarUrl'])
|
||||
.whereRef('users.id', '=', 'templates.creatorId'),
|
||||
).as('creator');
|
||||
}
|
||||
}
|
||||
@@ -230,4 +230,24 @@ export class WorkspaceRepo {
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async updateTemplateSettings(
|
||||
workspaceId: string,
|
||||
prefKey: string,
|
||||
prefValue: string | boolean,
|
||||
trx?: KyselyTransaction,
|
||||
) {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.updateTable('workspaces')
|
||||
.set({
|
||||
settings: sql`COALESCE(settings, '{}'::jsonb)
|
||||
|| jsonb_build_object('templates', COALESCE(settings->'templates', '{}'::jsonb)
|
||||
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where('id', '=', workspaceId)
|
||||
.returning(this.baseFields)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+32
@@ -175,6 +175,17 @@ export interface Comments {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface Favorites {
|
||||
id: Generated<string>;
|
||||
userId: string;
|
||||
pageId: string | null;
|
||||
spaceId: string | null;
|
||||
templateId: string | null;
|
||||
type: string;
|
||||
workspaceId: string;
|
||||
createdAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
export interface FileTasks {
|
||||
createdAt: Generated<Timestamp>;
|
||||
creatorId: string | null;
|
||||
@@ -430,6 +441,25 @@ export interface PagePermissions {
|
||||
updatedAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
export interface Templates {
|
||||
id: Generated<string>;
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
content: Json | null;
|
||||
ydoc: Buffer | null;
|
||||
icon: string | null;
|
||||
spaceId: string | null;
|
||||
workspaceId: string;
|
||||
creatorId: string | null;
|
||||
lastUpdatedById: string | null;
|
||||
collaboratorIds: string[] | null;
|
||||
textContent: string | null;
|
||||
tsv: string | null;
|
||||
createdAt: Generated<Timestamp>;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
deletedAt: Timestamp | null;
|
||||
}
|
||||
|
||||
export interface AiChats {
|
||||
id: Generated<string>;
|
||||
workspaceId: string;
|
||||
@@ -481,6 +511,7 @@ export interface DB {
|
||||
backlinks: Backlinks;
|
||||
billing: Billing;
|
||||
comments: Comments;
|
||||
favorites: Favorites;
|
||||
fileTasks: FileTasks;
|
||||
groups: Groups;
|
||||
groupUsers: GroupUsers;
|
||||
@@ -492,6 +523,7 @@ export interface DB {
|
||||
shares: Shares;
|
||||
spaceMembers: SpaceMembers;
|
||||
spaces: Spaces;
|
||||
templates: Templates;
|
||||
userMfa: UserMfa;
|
||||
users: Users;
|
||||
userSessions: UserSessions;
|
||||
|
||||
@@ -22,12 +22,14 @@ import {
|
||||
AuthProviders,
|
||||
AuthAccounts,
|
||||
Shares,
|
||||
Favorites,
|
||||
FileTasks,
|
||||
UserMfa as _UserMFA,
|
||||
UserSessions,
|
||||
ApiKeys,
|
||||
Watchers,
|
||||
Audit as _Audit,
|
||||
Templates,
|
||||
} from './db';
|
||||
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
|
||||
|
||||
@@ -135,6 +137,11 @@ export type Share = Selectable<Shares>;
|
||||
export type InsertableShare = Insertable<Shares>;
|
||||
export type UpdatableShare = Updateable<Omit<Shares, 'id'>>;
|
||||
|
||||
// Favorite
|
||||
export type Favorite = Selectable<Favorites>;
|
||||
export type InsertableFavorite = Insertable<Favorites>;
|
||||
export type UpdatableFavorite = Updateable<Omit<Favorites, 'id'>>;
|
||||
|
||||
// File Task
|
||||
export type FileTask = Selectable<FileTasks>;
|
||||
export type InsertableFileTask = Insertable<FileTasks>;
|
||||
@@ -184,3 +191,8 @@ export type UpdatableUserSession = Updateable<Omit<UserSessions, 'id'>>;
|
||||
export type Audit = Selectable<_Audit>;
|
||||
export type InsertableAudit = Insertable<_Audit>;
|
||||
export type UpdatableAudit = Updateable<Omit<_Audit, 'id'>>;
|
||||
|
||||
// Template
|
||||
export type Template = Selectable<Templates>;
|
||||
export type InsertableTemplate = Insertable<Templates>;
|
||||
export type UpdatableTemplate = Updateable<Omit<Templates, 'id'>>;
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: d3bc4c5160...d80f660b20
Reference in New Issue
Block a user