diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index b83b3548..7f3eb11c 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -7,6 +7,7 @@ "Add members": "Add members", "Add to groups": "Add to groups", "Add space members": "Add space members", + "Add to favorites": "Add to favorites", "Admin": "Admin", "Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "Are you sure you want to delete this group? Members will lose access to resources this group has access to.", "Are you sure you want to delete this page?": "Are you sure you want to delete this page?", @@ -74,6 +75,9 @@ "Failed to import pages": "Failed to import pages", "Failed to load page. An error occurred.": "Failed to load page. An error occurred.", "Failed to update data": "Failed to update data", + "Favorite spaces": "Favorite spaces", + "Favorite spaces appear here": "Favorite spaces appear here", + "Favorites": "Favorites", "Full access": "Full access", "Full page width": "Full page width", "Full width": "Full width", @@ -92,6 +96,7 @@ "Invite by email": "Invite by email", "Invite members": "Invite members", "Invite new members": "Invite new members", + "Invite People": "Invite People", "Invited members who are yet to accept their invitation will appear here.": "Invited members who are yet to accept their invitation will appear here.", "Invited members will be granted access to spaces the groups can access": "Invited members will be granted access to spaces the groups can access", "Join the workspace": "Join the workspace", @@ -139,6 +144,7 @@ "Profile": "Profile", "Recently updated": "Recently updated", "Remove": "Remove", + "Remove from favorites": "Remove from favorites", "Remove group member": "Remove group member", "Remove space member": "Remove space member", "Restore": "Restore", @@ -175,6 +181,7 @@ "Successfully imported": "Successfully imported", "Successfully restored": "Successfully restored", "System settings": "System settings", + "Templates": "Templates", "Theme": "Theme", "To change your email, you have to enter your password and new email.": "To change your email, you have to enter your password and new email.", "Toggle full page width": "Toggle full page width", @@ -478,6 +485,7 @@ "Replace (Enter)": "Replace (Enter)", "Replace all (Ctrl+Alt+Enter)": "Replace all (Ctrl+Alt+Enter)", "Replace all": "Replace all", + "View all": "View all", "View all spaces": "View all spaces", "Error": "Error", "Failed to disable MFA": "Failed to disable MFA", diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index 382660c7..8c24b3d1 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -38,6 +38,9 @@ import UserApiKeys from "@/ee/api-key/pages/user-api-keys"; import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys"; import AiSettings from "@/ee/ai/pages/ai-settings.tsx"; import AuditLogs from "@/ee/audit/pages/audit-logs.tsx"; +import TemplateList from "@/ee/template/pages/template-list"; +import TemplateEditor from "@/ee/template/pages/template-editor"; +import FavoritesPage from "@/pages/favorites/favorites-page"; import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx"; import VerifyEmail from "@/ee/pages/verify-email.tsx"; @@ -85,6 +88,12 @@ export default function App() { } /> } /> } /> + } /> + } /> + } + /> } /> } /> p.items) ?? []; if (isLoading) { return ; @@ -33,58 +35,72 @@ export default function RecentChanges({ spaceId }: Props) { return {t("Failed to fetch recent pages")}; } - return pages && pages.items.length > 0 ? ( - - - - {pages.items.map((page) => ( - - - - - {page.icon || ( - - - - )} - - - {page.title || t("Untitled")} - - - - - {!spaceId && ( + return pages.length > 0 ? ( + <> + +
+ + {pages.map((page) => ( + - - {page?.space.name} - + + {page.icon || ( + + + + )} + + + {page.title || t("Untitled")} + + + - )} - - - {formattedDate(page.updatedAt)} - - - - ))} - -
-
+ {!spaceId && ( + + + {page?.space.name} + + + )} + + + {formattedDate(page.updatedAt)} + + + + ))} + + + + {hasNextPage && ( + + )} + ) : ( ( @@ -67,29 +64,25 @@ export function AppHeader() { <> - {!hideSidebar && ( - <> - - - + + + - - - - - )} + + + diff --git a/apps/client/src/components/layouts/global/global-app-shell.tsx b/apps/client/src/components/layouts/global/global-app-shell.tsx index 06629898..64bd3dde 100644 --- a/apps/client/src/components/layouts/global/global-app-shell.tsx +++ b/apps/client/src/components/layouts/global/global-app-shell.tsx @@ -16,6 +16,7 @@ import Aside from "@/components/layouts/global/aside.tsx"; import classes from "./app-shell.module.css"; import { useTrialEndAction } from "@/ee/hooks/use-trial-end-action.tsx"; import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts"; +import GlobalSidebar from "@/components/layouts/global/global-sidebar.tsx"; export default function GlobalAppShell({ children, @@ -74,24 +75,20 @@ export default function GlobalAppShell({ const isSettingsRoute = location.pathname.startsWith("/settings"); const isSpaceRoute = location.pathname.startsWith("/s/"); const isAiRoute = location.pathname.startsWith("/ai"); - const isHomeRoute = location.pathname.startsWith("/home"); - const isSpacesRoute = location.pathname === "/spaces"; const isPageRoute = location.pathname.includes("/p/"); - const hideSidebar = isHomeRoute || isSpacesRoute; + const showGlobalSidebar = !isSpaceRoute && !isSettingsRoute && !isAiRoute; return ( - {!hideSidebar && ( - - {!isAiRoute &&
} - {isSpaceRoute && } - {isSettingsRoute && } - {isAiRoute && } - - )} + + {isSpaceRoute && ( +
+ )} + {isSpaceRoute && } + {isSettingsRoute && } + {isAiRoute && } + {showGlobalSidebar && } + {isSettingsRoute ? ( - {children} + {children} ) : ( children )} diff --git a/apps/client/src/components/layouts/global/global-sidebar.module.css b/apps/client/src/components/layouts/global/global-sidebar.module.css new file mode 100644 index 00000000..37cb60b6 --- /dev/null +++ b/apps/client/src/components/layouts/global/global-sidebar.module.css @@ -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)); + } +} diff --git a/apps/client/src/components/layouts/global/global-sidebar.tsx b/apps/client/src/components/layouts/global/global-sidebar.tsx new file mode 100644 index 00000000..4d86a264 --- /dev/null +++ b/apps/client/src/components/layouts/global/global-sidebar.tsx @@ -0,0 +1,158 @@ +import { useEffect, useState } from "react"; +import { ScrollArea, Text, Divider, Modal } from "@mantine/core"; +import { + IconHome, + IconClock, + IconStar, + IconLayoutGrid, + IconSettings, + IconUserPlus, +} from "@tabler/icons-react"; +import { Link, useLocation } from "react-router-dom"; +import classes from "./global-sidebar.module.css"; +import { useTranslation } from "react-i18next"; +import { useAtom } from "jotai"; +import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom"; +import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar"; +import { useFavoritesQuery } from "@/features/favorite/queries/favorite-query"; +import { getSpaceUrl } from "@/lib/config"; +import { useDisclosure } from "@mantine/hooks"; +import { WorkspaceInviteForm } from "@/features/workspace/components/members/components/workspace-invite-form"; +import { CustomAvatar } from "@/components/ui/custom-avatar"; +import { AvatarIconType } from "@/features/attachments/types/attachment.types"; + +const mainNavItems = [ + { label: "Home", icon: IconHome, path: "/home" }, + { label: "Favorites", icon: IconStar, path: "/favorites" }, + { label: "Spaces", icon: IconLayoutGrid, path: "/spaces" }, +]; + +export default function GlobalSidebar() { + const { t } = useTranslation(); + const location = useLocation(); + const [active, setActive] = useState(location.pathname); + const [mobileSidebarOpened] = useAtom(mobileSidebarAtom); + const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom); + const { data: favoriteSpacesData } = useFavoritesQuery("space"); + const favoriteSpaces = favoriteSpacesData?.pages.flatMap((p) => p.items) ?? []; + const sortedFavoriteSpaces = [...favoriteSpaces] + .filter((fav) => fav.space) + .sort((a, b) => { + const cmp = (a.space!.name ?? "").localeCompare(b.space!.name ?? "", undefined, { sensitivity: "base" }); + return cmp !== 0 ? cmp : a.id.localeCompare(b.id); + }); + const [inviteOpened, { open: openInvite, close: closeInvite }] = + useDisclosure(false); + + useEffect(() => { + setActive(location.pathname); + }, [location.pathname]); + + const handleNavClick = () => { + if (mobileSidebarOpened) { + toggleMobileSidebar(); + } + }; + + return ( +
+ +
+ {mainNavItems.map((item) => ( + + + {t(item.label)} + + ))} +
+ + +
+ {t("Favorite spaces")} + {sortedFavoriteSpaces.length === 0 ? ( + + {t("Favorite spaces appear here")} + + ) : ( + <> + {sortedFavoriteSpaces.slice(0, 10).map((fav) => ( + + + + {fav.space!.name} + + + ))} + {sortedFavoriteSpaces.length > 10 && ( + + + {t("View all")} + + + )} + + )} +
+ +
+ + + + + + + + + +
+ ); +} diff --git a/apps/client/src/components/ui/destination-picker/destination-picker-modal.tsx b/apps/client/src/components/ui/destination-picker/destination-picker-modal.tsx new file mode 100644 index 00000000..04e9ef7d --- /dev/null +++ b/apps/client/src/components/ui/destination-picker/destination-picker-modal.tsx @@ -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(null); + + useEffect(() => { + if (!opened) { + setSelection(null); + } + }, [opened]); + + return ( + e.stopPropagation()} + > + + + + {title} + + + + + + + + + + + + + ); +} diff --git a/apps/client/src/components/ui/destination-picker/destination-picker.module.css b/apps/client/src/components/ui/destination-picker/destination-picker.module.css new file mode 100644 index 00000000..ec598ac5 --- /dev/null +++ b/apps/client/src/components/ui/destination-picker/destination-picker.module.css @@ -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; +} diff --git a/apps/client/src/components/ui/destination-picker/destination-picker.tsx b/apps/client/src/components/ui/destination-picker/destination-picker.tsx new file mode 100644 index 00000000..1ddc747d --- /dev/null +++ b/apps/client/src/components/ui/destination-picker/destination-picker.tsx @@ -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(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) => { + 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, space: ISpace) => { + if (!page.id) return; + updateSelection({ + type: "page", + spaceId: page.spaceId ?? space.id, + pageId: page.id, + page, + space, + }); + }, + [updateSelection], + ); + + return ( + <> + } + placeholder={t("Search pages and spaces...")} + variant="filled" + value={searchQuery} + onChange={(e) => setSearchQuery(e.currentTarget.value)} + className={classes.searchInput} + /> + + + {isSearching ? ( + searchLoading ? ( +
+ +
+ ) : searchData?.pages && searchData.pages.length > 0 ? ( + searchData.pages.map( + (page) => + page && ( +
handleSearchResultClick(page)} + > +
+ {page.icon ? ( + page.icon + ) : ( + + )} +
+
+ {page.title || t("Untitled")} +
+ {page.space && ( +
+ {page.space.name} +
+ )} +
+ ), + ) + ) : ( +
{t("No results found")}
+ ) + ) : spacesLoading ? ( +
+ +
+ ) : ( + spacesData?.items?.map((space) => ( + + )) + )} +
+ + {selection && ( +
+ {selection.type === "space" + ? selection.space.name + : `${selection.space.name} / ${selection.page.title || t("Untitled")}`} +
+ )} + + ); +} diff --git a/apps/client/src/components/ui/destination-picker/destination-picker.types.ts b/apps/client/src/components/ui/destination-picker/destination-picker.types.ts new file mode 100644 index 00000000..005259b3 --- /dev/null +++ b/apps/client/src/components/ui/destination-picker/destination-picker.types.ts @@ -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; + space: Partial; + }; + +export type DestinationPickerModalProps = { + opened: boolean; + onClose: () => void; + title: string; + actionLabel: string; + onSelect: (selection: DestinationSelection) => void | Promise; + loading?: boolean; + excludePageId?: string; + pageLimit?: number; +}; diff --git a/apps/client/src/components/ui/destination-picker/page-children.tsx b/apps/client/src/components/ui/destination-picker/page-children.tsx new file mode 100644 index 00000000..9db5fd71 --- /dev/null +++ b/apps/client/src/components/ui/destination-picker/page-children.tsx @@ -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) => 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) => + lastPage.meta?.nextCursor ?? undefined, + }); + + const pages = data?.pages.flatMap((page) => page.items) ?? []; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (pages.length === 0) { + return ( +
+ {pageId ? t("No pages inside") : t("No pages in this space")} +
+ ); + } + + return ( + <> + {pages.map((page) => ( + + ))} + {hasNextPage && ( +
fetchNextPage()}> + {t("Load more")} +
+ )} + + ); +} diff --git a/apps/client/src/components/ui/destination-picker/page-row.tsx b/apps/client/src/components/ui/destination-picker/page-row.tsx new file mode 100644 index 00000000..58c7a0a8 --- /dev/null +++ b/apps/client/src/components/ui/destination-picker/page-row.tsx @@ -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; + depth: number; + limit: number; + selectedId: string | null; + excludePageId?: string; + onSelect: (page: Partial) => 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 ( + <> +
!isExcluded && onSelect(page)} + > + {page.hasChildren ? ( +
{ + e.stopPropagation(); + setExpanded(!expanded); + }} + > + +
+ ) : ( +
+ )} + +
+ {page.icon ? ( + page.icon + ) : ( + + )} +
+ +
+ {page.title || t("Untitled")} +
+
+ + {expanded && page.hasChildren && ( + + )} + + ); +} diff --git a/apps/client/src/components/ui/destination-picker/space-row.tsx b/apps/client/src/components/ui/destination-picker/space-row.tsx new file mode 100644 index 00000000..59273af7 --- /dev/null +++ b/apps/client/src/components/ui/destination-picker/space-row.tsx @@ -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, 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 = ( +
writable && onSelectSpace(space)} + > + {writable ? ( +
{ + e.stopPropagation(); + setExpanded(!expanded); + }} + > + +
+ ) : ( +
+ )} + + + +
{space.name}
+ + {!writable && ( + + )} +
+ ); + + return ( + <> + {writable ? ( + rowContent + ) : ( + +
{rowContent}
+
+ )} + + {expanded && writable && ( + onSelectPage(page, space)} + /> + )} + + ); +} diff --git a/apps/client/src/ee/ai-chat/components/ai-chat-sidebar.tsx b/apps/client/src/ee/ai-chat/components/ai-chat-sidebar.tsx index e3df3e52..5d790329 100644 --- a/apps/client/src/ee/ai-chat/components/ai-chat-sidebar.tsx +++ b/apps/client/src/ee/ai-chat/components/ai-chat-sidebar.tsx @@ -11,53 +11,9 @@ import { useSearchChatsQuery, } from "../queries/ai-chat-query"; import AiChatSidebarItem from "./ai-chat-sidebar-item"; -import type { AiChat } from "../types/ai-chat.types"; +import { groupChatsByAge } from "../utils/group-chats-by-age"; import classes from "../styles/chat-sidebar.module.css"; -type ChatGroup = { key: string; label: string; chats: AiChat[] }; - -function groupChatsByAge( - chats: AiChat[], - t: (key: string) => string, -): ChatGroup[] { - if (chats.length === 0) return []; - - const now = new Date(); - const startOfToday = new Date( - now.getFullYear(), - now.getMonth(), - now.getDate(), - ).getTime(); - const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000; - const startOfLast7 = startOfToday - 7 * 24 * 60 * 60 * 1000; - const startOfLast30 = startOfToday - 30 * 24 * 60 * 60 * 1000; - - const buckets: Record = { - today: { key: "today", label: t("Today"), chats: [] }, - yesterday: { key: "yesterday", label: t("Yesterday"), chats: [] }, - last7: { key: "last7", label: t("Previous 7 days"), chats: [] }, - last30: { key: "last30", label: t("Previous 30 days"), chats: [] }, - older: { key: "older", label: t("Older"), chats: [] }, - }; - - for (const chat of chats) { - const ts = new Date(chat.updatedAt).getTime(); - if (ts >= startOfToday) buckets.today.chats.push(chat); - else if (ts >= startOfYesterday) buckets.yesterday.chats.push(chat); - else if (ts >= startOfLast7) buckets.last7.chats.push(chat); - else if (ts >= startOfLast30) buckets.last30.chats.push(chat); - else buckets.older.chats.push(chat); - } - - return [ - buckets.today, - buckets.yesterday, - buckets.last7, - buckets.last30, - buckets.older, - ].filter((b) => b.chats.length > 0); -} - export default function AiChatSidebar() { const { t } = useTranslation(); const navigate = useNavigate(); diff --git a/apps/client/src/ee/ai-chat/utils/group-chats-by-age.ts b/apps/client/src/ee/ai-chat/utils/group-chats-by-age.ts new file mode 100644 index 00000000..d2b1904f --- /dev/null +++ b/apps/client/src/ee/ai-chat/utils/group-chats-by-age.ts @@ -0,0 +1,45 @@ +import type { AiChat } from "../types/ai-chat.types"; + +export type ChatGroup = { key: string; label: string; chats: AiChat[] }; + +export function groupChatsByAge( + chats: AiChat[], + t: (key: string) => string, +): ChatGroup[] { + if (chats.length === 0) return []; + + const now = new Date(); + const startOfToday = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + ).getTime(); + const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000; + const startOfLast7 = startOfToday - 7 * 24 * 60 * 60 * 1000; + const startOfLast30 = startOfToday - 30 * 24 * 60 * 60 * 1000; + + const buckets: Record = { + today: { key: "today", label: t("Today"), chats: [] }, + yesterday: { key: "yesterday", label: t("Yesterday"), chats: [] }, + last7: { key: "last7", label: t("Previous 7 days"), chats: [] }, + last30: { key: "last30", label: t("Previous 30 days"), chats: [] }, + older: { key: "older", label: t("Older"), chats: [] }, + }; + + for (const chat of chats) { + const ts = new Date(chat.updatedAt).getTime(); + if (ts >= startOfToday) buckets.today.chats.push(chat); + else if (ts >= startOfYesterday) buckets.yesterday.chats.push(chat); + else if (ts >= startOfLast7) buckets.last7.chats.push(chat); + else if (ts >= startOfLast30) buckets.last30.chats.push(chat); + else buckets.older.chats.push(chat); + } + + return [ + buckets.today, + buckets.yesterday, + buckets.last7, + buckets.last30, + buckets.older, + ].filter((b) => b.chats.length > 0); +} diff --git a/apps/client/src/ee/features.ts b/apps/client/src/ee/features.ts index 4cd802fa..a9ab8b0d 100644 --- a/apps/client/src/ee/features.ts +++ b/apps/client/src/ee/features.ts @@ -16,5 +16,6 @@ export const Feature = { AUDIT_LOGS: 'audit:logs', RETENTION: 'retention', SHARING_CONTROLS: 'sharing:controls', + TEMPLATES: 'templates', VIEWER_COMMENTS: 'comment:viewer', } as const; diff --git a/apps/client/src/ee/security/components/allow-member-templates.tsx b/apps/client/src/ee/security/components/allow-member-templates.tsx new file mode 100644 index 00000000..f547d164 --- /dev/null +++ b/apps/client/src/ee/security/components/allow-member-templates.tsx @@ -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 ( + +
+ {t("Allow members to create templates")} + + {t( + "Allow non-admin members to create and manage templates in their spaces.", + )} + +
+ + +
+ ); +} + +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) => { + 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 ( + + + + ); +} diff --git a/apps/client/src/ee/security/pages/security.tsx b/apps/client/src/ee/security/pages/security.tsx index 568e3bec..254809c7 100644 --- a/apps/client/src/ee/security/pages/security.tsx +++ b/apps/client/src/ee/security/pages/security.tsx @@ -12,6 +12,7 @@ import { useTranslation } from "react-i18next"; import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx"; import DisablePublicSharing from "@/ee/security/components/disable-public-sharing.tsx"; import TrashRetention from "@/ee/security/components/trash-retention.tsx"; + import { useHasFeature } from "@/ee/hooks/use-feature"; import { Feature } from "@/ee/features"; diff --git a/apps/client/src/ee/template/components/create-template-modal.tsx b/apps/client/src/ee/template/components/create-template-modal.tsx new file mode 100644 index 00000000..00fb7746 --- /dev/null +++ b/apps/client/src/ee/template/components/create-template-modal.tsx @@ -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(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 ( + + + setTitle(e.currentTarget.value)} + data-autofocus + onKeyDown={(e) => { + if (e.key === "Enter" && title.trim() && !createMutation.isPending) { + handleCreate(); + } + }} + /> + + + setDraftSpaceId(val || null) + } + searchable + size="sm" + comboboxProps={{ withinPortal: false }} + /> + + + + + + + + + + +
+ + +
+
+ + handleIconChange(emoji.native) + } + icon={ + icon ? ( + {icon} + ) : ( + + ) + } + removeEmojiAction={() => + handleIconChange(null) + } + readOnly={false} + actionIconProps={icon ? { size: "3rem", variant: "transparent" } : undefined} + /> +
+ + handleTitleChange(e.currentTarget.value) + } + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + editor?.commands.focus("start"); + } + }} + /> + {existingTemplate && ( + + )} +
+ +
+ + + ); +} diff --git a/apps/client/src/ee/template/pages/template-list.tsx b/apps/client/src/ee/template/pages/template-list.tsx new file mode 100644 index 00000000..3491403e --- /dev/null +++ b/apps/client/src/ee/template/pages/template-list.tsx @@ -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(null); + const [selectedTemplate, setSelectedTemplate] = useState( + 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 ( + <> + + + {t("Templates")} - {getAppName()} + + + + + + {t("Templates")} + {isWorkspaceAdmin && ( + + )} + + + +