feat: favorites and templates(ee)

This commit is contained in:
Philipinho
2026-04-12 20:16:46 +01:00
parent 74eddb0638
commit 74118e11d7
89 changed files with 4708 additions and 196 deletions
+1 -1
View File
@@ -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",
+9
View File
@@ -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)}
/>
)}
</>
);
}
+1
View File
@@ -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[];
+2 -2
View File
@@ -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
View File
@@ -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>
);
}
+4 -1
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "server", "name": "server",
"version": "0.70.2", "version": "0.70.3",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
+2
View File
@@ -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({
+35 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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;
} }