mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
feat: favorites and templates(ee)
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "client",
|
"name": "client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.70.2",
|
"version": "0.70.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"Add members": "Add members",
|
"Add members": "Add members",
|
||||||
"Add to groups": "Add to groups",
|
"Add to groups": "Add to groups",
|
||||||
"Add space members": "Add space members",
|
"Add space members": "Add space members",
|
||||||
|
"Add to favorites": "Add to favorites",
|
||||||
"Admin": "Admin",
|
"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 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?",
|
"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 import pages": "Failed to import pages",
|
||||||
"Failed to load page. An error occurred.": "Failed to load page. An error occurred.",
|
"Failed to load page. An error occurred.": "Failed to load page. An error occurred.",
|
||||||
"Failed to update data": "Failed to update data",
|
"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 access": "Full access",
|
||||||
"Full page width": "Full page width",
|
"Full page width": "Full page width",
|
||||||
"Full width": "Full width",
|
"Full width": "Full width",
|
||||||
@@ -92,6 +96,7 @@
|
|||||||
"Invite by email": "Invite by email",
|
"Invite by email": "Invite by email",
|
||||||
"Invite members": "Invite members",
|
"Invite members": "Invite members",
|
||||||
"Invite new members": "Invite new 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 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",
|
"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",
|
"Join the workspace": "Join the workspace",
|
||||||
@@ -139,6 +144,7 @@
|
|||||||
"Profile": "Profile",
|
"Profile": "Profile",
|
||||||
"Recently updated": "Recently updated",
|
"Recently updated": "Recently updated",
|
||||||
"Remove": "Remove",
|
"Remove": "Remove",
|
||||||
|
"Remove from favorites": "Remove from favorites",
|
||||||
"Remove group member": "Remove group member",
|
"Remove group member": "Remove group member",
|
||||||
"Remove space member": "Remove space member",
|
"Remove space member": "Remove space member",
|
||||||
"Restore": "Restore",
|
"Restore": "Restore",
|
||||||
@@ -175,6 +181,7 @@
|
|||||||
"Successfully imported": "Successfully imported",
|
"Successfully imported": "Successfully imported",
|
||||||
"Successfully restored": "Successfully restored",
|
"Successfully restored": "Successfully restored",
|
||||||
"System settings": "System settings",
|
"System settings": "System settings",
|
||||||
|
"Templates": "Templates",
|
||||||
"Theme": "Theme",
|
"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.",
|
"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",
|
"Toggle full page width": "Toggle full page width",
|
||||||
@@ -468,6 +475,7 @@
|
|||||||
"Replace (Enter)": "Replace (Enter)",
|
"Replace (Enter)": "Replace (Enter)",
|
||||||
"Replace all (Ctrl+Alt+Enter)": "Replace all (Ctrl+Alt+Enter)",
|
"Replace all (Ctrl+Alt+Enter)": "Replace all (Ctrl+Alt+Enter)",
|
||||||
"Replace all": "Replace all",
|
"Replace all": "Replace all",
|
||||||
|
"View all": "View all",
|
||||||
"View all spaces": "View all spaces",
|
"View all spaces": "View all spaces",
|
||||||
"Error": "Error",
|
"Error": "Error",
|
||||||
"Failed to disable MFA": "Failed to disable MFA",
|
"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 WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
|
||||||
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
|
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
|
||||||
import AuditLogs from "@/ee/audit/pages/audit-logs.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 VerifyEmail from "@/ee/pages/verify-email.tsx";
|
import VerifyEmail from "@/ee/pages/verify-email.tsx";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -82,6 +85,12 @@ export default function App() {
|
|||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
<Route path={"/home"} element={<Home />} />
|
<Route path={"/home"} element={<Home />} />
|
||||||
<Route path={"/spaces"} element={<SpacesPage />} />
|
<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"} element={<SpaceHome />} />
|
||||||
<Route path={"/s/:spaceSlug/trash"} element={<SpaceTrash />} />
|
<Route path={"/s/:spaceSlug/trash"} element={<SpaceTrash />} />
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
Table,
|
Table,
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
|
Button,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx";
|
import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx";
|
||||||
@@ -23,7 +24,8 @@ interface Props {
|
|||||||
|
|
||||||
export default function RecentChanges({ spaceId }: Props) {
|
export default function RecentChanges({ spaceId }: Props) {
|
||||||
const { t } = useTranslation();
|
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) {
|
if (isLoading) {
|
||||||
return <PageListSkeleton />;
|
return <PageListSkeleton />;
|
||||||
@@ -33,11 +35,12 @@ export default function RecentChanges({ spaceId }: Props) {
|
|||||||
return <Text>{t("Failed to fetch recent pages")}</Text>;
|
return <Text>{t("Failed to fetch recent pages")}</Text>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return pages && pages.items.length > 0 ? (
|
return pages.length > 0 ? (
|
||||||
|
<>
|
||||||
<Table.ScrollContainer minWidth={500}>
|
<Table.ScrollContainer minWidth={500}>
|
||||||
<Table highlightOnHover verticalSpacing="sm">
|
<Table highlightOnHover verticalSpacing="sm">
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{pages.items.map((page) => (
|
{pages.map((page) => (
|
||||||
<Table.Tr key={page.id}>
|
<Table.Tr key={page.id}>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
@@ -85,6 +88,19 @@ export default function RecentChanges({ spaceId }: Props) {
|
|||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
</Table.ScrollContainer>
|
</Table.ScrollContainer>
|
||||||
|
{hasNextPage && (
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
fullWidth
|
||||||
|
mt="sm"
|
||||||
|
mb="xl"
|
||||||
|
onClick={() => fetchNextPage()}
|
||||||
|
loading={isFetchingNextPage}
|
||||||
|
>
|
||||||
|
{t("Load more")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={IconFiles}
|
icon={IconFiles}
|
||||||
|
|||||||
@@ -35,10 +35,6 @@ export function AppHeader() {
|
|||||||
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
|
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
|
||||||
const { isTrial, trialDaysLeft } = useTrial();
|
const { isTrial, trialDaysLeft } = useTrial();
|
||||||
|
|
||||||
const isHomeRoute = location.pathname.startsWith("/home");
|
|
||||||
const isSpacesRoute = location.pathname === "/spaces";
|
|
||||||
const hideSidebar = isHomeRoute || isSpacesRoute;
|
|
||||||
|
|
||||||
const items = links.map((link) => (
|
const items = links.map((link) => (
|
||||||
<Link key={link.label} to={link.link} className={classes.link}>
|
<Link key={link.label} to={link.link} className={classes.link}>
|
||||||
{t(link.label)}
|
{t(link.label)}
|
||||||
@@ -49,8 +45,6 @@ export function AppHeader() {
|
|||||||
<>
|
<>
|
||||||
<Group h="100%" px="md" justify="space-between" wrap={"nowrap"}>
|
<Group h="100%" px="md" justify="space-between" wrap={"nowrap"}>
|
||||||
<Group wrap="nowrap">
|
<Group wrap="nowrap">
|
||||||
{!hideSidebar && (
|
|
||||||
<>
|
|
||||||
<Tooltip label={t("Sidebar toggle")}>
|
<Tooltip label={t("Sidebar toggle")}>
|
||||||
<SidebarToggle
|
<SidebarToggle
|
||||||
aria-label={t("Sidebar toggle")}
|
aria-label={t("Sidebar toggle")}
|
||||||
@@ -70,8 +64,6 @@ export function AppHeader() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Text
|
<Text
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import Aside from "@/components/layouts/global/aside.tsx";
|
|||||||
import classes from "./app-shell.module.css";
|
import classes from "./app-shell.module.css";
|
||||||
import { useTrialEndAction } from "@/ee/hooks/use-trial-end-action.tsx";
|
import { useTrialEndAction } from "@/ee/hooks/use-trial-end-action.tsx";
|
||||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||||
|
import GlobalSidebar from "@/components/layouts/global/global-sidebar.tsx";
|
||||||
|
|
||||||
export default function GlobalAppShell({
|
export default function GlobalAppShell({
|
||||||
children,
|
children,
|
||||||
@@ -72,24 +73,20 @@ export default function GlobalAppShell({
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isSettingsRoute = location.pathname.startsWith("/settings");
|
const isSettingsRoute = location.pathname.startsWith("/settings");
|
||||||
const isSpaceRoute = location.pathname.startsWith("/s/");
|
const isSpaceRoute = location.pathname.startsWith("/s/");
|
||||||
const isHomeRoute = location.pathname.startsWith("/home");
|
|
||||||
const isSpacesRoute = location.pathname === "/spaces";
|
|
||||||
const isPageRoute = location.pathname.includes("/p/");
|
const isPageRoute = location.pathname.includes("/p/");
|
||||||
const hideSidebar = isHomeRoute || isSpacesRoute;
|
const showGlobalSidebar = !isSpaceRoute && !isSettingsRoute;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
header={{ height: 45 }}
|
header={{ height: 45 }}
|
||||||
navbar={
|
navbar={{
|
||||||
!hideSidebar && {
|
|
||||||
width: isSpaceRoute ? sidebarWidth : 300,
|
width: isSpaceRoute ? sidebarWidth : 300,
|
||||||
breakpoint: "sm",
|
breakpoint: "sm",
|
||||||
collapsed: {
|
collapsed: {
|
||||||
mobile: !mobileOpened,
|
mobile: !mobileOpened,
|
||||||
desktop: !desktopOpened,
|
desktop: !desktopOpened,
|
||||||
},
|
},
|
||||||
}
|
}}
|
||||||
}
|
|
||||||
aside={
|
aside={
|
||||||
isPageRoute && {
|
isPageRoute && {
|
||||||
width: 350,
|
width: 350,
|
||||||
@@ -102,17 +99,16 @@ export default function GlobalAppShell({
|
|||||||
<AppShell.Header px="md" className={classes.header}>
|
<AppShell.Header px="md" className={classes.header}>
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
</AppShell.Header>
|
</AppShell.Header>
|
||||||
{!hideSidebar && (
|
|
||||||
<AppShell.Navbar
|
<AppShell.Navbar
|
||||||
className={classes.navbar}
|
className={classes.navbar}
|
||||||
withBorder={false}
|
withBorder={false}
|
||||||
ref={sidebarRef}
|
ref={sidebarRef}
|
||||||
>
|
>
|
||||||
<div className={classes.resizeHandle} onMouseDown={startResizing} />
|
{isSpaceRoute && <div className={classes.resizeHandle} onMouseDown={startResizing} />}
|
||||||
{isSpaceRoute && <SpaceSidebar />}
|
{isSpaceRoute && <SpaceSidebar />}
|
||||||
{isSettingsRoute && <SettingsSidebar />}
|
{isSettingsRoute && <SettingsSidebar />}
|
||||||
|
{showGlobalSidebar && <GlobalSidebar />}
|
||||||
</AppShell.Navbar>
|
</AppShell.Navbar>
|
||||||
)}
|
|
||||||
<AppShell.Main>
|
<AppShell.Main>
|
||||||
{isSettingsRoute ? (
|
{isSettingsRoute ? (
|
||||||
<Container size={850}>{children}</Container>
|
<Container size={850}>{children}</Container>
|
||||||
|
|||||||
@@ -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,202 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { ScrollArea, Text, Divider, Modal, Tooltip } from "@mantine/core";
|
||||||
|
import {
|
||||||
|
IconHome,
|
||||||
|
IconClock,
|
||||||
|
IconStar,
|
||||||
|
IconLayoutGrid,
|
||||||
|
IconSettings,
|
||||||
|
IconUserPlus,
|
||||||
|
IconSearch,
|
||||||
|
IconTemplate,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { Link, useLocation } from "react-router-dom";
|
||||||
|
import classes from "./global-sidebar.module.css";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { searchSpotlight } from "@/features/search/constants";
|
||||||
|
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";
|
||||||
|
import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
|
||||||
|
import { Feature } from "@/ee/features";
|
||||||
|
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
||||||
|
|
||||||
|
const mainNavItems = [
|
||||||
|
{ label: "Home", icon: IconHome, path: "/home" },
|
||||||
|
{ label: "Favorites", icon: IconStar, path: "/favorites" },
|
||||||
|
{ label: "Spaces", icon: IconLayoutGrid, path: "/spaces" },
|
||||||
|
{ label: "Templates", icon: IconTemplate, path: "/templates", feature: Feature.TEMPLATES },
|
||||||
|
];
|
||||||
|
|
||||||
|
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 [entitlements] = useAtom(entitlementAtom);
|
||||||
|
const upgradeLabel = useUpgradeLabel();
|
||||||
|
const hasFeature = (f: string) =>
|
||||||
|
entitlements?.features?.includes(f) ?? false;
|
||||||
|
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}>
|
||||||
|
<a
|
||||||
|
className={classes.link}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
searchSpotlight.open();
|
||||||
|
}}
|
||||||
|
href="#"
|
||||||
|
>
|
||||||
|
<IconSearch className={classes.linkIcon} stroke={2} />
|
||||||
|
<span>{t("Search")}</span>
|
||||||
|
</a>
|
||||||
|
{mainNavItems.map((item) => {
|
||||||
|
const isDisabled = item.feature && !hasFeature(item.feature);
|
||||||
|
|
||||||
|
const linkElement = (
|
||||||
|
<Link
|
||||||
|
key={item.label}
|
||||||
|
className={classes.link}
|
||||||
|
data-active={active === item.path || undefined}
|
||||||
|
data-disabled={isDisabled || undefined}
|
||||||
|
to={isDisabled ? "#" : item.path}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (isDisabled) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleNavClick();
|
||||||
|
}}
|
||||||
|
style={isDisabled ? { opacity: 0.5, cursor: "not-allowed" } : undefined}
|
||||||
|
>
|
||||||
|
<item.icon className={classes.linkIcon} stroke={2} />
|
||||||
|
<span>{t(item.label)}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isDisabled) {
|
||||||
|
return (
|
||||||
|
<Tooltip key={item.label} label={upgradeLabel} position="right" withArrow>
|
||||||
|
{linkElement}
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return linkElement;
|
||||||
|
})}
|
||||||
|
</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)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,4 +16,5 @@ export const Feature = {
|
|||||||
AUDIT_LOGS: 'audit:logs',
|
AUDIT_LOGS: 'audit:logs',
|
||||||
RETENTION: 'retention',
|
RETENTION: 'retention',
|
||||||
SHARING_CONTROLS: 'sharing:controls',
|
SHARING_CONTROLS: 'sharing:controls',
|
||||||
|
TEMPLATES: 'templates',
|
||||||
} as const;
|
} 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 EnforceMfa from "@/ee/security/components/enforce-mfa.tsx";
|
||||||
import DisablePublicSharing from "@/ee/security/components/disable-public-sharing.tsx";
|
import DisablePublicSharing from "@/ee/security/components/disable-public-sharing.tsx";
|
||||||
import TrashRetention from "@/ee/security/components/trash-retention.tsx";
|
import TrashRetention from "@/ee/security/components/trash-retention.tsx";
|
||||||
|
import AllowMemberTemplates from "@/ee/security/components/allow-member-templates.tsx";
|
||||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||||
import { Feature } from "@/ee/features";
|
import { Feature } from "@/ee/features";
|
||||||
|
|
||||||
@@ -43,6 +44,9 @@ export default function Security() {
|
|||||||
<TrashRetention />
|
<TrashRetention />
|
||||||
<Divider my="lg" />
|
<Divider my="lg" />
|
||||||
|
|
||||||
|
<AllowMemberTemplates />
|
||||||
|
<Divider my="lg" />
|
||||||
|
|
||||||
<Title order={4} my="lg">
|
<Title order={4} my="lg">
|
||||||
Single sign-on (SSO)
|
Single sign-on (SSO)
|
||||||
</Title>
|
</Title>
|
||||||
|
|||||||
@@ -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,371 @@
|
|||||||
|
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 [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 updateMutation.mutateAsync({
|
||||||
|
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, updateMutation]);
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
@@ -351,7 +351,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
.run(),
|
.run(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Draw.io (diagrams.net) ",
|
title: "Draw.io (diagrams.net)",
|
||||||
description: "Insert and design Drawio diagrams",
|
description: "Insert and design Drawio diagrams",
|
||||||
searchTerms: ["drawio", "diagrams", "charts", "uml", "whiteboard"],
|
searchTerms: ["drawio", "diagrams", "charts", "uml", "whiteboard"],
|
||||||
icon: IconDrawio,
|
icon: IconDrawio,
|
||||||
@@ -620,8 +620,10 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
|
|
||||||
export const getSuggestionItems = ({
|
export const getSuggestionItems = ({
|
||||||
query,
|
query,
|
||||||
|
excludeItems,
|
||||||
}: {
|
}: {
|
||||||
query: string;
|
query: string;
|
||||||
|
excludeItems?: Set<string>;
|
||||||
}): SlashMenuGroupedItemsType => {
|
}): SlashMenuGroupedItemsType => {
|
||||||
const search = query.toLowerCase();
|
const search = query.toLowerCase();
|
||||||
const filteredGroups: SlashMenuGroupedItemsType = {};
|
const filteredGroups: SlashMenuGroupedItemsType = {};
|
||||||
@@ -638,6 +640,7 @@ export const getSuggestionItems = ({
|
|||||||
|
|
||||||
for (const [group, items] of Object.entries(CommandGroups)) {
|
for (const [group, items] of Object.entries(CommandGroups)) {
|
||||||
const filteredItems = items.filter((item) => {
|
const filteredItems = items.filter((item) => {
|
||||||
|
if (excludeItems?.has(item.title)) return false;
|
||||||
return (
|
return (
|
||||||
fuzzyMatch(search, item.title) ||
|
fuzzyMatch(search, item.title) ||
|
||||||
item.description.toLowerCase().includes(search) ||
|
item.description.toLowerCase().includes(search) ||
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import { TextStyle } from "@tiptap/extension-text-style";
|
|||||||
import { Color } from "@tiptap/extension-color";
|
import { Color } from "@tiptap/extension-color";
|
||||||
import GlobalDragHandle from "tiptap-extension-global-drag-handle";
|
import GlobalDragHandle from "tiptap-extension-global-drag-handle";
|
||||||
import { Youtube } from "@tiptap/extension-youtube";
|
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 { Collaboration, isChangeOrigin } from "@tiptap/extension-collaboration";
|
||||||
import { CollaborationCaret } from "@tiptap/extension-collaboration-caret";
|
import { CollaborationCaret } from "@tiptap/extension-collaboration-caret";
|
||||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||||
@@ -47,7 +49,7 @@ import {
|
|||||||
SharedStorage,
|
SharedStorage,
|
||||||
Columns,
|
Columns,
|
||||||
Column,
|
Column,
|
||||||
Status
|
Status,
|
||||||
} from "@docmost/editor-ext";
|
} from "@docmost/editor-ext";
|
||||||
import {
|
import {
|
||||||
randomElement,
|
randomElement,
|
||||||
@@ -347,6 +349,27 @@ export const mainExtensions = [
|
|||||||
|
|
||||||
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
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) => [
|
export const collabExtensions: CollabExtensions = (provider, user) => [
|
||||||
Collaboration.configure({
|
Collaboration.configure({
|
||||||
document: provider.document,
|
document: provider.document,
|
||||||
|
|||||||
@@ -47,4 +47,5 @@ const SlashCommand = Command.configure({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export { Command as SlashCommandExtension };
|
||||||
export default SlashCommand;
|
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,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,46 @@
|
|||||||
import { Text, Tabs, Space } from "@mantine/core";
|
import { Text, Tabs, Space } from "@mantine/core";
|
||||||
import { IconClockHour3 } from "@tabler/icons-react";
|
import { IconClockHour3, IconStar, IconUser } from "@tabler/icons-react";
|
||||||
import RecentChanges from "@/components/common/recent-changes.tsx";
|
import RecentChanges from "@/components/common/recent-changes";
|
||||||
|
import FavoritesPages from "./favorites-pages";
|
||||||
|
import CreatedByMe from "./created-by-me";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "home-tab";
|
||||||
|
const DEFAULT_TAB = "recent";
|
||||||
|
const VALID_TABS = ["recent", "favorites", "created"];
|
||||||
|
|
||||||
|
function getStoredTab(): string {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return stored && VALID_TABS.includes(stored) ? stored : DEFAULT_TAB;
|
||||||
|
}
|
||||||
|
|
||||||
export default function HomeTabs() {
|
export default function HomeTabs() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs defaultValue="recent">
|
<Tabs
|
||||||
|
color="dark"
|
||||||
|
defaultValue={getStoredTab()}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (value) localStorage.setItem(STORAGE_KEY, value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
<Tabs.Tab value="recent" leftSection={<IconClockHour3 size={18} />}>
|
<Tabs.Tab value="recent" leftSection={<IconClockHour3 size={18} />}>
|
||||||
<Text size="sm" fw={500}>
|
<Text size="sm" fw={500}>
|
||||||
{t("Recently updated")}
|
{t("Recently updated")}
|
||||||
</Text>
|
</Text>
|
||||||
</Tabs.Tab>
|
</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>
|
</Tabs.List>
|
||||||
|
|
||||||
<Space my="md" />
|
<Space my="md" />
|
||||||
@@ -21,6 +48,12 @@ export default function HomeTabs() {
|
|||||||
<Tabs.Panel value="recent">
|
<Tabs.Panel value="recent">
|
||||||
<RecentChanges />
|
<RecentChanges />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
<Tabs.Panel value="favorites">
|
||||||
|
<FavoritesPages />
|
||||||
|
</Tabs.Panel>
|
||||||
|
<Tabs.Panel value="created">
|
||||||
|
<CreatedByMe />
|
||||||
|
</Tabs.Panel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
IconMarkdown,
|
IconMarkdown,
|
||||||
IconMessage,
|
IconMessage,
|
||||||
IconPrinter,
|
IconPrinter,
|
||||||
|
IconStar,
|
||||||
|
IconStarFilled,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
IconWifiOff,
|
IconWifiOff,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
@@ -40,6 +42,11 @@ import { PageStateSegmentedControl } from "@/features/user/components/page-state
|
|||||||
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
||||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||||
import { PageShareModal } from "@/ee/page-permission";
|
import { PageShareModal } from "@/ee/page-permission";
|
||||||
|
import {
|
||||||
|
useFavoriteIds,
|
||||||
|
useAddFavoriteMutation,
|
||||||
|
useRemoveFavoriteMutation,
|
||||||
|
} from "@/features/favorite/queries/favorite-query";
|
||||||
|
|
||||||
interface PageHeaderMenuProps {
|
interface PageHeaderMenuProps {
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
@@ -123,6 +130,10 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
] = useDisclosure(false);
|
] = useDisclosure(false);
|
||||||
const [pageEditor] = useAtom(pageEditorAtom);
|
const [pageEditor] = useAtom(pageEditorAtom);
|
||||||
const pageUpdatedAt = useTimeAgo(page?.updatedAt);
|
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 handleCopyLink = () => {
|
const handleCopyLink = () => {
|
||||||
const pageUrl =
|
const pageUrl =
|
||||||
@@ -155,6 +166,16 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
openDeleteModal({ onConfirm: () => tree?.delete(page.id) });
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Menu
|
<Menu
|
||||||
@@ -185,6 +206,19 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
>
|
>
|
||||||
{t("Copy as Markdown")}
|
{t("Copy as Markdown")}
|
||||||
</Menu.Item>
|
</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>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|
||||||
<Menu.Item leftSection={<IconArrowsHorizontal size={16} />}>
|
<Menu.Item leftSection={<IconArrowsHorizontal size={16} />}>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
movePage,
|
movePage,
|
||||||
getPageBreadcrumbs,
|
getPageBreadcrumbs,
|
||||||
getRecentChanges,
|
getRecentChanges,
|
||||||
|
getCreatedByPages,
|
||||||
getAllSidebarPages,
|
getAllSidebarPages,
|
||||||
getDeletedPages,
|
getDeletedPages,
|
||||||
restorePage,
|
restorePage,
|
||||||
@@ -252,7 +253,7 @@ export function useGetSidebarPagesQuery(
|
|||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: ["sidebar-pages", data],
|
queryKey: ["sidebar-pages", data],
|
||||||
enabled: !!data?.pageId || !!data?.spaceId,
|
enabled: !!data?.pageId || !!data?.spaceId,
|
||||||
queryFn: ({ pageParam }) => getSidebarPages({ ...data, cursor: pageParam }),
|
queryFn: ({ pageParam }) => getSidebarPages({ ...data, cursor: pageParam, limit: 100 }),
|
||||||
initialPageParam: undefined,
|
initialPageParam: undefined,
|
||||||
getNextPageParam: (lastPage) =>
|
getNextPageParam: (lastPage) =>
|
||||||
lastPage.meta?.nextCursor ?? undefined,
|
lastPage.meta?.nextCursor ?? undefined,
|
||||||
@@ -263,7 +264,7 @@ export function useGetRootSidebarPagesQuery(data: SidebarPagesParams) {
|
|||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: ["root-sidebar-pages", data.spaceId],
|
queryKey: ["root-sidebar-pages", data.spaceId],
|
||||||
queryFn: async ({ pageParam }) => {
|
queryFn: async ({ pageParam }) => {
|
||||||
return getSidebarPages({ spaceId: data.spaceId, cursor: pageParam });
|
return getSidebarPages({ spaceId: data.spaceId, cursor: pageParam, limit: 100 });
|
||||||
},
|
},
|
||||||
initialPageParam: undefined,
|
initialPageParam: undefined,
|
||||||
getNextPageParam: (lastPage) =>
|
getNextPageParam: (lastPage) =>
|
||||||
@@ -293,12 +294,26 @@ export async function fetchAllAncestorChildren(params: SidebarPagesParams) {
|
|||||||
return buildTree(allItems);
|
return buildTree(allItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useRecentChangesQuery(
|
export function useRecentChangesQuery(spaceId?: string) {
|
||||||
spaceId?: string,
|
return useInfiniteQuery({
|
||||||
): UseQueryResult<IPagination<IPage>, Error> {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["recent-changes", spaceId],
|
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,
|
refetchOnMount: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export async function getAllSidebarPages(
|
|||||||
const pageParams: (string | undefined)[] = [];
|
const pageParams: (string | undefined)[] = [];
|
||||||
|
|
||||||
do {
|
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;
|
const data: IPagination<IPage> = req.data;
|
||||||
pages.push(data);
|
pages.push(data);
|
||||||
@@ -100,9 +100,16 @@ export async function getPageBreadcrumbs(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getRecentChanges(
|
export async function getRecentChanges(
|
||||||
spaceId?: string,
|
params?: QueryParams & { spaceId?: string },
|
||||||
): Promise<IPagination<IPage>> {
|
): 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;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import {
|
|||||||
IconLink,
|
IconLink,
|
||||||
IconPlus,
|
IconPlus,
|
||||||
IconPointFilled,
|
IconPointFilled,
|
||||||
|
IconStar,
|
||||||
|
IconStarFilled,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import {
|
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 { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||||
import CopyPageModal from "../../components/copy-page-modal.tsx";
|
import CopyPageModal from "../../components/copy-page-modal.tsx";
|
||||||
import { duplicatePage } from "../../services/page-service.ts";
|
import { duplicatePage } from "../../services/page-service.ts";
|
||||||
|
import { useFavoriteIds, useAddFavoriteMutation, useRemoveFavoriteMutation } from "@/features/favorite/queries/favorite-query";
|
||||||
|
|
||||||
interface SpaceTreeProps {
|
interface SpaceTreeProps {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
@@ -506,6 +509,10 @@ function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) {
|
|||||||
copyPageModalOpened,
|
copyPageModalOpened,
|
||||||
{ open: openCopyPageModal, close: closeCopySpaceModal },
|
{ open: openCopyPageModal, close: closeCopySpaceModal },
|
||||||
] = useDisclosure(false);
|
] = useDisclosure(false);
|
||||||
|
const favoriteIds = useFavoriteIds("page");
|
||||||
|
const addFavorite = useAddFavoriteMutation();
|
||||||
|
const removeFavorite = useRemoveFavoriteMutation();
|
||||||
|
const isFavorited = favoriteIds.has(node.data.id);
|
||||||
|
|
||||||
const handleCopyLink = () => {
|
const handleCopyLink = () => {
|
||||||
const pageUrl =
|
const pageUrl =
|
||||||
@@ -608,6 +615,21 @@ function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) {
|
|||||||
{t("Copy link")}
|
{t("Copy link")}
|
||||||
</Menu.Item>
|
</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
|
<Menu.Item
|
||||||
leftSection={<IconFileExport size={16} />}
|
leftSection={<IconFileExport size={16} />}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export interface SidebarPagesParams {
|
|||||||
spaceId?: string;
|
spaceId?: string;
|
||||||
pageId?: string;
|
pageId?: string;
|
||||||
cursor?: string;
|
cursor?: string;
|
||||||
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IPageInput {
|
export interface IPageInput {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
IconPlus,
|
IconPlus,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
|
IconTemplate,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import classes from "./space-sidebar.module.css";
|
import classes from "./space-sidebar.module.css";
|
||||||
@@ -39,6 +40,9 @@ import ExportModal from "@/components/common/export-modal";
|
|||||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||||
import { searchSpotlight } from "@/features/search/constants";
|
import { searchSpotlight } from "@/features/search/constants";
|
||||||
|
import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
|
||||||
|
import { Feature } from "@/ee/features";
|
||||||
|
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
||||||
|
|
||||||
export function SpaceSidebar() {
|
export function SpaceSidebar() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -48,6 +52,9 @@ export function SpaceSidebar() {
|
|||||||
useDisclosure(false);
|
useDisclosure(false);
|
||||||
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
|
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
|
||||||
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
|
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
|
||||||
|
const [entitlements] = useAtom(entitlementAtom);
|
||||||
|
const upgradeLabel = useUpgradeLabel();
|
||||||
|
const hasTemplates = entitlements?.features?.includes(Feature.TEMPLATES) ?? false;
|
||||||
|
|
||||||
const { spaceSlug } = useParams();
|
const { spaceSlug } = useParams();
|
||||||
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
||||||
@@ -74,11 +81,13 @@ export function SpaceSidebar() {
|
|||||||
marginBottom: 3,
|
marginBottom: 3,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Group gap={4} wrap="nowrap" justify="space-between" style={{ width: "100%" }}>
|
||||||
<SwitchSpace
|
<SwitchSpace
|
||||||
spaceName={space?.name}
|
spaceName={space?.name}
|
||||||
spaceSlug={space?.slug}
|
spaceSlug={space?.slug}
|
||||||
spaceIcon={space?.logo}
|
spaceIcon={space?.logo}
|
||||||
/>
|
/>
|
||||||
|
</Group>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={classes.section}>
|
<div className={classes.section}>
|
||||||
@@ -128,6 +137,44 @@ export function SpaceSidebar() {
|
|||||||
</div>
|
</div>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
|
|
||||||
|
{hasTemplates ? (
|
||||||
|
<UnstyledButton
|
||||||
|
component={Link}
|
||||||
|
to="/templates"
|
||||||
|
className={clsx(
|
||||||
|
classes.menu,
|
||||||
|
location.pathname.toLowerCase() === "/templates"
|
||||||
|
? classes.activeButton
|
||||||
|
: "",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={classes.menuItemInner}>
|
||||||
|
<IconTemplate
|
||||||
|
size={18}
|
||||||
|
className={classes.menuItemIcon}
|
||||||
|
stroke={2}
|
||||||
|
/>
|
||||||
|
<span>{t("Templates")}</span>
|
||||||
|
</div>
|
||||||
|
</UnstyledButton>
|
||||||
|
) : (
|
||||||
|
<Tooltip label={upgradeLabel} position="right" withArrow>
|
||||||
|
<UnstyledButton
|
||||||
|
className={classes.menu}
|
||||||
|
style={{ opacity: 0.5, cursor: "not-allowed" }}
|
||||||
|
>
|
||||||
|
<div className={classes.menuItemInner}>
|
||||||
|
<IconTemplate
|
||||||
|
size={18}
|
||||||
|
className={classes.menuItemIcon}
|
||||||
|
stroke={2}
|
||||||
|
/>
|
||||||
|
<span>{t("Templates")}</span>
|
||||||
|
</div>
|
||||||
|
</UnstyledButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
{spaceAbility.can(
|
{spaceAbility.can(
|
||||||
SpaceCaslAction.Manage,
|
SpaceCaslAction.Manage,
|
||||||
SpaceCaslSubject.Page,
|
SpaceCaslSubject.Page,
|
||||||
|
|||||||
@@ -7,9 +7,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cardSection {
|
.cardSection {
|
||||||
|
position: relative;
|
||||||
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
|
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 {
|
.title {
|
||||||
font-family:
|
font-family:
|
||||||
Greycliff CF,
|
Greycliff CF,
|
||||||
|
|||||||
@@ -12,12 +12,15 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { IconArrowRight } from "@tabler/icons-react";
|
import { IconArrowRight } from "@tabler/icons-react";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
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() {
|
export default function SpaceGrid() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data, isLoading } = useGetSpacesQuery({ limit: 10 });
|
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
|
<Card
|
||||||
key={space.id}
|
key={space.id}
|
||||||
p="xs"
|
p="xs"
|
||||||
@@ -28,7 +31,11 @@ export default function SpaceGrid() {
|
|||||||
className={classes.card}
|
className={classes.card}
|
||||||
withBorder
|
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
|
<CustomAvatar
|
||||||
name={space.name}
|
name={space.name}
|
||||||
avatarUrl={space.logo}
|
avatarUrl={space.logo}
|
||||||
@@ -59,7 +66,7 @@ export default function SpaceGrid() {
|
|||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3 }}>{cards}</SimpleGrid>
|
<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">
|
<Group justify="flex-end" mt="lg">
|
||||||
<Button
|
<Button
|
||||||
component={Link}
|
component={Link}
|
||||||
|
|||||||
@@ -1,23 +1,50 @@
|
|||||||
import { Text, Tabs, Space } from "@mantine/core";
|
import { Text, Tabs, Space } from "@mantine/core";
|
||||||
import { IconClockHour3 } from "@tabler/icons-react";
|
import { IconClockHour3, IconStar, IconUser } from "@tabler/icons-react";
|
||||||
import RecentChanges from "@/components/common/recent-changes.tsx";
|
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 { 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 { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "space-home-tab";
|
||||||
|
const DEFAULT_TAB = "recent";
|
||||||
|
const VALID_TABS = ["recent", "favorites", "created"];
|
||||||
|
|
||||||
|
function getStoredTab(): string {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return stored && VALID_TABS.includes(stored) ? stored : DEFAULT_TAB;
|
||||||
|
}
|
||||||
|
|
||||||
export default function SpaceHomeTabs() {
|
export default function SpaceHomeTabs() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { spaceSlug } = useParams();
|
const { spaceSlug } = useParams();
|
||||||
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs defaultValue="recent">
|
<Tabs
|
||||||
|
color="dark"
|
||||||
|
defaultValue={getStoredTab()}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (value) localStorage.setItem(STORAGE_KEY, value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
<Tabs.Tab value="recent" leftSection={<IconClockHour3 size={18} />}>
|
<Tabs.Tab value="recent" leftSection={<IconClockHour3 size={18} />}>
|
||||||
<Text size="sm" fw={500}>
|
<Text size="sm" fw={500}>
|
||||||
{t("Recently updated")}
|
{t("Recently updated")}
|
||||||
</Text>
|
</Text>
|
||||||
</Tabs.Tab>
|
</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>
|
</Tabs.List>
|
||||||
|
|
||||||
<Space my="md" />
|
<Space my="md" />
|
||||||
@@ -25,6 +52,12 @@ export default function SpaceHomeTabs() {
|
|||||||
<Tabs.Panel value="recent">
|
<Tabs.Panel value="recent">
|
||||||
{space?.id && <RecentChanges spaceId={space.id} />}
|
{space?.id && <RecentChanges spaceId={space.id} />}
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
<Tabs.Panel value="favorites">
|
||||||
|
<FavoritesPages />
|
||||||
|
</Tabs.Panel>
|
||||||
|
<Tabs.Panel value="created">
|
||||||
|
{space?.id && <CreatedByMe spaceId={space.id} />}
|
||||||
|
</Tabs.Panel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Anchor,
|
Anchor,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconDots, IconSettings } from "@tabler/icons-react";
|
import { IconDots, IconSettings } from "@tabler/icons-react";
|
||||||
|
import StarButton from "@/features/favorite/components/star-button";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
@@ -117,6 +118,7 @@ export default function AllSpacesList({
|
|||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Group gap="xs" justify="flex-end">
|
<Group gap="xs" justify="flex-end">
|
||||||
|
<StarButton type="space" spaceId={space.id} size={16} />
|
||||||
<Menu position="bottom-end">
|
<Menu position="bottom-end">
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<ActionIcon variant="subtle" color="gray">
|
<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) {
|
if (spaceId) {
|
||||||
// this endpoint only accepts uuid for now
|
// this endpoint only accepts uuid for now
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchInfiniteQuery({
|
||||||
queryKey: ["recent-changes", spaceId],
|
queryKey: ["recent-changes", spaceId],
|
||||||
queryFn: () => getRecentChanges(spaceId),
|
queryFn: () => getRecentChanges({ spaceId }),
|
||||||
|
initialPageParam: undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,12 +27,14 @@ export interface IWorkspace {
|
|||||||
mcpEnabled?: boolean;
|
mcpEnabled?: boolean;
|
||||||
trashRetentionDays?: number;
|
trashRetentionDays?: number;
|
||||||
restrictApiToAdmins?: boolean;
|
restrictApiToAdmins?: boolean;
|
||||||
|
allowMemberTemplates?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IWorkspaceSettings {
|
export interface IWorkspaceSettings {
|
||||||
ai?: IWorkspaceAiSettings;
|
ai?: IWorkspaceAiSettings;
|
||||||
sharing?: IWorkspaceSharingSettings;
|
sharing?: IWorkspaceSharingSettings;
|
||||||
api?: IWorkspaceApiSettings;
|
api?: IWorkspaceApiSettings;
|
||||||
|
templates?: IWorkspaceTemplateSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IWorkspaceApiSettings {
|
export interface IWorkspaceApiSettings {
|
||||||
@@ -49,6 +51,10 @@ export interface IWorkspaceSharingSettings {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IWorkspaceTemplateSettings {
|
||||||
|
allowMemberTemplates?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ICreateInvite {
|
export interface ICreateInvite {
|
||||||
role: string;
|
role: string;
|
||||||
emails: string[];
|
emails: string[];
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ function getSnapshot() {
|
|||||||
return tick;
|
return tick;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTimeAgo(date: Date | string) {
|
export function useTimeAgo(date: Date | string | undefined) {
|
||||||
const currentTick = useSyncExternalStore(subscribe, getSnapshot);
|
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 = {
|
const APP_ROUTE = {
|
||||||
HOME: "/home",
|
HOME: "/home",
|
||||||
SPACES: "/spaces",
|
SPACES: "/spaces",
|
||||||
|
FAVORITES: "/favorites",
|
||||||
SEARCH: "/search",
|
SEARCH: "/search",
|
||||||
AUTH: {
|
AUTH: {
|
||||||
LOGIN: "/login",
|
LOGIN: "/login",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { getAppName } from "@/lib/config";
|
|||||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
|
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
|
||||||
import CreateSpaceModal from "@/features/space/components/create-space-modal";
|
import CreateSpaceModal from "@/features/space/components/create-space-modal";
|
||||||
import { AllSpacesList } from "@/features/space/components/spaces-page";
|
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 { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
|
||||||
import useUserRole from "@/hooks/use-user-role";
|
import useUserRole from "@/hooks/use-user-role";
|
||||||
|
|
||||||
@@ -33,9 +34,11 @@ export default function Spaces() {
|
|||||||
{isAdmin && <CreateSpaceModal />}
|
{isAdmin && <CreateSpaceModal />}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
<FavoriteSpacesGrid />
|
||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
<Text size="sm" c="dimmed" mb="md">
|
<Text size="sm" c="dimmed" mb="md">
|
||||||
{t("Spaces you belong to")}
|
{t("All spaces")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<AllSpacesList
|
<AllSpacesList
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "server",
|
"name": "server",
|
||||||
"version": "0.70.2",
|
"version": "0.70.3",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { AuditContextMiddleware } from '../common/middlewares/audit-context.midd
|
|||||||
import { ShareModule } from './share/share.module';
|
import { ShareModule } from './share/share.module';
|
||||||
import { NotificationModule } from './notification/notification.module';
|
import { NotificationModule } from './notification/notification.module';
|
||||||
import { WatcherModule } from './watcher/watcher.module';
|
import { WatcherModule } from './watcher/watcher.module';
|
||||||
|
import { FavoriteModule } from './favorite/favorite.module';
|
||||||
import { ClsMiddleware } from 'nestjs-cls';
|
import { ClsMiddleware } from 'nestjs-cls';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -30,6 +31,7 @@ import { ClsMiddleware } from 'nestjs-cls';
|
|||||||
PageModule,
|
PageModule,
|
||||||
AttachmentModule,
|
AttachmentModule,
|
||||||
CommentModule,
|
CommentModule,
|
||||||
|
FavoriteModule,
|
||||||
SearchModule,
|
SearchModule,
|
||||||
SpaceModule,
|
SpaceModule,
|
||||||
GroupModule,
|
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 { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||||
import { executeTx } from '@docmost/db/utils';
|
import { executeTx } from '@docmost/db/utils';
|
||||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.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 { AuditEvent, AuditResource } from '../../../common/events/audit-events';
|
||||||
import {
|
import {
|
||||||
AUDIT_SERVICE,
|
AUDIT_SERVICE,
|
||||||
@@ -29,6 +30,7 @@ export class GroupUserService {
|
|||||||
@Inject(forwardRef(() => GroupService))
|
@Inject(forwardRef(() => GroupService))
|
||||||
private groupService: GroupService,
|
private groupService: GroupService,
|
||||||
private readonly watcherRepo: WatcherRepo,
|
private readonly watcherRepo: WatcherRepo,
|
||||||
|
private readonly favoriteRepo: FavoriteRepo,
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||||
) {}
|
) {}
|
||||||
@@ -137,6 +139,12 @@ export class GroupUserService {
|
|||||||
spaceId,
|
spaceId,
|
||||||
{ trx },
|
{ 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 { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
||||||
import { GroupUserService } from './group-user.service';
|
import { GroupUserService } from './group-user.service';
|
||||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
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 { executeTx } from '@docmost/db/utils';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
|
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
|
||||||
@@ -34,6 +35,7 @@ export class GroupService {
|
|||||||
@Inject(forwardRef(() => GroupUserService))
|
@Inject(forwardRef(() => GroupUserService))
|
||||||
private groupUserService: GroupUserService,
|
private groupUserService: GroupUserService,
|
||||||
private readonly watcherRepo: WatcherRepo,
|
private readonly watcherRepo: WatcherRepo,
|
||||||
|
private readonly favoriteRepo: FavoriteRepo,
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||||
) {}
|
) {}
|
||||||
@@ -189,6 +191,12 @@ export class GroupService {
|
|||||||
spaceId,
|
spaceId,
|
||||||
{ trx },
|
{ trx },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await this.favoriteRepo.deleteByUsersWithoutSpaceAccess(
|
||||||
|
userIds,
|
||||||
|
spaceId,
|
||||||
|
{ trx },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreatedByUserDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
userId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
spaceId?: string;
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { IsOptional, IsString, IsUUID } from 'class-validator';
|
import { IsOptional, IsString, IsUUID } from 'class-validator';
|
||||||
import { SpaceIdDto } from './page.dto';
|
|
||||||
|
|
||||||
export class SidebarPageDto {
|
export class SidebarPageDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
import { RecentPageDto } from './dto/recent-page.dto';
|
import { RecentPageDto } from './dto/recent-page.dto';
|
||||||
|
import { CreatedByUserDto } from './dto/created-by-user.dto';
|
||||||
import { DuplicatePageDto } from './dto/duplicate-page.dto';
|
import { DuplicatePageDto } from './dto/duplicate-page.dto';
|
||||||
import { DeletedPageDto } from './dto/deleted-page.dto';
|
import { DeletedPageDto } from './dto/deleted-page.dto';
|
||||||
import {
|
import {
|
||||||
@@ -336,6 +337,29 @@ export class PageController {
|
|||||||
return this.pageService.getRecentPages(user.id, pagination);
|
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)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('trash')
|
@Post('trash')
|
||||||
async getDeletedPages(
|
async getDeletedPages(
|
||||||
|
|||||||
@@ -296,7 +296,7 @@ export class PageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await executeWithCursorPagination(query, {
|
const result = await executeWithCursorPagination(query, {
|
||||||
perPage: 200,
|
perPage: pagination.limit,
|
||||||
cursor: pagination.cursor,
|
cursor: pagination.cursor,
|
||||||
beforeCursor: pagination.beforeCursor,
|
beforeCursor: pagination.beforeCursor,
|
||||||
fields: [
|
fields: [
|
||||||
@@ -825,6 +825,33 @@ export class PageService {
|
|||||||
return result;
|
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(
|
async getDeletedSpacePages(
|
||||||
spaceId: string,
|
spaceId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
|||||||
@@ -91,9 +91,15 @@ export class SearchService {
|
|||||||
return { items: [] };
|
return { items: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isRestricted =
|
||||||
|
await this.pagePermissionRepo.hasRestrictedAncestor(share.pageId);
|
||||||
|
if (isRestricted) {
|
||||||
|
return { items: [] };
|
||||||
|
}
|
||||||
|
|
||||||
const pageIdsToSearch = [];
|
const pageIdsToSearch = [];
|
||||||
if (share.includeSubPages) {
|
if (share.includeSubPages) {
|
||||||
const pageList = await this.pageRepo.getPageAndDescendants(
|
const pageList = await this.pageRepo.getPageAndDescendantsExcludingRestricted(
|
||||||
share.pageId,
|
share.pageId,
|
||||||
{
|
{
|
||||||
includeContent: false,
|
includeContent: false,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { UpdateSpaceMemberRoleDto } from '../dto/update-space-member-role.dto';
|
|||||||
import { SpaceRole } from '../../../common/helpers/types/permission';
|
import { SpaceRole } from '../../../common/helpers/types/permission';
|
||||||
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
||||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
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 { executeTx } from '@docmost/db/utils';
|
||||||
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
|
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
|
||||||
import {
|
import {
|
||||||
@@ -31,6 +32,7 @@ export class SpaceMemberService {
|
|||||||
private groupUserRepo: GroupUserRepo,
|
private groupUserRepo: GroupUserRepo,
|
||||||
private spaceRepo: SpaceRepo,
|
private spaceRepo: SpaceRepo,
|
||||||
private watcherRepo: WatcherRepo,
|
private watcherRepo: WatcherRepo,
|
||||||
|
private favoriteRepo: FavoriteRepo,
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||||
) {}
|
) {}
|
||||||
@@ -272,6 +274,12 @@ export class SpaceMemberService {
|
|||||||
dto.spaceId,
|
dto.spaceId,
|
||||||
{ trx },
|
{ trx },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await this.favoriteRepo.deleteByUsersWithoutSpaceAccess(
|
||||||
|
affectedUserIds,
|
||||||
|
dto.spaceId,
|
||||||
|
{ trx },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.auditService.log({
|
this.auditService.log({
|
||||||
|
|||||||
@@ -53,7 +53,41 @@ export class SpaceController {
|
|||||||
pagination: PaginationOptions,
|
pagination: PaginationOptions,
|
||||||
@AuthUser() user: User,
|
@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)
|
@HttpCode(HttpStatus.OK)
|
||||||
|
|||||||
@@ -50,4 +50,8 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
trashRetentionDays: number;
|
trashRetentionDays: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
allowMemberTemplates: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { isPageEmbeddingsTableExists } from '@docmost/db/helpers/helpers';
|
|||||||
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
||||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.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 { AuditEvent, AuditResource } from '../../../common/events/audit-events';
|
||||||
import {
|
import {
|
||||||
AUDIT_SERVICE,
|
AUDIT_SERVICE,
|
||||||
@@ -62,6 +63,7 @@ export class WorkspaceService {
|
|||||||
private licenseCheckService: LicenseCheckService,
|
private licenseCheckService: LicenseCheckService,
|
||||||
private shareRepo: ShareRepo,
|
private shareRepo: ShareRepo,
|
||||||
private watcherRepo: WatcherRepo,
|
private watcherRepo: WatcherRepo,
|
||||||
|
private favoriteRepo: FavoriteRepo,
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
||||||
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
|
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
|
||||||
@@ -325,7 +327,8 @@ export class WorkspaceService {
|
|||||||
typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' ||
|
typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' ||
|
||||||
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
|
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
|
||||||
typeof updateWorkspaceDto.mcpEnabled !== 'undefined' ||
|
typeof updateWorkspaceDto.mcpEnabled !== 'undefined' ||
|
||||||
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined'
|
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined' ||
|
||||||
|
typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined'
|
||||||
) {
|
) {
|
||||||
const ws = await this.db
|
const ws = await this.db
|
||||||
.selectFrom('workspaces')
|
.selectFrom('workspaces')
|
||||||
@@ -348,7 +351,8 @@ export class WorkspaceService {
|
|||||||
if (
|
if (
|
||||||
typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' ||
|
typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' ||
|
||||||
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
|
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
|
||||||
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined'
|
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined' ||
|
||||||
|
typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined'
|
||||||
) {
|
) {
|
||||||
if (!this.licenseCheckService.hasFeature(ws.licenseKey, 'security:settings', ws.plan)) {
|
if (!this.licenseCheckService.hasFeature(ws.licenseKey, 'security:settings', ws.plan)) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
@@ -455,11 +459,26 @@ 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
delete updateWorkspaceDto.restrictApiToAdmins;
|
delete updateWorkspaceDto.restrictApiToAdmins;
|
||||||
delete updateWorkspaceDto.aiSearch;
|
delete updateWorkspaceDto.aiSearch;
|
||||||
delete updateWorkspaceDto.generativeAi;
|
delete updateWorkspaceDto.generativeAi;
|
||||||
delete updateWorkspaceDto.disablePublicSharing;
|
delete updateWorkspaceDto.disablePublicSharing;
|
||||||
delete updateWorkspaceDto.mcpEnabled;
|
delete updateWorkspaceDto.mcpEnabled;
|
||||||
|
delete updateWorkspaceDto.allowMemberTemplates;
|
||||||
|
|
||||||
await this.workspaceRepo.updateWorkspace(
|
await this.workspaceRepo.updateWorkspace(
|
||||||
updateWorkspaceDto,
|
updateWorkspaceDto,
|
||||||
@@ -785,6 +804,10 @@ export class WorkspaceService {
|
|||||||
await this.watcherRepo.deleteByUserAndWorkspace(userId, workspaceId, {
|
await this.watcherRepo.deleteByUserAndWorkspace(userId, workspaceId, {
|
||||||
trx,
|
trx,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.favoriteRepo.deleteByUserAndWorkspace(userId, workspaceId, {
|
||||||
|
trx,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
this.auditService.log({
|
this.auditService.log({
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
|
|||||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||||
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
|
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
|
||||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.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 { PageListener } from '@docmost/db/listeners/page.listener';
|
||||||
import { PostgresJSDialect } from 'kysely-postgres-js';
|
import { PostgresJSDialect } from 'kysely-postgres-js';
|
||||||
import * as postgres from 'postgres';
|
import * as postgres from 'postgres';
|
||||||
@@ -74,12 +76,14 @@ import { normalizePostgresUrl } from '../common/helpers';
|
|||||||
PagePermissionRepo,
|
PagePermissionRepo,
|
||||||
PageHistoryRepo,
|
PageHistoryRepo,
|
||||||
CommentRepo,
|
CommentRepo,
|
||||||
|
FavoriteRepo,
|
||||||
AttachmentRepo,
|
AttachmentRepo,
|
||||||
UserTokenRepo,
|
UserTokenRepo,
|
||||||
BacklinkRepo,
|
BacklinkRepo,
|
||||||
ShareRepo,
|
ShareRepo,
|
||||||
NotificationRepo,
|
NotificationRepo,
|
||||||
WatcherRepo,
|
WatcherRepo,
|
||||||
|
TemplateRepo,
|
||||||
PageListener,
|
PageListener,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
@@ -93,12 +97,14 @@ import { normalizePostgresUrl } from '../common/helpers';
|
|||||||
PagePermissionRepo,
|
PagePermissionRepo,
|
||||||
PageHistoryRepo,
|
PageHistoryRepo,
|
||||||
CommentRepo,
|
CommentRepo,
|
||||||
|
FavoriteRepo,
|
||||||
AttachmentRepo,
|
AttachmentRepo,
|
||||||
UserTokenRepo,
|
UserTokenRepo,
|
||||||
BacklinkRepo,
|
BacklinkRepo,
|
||||||
ShareRepo,
|
ShareRepo,
|
||||||
NotificationRepo,
|
NotificationRepo,
|
||||||
WatcherRepo,
|
WatcherRepo,
|
||||||
|
TemplateRepo,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class DatabaseModule implements OnApplicationBootstrap {
|
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) {
|
async getDeletedPagesInSpace(spaceId: string, pagination: PaginationOptions) {
|
||||||
const query = this.db
|
const query = this.db
|
||||||
.selectFrom('pages')
|
.selectFrom('pages')
|
||||||
|
|||||||
@@ -290,6 +290,32 @@ export class SpaceMemberRepo {
|
|||||||
return membership.map((space) => space.id);
|
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) {
|
async getUserSpaces(userId: string, pagination: PaginationOptions) {
|
||||||
let query = this.db
|
let query = this.db
|
||||||
.selectFrom('spaces')
|
.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();
|
.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
@@ -174,6 +174,17 @@ export interface Comments {
|
|||||||
workspaceId: string;
|
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 {
|
export interface FileTasks {
|
||||||
createdAt: Generated<Timestamp>;
|
createdAt: Generated<Timestamp>;
|
||||||
creatorId: string | null;
|
creatorId: string | null;
|
||||||
@@ -429,6 +440,25 @@ export interface PagePermissions {
|
|||||||
updatedAt: Generated<Timestamp>;
|
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 DB {
|
export interface DB {
|
||||||
apiKeys: ApiKeys;
|
apiKeys: ApiKeys;
|
||||||
attachments: Attachments;
|
attachments: Attachments;
|
||||||
@@ -438,6 +468,7 @@ export interface DB {
|
|||||||
backlinks: Backlinks;
|
backlinks: Backlinks;
|
||||||
billing: Billing;
|
billing: Billing;
|
||||||
comments: Comments;
|
comments: Comments;
|
||||||
|
favorites: Favorites;
|
||||||
fileTasks: FileTasks;
|
fileTasks: FileTasks;
|
||||||
groups: Groups;
|
groups: Groups;
|
||||||
groupUsers: GroupUsers;
|
groupUsers: GroupUsers;
|
||||||
@@ -449,6 +480,7 @@ export interface DB {
|
|||||||
shares: Shares;
|
shares: Shares;
|
||||||
spaceMembers: SpaceMembers;
|
spaceMembers: SpaceMembers;
|
||||||
spaces: Spaces;
|
spaces: Spaces;
|
||||||
|
templates: Templates;
|
||||||
userMfa: UserMfa;
|
userMfa: UserMfa;
|
||||||
users: Users;
|
users: Users;
|
||||||
userTokens: UserTokens;
|
userTokens: UserTokens;
|
||||||
|
|||||||
@@ -20,11 +20,13 @@ import {
|
|||||||
AuthProviders,
|
AuthProviders,
|
||||||
AuthAccounts,
|
AuthAccounts,
|
||||||
Shares,
|
Shares,
|
||||||
|
Favorites,
|
||||||
FileTasks,
|
FileTasks,
|
||||||
UserMfa as _UserMFA,
|
UserMfa as _UserMFA,
|
||||||
ApiKeys,
|
ApiKeys,
|
||||||
Watchers,
|
Watchers,
|
||||||
Audit as _Audit,
|
Audit as _Audit,
|
||||||
|
Templates,
|
||||||
} from './db';
|
} from './db';
|
||||||
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
|
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
|
||||||
|
|
||||||
@@ -117,6 +119,11 @@ export type Share = Selectable<Shares>;
|
|||||||
export type InsertableShare = Insertable<Shares>;
|
export type InsertableShare = Insertable<Shares>;
|
||||||
export type UpdatableShare = Updateable<Omit<Shares, 'id'>>;
|
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
|
// File Task
|
||||||
export type FileTask = Selectable<FileTasks>;
|
export type FileTask = Selectable<FileTasks>;
|
||||||
export type InsertableFileTask = Insertable<FileTasks>;
|
export type InsertableFileTask = Insertable<FileTasks>;
|
||||||
@@ -161,3 +168,8 @@ export type UpdatablePagePermission = Updateable<Omit<_PagePermissions, 'id'>>;
|
|||||||
export type Audit = Selectable<_Audit>;
|
export type Audit = Selectable<_Audit>;
|
||||||
export type InsertableAudit = Insertable<_Audit>;
|
export type InsertableAudit = Insertable<_Audit>;
|
||||||
export type UpdatableAudit = Updateable<Omit<_Audit, 'id'>>;
|
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: 0b5c8646e6...16cec9b3b0
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "docmost",
|
"name": "docmost",
|
||||||
"homepage": "https://docmost.com",
|
"homepage": "https://docmost.com",
|
||||||
"version": "0.70.2",
|
"version": "0.70.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nx run-many -t build",
|
"build": "nx run-many -t build",
|
||||||
|
|||||||
@@ -1,80 +1,111 @@
|
|||||||
import { findChildren } from '@tiptap/core'
|
import { findChildren } from '@tiptap/core';
|
||||||
import type { Node as ProsemirrorNode } from '@tiptap/pm/model'
|
import type { Node as ProsemirrorNode } from '@tiptap/pm/model';
|
||||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||||
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import highlight from 'highlight.js/lib/core'
|
import highlight from 'highlight.js/lib/core';
|
||||||
|
|
||||||
function parseNodes(nodes: any[], className: string[] = []): { text: string; classes: string[] }[] {
|
function parseNodes(
|
||||||
|
nodes: any[],
|
||||||
|
className: string[] = [],
|
||||||
|
): { text: string; classes: string[] }[] {
|
||||||
return nodes
|
return nodes
|
||||||
.map(node => {
|
.map((node) => {
|
||||||
const classes = [...className, ...(node.properties ? node.properties.className : [])]
|
const classes = [
|
||||||
|
...className,
|
||||||
|
...(node.properties ? node.properties.className : []),
|
||||||
|
];
|
||||||
|
|
||||||
if (node.children) {
|
if (node.children) {
|
||||||
return parseNodes(node.children, classes)
|
return parseNodes(node.children, classes);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
text: node.value,
|
text: node.value,
|
||||||
classes,
|
classes,
|
||||||
}
|
};
|
||||||
})
|
})
|
||||||
.flat()
|
.flat();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHighlightNodes(result: any) {
|
function getHighlightNodes(result: any) {
|
||||||
// `.value` for lowlight v1, `.children` for lowlight v2
|
// `.value` for lowlight v1, `.children` for lowlight v2
|
||||||
return result.value || result.children || []
|
return result.value || result.children || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function registered(aliasOrLanguage: string) {
|
function registered(aliasOrLanguage: string) {
|
||||||
return Boolean(highlight.getLanguage(aliasOrLanguage))
|
return Boolean(highlight.getLanguage(aliasOrLanguage));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Max characters to sample for auto-detection to avoid performance issues with large code blocks
|
||||||
|
const AUTO_DETECT_SAMPLE_SIZE = 3000;
|
||||||
|
|
||||||
function getDecorations({
|
function getDecorations({
|
||||||
doc,
|
doc,
|
||||||
name,
|
name,
|
||||||
lowlight,
|
lowlight,
|
||||||
defaultLanguage,
|
defaultLanguage,
|
||||||
}: {
|
}: {
|
||||||
doc: ProsemirrorNode
|
doc: ProsemirrorNode;
|
||||||
name: string
|
name: string;
|
||||||
lowlight: any
|
lowlight: any;
|
||||||
defaultLanguage: string | null | undefined
|
defaultLanguage: string | null | undefined;
|
||||||
}) {
|
}) {
|
||||||
const decorations: Decoration[] = []
|
const decorations: Decoration[] = [];
|
||||||
|
|
||||||
findChildren(doc, node => node.type.name === name).forEach(block => {
|
findChildren(doc, (node) => node.type.name === name).forEach((block) => {
|
||||||
let from = block.pos + 1
|
let from = block.pos + 1;
|
||||||
const language = block.node.attrs.language || defaultLanguage
|
const language = block.node.attrs.language || defaultLanguage;
|
||||||
const languages = lowlight.listLanguages()
|
const languages = lowlight.listLanguages();
|
||||||
|
const textContent = block.node.textContent;
|
||||||
|
|
||||||
const nodes =
|
let nodes;
|
||||||
language && (languages.includes(language) || registered(language) || lowlight.registered?.(language))
|
if (
|
||||||
? getHighlightNodes(lowlight.highlight(language, block.node.textContent))
|
language &&
|
||||||
: getHighlightNodes(lowlight.highlightAuto(block.node.textContent))
|
(languages.includes(language) ||
|
||||||
|
registered(language) ||
|
||||||
|
lowlight.registered?.(language))
|
||||||
|
) {
|
||||||
|
nodes = getHighlightNodes(lowlight.highlight(language, textContent));
|
||||||
|
} else {
|
||||||
|
// For auto-detection, sample a limited portion to detect the language,
|
||||||
|
// then highlight the full content with the detected language
|
||||||
|
const sample =
|
||||||
|
textContent.length > AUTO_DETECT_SAMPLE_SIZE
|
||||||
|
? textContent.slice(0, AUTO_DETECT_SAMPLE_SIZE)
|
||||||
|
: textContent;
|
||||||
|
const autoResult = lowlight.highlightAuto(sample);
|
||||||
|
const detectedLanguage = autoResult.data?.language;
|
||||||
|
if (detectedLanguage && textContent.length > AUTO_DETECT_SAMPLE_SIZE) {
|
||||||
|
nodes = getHighlightNodes(
|
||||||
|
lowlight.highlight(detectedLanguage, textContent),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
nodes = getHighlightNodes(autoResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
parseNodes(nodes).forEach(node => {
|
parseNodes(nodes).forEach((node) => {
|
||||||
const to = from + node.text.length
|
const to = from + node.text.length;
|
||||||
|
|
||||||
if (node.classes.length) {
|
if (node.classes.length) {
|
||||||
const decoration = Decoration.inline(from, to, {
|
const decoration = Decoration.inline(from, to, {
|
||||||
class: node.classes.join(' '),
|
class: node.classes.join(' '),
|
||||||
})
|
});
|
||||||
|
|
||||||
decorations.push(decoration)
|
decorations.push(decoration);
|
||||||
}
|
}
|
||||||
|
|
||||||
from = to
|
from = to;
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
return DecorationSet.create(doc, decorations)
|
return DecorationSet.create(doc, decorations);
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||||
function isFunction(param: any): param is Function {
|
function isFunction(param: any): param is Function {
|
||||||
return typeof param === 'function'
|
return typeof param === 'function';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LowlightPlugin({
|
export function LowlightPlugin({
|
||||||
@@ -82,12 +113,18 @@ export function LowlightPlugin({
|
|||||||
lowlight,
|
lowlight,
|
||||||
defaultLanguage,
|
defaultLanguage,
|
||||||
}: {
|
}: {
|
||||||
name: string
|
name: string;
|
||||||
lowlight: any
|
lowlight: any;
|
||||||
defaultLanguage: string | null | undefined
|
defaultLanguage: string | null | undefined;
|
||||||
}) {
|
}) {
|
||||||
if (!['highlight', 'highlightAuto', 'listLanguages'].every(api => isFunction(lowlight[api]))) {
|
if (
|
||||||
throw Error('You should provide an instance of lowlight to use the code-block-lowlight extension')
|
!['highlight', 'highlightAuto', 'listLanguages'].every((api) =>
|
||||||
|
isFunction(lowlight[api]),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw Error(
|
||||||
|
'You should provide an instance of lowlight to use the code-block-lowlight extension',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const lowlightPlugin: Plugin<any> = new Plugin({
|
const lowlightPlugin: Plugin<any> = new Plugin({
|
||||||
@@ -102,10 +139,16 @@ export function LowlightPlugin({
|
|||||||
defaultLanguage,
|
defaultLanguage,
|
||||||
}),
|
}),
|
||||||
apply: (transaction, decorationSet, oldState, newState) => {
|
apply: (transaction, decorationSet, oldState, newState) => {
|
||||||
const oldNodeName = oldState.selection.$head.parent.type.name
|
const oldNodeName = oldState.selection.$head.parent.type.name;
|
||||||
const newNodeName = newState.selection.$head.parent.type.name
|
const newNodeName = newState.selection.$head.parent.type.name;
|
||||||
const oldNodes = findChildren(oldState.doc, node => node.type.name === name)
|
const oldNodes = findChildren(
|
||||||
const newNodes = findChildren(newState.doc, node => node.type.name === name)
|
oldState.doc,
|
||||||
|
(node) => node.type.name === name,
|
||||||
|
);
|
||||||
|
const newNodes = findChildren(
|
||||||
|
newState.doc,
|
||||||
|
(node) => node.type.name === name,
|
||||||
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
transaction.docChanged &&
|
transaction.docChanged &&
|
||||||
@@ -117,23 +160,23 @@ export function LowlightPlugin({
|
|||||||
// OR transaction has changes that completely encapsulte a node
|
// OR transaction has changes that completely encapsulte a node
|
||||||
// (for example, a transaction that affects the entire document).
|
// (for example, a transaction that affects the entire document).
|
||||||
// Such transactions can happen during collab syncing via y-prosemirror, for example.
|
// Such transactions can happen during collab syncing via y-prosemirror, for example.
|
||||||
transaction.steps.some(step => {
|
transaction.steps.some((step) => {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
return (
|
return (
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
step.from !== undefined &&
|
step.from !== undefined &&
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
step.to !== undefined &&
|
step.to !== undefined &&
|
||||||
oldNodes.some(node => {
|
oldNodes.some((node) => {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
return (
|
return (
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
node.pos >= step.from &&
|
node.pos >= step.from &&
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
node.pos + node.node.nodeSize <= step.to
|
node.pos + node.node.nodeSize <= step.to
|
||||||
)
|
);
|
||||||
})
|
})
|
||||||
)
|
);
|
||||||
}))
|
}))
|
||||||
) {
|
) {
|
||||||
return getDecorations({
|
return getDecorations({
|
||||||
@@ -141,19 +184,19 @@ export function LowlightPlugin({
|
|||||||
name,
|
name,
|
||||||
lowlight,
|
lowlight,
|
||||||
defaultLanguage,
|
defaultLanguage,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return decorationSet.map(transaction.mapping, transaction.doc)
|
return decorationSet.map(transaction.mapping, transaction.doc);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
decorations(state) {
|
decorations(state) {
|
||||||
return lowlightPlugin.getState(state)
|
return lowlightPlugin.getState(state);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return lowlightPlugin
|
return lowlightPlugin;
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user