mirror of
https://github.com/docmost/docmost.git
synced 2026-08-29 18:14:58 +08:00
Merge branch 'main' into tiptap3-migration
This commit is contained in:
@@ -6,15 +6,17 @@ import {
|
||||
EditorMenuProps,
|
||||
ShouldShowProps,
|
||||
} from "@/features/editor/components/table/types/types.ts";
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { ActionIcon, Tooltip, Divider } from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangleFilled,
|
||||
IconCircleCheckFilled,
|
||||
IconCircleXFilled,
|
||||
IconInfoCircleFilled,
|
||||
IconMoodSmile,
|
||||
} from "@tabler/icons-react";
|
||||
import { CalloutType } from "@docmost/editor-ext";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
|
||||
|
||||
export function CalloutMenu({ editor }: EditorMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -36,6 +38,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
const shouldShow = useCallback(
|
||||
({ state }: ShouldShowProps) => {
|
||||
if (!state) {
|
||||
@@ -70,6 +73,37 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
const setCalloutIcon = useCallback(
|
||||
(emoji: any) => {
|
||||
const emojiChar = emoji?.native || emoji?.emoji || emoji;
|
||||
editor
|
||||
.chain()
|
||||
.focus(undefined, { scrollIntoView: false })
|
||||
.updateCalloutIcon(emojiChar)
|
||||
.run();
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
const removeCalloutIcon = useCallback(() => {
|
||||
editor
|
||||
.chain()
|
||||
.focus(undefined, { scrollIntoView: false })
|
||||
.updateCalloutIcon("")
|
||||
.run();
|
||||
}, [editor]);
|
||||
|
||||
const getCurrentIcon = () => {
|
||||
const { selection } = editor.state;
|
||||
const predicate = (node: PMNode) => node.type.name === "callout";
|
||||
const parent = findParentNode(predicate)(selection);
|
||||
const icon = parent?.node.attrs.icon;
|
||||
return icon || null;
|
||||
};
|
||||
|
||||
const currentIcon = getCurrentIcon();
|
||||
|
||||
return (
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
@@ -136,6 +170,18 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
||||
<IconCircleXFilled size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<EmojiPicker
|
||||
onEmojiSelect={setCalloutIcon}
|
||||
removeEmojiAction={removeCalloutIcon}
|
||||
readOnly={false}
|
||||
icon={currentIcon || <IconMoodSmile size={18} />}
|
||||
actionIconProps={{
|
||||
size: "lg",
|
||||
variant: "default",
|
||||
c: undefined,
|
||||
}}
|
||||
/>
|
||||
</ActionIcon.Group>
|
||||
</BaseBubbleMenu>
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ import { CalloutType } from "@docmost/editor-ext";
|
||||
|
||||
export default function CalloutView(props: NodeViewProps) {
|
||||
const { node } = props;
|
||||
const { type } = node.attrs;
|
||||
const { type, icon } = node.attrs;
|
||||
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
@@ -19,7 +19,7 @@ export default function CalloutView(props: NodeViewProps) {
|
||||
variant="light"
|
||||
title=""
|
||||
color={getCalloutColor(type)}
|
||||
icon={getCalloutIcon(type)}
|
||||
icon={getCalloutIcon(type, icon)}
|
||||
p="xs"
|
||||
classNames={{
|
||||
message: classes.message,
|
||||
@@ -32,7 +32,11 @@ export default function CalloutView(props: NodeViewProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function getCalloutIcon(type: CalloutType) {
|
||||
function getCalloutIcon(type: CalloutType, customIcon?: string) {
|
||||
if (customIcon && customIcon.trim() !== "") {
|
||||
return <span style={{ fontSize: '18px' }}>{customIcon}</span>;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "info":
|
||||
return <IconInfoCircleFilled />;
|
||||
|
||||
@@ -4,11 +4,7 @@ import mermaid from "mermaid";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import classes from "./code-block.module.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
suppressErrorRendering: true,
|
||||
});
|
||||
import { useComputedColorScheme } from "@mantine/core";
|
||||
|
||||
interface MermaidViewProps {
|
||||
props: NodeViewProps;
|
||||
@@ -16,12 +12,22 @@ interface MermaidViewProps {
|
||||
|
||||
export default function MermaidView({ props }: MermaidViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const computedColorScheme = useComputedColorScheme();
|
||||
const { node } = props;
|
||||
const [preview, setPreview] = useState<string>("");
|
||||
|
||||
// Update Mermaid config when theme changes.
|
||||
useEffect(() => {
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
suppressErrorRendering: true,
|
||||
theme: computedColorScheme === "light" ? "default" : "dark",
|
||||
});
|
||||
}, [computedColorScheme]);
|
||||
|
||||
// Re-render the diagram whenever the node content or theme changes.
|
||||
useEffect(() => {
|
||||
const id = `mermaid-${uuidv4()}`;
|
||||
|
||||
if (node.textContent.length > 0) {
|
||||
mermaid
|
||||
.render(id, node.textContent)
|
||||
@@ -40,7 +46,7 @@ export default function MermaidView({ props }: MermaidViewProps) {
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [node.textContent]);
|
||||
}, [node.textContent, computedColorScheme]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -146,6 +146,7 @@ export default function DrawioView(props: NodeViewProps) {
|
||||
variant="default"
|
||||
color="gray"
|
||||
mx="xs"
|
||||
className="print-hide"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
|
||||
@@ -184,6 +184,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
||||
variant="default"
|
||||
color="gray"
|
||||
mx="xs"
|
||||
className="print-hide"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
|
||||
@@ -17,8 +17,10 @@ import {
|
||||
IconTable,
|
||||
IconTypography,
|
||||
IconMenu4,
|
||||
IconCalendar, IconAppWindow,
|
||||
} from '@tabler/icons-react';
|
||||
IconCalendar,
|
||||
IconAppWindow,
|
||||
IconSitemap,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
CommandProps,
|
||||
SlashMenuGroupedItemsType,
|
||||
@@ -360,6 +362,15 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
.run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Subpages (Child pages)",
|
||||
description: "List all subpages of the current page",
|
||||
searchTerms: ["subpages", "child", "children", "nested", "hierarchy"],
|
||||
icon: IconSitemap,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).insertSubpages().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Iframe embed",
|
||||
description: "Embed any Iframe",
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
BubbleMenu as BaseBubbleMenu,
|
||||
posToDOMRect,
|
||||
findParentNode,
|
||||
} from "@tiptap/react";
|
||||
import { Node as PMNode } from "@tiptap/pm/model";
|
||||
import React, { useCallback } from "react";
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { IconTrash } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { sticky } from "tippy.js";
|
||||
|
||||
interface SubpagesMenuProps {
|
||||
editor: Editor;
|
||||
}
|
||||
|
||||
interface ShouldShowProps {
|
||||
state: any;
|
||||
from?: number;
|
||||
to?: number;
|
||||
}
|
||||
|
||||
export const SubpagesMenu = React.memo(
|
||||
({ editor }: SubpagesMenuProps): JSX.Element => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const shouldShow = useCallback(
|
||||
({ state }: ShouldShowProps) => {
|
||||
if (!state) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return editor.isActive("subpages");
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
const getReferenceClientRect = useCallback(() => {
|
||||
const { selection } = editor.state;
|
||||
const predicate = (node: PMNode) => node.type.name === "subpages";
|
||||
const parent = findParentNode(predicate)(selection);
|
||||
|
||||
if (parent) {
|
||||
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||
return dom.getBoundingClientRect();
|
||||
}
|
||||
|
||||
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||
}, [editor]);
|
||||
|
||||
const deleteNode = useCallback(() => {
|
||||
const { selection } = editor.state;
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setNodeSelection(selection.from)
|
||||
.deleteSelection()
|
||||
.run();
|
||||
}, [editor]);
|
||||
|
||||
return (
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
pluginKey={`subpages-menu}`}
|
||||
updateDelay={0}
|
||||
tippyOptions={{
|
||||
getReferenceClientRect,
|
||||
offset: [0, 8],
|
||||
zIndex: 99,
|
||||
popperOptions: {
|
||||
modifiers: [{ name: "flip", enabled: false }],
|
||||
},
|
||||
plugins: [sticky],
|
||||
sticky: "popper",
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<Tooltip position="top" label={t("Delete")}>
|
||||
<ActionIcon
|
||||
onClick={deleteNode}
|
||||
variant="default"
|
||||
size="lg"
|
||||
color="red"
|
||||
aria-label={t("Delete")}
|
||||
>
|
||||
<IconTrash size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</BaseBubbleMenu>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default SubpagesMenu;
|
||||
@@ -0,0 +1,120 @@
|
||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { Stack, Text, Anchor, ActionIcon } from "@mantine/core";
|
||||
import { IconFileDescription } from "@tabler/icons-react";
|
||||
import { useGetSidebarPagesQuery } from "@/features/page/queries/page-query";
|
||||
import { useMemo } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import classes from "./subpages.module.css";
|
||||
import styles from "../mention/mention.module.css";
|
||||
import {
|
||||
buildPageUrl,
|
||||
buildSharedPageUrl,
|
||||
} from "@/features/page/page.utils.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { sortPositionKeys } from "@/features/page/tree/utils/utils";
|
||||
import { useSharedPageSubpages } from "@/features/share/hooks/use-shared-page-subpages";
|
||||
|
||||
export default function SubpagesView(props: NodeViewProps) {
|
||||
const { editor } = props;
|
||||
const { spaceSlug, shareId } = useParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const currentPageId = editor.storage.pageId;
|
||||
|
||||
// Get subpages from shared tree if we're in a shared context
|
||||
const sharedSubpages = useSharedPageSubpages(currentPageId);
|
||||
|
||||
const { data, isLoading, error } = useGetSidebarPagesQuery({
|
||||
pageId: currentPageId,
|
||||
});
|
||||
|
||||
const subpages = useMemo(() => {
|
||||
// If we're in a shared context, use the shared subpages
|
||||
if (shareId && sharedSubpages) {
|
||||
return sharedSubpages.map((node) => ({
|
||||
id: node.value,
|
||||
slugId: node.slugId,
|
||||
title: node.name,
|
||||
icon: node.icon,
|
||||
position: node.position,
|
||||
}));
|
||||
}
|
||||
|
||||
// Otherwise use the API data
|
||||
if (!data?.pages) return [];
|
||||
const allPages = data.pages.flatMap((page) => page.items);
|
||||
return sortPositionKeys(allPages);
|
||||
}, [data, shareId, sharedSubpages]);
|
||||
|
||||
if (isLoading && !shareId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (error && !shareId) {
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<Text c="dimmed" size="md" py="md">
|
||||
{t("Failed to load subpages")}
|
||||
</Text>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (subpages.length === 0) {
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<div className={classes.container}>
|
||||
<Text c="dimmed" size="md" py="md">
|
||||
{t("No subpages")}
|
||||
</Text>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<div className={classes.container}>
|
||||
<Stack gap={5}>
|
||||
{subpages.map((page) => (
|
||||
<Anchor
|
||||
key={page.id}
|
||||
component={Link}
|
||||
fw={500}
|
||||
to={
|
||||
shareId
|
||||
? buildSharedPageUrl({
|
||||
shareId,
|
||||
pageSlugId: page.slugId,
|
||||
pageTitle: page.title,
|
||||
})
|
||||
: buildPageUrl(spaceSlug, page.slugId, page.title)
|
||||
}
|
||||
underline="never"
|
||||
className={styles.pageMentionLink}
|
||||
draggable={false}
|
||||
>
|
||||
{page?.icon ? (
|
||||
<span style={{ marginRight: "4px" }}>{page.icon}</span>
|
||||
) : (
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color="gray"
|
||||
component="span"
|
||||
size={18}
|
||||
style={{ verticalAlign: "text-bottom" }}
|
||||
>
|
||||
<IconFileDescription size={18} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
|
||||
<span className={styles.pageMentionText}>
|
||||
{page?.title || t("untitled")}
|
||||
</span>
|
||||
</Anchor>
|
||||
))}
|
||||
</Stack>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
.container {
|
||||
margin: 0;
|
||||
padding-left: 4px;
|
||||
user-select: none;
|
||||
|
||||
a {
|
||||
border: none !important;
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,8 @@ import {
|
||||
Embed,
|
||||
SearchAndReplace,
|
||||
Mention,
|
||||
Subpages,
|
||||
TableDndExtension,
|
||||
} from "@docmost/editor-ext";
|
||||
import {
|
||||
randomElement,
|
||||
@@ -61,6 +63,7 @@ import CodeBlockView from "@/features/editor/components/code-block/code-block-vi
|
||||
import DrawioView from "../components/drawio/drawio-view";
|
||||
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx";
|
||||
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
||||
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
|
||||
import plaintext from "highlight.js/lib/languages/plaintext";
|
||||
import powershell from "highlight.js/lib/languages/powershell";
|
||||
import abap from "highlightjs-sap-abap";
|
||||
@@ -172,6 +175,7 @@ export const mainExtensions = [
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableDndExtension,
|
||||
MathInline.configure({
|
||||
view: MathInlineView,
|
||||
}),
|
||||
@@ -216,6 +220,9 @@ export const mainExtensions = [
|
||||
Embed.configure({
|
||||
view: EmbedView,
|
||||
}),
|
||||
Subpages.configure({
|
||||
view: SubpagesView,
|
||||
}),
|
||||
MarkdownClipboard.configure({
|
||||
transformPastedText: true,
|
||||
}),
|
||||
|
||||
@@ -32,6 +32,7 @@ import TableMenu from "@/features/editor/components/table/table-menu.tsx";
|
||||
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
|
||||
import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
|
||||
import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
|
||||
import SubpagesMenu from "@/features/editor/components/subpages/subpages-menu.tsx";
|
||||
import {
|
||||
handleFileDrop,
|
||||
handlePaste,
|
||||
@@ -50,6 +51,7 @@ import { extractPageSlugId } from "@/lib";
|
||||
import { FIVE_MINUTES } from "@/lib/constants.ts";
|
||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
import { searchSpotlight } from '@/features/search/constants.ts';
|
||||
|
||||
interface PageEditorProps {
|
||||
pageId: string;
|
||||
@@ -213,6 +215,10 @@ export default function PageEditor({
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
if ((event.ctrlKey || event.metaKey) && event.code === 'KeyK') {
|
||||
searchSpotlight.open();
|
||||
return true;
|
||||
}
|
||||
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
|
||||
const slashCommand = document.querySelector("#slash-command");
|
||||
if (slashCommand) {
|
||||
@@ -390,7 +396,7 @@ export default function PageEditor({
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<div className="editor-container" style={{ position: "relative" }}>
|
||||
<div ref={menuContainerRef}>
|
||||
<EditorContent editor={editor} />
|
||||
|
||||
@@ -406,6 +412,7 @@ export default function PageEditor({
|
||||
<ImageMenu editor={editor} />
|
||||
<VideoMenu editor={editor} />
|
||||
<CalloutMenu editor={editor} />
|
||||
<SubpagesMenu editor={editor} />
|
||||
<ExcalidrawMenu editor={editor} />
|
||||
<DrawioMenu editor={editor} />
|
||||
<LinkMenu editor={editor} appendTo={menuContainerRef} />
|
||||
|
||||
@@ -6,21 +6,19 @@ import { Document } from "@tiptap/extension-document";
|
||||
import { Heading } from "@tiptap/extension-heading";
|
||||
import { Text } from "@tiptap/extension-text";
|
||||
import { Placeholder } from "@tiptap/extension-placeholder";
|
||||
import { useAtom } from "jotai/index";
|
||||
import {
|
||||
pageEditorAtom,
|
||||
readOnlyEditorAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { useAtom } from "jotai";
|
||||
import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||
|
||||
interface PageEditorProps {
|
||||
title: string;
|
||||
content: any;
|
||||
pageId?: string;
|
||||
}
|
||||
|
||||
export default function ReadonlyPageEditor({
|
||||
title,
|
||||
content,
|
||||
pageId,
|
||||
}: PageEditorProps) {
|
||||
const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom);
|
||||
|
||||
@@ -56,6 +54,9 @@ export default function ReadonlyPageEditor({
|
||||
content={content}
|
||||
onCreate={({ editor }) => {
|
||||
if (editor) {
|
||||
if (pageId) {
|
||||
editor.storage.pageId = pageId;
|
||||
}
|
||||
// @ts-ignore
|
||||
setReadOnlyEditor(editor);
|
||||
}
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 0.65em;
|
||||
margin-bottom: 0.65em;
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
ul,
|
||||
@@ -57,6 +57,7 @@
|
||||
h5,
|
||||
h6 {
|
||||
line-height: 1.1;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
code {
|
||||
@@ -136,7 +137,7 @@
|
||||
|
||||
.selection,
|
||||
*::selection {
|
||||
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-gray-7));
|
||||
background-color: Highlight;
|
||||
}
|
||||
|
||||
.comment-mark {
|
||||
|
||||
@@ -45,6 +45,10 @@
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&[data-direction='horizontal'] {
|
||||
rotate: 90deg;
|
||||
}
|
||||
}
|
||||
|
||||
.dark .drag-handle {
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
@media print {
|
||||
.mantine-AppShell-header,
|
||||
.mantine-AppShell-navbar,
|
||||
.mantine-AppShell-aside{
|
||||
display: none !important;
|
||||
}
|
||||
.mantine-AppShell-header,
|
||||
.mantine-AppShell-navbar,
|
||||
.mantine-AppShell-aside,
|
||||
.print-hide,
|
||||
.drag-handle {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.mantine-AppShell-main {
|
||||
padding-top: 0 !important;
|
||||
}
|
||||
.mantine-AppShell-main {
|
||||
padding-top: 0 !important;
|
||||
min-height: auto !important;
|
||||
}
|
||||
|
||||
.ProseMirror-selectednode {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.tableWrapper {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
.table-dnd-preview {
|
||||
padding: 0;
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.table-dnd-drop-indicator {
|
||||
background-color: #adf;
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
@@ -118,3 +128,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.editor-container:has(.table-dnd-drop-indicator[data-dragging="true"]) {
|
||||
.prosemirror-dropcursor-block {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.prosemirror-dropcursor-inline {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ ul[data-type="taskList"] {
|
||||
display: flex;
|
||||
|
||||
> label {
|
||||
padding-top: 0.2rem;
|
||||
flex: 0 0 auto;
|
||||
margin-right: 0.5rem;
|
||||
user-select: none;
|
||||
|
||||
@@ -26,6 +26,7 @@ import { UpdateEvent } from "@/features/websocket/types";
|
||||
import localEmitter from "@/lib/local-emitter.ts";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
import { searchSpotlight } from "@/features/search/constants.ts";
|
||||
|
||||
export interface TitleEditorProps {
|
||||
pageId: string;
|
||||
@@ -86,6 +87,20 @@ export function TitleEditor({
|
||||
content: title,
|
||||
immediatelyRender: true,
|
||||
shouldRerenderOnTransaction: false,
|
||||
editorProps: {
|
||||
handleDOMEvents: {
|
||||
keydown: (_view, event) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.code === "KeyS") {
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
if ((event.ctrlKey || event.metaKey) && event.code === "KeyK") {
|
||||
searchSpotlight.open();
|
||||
return true;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -193,7 +208,7 @@ export function TitleEditor({
|
||||
onKeyDown={(event) => {
|
||||
// First handle the search hotkey
|
||||
getHotkeyHandler([["mod+F", openSearchDialog]])(event);
|
||||
|
||||
|
||||
// Then handle other key events
|
||||
handleTitleKeyDown(event);
|
||||
}}
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import { Modal, Button, Group, Text, Select, Switch } from "@mantine/core";
|
||||
import { exportPage } from "@/features/page/services/page-service.ts";
|
||||
import { useState } from "react";
|
||||
import * as React from "react";
|
||||
import { ExportFormat } from "@/features/page/types/page.types.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface PageExportModalProps {
|
||||
pageId: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function PageExportModal({
|
||||
pageId,
|
||||
open,
|
||||
onClose,
|
||||
}: PageExportModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
await exportPage({ pageId: pageId, format });
|
||||
onClose();
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
message: t("Export failed:") + err.response?.data.message,
|
||||
color: "red",
|
||||
});
|
||||
console.error("export error", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (format: ExportFormat) => {
|
||||
setFormat(format);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal.Root
|
||||
opened={open}
|
||||
onClose={onClose}
|
||||
size={500}
|
||||
padding="xl"
|
||||
yOffset="10vh"
|
||||
xOffset={0}
|
||||
mah={400}
|
||||
>
|
||||
<Modal.Overlay />
|
||||
<Modal.Content style={{ overflow: "hidden" }}>
|
||||
<Modal.Header py={0}>
|
||||
<Modal.Title fw={500}>{t("Export page")}</Modal.Title>
|
||||
<Modal.CloseButton />
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div>
|
||||
<Text size="md">{t("Format")}</Text>
|
||||
</div>
|
||||
<ExportFormatSelection format={format} onChange={handleChange} />
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between" wrap="nowrap" pt="md">
|
||||
<div>
|
||||
<Text size="md">{t("Include subpages")}</Text>
|
||||
</div>
|
||||
<Switch defaultChecked />
|
||||
</Group>
|
||||
|
||||
<Group justify="center" mt="md">
|
||||
<Button onClick={onClose} variant="default">
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleExport}>{t("Export")}</Button>
|
||||
</Group>
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExportFormatSelection {
|
||||
format: ExportFormat;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
function ExportFormatSelection({ format, onChange }: ExportFormatSelection) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Select
|
||||
data={[
|
||||
{ value: "markdown", label: "Markdown" },
|
||||
{ value: "html", label: "HTML" },
|
||||
]}
|
||||
defaultValue={format}
|
||||
onChange={onChange}
|
||||
styles={{ wrapper: { maxWidth: 120 } }}
|
||||
comboboxProps={{ width: "120" }}
|
||||
allowDeselect={false}
|
||||
withCheckIcon={false}
|
||||
aria-label={t("Select export format")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -126,8 +126,9 @@ export function useUpdatePageMutation() {
|
||||
export function useRemovePageMutation() {
|
||||
return useMutation({
|
||||
mutationFn: (pageId: string) => deletePage(pageId, false),
|
||||
onSuccess: () => {
|
||||
onSuccess: (_, pageId) => {
|
||||
notifications.show({ message: "Page moved to trash" });
|
||||
invalidateOnDeletePage(pageId);
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (item) =>
|
||||
["trash-list"].includes(item.queryKey[0] as string),
|
||||
@@ -251,6 +252,7 @@ export function useGetSidebarPagesQuery(
|
||||
): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, unknown>> {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["sidebar-pages", data],
|
||||
enabled: !!data?.pageId || !!data?.spaceId,
|
||||
queryFn: ({ pageParam }) => getSidebarPages({ ...data, page: pageParam }),
|
||||
initialPageParam: 1,
|
||||
getPreviousPageParam: (firstPage) =>
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query";
|
||||
import {
|
||||
Container,
|
||||
Title,
|
||||
Table,
|
||||
Group,
|
||||
ActionIcon,
|
||||
Text,
|
||||
Alert,
|
||||
Stack,
|
||||
Menu,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconInfoCircle,
|
||||
IconDots,
|
||||
IconRestore,
|
||||
IconTrash,
|
||||
IconFileDescription,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
useDeletedPagesQuery,
|
||||
useRestorePageMutation,
|
||||
useDeletePageMutation,
|
||||
} from "@/features/page/queries/page-query";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formattedDate } from "@/lib/time";
|
||||
import { useState } from "react";
|
||||
import TrashPageContentModal from "@/features/page/trash/components/trash-page-content-modal";
|
||||
import { UserInfo } from "@/components/common/user-info.tsx";
|
||||
import Paginate from "@/components/common/paginate.tsx";
|
||||
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
|
||||
|
||||
export default function Trash() {
|
||||
const { t } = useTranslation();
|
||||
const { spaceSlug } = useParams();
|
||||
const { page, setPage } = usePaginateAndSearch();
|
||||
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
||||
const { data: deletedPages, isLoading } = useDeletedPagesQuery(space?.id, {
|
||||
page, limit: 50
|
||||
});
|
||||
const restorePageMutation = useRestorePageMutation();
|
||||
const deletePageMutation = useDeletePageMutation();
|
||||
|
||||
const [selectedPage, setSelectedPage] = useState<{
|
||||
title: string;
|
||||
content: any;
|
||||
} | null>(null);
|
||||
const [modalOpened, setModalOpened] = useState(false);
|
||||
|
||||
const handleRestorePage = async (pageId: string) => {
|
||||
await restorePageMutation.mutateAsync(pageId);
|
||||
};
|
||||
|
||||
const handleDeletePage = async (pageId: string) => {
|
||||
await deletePageMutation.mutateAsync(pageId);
|
||||
};
|
||||
|
||||
const openDeleteModal = (pageId: string, pageTitle: string) => {
|
||||
modals.openConfirmModal({
|
||||
title: t("Are you sure you want to delete this page?"),
|
||||
children: (
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.",
|
||||
{ title: pageTitle || "Untitled" },
|
||||
)}
|
||||
</Text>
|
||||
),
|
||||
centered: true,
|
||||
labels: { confirm: t("Delete"), cancel: t("Cancel") },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: () => handleDeletePage(pageId),
|
||||
});
|
||||
};
|
||||
|
||||
const openRestoreModal = (pageId: string, pageTitle: string) => {
|
||||
modals.openConfirmModal({
|
||||
title: t("Restore page"),
|
||||
children: (
|
||||
<Text size="sm">
|
||||
{t("Restore '{{title}}' and its sub-pages?", {
|
||||
title: pageTitle || "Untitled",
|
||||
})}
|
||||
</Text>
|
||||
),
|
||||
centered: true,
|
||||
labels: { confirm: t("Restore"), cancel: t("Cancel") },
|
||||
confirmProps: { color: "blue" },
|
||||
onConfirm: () => handleRestorePage(pageId),
|
||||
});
|
||||
};
|
||||
|
||||
const hasPages = deletedPages && deletedPages.items.length > 0;
|
||||
|
||||
const handlePageClick = (page: any) => {
|
||||
setSelectedPage({ title: page.title, content: page.content });
|
||||
setModalOpened(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size="lg" py="lg">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Title order={2}>{t("Trash")}</Title>
|
||||
</Group>
|
||||
|
||||
<Alert icon={<IconInfoCircle size={16} />} variant="light" color="red">
|
||||
<Text size="sm">
|
||||
{t("Pages in trash will be permanently deleted after 30 days.")}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
{isLoading || !deletedPages ? (
|
||||
<></>
|
||||
) : hasPages ? (
|
||||
<Table.ScrollContainer minWidth={500}>
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("Page")}</Table.Th>
|
||||
<Table.Th style={{ whiteSpace: "nowrap" }}>
|
||||
{t("Deleted by")}
|
||||
</Table.Th>
|
||||
<Table.Th style={{ whiteSpace: "nowrap" }}>
|
||||
{t("Deleted at")}
|
||||
</Table.Th>
|
||||
<Table.Th></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{deletedPages.items.map((page) => (
|
||||
<Table.Tr key={page.id}>
|
||||
<Table.Td>
|
||||
<Group
|
||||
wrap="nowrap"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => handlePageClick(page)}
|
||||
>
|
||||
{page.icon || (
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color="gray"
|
||||
size={18}
|
||||
>
|
||||
<IconFileDescription size={18} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<div>
|
||||
<Text fw={500} size="sm" lineClamp={1}>
|
||||
{page.title || t("Untitled")}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<UserInfo user={page.deletedBy} size="sm" />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text
|
||||
c="dimmed"
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
size="xs"
|
||||
fw={500}
|
||||
>
|
||||
{formattedDate(page.deletedAt)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Menu>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray">
|
||||
<IconDots size={20} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconRestore size={16} />}
|
||||
onClick={() =>
|
||||
openRestoreModal(page.id, page.title)
|
||||
}
|
||||
>
|
||||
{t("Restore")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconTrash size={16} />}
|
||||
onClick={() => openDeleteModal(page.id, page.title)}
|
||||
>
|
||||
{t("Delete")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
) : (
|
||||
<Text ta="center" py="xl" c="dimmed">
|
||||
{t("No pages in trash")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{deletedPages && deletedPages.items.length > 0 && (
|
||||
<Paginate
|
||||
currentPage={page}
|
||||
hasPrevPage={deletedPages.meta.hasPrevPage}
|
||||
hasNextPage={deletedPages.meta.hasNextPage}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{selectedPage && (
|
||||
<TrashPageContentModal
|
||||
opened={modalOpened}
|
||||
onClose={() => setModalOpened(false)}
|
||||
pageTitle={selectedPage.title}
|
||||
pageContent={selectedPage.content}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -60,7 +60,7 @@ export interface ICopyPageToSpace {
|
||||
}
|
||||
|
||||
export interface SidebarPagesParams {
|
||||
spaceId: string;
|
||||
spaceId?: string;
|
||||
pageId?: string;
|
||||
page?: number; // pagination
|
||||
}
|
||||
@@ -79,6 +79,7 @@ export interface IExportPageParams {
|
||||
pageId: string;
|
||||
format: ExportFormat;
|
||||
includeChildren?: boolean;
|
||||
includeAttachments?: boolean;
|
||||
}
|
||||
|
||||
export enum ExportFormat {
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Group,
|
||||
Center,
|
||||
Text,
|
||||
Badge,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
getDefaultZIndex,
|
||||
} from "@mantine/core";
|
||||
import { Spotlight } from "@mantine/spotlight";
|
||||
import { Link } from "react-router-dom";
|
||||
import { IconFile, IconDownload } from "@tabler/icons-react";
|
||||
import { buildPageUrl } from "@/features/page/page.utils";
|
||||
import { getPageIcon } from "@/lib";
|
||||
import {
|
||||
IAttachmentSearch,
|
||||
IPageSearch,
|
||||
} from "@/features/search/types/search.types";
|
||||
import DOMPurify from "dompurify";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface SearchResultItemProps {
|
||||
result: IPageSearch | IAttachmentSearch;
|
||||
isAttachmentResult: boolean;
|
||||
showSpace?: boolean;
|
||||
}
|
||||
|
||||
export function SearchResultItem({
|
||||
result,
|
||||
isAttachmentResult,
|
||||
showSpace,
|
||||
}: SearchResultItemProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (isAttachmentResult) {
|
||||
const attachmentResult = result as IAttachmentSearch;
|
||||
|
||||
const handleDownload = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const downloadUrl = `/api/files/${attachmentResult.id}/${attachmentResult.fileName}`;
|
||||
window.open(downloadUrl, "_blank");
|
||||
};
|
||||
|
||||
return (
|
||||
<Spotlight.Action
|
||||
component={Link}
|
||||
//@ts-ignore
|
||||
to={buildPageUrl(
|
||||
attachmentResult.space.slug,
|
||||
attachmentResult.page.slugId,
|
||||
attachmentResult.page.title,
|
||||
)}
|
||||
style={{ userSelect: "none" }}
|
||||
>
|
||||
<Group wrap="nowrap" w="100%">
|
||||
<Center>
|
||||
<IconFile size={16} />
|
||||
</Center>
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text>{attachmentResult.fileName}</Text>
|
||||
<Text size="xs" opacity={0.6}>
|
||||
{attachmentResult.space.name} • {attachmentResult.page.title}
|
||||
</Text>
|
||||
|
||||
{attachmentResult?.highlight && (
|
||||
<Text
|
||||
opacity={0.6}
|
||||
size="xs"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(attachmentResult.highlight, {
|
||||
ALLOWED_TAGS: ["mark", "em", "strong", "b"],
|
||||
ALLOWED_ATTR: [],
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Tooltip
|
||||
label={t("Download attachment")}
|
||||
zIndex={getDefaultZIndex("max")}
|
||||
withArrow
|
||||
>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={handleDownload}>
|
||||
<IconDownload size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Spotlight.Action>
|
||||
);
|
||||
} else {
|
||||
const pageResult = result as IPageSearch;
|
||||
return (
|
||||
<Spotlight.Action
|
||||
component={Link}
|
||||
//@ts-ignore
|
||||
to={buildPageUrl(
|
||||
pageResult.space.slug,
|
||||
pageResult.slugId,
|
||||
pageResult.title,
|
||||
)}
|
||||
style={{ userSelect: "none" }}
|
||||
>
|
||||
<Group wrap="nowrap" w="100%">
|
||||
<Center>{getPageIcon(pageResult?.icon)}</Center>
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text>{pageResult.title}</Text>
|
||||
|
||||
{showSpace && pageResult.space && (
|
||||
<Badge variant="light" size="xs" color="gray">
|
||||
{pageResult.space.name}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{pageResult?.highlight && (
|
||||
<Text
|
||||
opacity={0.6}
|
||||
size="xs"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(pageResult.highlight, {
|
||||
ALLOWED_TAGS: ["mark", "em", "strong", "b"],
|
||||
ALLOWED_ATTR: [],
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
</Spotlight.Action>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
.filtersContainer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding: 8px 0;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.filterButton {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-6));
|
||||
&:hover {
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-6));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import React, { useState, useMemo, useEffect } from "react";
|
||||
import {
|
||||
Button,
|
||||
Menu,
|
||||
Text,
|
||||
TextInput,
|
||||
Divider,
|
||||
Badge,
|
||||
ScrollArea,
|
||||
Avatar,
|
||||
Group,
|
||||
getDefaultZIndex,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconBuilding,
|
||||
IconFileDescription,
|
||||
IconSearch,
|
||||
IconCheck,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
|
||||
import { useLicense } from "@/ee/hooks/use-license";
|
||||
import classes from "./search-spotlight-filters.module.css";
|
||||
import { isCloud } from "@/lib/config.ts";
|
||||
|
||||
interface SearchSpotlightFiltersProps {
|
||||
onFiltersChange?: (filters: any) => void;
|
||||
spaceId?: string;
|
||||
}
|
||||
|
||||
export function SearchSpotlightFilters({
|
||||
onFiltersChange,
|
||||
spaceId,
|
||||
}: SearchSpotlightFiltersProps) {
|
||||
const { t } = useTranslation();
|
||||
const { hasLicenseKey } = useLicense();
|
||||
const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>(
|
||||
spaceId || null,
|
||||
);
|
||||
const [spaceSearchQuery, setSpaceSearchQuery] = useState("");
|
||||
const [debouncedSpaceQuery] = useDebouncedValue(spaceSearchQuery, 300);
|
||||
const [contentType, setContentType] = useState<string | null>("page");
|
||||
|
||||
const { data: spacesData } = useGetSpacesQuery({
|
||||
page: 1,
|
||||
limit: 100,
|
||||
query: debouncedSpaceQuery,
|
||||
});
|
||||
|
||||
const selectedSpaceData = useMemo(() => {
|
||||
if (!spacesData?.items || !selectedSpaceId) return null;
|
||||
return spacesData.items.find((space) => space.id === selectedSpaceId);
|
||||
}, [spacesData?.items, selectedSpaceId]);
|
||||
|
||||
const availableSpaces = useMemo(() => {
|
||||
const spaces = spacesData?.items || [];
|
||||
if (!selectedSpaceId) return spaces;
|
||||
|
||||
// Sort to put selected space first
|
||||
return [...spaces].sort((a, b) => {
|
||||
if (a.id === selectedSpaceId) return -1;
|
||||
if (b.id === selectedSpaceId) return 1;
|
||||
return 0;
|
||||
});
|
||||
}, [spacesData?.items, selectedSpaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (onFiltersChange) {
|
||||
onFiltersChange({
|
||||
spaceId: selectedSpaceId,
|
||||
contentType,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const contentTypeOptions = [
|
||||
{ value: "page", label: t("Pages") },
|
||||
{
|
||||
value: "attachment",
|
||||
label: t("Attachments"),
|
||||
disabled: !isCloud() && !hasLicenseKey,
|
||||
},
|
||||
];
|
||||
|
||||
const handleSpaceSelect = (spaceId: string | null) => {
|
||||
setSelectedSpaceId(spaceId);
|
||||
|
||||
if (onFiltersChange) {
|
||||
onFiltersChange({
|
||||
spaceId: spaceId,
|
||||
contentType,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleFilterChange = (filterType: string, value: any) => {
|
||||
let newSelectedSpaceId = selectedSpaceId;
|
||||
let newContentType = contentType;
|
||||
|
||||
switch (filterType) {
|
||||
case "spaceId":
|
||||
newSelectedSpaceId = value;
|
||||
setSelectedSpaceId(value);
|
||||
break;
|
||||
case "contentType":
|
||||
newContentType = value;
|
||||
setContentType(value);
|
||||
break;
|
||||
}
|
||||
|
||||
if (onFiltersChange) {
|
||||
onFiltersChange({
|
||||
spaceId: newSelectedSpaceId,
|
||||
contentType: newContentType,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.filtersContainer}>
|
||||
<Menu
|
||||
shadow="md"
|
||||
width={250}
|
||||
position="bottom-start"
|
||||
zIndex={getDefaultZIndex("max")}
|
||||
>
|
||||
<Menu.Target>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
rightSection={<IconChevronDown size={14} />}
|
||||
leftSection={<IconBuilding size={16} />}
|
||||
className={classes.filterButton}
|
||||
fw={500}
|
||||
>
|
||||
{selectedSpaceId
|
||||
? `${t("Space")}: ${selectedSpaceData?.name || t("Unknown")}`
|
||||
: `${t("Space")}: ${t("All spaces")}`}
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<TextInput
|
||||
placeholder={t("Find a space")}
|
||||
data-autofocus
|
||||
autoFocus
|
||||
leftSection={<IconSearch size={16} />}
|
||||
value={spaceSearchQuery}
|
||||
onChange={(e) => setSpaceSearchQuery(e.target.value)}
|
||||
size="sm"
|
||||
variant="filled"
|
||||
radius="sm"
|
||||
styles={{ input: { marginBottom: 8 } }}
|
||||
/>
|
||||
|
||||
<ScrollArea.Autosize mah={280}>
|
||||
<Menu.Item onClick={() => handleSpaceSelect(null)}>
|
||||
<Group flex="1" gap="xs">
|
||||
<Avatar
|
||||
color="initials"
|
||||
variant="filled"
|
||||
name={t("All spaces")}
|
||||
size={20}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("All spaces")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("Search in all your spaces")}
|
||||
</Text>
|
||||
</div>
|
||||
{!selectedSpaceId && <IconCheck size={20} />}
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
|
||||
<Divider my="xs" />
|
||||
|
||||
{availableSpaces.map((space) => (
|
||||
<Menu.Item
|
||||
key={space.id}
|
||||
onClick={() => handleSpaceSelect(space.id)}
|
||||
>
|
||||
<Group flex="1" gap="xs">
|
||||
<Avatar
|
||||
color="initials"
|
||||
variant="filled"
|
||||
name={space.name}
|
||||
size={20}
|
||||
/>
|
||||
<Text size="sm" fw={500} style={{ flex: 1 }} truncate>
|
||||
{space.name}
|
||||
</Text>
|
||||
{selectedSpaceId === space.id && <IconCheck size={20} />}
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</ScrollArea.Autosize>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
<Menu
|
||||
shadow="md"
|
||||
width={220}
|
||||
position="bottom-start"
|
||||
zIndex={getDefaultZIndex("max")}
|
||||
>
|
||||
<Menu.Target>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
rightSection={<IconChevronDown size={14} />}
|
||||
leftSection={<IconFileDescription size={16} />}
|
||||
className={classes.filterButton}
|
||||
fw={500}
|
||||
>
|
||||
{contentType
|
||||
? `${t("Type")}: ${contentTypeOptions.find((opt) => opt.value === contentType)?.label || t(contentType === "page" ? "Pages" : "Attachments")}`
|
||||
: t("Type")}
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{contentTypeOptions.map((option) => (
|
||||
<Menu.Item
|
||||
key={option.value}
|
||||
onClick={() =>
|
||||
!option.disabled &&
|
||||
contentType !== option.value &&
|
||||
handleFilterChange("contentType", option.value)
|
||||
}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
<Group flex="1" gap="xs">
|
||||
<div>
|
||||
<Text size="sm">{option.label}</Text>
|
||||
{option.disabled && (
|
||||
<Badge size="xs" mt={4}>
|
||||
{t("Enterprise")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{contentType === option.value && <IconCheck size={20} />}
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Spotlight } from "@mantine/spotlight";
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { searchSpotlightStore } from "../constants.ts";
|
||||
import { SearchSpotlightFilters } from "./search-spotlight-filters.tsx";
|
||||
import { useUnifiedSearch } from "../hooks/use-unified-search.ts";
|
||||
import { SearchResultItem } from "./search-result-item.tsx";
|
||||
import { useLicense } from "@/ee/hooks/use-license.tsx";
|
||||
|
||||
interface SearchSpotlightProps {
|
||||
spaceId?: string;
|
||||
}
|
||||
export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
||||
const { t } = useTranslation();
|
||||
const { hasLicenseKey } = useLicense();
|
||||
const [query, setQuery] = useState("");
|
||||
const [debouncedSearchQuery] = useDebouncedValue(query, 300);
|
||||
const [filters, setFilters] = useState<{
|
||||
spaceId?: string | null;
|
||||
contentType?: string;
|
||||
}>({
|
||||
contentType: "page",
|
||||
});
|
||||
|
||||
// Build unified search params
|
||||
const searchParams = useMemo(() => {
|
||||
const params: any = {
|
||||
query: debouncedSearchQuery,
|
||||
contentType: filters.contentType || "page", // Only used for frontend routing
|
||||
};
|
||||
|
||||
// Handle space filtering - only pass spaceId if a specific space is selected
|
||||
if (filters.spaceId) {
|
||||
params.spaceId = filters.spaceId;
|
||||
}
|
||||
|
||||
return params;
|
||||
}, [debouncedSearchQuery, filters]);
|
||||
|
||||
const { data: searchResults, isLoading } = useUnifiedSearch(searchParams);
|
||||
|
||||
// Determine result type for rendering
|
||||
const isAttachmentSearch =
|
||||
filters.contentType === "attachment" && hasLicenseKey;
|
||||
|
||||
const resultItems = (searchResults || []).map((result) => (
|
||||
<SearchResultItem
|
||||
key={result.id}
|
||||
result={result}
|
||||
isAttachmentResult={isAttachmentSearch}
|
||||
showSpace={!filters.spaceId}
|
||||
/>
|
||||
));
|
||||
|
||||
const handleFiltersChange = (newFilters: any) => {
|
||||
setFilters(newFilters);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Spotlight.Root
|
||||
size="xl"
|
||||
maxHeight={600}
|
||||
store={searchSpotlightStore}
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
scrollable
|
||||
overlayProps={{
|
||||
backgroundOpacity: 0.55,
|
||||
}}
|
||||
>
|
||||
<Spotlight.Search
|
||||
placeholder={t("Search...")}
|
||||
leftSection={<IconSearch size={20} stroke={1.5} />}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: "4px 16px",
|
||||
}}
|
||||
>
|
||||
<SearchSpotlightFilters
|
||||
onFiltersChange={handleFiltersChange}
|
||||
spaceId={spaceId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Spotlight.ActionsList>
|
||||
{query.length === 0 && resultItems.length === 0 && (
|
||||
<Spotlight.Empty>{t("Start typing to search...")}</Spotlight.Empty>
|
||||
)}
|
||||
|
||||
{query.length > 0 && !isLoading && resultItems.length === 0 && (
|
||||
<Spotlight.Empty>{t("No results found...")}</Spotlight.Empty>
|
||||
)}
|
||||
|
||||
{resultItems.length > 0 && <>{resultItems}</>}
|
||||
</Spotlight.ActionsList>
|
||||
</Spotlight.Root>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+7
-1
@@ -9,6 +9,7 @@ import { buildSharedPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { getPageIcon } from "@/lib";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { shareSearchSpotlightStore } from "@/features/search/constants.ts";
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
interface ShareSearchSpotlightProps {
|
||||
shareId?: string;
|
||||
@@ -47,7 +48,12 @@ export function ShareSearchSpotlight({ shareId }: ShareSearchSpotlightProps) {
|
||||
<Text
|
||||
opacity={0.6}
|
||||
size="xs"
|
||||
dangerouslySetInnerHTML={{ __html: page.highlight }}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(page.highlight, {
|
||||
ALLOWED_TAGS: ["mark", "em", "strong", "b"],
|
||||
ALLOWED_ATTR: []
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||
import {
|
||||
searchPage,
|
||||
searchAttachments,
|
||||
} from "@/features/search/services/search-service";
|
||||
import {
|
||||
IAttachmentSearch,
|
||||
IPageSearch,
|
||||
IPageSearchParams,
|
||||
} from "@/features/search/types/search.types";
|
||||
import { useLicense } from "@/ee/hooks/use-license";
|
||||
import { isCloud } from "@/lib/config";
|
||||
|
||||
export type UnifiedSearchResult = IPageSearch | IAttachmentSearch;
|
||||
|
||||
export interface UseUnifiedSearchParams extends IPageSearchParams {
|
||||
contentType?: string;
|
||||
}
|
||||
|
||||
export function useUnifiedSearch(
|
||||
params: UseUnifiedSearchParams,
|
||||
): UseQueryResult<UnifiedSearchResult[], Error> {
|
||||
const { hasLicenseKey } = useLicense();
|
||||
|
||||
const isAttachmentSearch =
|
||||
params.contentType === "attachment" && (isCloud() || hasLicenseKey);
|
||||
const searchType = isAttachmentSearch ? "attachment" : "page";
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["unified-search", searchType, params],
|
||||
queryFn: async () => {
|
||||
// Remove contentType from backend params since it's only used for frontend routing
|
||||
const { contentType, ...backendParams } = params;
|
||||
|
||||
if (isAttachmentSearch) {
|
||||
return await searchAttachments(backendParams);
|
||||
} else {
|
||||
return await searchPage(backendParams);
|
||||
}
|
||||
},
|
||||
enabled: !!params.query,
|
||||
});
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||
import {
|
||||
searchAttachments,
|
||||
searchPage,
|
||||
searchShare,
|
||||
searchSuggestions,
|
||||
} from "@/features/search/services/search-service";
|
||||
} from '@/features/search/services/search-service';
|
||||
import {
|
||||
IAttachmentSearch,
|
||||
IPageSearch,
|
||||
IPageSearchParams,
|
||||
ISuggestionResult,
|
||||
SearchSuggestionParams,
|
||||
} from "@/features/search/types/search.types";
|
||||
} from '@/features/search/types/search.types';
|
||||
|
||||
export function usePageSearchQuery(
|
||||
params: IPageSearchParams,
|
||||
@@ -41,3 +43,13 @@ export function useShareSearchQuery(
|
||||
enabled: !!params.query,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAttachmentSearchQuery(
|
||||
params: IPageSearchParams,
|
||||
): UseQueryResult<IAttachmentSearch[], Error> {
|
||||
return useQuery({
|
||||
queryKey: ["attachment-search", params],
|
||||
queryFn: () => searchAttachments(params),
|
||||
enabled: !!params.query,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { Group, Center, Text } from "@mantine/core";
|
||||
import { Spotlight } from "@mantine/spotlight";
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
import React, { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { usePageSearchQuery } from "@/features/search/queries/search-query";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { getPageIcon } from "@/lib";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { searchSpotlightStore } from "./constants";
|
||||
|
||||
interface SearchSpotlightProps {
|
||||
spaceId?: string;
|
||||
}
|
||||
export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState("");
|
||||
const [debouncedSearchQuery] = useDebouncedValue(query, 300);
|
||||
|
||||
const { data: searchResults } = usePageSearchQuery({
|
||||
query: debouncedSearchQuery,
|
||||
spaceId,
|
||||
});
|
||||
|
||||
const pages = (
|
||||
searchResults && searchResults.length > 0 ? searchResults : []
|
||||
).map((page) => (
|
||||
<Spotlight.Action
|
||||
key={page.id}
|
||||
component={Link}
|
||||
//@ts-ignore
|
||||
to={buildPageUrl(page.space.slug, page.slugId, page.title)}
|
||||
style={{ userSelect: "none" }}
|
||||
>
|
||||
<Group wrap="nowrap" w="100%">
|
||||
<Center>{getPageIcon(page?.icon)}</Center>
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text>{page.title}</Text>
|
||||
|
||||
{page?.highlight && (
|
||||
<Text
|
||||
opacity={0.6}
|
||||
size="xs"
|
||||
dangerouslySetInnerHTML={{ __html: page.highlight }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
</Spotlight.Action>
|
||||
));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Spotlight.Root
|
||||
store={searchSpotlightStore}
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
scrollable
|
||||
overlayProps={{
|
||||
backgroundOpacity: 0.55,
|
||||
}}
|
||||
>
|
||||
<Spotlight.Search
|
||||
placeholder={t("Search...")}
|
||||
leftSection={<IconSearch size={20} stroke={1.5} />}
|
||||
/>
|
||||
<Spotlight.ActionsList>
|
||||
{query.length === 0 && pages.length === 0 && (
|
||||
<Spotlight.Empty>{t("Start typing to search...")}</Spotlight.Empty>
|
||||
)}
|
||||
|
||||
{query.length > 0 && pages.length === 0 && (
|
||||
<Spotlight.Empty>{t("No results found...")}</Spotlight.Empty>
|
||||
)}
|
||||
|
||||
{pages.length > 0 && pages}
|
||||
</Spotlight.ActionsList>
|
||||
</Spotlight.Root>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import api from "@/lib/api-client";
|
||||
import {
|
||||
IAttachmentSearch,
|
||||
IPageSearch,
|
||||
IPageSearchParams,
|
||||
ISuggestionResult,
|
||||
SearchSuggestionParams,
|
||||
} from "@/features/search/types/search.types";
|
||||
} from '@/features/search/types/search.types';
|
||||
|
||||
export async function searchPage(
|
||||
params: IPageSearchParams,
|
||||
@@ -26,3 +27,10 @@ export async function searchShare(
|
||||
const req = await api.post<IPageSearch[]>("/search/share-search", params);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function searchAttachments(
|
||||
params: IPageSearchParams,
|
||||
): Promise<IAttachmentSearch[]> {
|
||||
const req = await api.post<IAttachmentSearch[]>("/search-attachments", params);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
@@ -37,3 +37,25 @@ export interface IPageSearchParams {
|
||||
spaceId?: string;
|
||||
shareId?: string;
|
||||
}
|
||||
|
||||
export interface IAttachmentSearch {
|
||||
id: string;
|
||||
fileName: string;
|
||||
pageId: string;
|
||||
creatorId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
rank: string;
|
||||
highlight: string;
|
||||
space: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
icon: string;
|
||||
};
|
||||
page: {
|
||||
id: string;
|
||||
title: string;
|
||||
slugId: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { atom } from "jotai";
|
||||
import { ISharedPageTree } from "@/features/share/types/share.types";
|
||||
import { SharedPageTreeNode } from "@/features/share/utils";
|
||||
|
||||
export const sharedPageTreeAtom = atom<ISharedPageTree | null>(null);
|
||||
export const sharedTreeDataAtom = atom<SharedPageTreeNode[] | null>(null);
|
||||
@@ -1,9 +1,7 @@
|
||||
import React from "react";
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Affix,
|
||||
AppShell,
|
||||
Button,
|
||||
Group,
|
||||
ScrollArea,
|
||||
Tooltip,
|
||||
@@ -14,8 +12,10 @@ import SharedTree from "@/features/share/components/shared-tree.tsx";
|
||||
import { TableOfContents } from "@/features/editor/components/table-of-contents/table-of-contents.tsx";
|
||||
import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import { ThemeToggle } from "@/components/theme-toggle.tsx";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { useAtom } from "jotai";
|
||||
import { sharedPageTreeAtom, sharedTreeDataAtom } from "@/features/share/atoms/shared-page-atom";
|
||||
import { buildSharedPageTree } from "@/features/share/utils";
|
||||
import {
|
||||
desktopSidebarAtom,
|
||||
mobileSidebarAtom,
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
SearchControl,
|
||||
SearchMobileControl,
|
||||
} from "@/features/search/components/search-control.tsx";
|
||||
import { ShareSearchSpotlight } from "@/features/search/share-search-spotlight";
|
||||
import { ShareSearchSpotlight } from "@/features/search/components/share-search-spotlight.tsx";
|
||||
import { shareSearchSpotlight } from "@/features/search/constants";
|
||||
import ShareBranding from '@/features/share/components/share-branding.tsx';
|
||||
|
||||
@@ -60,6 +60,22 @@ export default function ShareShell({
|
||||
const { data } = useGetSharedPageTreeQuery(shareId);
|
||||
const readOnlyEditor = useAtomValue(readOnlyEditorAtom);
|
||||
|
||||
// @ts-ignore
|
||||
const setSharedPageTree = useSetAtom(sharedPageTreeAtom);
|
||||
// @ts-ignore
|
||||
const setSharedTreeData = useSetAtom(sharedTreeDataAtom);
|
||||
|
||||
// Build and set the tree data when it changes
|
||||
const treeData = useMemo(() => {
|
||||
if (!data?.pageTree) return null;
|
||||
return buildSharedPageTree(data.pageTree);
|
||||
}, [data?.pageTree]);
|
||||
|
||||
useEffect(() => {
|
||||
setSharedPageTree(data || null);
|
||||
setSharedTreeData(treeData);
|
||||
}, [data, treeData, setSharedPageTree, setSharedTreeData]);
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
header={{ height: 50 }}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useMemo } from "react";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { sharedTreeDataAtom } from "@/features/share/atoms/shared-page-atom";
|
||||
import { SharedPageTreeNode } from "@/features/share/utils";
|
||||
|
||||
export function useSharedPageSubpages(pageId: string | undefined) {
|
||||
const treeData = useAtomValue(sharedTreeDataAtom);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!treeData || !pageId) return [];
|
||||
|
||||
function findSubpages(nodes: SharedPageTreeNode[]): SharedPageTreeNode[] {
|
||||
for (const node of nodes) {
|
||||
if (node.value === pageId || node.slugId === pageId) {
|
||||
return node.children || [];
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
const subpages = findSubpages(node.children);
|
||||
if (subpages.length > 0) {
|
||||
return subpages;
|
||||
}
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
return findSubpages(treeData);
|
||||
}, [treeData, pageId]);
|
||||
}
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
import classes from "./space-sidebar.module.css";
|
||||
import React from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import { SearchSpotlight } from "@/features/search/search-spotlight.tsx";
|
||||
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
||||
import { Link, useLocation, useParams } from "react-router-dom";
|
||||
import clsx from "clsx";
|
||||
@@ -195,8 +194,6 @@ export function SpaceSidebar() {
|
||||
onClose={closeSettings}
|
||||
spaceId={space?.slug}
|
||||
/>
|
||||
|
||||
<SearchSpotlight spaceId={space.id} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,41 @@
|
||||
import { atom } from "jotai";
|
||||
import { atomWithStorage } from "jotai/utils";
|
||||
|
||||
import { ICurrentUser } from "@/features/user/types/user.types";
|
||||
import { focusAtom } from "jotai-optics";
|
||||
import { ICurrentUser, IUser } from "@/features/user/types/user.types";
|
||||
import { IWorkspace } from "@/features/workspace/types/workspace.types";
|
||||
|
||||
export const currentUserAtom = atomWithStorage<ICurrentUser | null>(
|
||||
"currentUser",
|
||||
null,
|
||||
);
|
||||
|
||||
export const userAtom = focusAtom(currentUserAtom, (optic) =>
|
||||
optic.prop("user"),
|
||||
export const userAtom = atom(
|
||||
(get) => {
|
||||
const currentUser = get(currentUserAtom);
|
||||
return currentUser?.user ?? null;
|
||||
},
|
||||
(get, set, newUser: IUser) => {
|
||||
const currentUser = get(currentUserAtom);
|
||||
if (currentUser) {
|
||||
set(currentUserAtom, {
|
||||
...currentUser,
|
||||
user: newUser,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
export const workspaceAtom = focusAtom(currentUserAtom, (optic) =>
|
||||
optic.prop("workspace"),
|
||||
|
||||
export const workspaceAtom = atom(
|
||||
(get) => {
|
||||
const currentUser = get(currentUserAtom);
|
||||
return currentUser?.workspace ?? null;
|
||||
},
|
||||
(get, set, newWorkspace: IWorkspace) => {
|
||||
const currentUser = get(currentUserAtom);
|
||||
if (currentUser) {
|
||||
set(currentUserAtom, {
|
||||
...currentUser,
|
||||
workspace: newWorkspace,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -25,7 +25,7 @@ function LanguageSwitcher() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [user, setUser] = useAtom(userAtom);
|
||||
const [language, setLanguage] = useState(
|
||||
user?.locale === "en" ? "en-US" : user.locale,
|
||||
user?.locale === "en" ? "en-US" : user?.locale,
|
||||
);
|
||||
|
||||
const handleChange = async (value: string) => {
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
import { Group, Text, MantineSize, SegmentedControl } from "@mantine/core";
|
||||
import { Text, MantineSize, SegmentedControl } from "@mantine/core";
|
||||
import { useAtom } from "jotai";
|
||||
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { updateUser } from "@/features/user/services/user-service.ts";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
import { ResponsiveSettingsRow, ResponsiveSettingsContent, ResponsiveSettingsControl } from "@/components/ui/responsive-settings-row";
|
||||
|
||||
export default function PageStatePref() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||
<div>
|
||||
<ResponsiveSettingsRow>
|
||||
<ResponsiveSettingsContent>
|
||||
<Text size="md">{t("Default page edit mode")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("Choose your preferred page edit mode. Avoid accidental edits.")}
|
||||
</Text>
|
||||
</div>
|
||||
</ResponsiveSettingsContent>
|
||||
|
||||
<PageStateSegmentedControl />
|
||||
</Group>
|
||||
<ResponsiveSettingsControl>
|
||||
<PageStateSegmentedControl />
|
||||
</ResponsiveSettingsControl>
|
||||
</ResponsiveSettingsRow>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { updateUser } from "@/features/user/services/user-service.ts";
|
||||
import { Group, MantineSize, Switch, Text } from "@mantine/core";
|
||||
import { MantineSize, Switch, Text } from "@mantine/core";
|
||||
import { useAtom } from "jotai/index";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ResponsiveSettingsRow, ResponsiveSettingsContent, ResponsiveSettingsControl } from "@/components/ui/responsive-settings-row";
|
||||
|
||||
export default function PageWidthPref() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||
<div>
|
||||
<ResponsiveSettingsRow>
|
||||
<ResponsiveSettingsContent>
|
||||
<Text size="md">{t("Full page width")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("Choose your preferred page width.")}
|
||||
</Text>
|
||||
</div>
|
||||
</ResponsiveSettingsContent>
|
||||
|
||||
<PageWidthToggle />
|
||||
</Group>
|
||||
<ResponsiveSettingsControl>
|
||||
<PageWidthToggle />
|
||||
</ResponsiveSettingsControl>
|
||||
</ResponsiveSettingsRow>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface IUser {
|
||||
deletedAt: Date;
|
||||
fullPageWidth: boolean; // used for update
|
||||
pageEditMode: string; // used for update
|
||||
hasGeneratedPassword?: boolean;
|
||||
}
|
||||
|
||||
export interface ICurrentUser {
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ export default function WorkspaceInvitesTable() {
|
||||
)}
|
||||
</Alert>
|
||||
|
||||
<Table.ScrollContainer minWidth={500}>
|
||||
<Table.ScrollContainer minWidth={600}>
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ import {
|
||||
useWorkspaceMembersQuery,
|
||||
} from "@/features/workspace/queries/workspace-query.ts";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
import React from "react";
|
||||
import RoleSelectMenu from "@/components/ui/role-select-menu.tsx";
|
||||
import {
|
||||
getUserRoleLabel,
|
||||
@@ -54,7 +54,7 @@ export default function WorkspaceMembersTable() {
|
||||
return (
|
||||
<>
|
||||
<SearchInput onSearch={handleSearch} />
|
||||
<Table.ScrollContainer minWidth={500}>
|
||||
<Table.ScrollContainer minWidth={600}>
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
|
||||
+2
-1
@@ -5,7 +5,8 @@ import { useState } from "react";
|
||||
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
||||
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
|
||||
import { TextInput, Button } from "@mantine/core";
|
||||
import { useForm, zodResolver } from "@mantine/form";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { zodResolver } from "mantine-form-zod-resolver";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
Reference in New Issue
Block a user