mirror of
https://github.com/docmost/docmost.git
synced 2026-05-15 05:04:06 +08:00
Merge branch 'main' into perm-x
This commit is contained in:
@@ -22,6 +22,7 @@ import {
|
||||
searchSpotlight,
|
||||
shareSearchSpotlight,
|
||||
} from "@/features/search/constants.ts";
|
||||
import { NotificationPopover } from "@/features/notification/components/notification-popover.tsx";
|
||||
|
||||
const links = [{ link: APP_ROUTE.HOME, label: "Home" }];
|
||||
|
||||
@@ -97,6 +98,7 @@ export function AppHeader() {
|
||||
</div>
|
||||
|
||||
<Group px={"xl"} wrap="nowrap">
|
||||
<NotificationPopover />
|
||||
{isCloud() && isTrial && trialDaysLeft !== 0 && (
|
||||
<Badge
|
||||
variant="light"
|
||||
|
||||
@@ -115,7 +115,6 @@ const groupedData: DataGroup[] = [
|
||||
icon: IconSparkles,
|
||||
path: "/settings/ai",
|
||||
isAdmin: true,
|
||||
isSelfhosted: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
.aiMenu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
min-height: 2.25rem;
|
||||
}
|
||||
|
||||
.aiInput {
|
||||
width: 100%;
|
||||
|
||||
& input {
|
||||
height: 44px;
|
||||
border-radius: 22px;
|
||||
padding-left: 20px;
|
||||
padding-right: 40px;
|
||||
border: 1px solid
|
||||
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
|
||||
&:focus {
|
||||
border-color: light-dark(
|
||||
var(--mantine-color-gray-4),
|
||||
var(--mantine-color-dark-3)
|
||||
);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
}
|
||||
}
|
||||
.menuItemSelected {
|
||||
background-color: var(--mantine-color-gray-1);
|
||||
|
||||
@mixin dark {
|
||||
background-color: var(--mantine-color-dark-5);
|
||||
}
|
||||
}
|
||||
|
||||
.resultPreview {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-white),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
border: 1px solid
|
||||
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.resultPreviewWrapper {
|
||||
font-size: var(--mantine-font-size-md);
|
||||
line-height: 1.6;
|
||||
padding: var(--mantine-spacing-md);
|
||||
|
||||
*:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
*:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { ActionIcon, TextInput, Tooltip } from "@mantine/core";
|
||||
import { useDebouncedCallback, useMediaQuery } from "@mantine/hooks";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useAtom } from "jotai";
|
||||
import { IconArrowUp } from "@tabler/icons-react";
|
||||
import { showAiMenuAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import { useAiGenerateStreamMutation } from "@/ee/ai/queries/ai-query.ts";
|
||||
import { AiAction } from "@/ee/ai/types/ai.types.ts";
|
||||
import { CommandItem, commandItems, CommandSet } from "./command-items.ts";
|
||||
import { CommandSelector } from "./command-selector.tsx";
|
||||
import { ResultPreview } from "./result-preview.tsx";
|
||||
import classes from "./ai-menu.module.css";
|
||||
import { marked } from "marked";
|
||||
import { DOMSerializer } from "@tiptap/pm/model";
|
||||
import { htmlToMarkdown } from "@docmost/editor-ext";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
interface EditorAiMenuProps {
|
||||
editor: Editor | null;
|
||||
}
|
||||
|
||||
const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
|
||||
const aiGenerateStreamMutation = useAiGenerateStreamMutation();
|
||||
const location = useLocation();
|
||||
const isSmBreakpoint = useMediaQuery("(max-width: 48em)");
|
||||
const [showAiMenu, setShowAiMenu] = useAtom(showAiMenuAtom);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [output, setOutput] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const [activeCommandSet, setActiveCommandSet] = useState<CommandSet>("main");
|
||||
const [lastAction, setLastAction] = useState<CommandItem | null>(null);
|
||||
const [menuPlacement, setMenuPlacement] = useState<{
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
}>({
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 0,
|
||||
});
|
||||
const currentItems = useMemo(() => {
|
||||
return commandItems[activeCommandSet].filter((item) => {
|
||||
return item.name.toLowerCase().includes(prompt.toLowerCase());
|
||||
});
|
||||
}, [prompt, output, activeCommandSet]);
|
||||
const updateMenuPlacement = useCallback(() => {
|
||||
if (!editor || !showAiMenu) return;
|
||||
|
||||
const { view } = editor;
|
||||
const { to } = editor.state.selection;
|
||||
const editorRect = view.dom.getBoundingClientRect();
|
||||
const cursorCoords = view.coordsAtPos(to);
|
||||
const topOffset = 8;
|
||||
const editorPadding = isSmBreakpoint ? 16 : 48;
|
||||
|
||||
setMenuPlacement({
|
||||
top: cursorCoords.bottom + topOffset + window.scrollY,
|
||||
left: editorRect.left + editorPadding + window.scrollX,
|
||||
width: editorRect.width - editorPadding * 2,
|
||||
});
|
||||
}, [editor, showAiMenu, isSmBreakpoint]);
|
||||
const resetMenu = useCallback(() => {
|
||||
setPrompt("");
|
||||
setOutput("");
|
||||
setActiveCommandSet("main");
|
||||
setLastAction(null);
|
||||
aiGenerateStreamMutation.reset();
|
||||
}, [aiGenerateStreamMutation.reset]);
|
||||
const debouncedUpdateMenuPlacement = useDebouncedCallback(
|
||||
updateMenuPlacement,
|
||||
60,
|
||||
);
|
||||
const handleGenerate = useCallback(
|
||||
(item?: CommandItem) => {
|
||||
if (!editor || isLoading) return;
|
||||
|
||||
let command: CommandItem | null = item || null;
|
||||
|
||||
if (!command) {
|
||||
if (!prompt) return;
|
||||
|
||||
command = {
|
||||
id: "custom",
|
||||
name: "Custom",
|
||||
action: AiAction.CUSTOM,
|
||||
prompt,
|
||||
};
|
||||
}
|
||||
|
||||
const { from, to } = editor.state.selection;
|
||||
const slice = editor.state.doc.slice(from, to);
|
||||
const serializer = DOMSerializer.fromSchema(editor.schema);
|
||||
const fragment = serializer.serializeFragment(slice.content);
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.appendChild(fragment);
|
||||
const content = htmlToMarkdown(wrapper.innerHTML);
|
||||
|
||||
setOutput("");
|
||||
setIsLoading(true);
|
||||
aiGenerateStreamMutation.mutate({
|
||||
action: command.action,
|
||||
prompt: command.prompt,
|
||||
content,
|
||||
onChunk: (chunk) => {
|
||||
setOutput((output) => output + chunk.content);
|
||||
},
|
||||
onComplete: () => {
|
||||
setIsLoading(false);
|
||||
setActiveCommandSet("result");
|
||||
},
|
||||
onError: () => {
|
||||
setIsLoading(false);
|
||||
resetMenu();
|
||||
},
|
||||
});
|
||||
setLastAction(command);
|
||||
},
|
||||
[
|
||||
editor,
|
||||
prompt,
|
||||
isLoading,
|
||||
aiGenerateStreamMutation.mutateAsync,
|
||||
resetMenu,
|
||||
],
|
||||
);
|
||||
const handleCommand = useCallback(
|
||||
(item?: CommandItem) => {
|
||||
setPrompt("");
|
||||
|
||||
if (!item) {
|
||||
return handleGenerate();
|
||||
}
|
||||
if (item.id === "back") {
|
||||
return setActiveCommandSet("main");
|
||||
}
|
||||
if (item.id === "result-replace") {
|
||||
const chain = editor.chain().focus();
|
||||
|
||||
if (lastAction.action === AiAction.CONTINUE_WRITING) {
|
||||
chain.setTextSelection(editor.state.selection.to);
|
||||
}
|
||||
|
||||
const html = (marked.parse(output) as string).trim();
|
||||
// Strip <p> wrapper for single-paragraph output to preserve inline context
|
||||
const content =
|
||||
html.startsWith("<p>") &&
|
||||
html.endsWith("</p>") &&
|
||||
html.lastIndexOf("<p>") === 0
|
||||
? html.slice(3, -4)
|
||||
: html;
|
||||
|
||||
chain.insertContent(content).run();
|
||||
|
||||
return setShowAiMenu(false);
|
||||
}
|
||||
if (item.id === "result-insert-below") {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setTextSelection(editor.state.selection.to)
|
||||
.insertContent(marked.parse(output))
|
||||
.run();
|
||||
|
||||
return setShowAiMenu(false);
|
||||
}
|
||||
if (item.id === "result-copy") {
|
||||
navigator.clipboard.writeText(output);
|
||||
|
||||
return setShowAiMenu(false);
|
||||
}
|
||||
if (item.id === "result-discard") {
|
||||
setOutput("");
|
||||
|
||||
return resetMenu();
|
||||
}
|
||||
if (item.id === "result-try-again" && lastAction) {
|
||||
return handleGenerate(lastAction);
|
||||
}
|
||||
if (item.subCommandSet) {
|
||||
return setActiveCommandSet(item.subCommandSet);
|
||||
}
|
||||
|
||||
return handleGenerate(item);
|
||||
},
|
||||
[editor, output, lastAction, handleGenerate, resetMenu],
|
||||
);
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
const totalItems = currentItems.length;
|
||||
const cycleSize = totalItems + 1;
|
||||
|
||||
if (event.key === "Escape") {
|
||||
return setShowAiMenu(false);
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
|
||||
return setSelectedIndex((selectedIndex) => {
|
||||
const direction = event.key === "ArrowDown" ? 1 : -1;
|
||||
const newIndex = selectedIndex + direction;
|
||||
|
||||
if (newIndex < -1) return cycleSize - 1;
|
||||
if (newIndex >= cycleSize) return 0;
|
||||
|
||||
return newIndex;
|
||||
});
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
|
||||
return handleCommand(currentItems[selectedIndex]);
|
||||
}
|
||||
},
|
||||
[currentItems, selectedIndex],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
|
||||
const handleClose = () => setShowAiMenu(false);
|
||||
const observer = new ResizeObserver(() => {
|
||||
debouncedUpdateMenuPlacement();
|
||||
});
|
||||
|
||||
updateMenuPlacement();
|
||||
editor.on("focus", handleClose);
|
||||
editor.on("blur", handleClose);
|
||||
window.addEventListener("resize", debouncedUpdateMenuPlacement);
|
||||
window.addEventListener("scroll", debouncedUpdateMenuPlacement, true);
|
||||
observer.observe(editor.view.dom);
|
||||
|
||||
return () => {
|
||||
editor.off("focus", handleClose);
|
||||
editor.off("blur", handleClose);
|
||||
window.removeEventListener("resize", debouncedUpdateMenuPlacement);
|
||||
window.removeEventListener("scroll", debouncedUpdateMenuPlacement, true);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [editor, updateMenuPlacement, debouncedUpdateMenuPlacement]);
|
||||
|
||||
useEffect(() => {
|
||||
setShowAiMenu(false);
|
||||
}, [location]);
|
||||
useEffect(() => {
|
||||
if (showAiMenu) {
|
||||
resetMenu();
|
||||
}
|
||||
}, [showAiMenu, resetMenu]);
|
||||
useEffect(() => {
|
||||
// Focus input when menu opens or command set changes
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.focus({ preventScroll: true });
|
||||
});
|
||||
}, [showAiMenu, isLoading, currentItems]);
|
||||
useEffect(() => {
|
||||
if (!currentItems.length) {
|
||||
setSelectedIndex(-1);
|
||||
}
|
||||
setSelectedIndex(prompt || activeCommandSet !== "main" ? 0 : -1);
|
||||
}, [prompt, activeCommandSet, currentItems]);
|
||||
|
||||
if (!showAiMenu) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
style={{
|
||||
zIndex: 200,
|
||||
position: "absolute",
|
||||
top: menuPlacement.top,
|
||||
left: menuPlacement.left,
|
||||
width: menuPlacement.width,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={classes.aiMenu}
|
||||
style={{ pointerEvents: "auto" }}
|
||||
tabIndex={0}
|
||||
ref={containerRef}
|
||||
>
|
||||
<ResultPreview output={output} isLoading={isLoading} />
|
||||
<CommandSelector
|
||||
selectedIndex={selectedIndex}
|
||||
isLoading={isLoading}
|
||||
output={output}
|
||||
currentItems={currentItems}
|
||||
handleCommand={handleCommand}
|
||||
>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
className={classes.aiInput}
|
||||
placeholder="Ask AI..."
|
||||
data-autofocus
|
||||
value={prompt}
|
||||
disabled={isLoading}
|
||||
onChange={(e) => setPrompt(e.currentTarget.value)}
|
||||
rightSection={
|
||||
<ActionIcon
|
||||
disabled={!prompt || isLoading}
|
||||
variant="filled"
|
||||
color="blue"
|
||||
radius="xl"
|
||||
size="sm"
|
||||
onClick={() => handleGenerate()}
|
||||
>
|
||||
<IconArrowUp size={14} stroke={2.5} />
|
||||
</ActionIcon>
|
||||
}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</CommandSelector>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
|
||||
export { EditorAiMenu };
|
||||
@@ -0,0 +1,219 @@
|
||||
import { AiAction } from "@/ee/ai/types/ai.types.ts";
|
||||
import {
|
||||
IconSparkles,
|
||||
IconArrowsMaximize,
|
||||
IconArrowsMinimize,
|
||||
IconWriting,
|
||||
IconHelp,
|
||||
IconList,
|
||||
IconMoodSmile,
|
||||
IconLanguage,
|
||||
IconTrash,
|
||||
IconRefresh,
|
||||
IconChevronLeft,
|
||||
IconCheck,
|
||||
IconArrowDownLeft,
|
||||
IconCopy,
|
||||
IconTextPlus,
|
||||
IconAlignJustified,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
interface CommandItem {
|
||||
name: string;
|
||||
id: string;
|
||||
icon?: typeof IconSparkles;
|
||||
action?: AiAction;
|
||||
prompt?: string;
|
||||
subCommandSet?: CommandSet;
|
||||
}
|
||||
|
||||
type CommandSet = "main" | "tone" | "translate" | "result";
|
||||
|
||||
const mainItems: CommandItem[] = [
|
||||
{
|
||||
id: "improve-writing",
|
||||
name: "Improve writing",
|
||||
icon: IconSparkles,
|
||||
action: AiAction.IMPROVE_WRITING,
|
||||
},
|
||||
{
|
||||
id: "fix-spelling-grammar",
|
||||
name: "Fix spelling & grammar",
|
||||
icon: IconCheck,
|
||||
action: AiAction.FIX_SPELLING_GRAMMAR,
|
||||
},
|
||||
{
|
||||
id: "make-longer",
|
||||
name: "Make longer",
|
||||
icon: IconTextPlus,
|
||||
action: AiAction.MAKE_LONGER,
|
||||
},
|
||||
{
|
||||
id: "make-shorter",
|
||||
name: "Make shorter",
|
||||
icon: IconAlignJustified,
|
||||
action: AiAction.MAKE_SHORTER,
|
||||
},
|
||||
{
|
||||
id: "continue-writing",
|
||||
name: "Continue writing",
|
||||
icon: IconWriting,
|
||||
action: AiAction.CONTINUE_WRITING,
|
||||
},
|
||||
{
|
||||
id: "explain",
|
||||
name: "Explain",
|
||||
icon: IconHelp,
|
||||
action: AiAction.EXPLAIN,
|
||||
},
|
||||
{
|
||||
id: "summarize",
|
||||
name: "Summarize",
|
||||
icon: IconList,
|
||||
action: AiAction.SUMMARIZE,
|
||||
},
|
||||
{
|
||||
id: "change-tone",
|
||||
name: "Change tone",
|
||||
icon: IconMoodSmile,
|
||||
subCommandSet: "tone",
|
||||
},
|
||||
{
|
||||
id: "translate",
|
||||
name: "Translate",
|
||||
icon: IconLanguage,
|
||||
subCommandSet: "translate",
|
||||
},
|
||||
];
|
||||
const toneItems: CommandItem[] = [
|
||||
{
|
||||
id: "back",
|
||||
name: "Back",
|
||||
icon: IconChevronLeft,
|
||||
},
|
||||
{
|
||||
id: "tone-professional",
|
||||
name: "Professional",
|
||||
icon: IconMoodSmile,
|
||||
action: AiAction.CHANGE_TONE,
|
||||
prompt: "Professional",
|
||||
},
|
||||
{
|
||||
id: "tone-casual",
|
||||
name: "Casual",
|
||||
icon: IconMoodSmile,
|
||||
action: AiAction.CHANGE_TONE,
|
||||
prompt: "Casual",
|
||||
},
|
||||
{
|
||||
id: "tone-friendly",
|
||||
name: "Friendly",
|
||||
icon: IconMoodSmile,
|
||||
action: AiAction.CHANGE_TONE,
|
||||
prompt: "Friendly",
|
||||
},
|
||||
];
|
||||
const translateItems: CommandItem[] = [
|
||||
{
|
||||
id: "back",
|
||||
name: "Back",
|
||||
icon: IconChevronLeft,
|
||||
},
|
||||
{
|
||||
id: "translate-english",
|
||||
name: "English",
|
||||
icon: IconLanguage,
|
||||
action: AiAction.TRANSLATE,
|
||||
prompt: "English",
|
||||
},
|
||||
{
|
||||
id: "translate-spanish",
|
||||
name: "Spanish",
|
||||
icon: IconLanguage,
|
||||
action: AiAction.TRANSLATE,
|
||||
prompt: "Spanish",
|
||||
},
|
||||
{
|
||||
id: "translate-german",
|
||||
name: "German",
|
||||
icon: IconLanguage,
|
||||
action: AiAction.TRANSLATE,
|
||||
prompt: "German",
|
||||
},
|
||||
{
|
||||
id: "translate-french",
|
||||
name: "French",
|
||||
icon: IconLanguage,
|
||||
action: AiAction.TRANSLATE,
|
||||
prompt: "French",
|
||||
},
|
||||
{
|
||||
id: "translate-dutch",
|
||||
name: "Dutch",
|
||||
icon: IconLanguage,
|
||||
action: AiAction.TRANSLATE,
|
||||
prompt: "Dutch",
|
||||
},
|
||||
{
|
||||
id: "translate-portuguese",
|
||||
name: "Portuguese",
|
||||
icon: IconLanguage,
|
||||
action: AiAction.TRANSLATE,
|
||||
prompt: "Portuguese",
|
||||
},
|
||||
{
|
||||
id: "translate-italian",
|
||||
name: "Italian",
|
||||
icon: IconLanguage,
|
||||
action: AiAction.TRANSLATE,
|
||||
prompt: "Italian",
|
||||
},
|
||||
{
|
||||
id: "translate-japanese",
|
||||
name: "Japanese",
|
||||
icon: IconLanguage,
|
||||
action: AiAction.TRANSLATE,
|
||||
prompt: "Japanese",
|
||||
},
|
||||
{
|
||||
id: "translate-korean",
|
||||
name: "Korean",
|
||||
icon: IconLanguage,
|
||||
action: AiAction.TRANSLATE,
|
||||
prompt: "Korean",
|
||||
},
|
||||
{
|
||||
id: "translate-swedish",
|
||||
name: "Swedish",
|
||||
icon: IconLanguage,
|
||||
action: AiAction.TRANSLATE,
|
||||
prompt: "Swedish",
|
||||
},
|
||||
{
|
||||
id: "translate-chinese",
|
||||
name: "Chinese (Simplified)",
|
||||
icon: IconLanguage,
|
||||
action: AiAction.TRANSLATE,
|
||||
prompt: "Simplified Chinese",
|
||||
},
|
||||
];
|
||||
const resultItems: CommandItem[] = [
|
||||
{ id: "result-replace", name: "Replace", icon: IconCheck },
|
||||
{ id: "result-insert-below", name: "Insert below", icon: IconArrowDownLeft },
|
||||
{ id: "result-copy", name: "Copy", icon: IconCopy },
|
||||
{ id: "result-discard", name: "Discard", icon: IconTrash },
|
||||
{
|
||||
id: "result-try-again",
|
||||
name: "Try again",
|
||||
icon: IconRefresh,
|
||||
},
|
||||
];
|
||||
const commandItems: Record<CommandSet, CommandItem[]> = {
|
||||
main: mainItems,
|
||||
tone: toneItems,
|
||||
translate: translateItems,
|
||||
result: resultItems,
|
||||
};
|
||||
|
||||
export type { CommandItem, CommandSet };
|
||||
export { commandItems };
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Loader, Menu, ScrollArea } from "@mantine/core";
|
||||
import { IconChevronRight } from "@tabler/icons-react";
|
||||
import { ReactNode } from "react";
|
||||
import { CommandItem } from "./command-items.ts";
|
||||
import classes from "./ai-menu.module.css";
|
||||
|
||||
interface CommandSelectorProps {
|
||||
selectedIndex: number;
|
||||
|
||||
isLoading: boolean;
|
||||
output: string;
|
||||
currentItems: CommandItem[];
|
||||
children: ReactNode;
|
||||
handleCommand(item: CommandItem): void;
|
||||
}
|
||||
|
||||
const CommandSelector = ({
|
||||
selectedIndex,
|
||||
children,
|
||||
isLoading,
|
||||
output,
|
||||
currentItems,
|
||||
handleCommand,
|
||||
}: CommandSelectorProps) => {
|
||||
return (
|
||||
<Menu
|
||||
opened={!isLoading && currentItems.length > 0}
|
||||
middlewares={{ flip: false }}
|
||||
position="bottom-start"
|
||||
offset={4}
|
||||
width={250}
|
||||
trapFocus={false}
|
||||
shadow="lg"
|
||||
>
|
||||
<Menu.Target>{children}</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<ScrollArea.Autosize type="scroll" scrollbarSize={5} mah={300}>
|
||||
{currentItems.map((item, index) => {
|
||||
const isSelected = selectedIndex === index;
|
||||
const showLoader =
|
||||
isLoading && output === "" && !item.subCommandSet;
|
||||
|
||||
return (
|
||||
<Menu.Item
|
||||
key={item.id}
|
||||
className={isSelected ? classes.menuItemSelected : undefined}
|
||||
leftSection={
|
||||
showLoader ? (
|
||||
<Loader size={14} />
|
||||
) : item.icon ? (
|
||||
<item.icon size={16} />
|
||||
) : undefined
|
||||
}
|
||||
rightSection={
|
||||
item.subCommandSet ? (
|
||||
<IconChevronRight size={14} />
|
||||
) : undefined
|
||||
}
|
||||
onClick={() => handleCommand(item)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{item.name}
|
||||
</Menu.Item>
|
||||
);
|
||||
})}
|
||||
</ScrollArea.Autosize>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
export { CommandSelector };
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Loader, Paper, ScrollArea } from "@mantine/core";
|
||||
import DOMPurify from "dompurify";
|
||||
import { marked } from "marked";
|
||||
import { memo } from "react";
|
||||
import classes from "./ai-menu.module.css";
|
||||
|
||||
interface ResultPreviewProps {
|
||||
output: string;
|
||||
isLoading: boolean;
|
||||
}
|
||||
const ResultPreview = memo(({ output, isLoading }: ResultPreviewProps) => {
|
||||
if (!output && !isLoading) return;
|
||||
|
||||
const parsedOutput = `${marked.parse(output)}`;
|
||||
|
||||
return (
|
||||
<Paper mb={4} shadow="lg" radius="md" className={classes.resultPreview}>
|
||||
<ScrollArea.Autosize mah={300} type="scroll" scrollbarSize={5}>
|
||||
<div className={classes.resultPreviewWrapper}>
|
||||
{parsedOutput && (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(parsedOutput) }}
|
||||
/>
|
||||
)}
|
||||
{isLoading && <Loader size={12} ml="xs" display="inline-block" />}
|
||||
</div>
|
||||
</ScrollArea.Autosize>
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
|
||||
export { ResultPreview };
|
||||
@@ -15,7 +15,7 @@ export default function EnableAiSearch() {
|
||||
<>
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||
<div>
|
||||
<Text size="md">{t("AI-powered search (Ask AI)")}</Text>
|
||||
<Text size="md">{t("AI-powered search (AI Answers)")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.",
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Group, Text, Switch } from "@mantine/core";
|
||||
import { useAtom } from "jotai";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx";
|
||||
|
||||
export default function EnableGenerativeAi() {
|
||||
const { t } = useTranslation();
|
||||
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
||||
const [checked, setChecked] = useState(workspace?.settings?.ai?.generative);
|
||||
const hasAccess = useIsCloudEE();
|
||||
|
||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.currentTarget.checked;
|
||||
try {
|
||||
const updatedWorkspace = await updateWorkspace({ generativeAi: value });
|
||||
setChecked(value);
|
||||
setWorkspace(updatedWorkspace);
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
message: err?.response?.data?.message,
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||
<div>
|
||||
<Text size="md">{t("Generative AI (Ask AI)")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
defaultChecked={checked}
|
||||
onChange={handleChange}
|
||||
disabled={!hasAccess}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMutation, UseMutationResult } from "@tanstack/react-query";
|
||||
import { useState, useCallback } from "react";
|
||||
import { askAi, IAiSearchResponse } from "@/ee/ai/services/ai-search-service.ts";
|
||||
import { aiAnswers, IAiSearchResponse } from "@/ee/ai/services/ai-search-service.ts";
|
||||
import { IPageSearchParams } from "@/features/search/types/search.types.ts";
|
||||
|
||||
// @ts-ignore
|
||||
@@ -26,7 +26,7 @@ export function useAiSearch(): UseAiSearchResult {
|
||||
|
||||
const { contentType, ...apiParams } = params;
|
||||
|
||||
return await askAi(apiParams, (chunk) => {
|
||||
return await aiAnswers(apiParams, (chunk) => {
|
||||
if (chunk.content) {
|
||||
setStreamingAnswer((prev) => prev + chunk.content);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { getAppName, isCloud } from "@/lib/config.ts";
|
||||
import { getAppName } from "@/lib/config.ts";
|
||||
import SettingsTitle from "@/components/settings/settings-title.tsx";
|
||||
import React from "react";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useLicense from "@/ee/hooks/use-license.tsx";
|
||||
import EnableAiSearch from "@/ee/ai/components/enable-ai-search.tsx";
|
||||
import { Alert } from "@mantine/core";
|
||||
import EnableGenerativeAi from "@/ee/ai/components/enable-generative-ai.tsx";
|
||||
import { Alert, Stack } from "@mantine/core";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx";
|
||||
import { isCloud } from "@/lib/config.ts";
|
||||
|
||||
export default function AiSettings() {
|
||||
const { t } = useTranslation();
|
||||
const { isAdmin } = useUserRole();
|
||||
const { hasLicenseKey } = useLicense();
|
||||
const hasAccess = useIsCloudEE();
|
||||
|
||||
if (!isAdmin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasAccess = isCloud() || (!isCloud() && hasLicenseKey);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
@@ -40,7 +40,10 @@ export default function AiSettings() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<EnableAiSearch />
|
||||
<Stack gap="md">
|
||||
{!isCloud() && <EnableAiSearch />}
|
||||
<EnableGenerativeAi />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,11 +15,11 @@ export interface IAiSearchResponse {
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function askAi(
|
||||
export async function aiAnswers(
|
||||
params: IPageSearchParams,
|
||||
onChunk?: (chunk: { content?: string; sources?: any[] }) => void,
|
||||
): Promise<IAiSearchResponse> {
|
||||
const response = await fetch("/api/ai/ask", {
|
||||
const response = await fetch("/api/ai/answers", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
||||
@@ -43,13 +43,16 @@ export async function generateAiContentStream(
|
||||
}
|
||||
|
||||
const processStream = async () => {
|
||||
let buffer = "";
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
const lines = chunk.split("\n");
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
@@ -66,7 +69,7 @@ export async function generateAiContentStream(
|
||||
onChunk(parsed);
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parse errors for incomplete chunks
|
||||
// Skip invalid JSON
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export enum AiAction {
|
||||
SIMPLIFY = "simplify",
|
||||
CHANGE_TONE = "change_tone",
|
||||
SUMMARIZE = "summarize",
|
||||
EXPLAIN = "explain",
|
||||
CONTINUE_WRITING = "continue_writing",
|
||||
TRANSLATE = "translate",
|
||||
CUSTOM = "custom",
|
||||
|
||||
@@ -31,6 +31,7 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const [, setAsideState] = useAtom(asideStateAtom);
|
||||
const useClickOutsideRef = useClickOutside(() => {
|
||||
if (document.querySelector("#mention")) return;
|
||||
handleDialogClose();
|
||||
});
|
||||
const createCommentMutation = useCreateCommentMutation();
|
||||
@@ -105,6 +106,7 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
||||
position={{ bottom: 500, right: 50 }}
|
||||
withCloseButton
|
||||
withBorder
|
||||
data-comment-dialog
|
||||
>
|
||||
<Stack gap={2}>
|
||||
<Group>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { EditorContent, useEditor } from "@tiptap/react";
|
||||
import { EditorContent, ReactNodeViewRenderer, useEditor } from "@tiptap/react";
|
||||
import { Placeholder } from "@tiptap/extension-placeholder";
|
||||
import { Underline } from "@tiptap/extension-underline";
|
||||
import { Link } from "@tiptap/extension-link";
|
||||
import { StarterKit } from "@tiptap/starter-kit";
|
||||
import { Mention, LinkExtension } from "@docmost/editor-ext";
|
||||
import classes from "./comment.module.css";
|
||||
import { useFocusWithin } from "@mantine/hooks";
|
||||
import clsx from "clsx";
|
||||
import { forwardRef, useEffect, useImperativeHandle } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import EmojiCommand from "@/features/editor/extensions/emoji-command";
|
||||
import mentionRenderItems from "@/features/editor/components/mention/mention-suggestion";
|
||||
import MentionView from "@/features/editor/components/mention/mention-view";
|
||||
|
||||
interface CommentEditorProps {
|
||||
defaultContent?: any;
|
||||
@@ -39,13 +40,29 @@ const CommentEditor = forwardRef(
|
||||
StarterKit.configure({
|
||||
gapcursor: false,
|
||||
dropcursor: false,
|
||||
link: false,
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: placeholder || t("Reply..."),
|
||||
}),
|
||||
Underline,
|
||||
Link,
|
||||
LinkExtension,
|
||||
EmojiCommand,
|
||||
Mention.configure({
|
||||
suggestion: {
|
||||
allowSpaces: true,
|
||||
items: () => [],
|
||||
// @ts-ignore
|
||||
render: mentionRenderItems,
|
||||
},
|
||||
HTMLAttributes: {
|
||||
class: "mention",
|
||||
},
|
||||
}).extend({
|
||||
addNodeView() {
|
||||
this.editor.isInitialized = true;
|
||||
return ReactNodeViewRenderer(MentionView);
|
||||
},
|
||||
}),
|
||||
],
|
||||
editorProps: {
|
||||
handleDOMEvents: {
|
||||
@@ -60,7 +77,8 @@ const CommentEditor = forwardRef(
|
||||
].includes(event.key)
|
||||
) {
|
||||
const emojiCommand = document.querySelector("#emoji-command");
|
||||
if (emojiCommand) {
|
||||
const mentionPopup = document.querySelector("#mention");
|
||||
if (emojiCommand || mentionPopup) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -108,7 +126,11 @@ const CommentEditor = forwardRef(
|
||||
}));
|
||||
|
||||
return (
|
||||
<div ref={focusRef} className={classes.commentEditor}>
|
||||
<div
|
||||
ref={focusRef}
|
||||
className={classes.commentEditor}
|
||||
data-editable={editable || undefined}
|
||||
>
|
||||
<EditorContent
|
||||
editor={commentEditor}
|
||||
className={clsx(classes.ProseMirror, { [classes.focused]: focused })}
|
||||
|
||||
@@ -32,11 +32,14 @@
|
||||
max-width: 100%;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 20vh;
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
&[data-editable] .ProseMirror :global(.ProseMirror){
|
||||
max-height: 50vh;
|
||||
overflow: hidden auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,3 +8,5 @@ export const titleEditorAtom = atom<Editor | null>(null);
|
||||
export const readOnlyEditorAtom = atom<Editor | null>(null);
|
||||
|
||||
export const yjsConnectionStatusAtom = atom<string>("");
|
||||
|
||||
export const showAiMenuAtom = atom(false);
|
||||
|
||||
@@ -1,11 +1,53 @@
|
||||
.bubbleMenu {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
max-width: 100vw;
|
||||
width: fit-content;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 4px 12px light-dark(#cfcfcf, #0f0f0f);
|
||||
border-radius: 6px;
|
||||
border: 1px solid
|
||||
light-dark(var(--mantine-color-gray-3), var(--mantine-color-gray-8));
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-white),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
|
||||
> * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.active {
|
||||
color: light-dark(var(--mantine-color-blue-8), var(--mantine-color-gray-5));
|
||||
}
|
||||
}
|
||||
|
||||
.buttonRoot {
|
||||
height: 34px;
|
||||
padding-left: rem(8);
|
||||
padding-right: rem(4);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.buttonSeparator {
|
||||
border-right: 1px solid
|
||||
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)) !important;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
align-self: center;
|
||||
margin: 0 4px;
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-3),
|
||||
var(--mantine-color-dark-3)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,10 +9,11 @@ import {
|
||||
IconStrikethrough,
|
||||
IconUnderline,
|
||||
IconMessage,
|
||||
IconSparkles,
|
||||
} from "@tabler/icons-react";
|
||||
import clsx from "clsx";
|
||||
import classes from "./bubble-menu.module.css";
|
||||
import { ActionIcon, rem, Tooltip } from "@mantine/core";
|
||||
import { ActionIcon, Button, rem, Tooltip } from "@mantine/core";
|
||||
import { ColorSelector } from "./color-selector";
|
||||
import { NodeSelector } from "./node-selector";
|
||||
import { TextAlignmentSelector } from "./text-alignment-selector";
|
||||
@@ -20,11 +21,13 @@ import {
|
||||
draftCommentIdAtom,
|
||||
showCommentPopupAtom,
|
||||
} from "@/features/comment/atoms/comment-atom";
|
||||
import { useAtom } from "jotai";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { v7 as uuid7 } from "uuid";
|
||||
import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
|
||||
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { showAiMenuAtom } from "@/features/editor/atoms/editor-atoms";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
|
||||
|
||||
export interface BubbleMenuItem {
|
||||
name: string;
|
||||
@@ -39,14 +42,22 @@ type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
|
||||
|
||||
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const [showAiMenu, setShowAiMenu] = useAtom(showAiMenuAtom);
|
||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||
const workspace = useAtomValue(workspaceAtom);
|
||||
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
|
||||
const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
|
||||
const showCommentPopupRef = useRef(showCommentPopup);
|
||||
const showAiMenuRef = useRef(showAiMenu);
|
||||
|
||||
useEffect(() => {
|
||||
showCommentPopupRef.current = showCommentPopup;
|
||||
}, [showCommentPopup]);
|
||||
|
||||
useEffect(() => {
|
||||
showAiMenuRef.current = showAiMenu;
|
||||
}, [showAiMenu]);
|
||||
|
||||
const editorState = useEditorState({
|
||||
editor: props.editor,
|
||||
selector: (ctx) => {
|
||||
@@ -123,6 +134,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
empty ||
|
||||
isNodeSelection(selection) ||
|
||||
isCellSelection(selection) ||
|
||||
showAiMenuRef.current ||
|
||||
showCommentPopupRef?.current
|
||||
) {
|
||||
return false;
|
||||
@@ -146,9 +158,31 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
|
||||
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
||||
|
||||
// Hide the bubble menu immediately when AI menu is shown
|
||||
if (showAiMenu) return;
|
||||
|
||||
return (
|
||||
<BubbleMenu {...bubbleMenuProps} style={{ zIndex: 200, position: "relative"}}>
|
||||
<BubbleMenu
|
||||
{...bubbleMenuProps}
|
||||
style={{ zIndex: 200, position: "relative" }}
|
||||
>
|
||||
<div className={classes.bubbleMenu}>
|
||||
{isGenerativeAiEnabled && (
|
||||
<>
|
||||
<Button
|
||||
variant="default"
|
||||
className={clsx(classes.buttonRoot)}
|
||||
radius="0"
|
||||
leftSection={<IconSparkles size={16} />}
|
||||
onClick={() => {
|
||||
setShowAiMenu(true);
|
||||
}}
|
||||
>
|
||||
{t("Ask AI")}
|
||||
</Button>
|
||||
<div className={classes.divider} />
|
||||
</>
|
||||
)}
|
||||
<NodeSelector
|
||||
editor={props.editor}
|
||||
isOpen={isNodeSelectorOpen}
|
||||
@@ -212,16 +246,18 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
}}
|
||||
/>
|
||||
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="0"
|
||||
aria-label={t(commentItem.name)}
|
||||
style={{ border: "none" }}
|
||||
onClick={commentItem.command}
|
||||
>
|
||||
<IconMessage size={16} stroke={2} />
|
||||
</ActionIcon>
|
||||
<Tooltip label={t(commentItem.name)} withArrow withinPortal={false}>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="6px"
|
||||
aria-label={t(commentItem.name)}
|
||||
style={{ border: "none" }}
|
||||
onClick={commentItem.command}
|
||||
>
|
||||
<IconMessage size={16} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</BubbleMenu>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { Dispatch, FC, SetStateAction } from "react";
|
||||
import { IconCheck, IconChevronDown, IconPalette } from "@tabler/icons-react";
|
||||
import { IconCheck, IconChevronDown } from "@tabler/icons-react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Popover,
|
||||
rem,
|
||||
@@ -15,6 +14,8 @@ import {
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { useEditorState } from "@tiptap/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
import classes from "./bubble-menu.module.css";
|
||||
|
||||
export interface BubbleColorMenuItem {
|
||||
name: string;
|
||||
@@ -166,14 +167,10 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
data-text-color={activeColorItem?.color || ""}
|
||||
data-highlight-color={activeHighlightItem?.color || ""}
|
||||
className="color-selector-trigger"
|
||||
className={clsx(["color-selector-trigger", classes.buttonRoot])}
|
||||
style={{
|
||||
height: "34px",
|
||||
border: "none",
|
||||
fontWeight: 500,
|
||||
fontSize: rem(16),
|
||||
paddingLeft: rem(8),
|
||||
paddingRight: rem(4),
|
||||
}}
|
||||
>
|
||||
A
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { Dispatch, FC, SetStateAction } from "react";
|
||||
import {
|
||||
IconBlockquote,
|
||||
IconCaretRightFilled,
|
||||
IconCheck,
|
||||
IconCheckbox,
|
||||
IconChevronDown,
|
||||
@@ -8,14 +9,16 @@ import {
|
||||
IconH1,
|
||||
IconH2,
|
||||
IconH3,
|
||||
IconInfoCircle,
|
||||
IconList,
|
||||
IconListNumbers,
|
||||
IconTypography,
|
||||
} from "@tabler/icons-react";
|
||||
import { Popover, Button, ScrollArea } from "@mantine/core";
|
||||
import { Popover, Button, ScrollArea, Tooltip } from "@mantine/core";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { useEditorState } from "@tiptap/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classes from "./bubble-menu.module.css";
|
||||
|
||||
interface NodeSelectorProps {
|
||||
editor: Editor | null;
|
||||
@@ -54,6 +57,8 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
isTaskItem: ctx.editor.isActive("taskItem"),
|
||||
isBlockquote: ctx.editor.isActive("blockquote"),
|
||||
isCodeBlock: ctx.editor.isActive("codeBlock"),
|
||||
isCallout: ctx.editor.isActive("callout"),
|
||||
isDetails: ctx.editor.isActive("details"),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -123,6 +128,18 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
command: () => editor.chain().focus().toggleCodeBlock().run(),
|
||||
isActive: () => editorState?.isCodeBlock,
|
||||
},
|
||||
{
|
||||
name: "Callout",
|
||||
icon: IconInfoCircle,
|
||||
command: () => editor.chain().focus().toggleCallout().run(),
|
||||
isActive: () => editorState?.isCallout,
|
||||
},
|
||||
{
|
||||
name: "Toggle block",
|
||||
icon: IconCaretRightFilled,
|
||||
command: () => editor.chain().focus().setDetails().run(),
|
||||
isActive: () => editorState?.isDetails,
|
||||
},
|
||||
];
|
||||
|
||||
const activeItem = items.filter((item) => item.isActive()).pop() ?? {
|
||||
@@ -132,15 +149,18 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
return (
|
||||
<Popover opened={isOpen} withArrow>
|
||||
<Popover.Target>
|
||||
<Button
|
||||
variant="default"
|
||||
style={{ border: "none", height: "34px" }}
|
||||
radius="0"
|
||||
rightSection={<IconChevronDown size={16} />}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
{t(activeItem?.name)}
|
||||
</Button>
|
||||
<Tooltip label={t("Turn into")} withArrow withinPortal={false} disabled={isOpen}>
|
||||
<Button
|
||||
className={classes.buttonRoot}
|
||||
variant="default"
|
||||
style={{ border: "none", height: "34px" }}
|
||||
radius="0"
|
||||
rightSection={<IconChevronDown size={16} />}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
{t(activeItem?.name)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Popover.Target>
|
||||
|
||||
<Popover.Dropdown>
|
||||
|
||||
+13
-11
@@ -7,7 +7,7 @@ import {
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
} from "@tabler/icons-react";
|
||||
import { Popover, Button, ScrollArea, rem } from "@mantine/core";
|
||||
import { Popover, Button, ScrollArea, Tooltip, rem } from "@mantine/core";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { useEditorState } from "@tiptap/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -84,16 +84,18 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
|
||||
return (
|
||||
<Popover opened={isOpen} withArrow>
|
||||
<Popover.Target>
|
||||
<Button
|
||||
variant="default"
|
||||
style={{ border: "none", height: "34px" }}
|
||||
px="5"
|
||||
radius="0"
|
||||
rightSection={<IconChevronDown size={16} />}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
<activeItem.icon style={{ width: rem(16) }} stroke={2} />
|
||||
</Button>
|
||||
<Tooltip label={t("Text align")} withArrow withinPortal={false} disabled={isOpen}>
|
||||
<Button
|
||||
variant="default"
|
||||
style={{ border: "none", height: "34px" }}
|
||||
px="5"
|
||||
radius="0"
|
||||
rightSection={<IconChevronDown size={16} />}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
<activeItem.icon style={{ width: rem(16) }} stroke={2} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Popover.Target>
|
||||
|
||||
<Popover.Dropdown>
|
||||
|
||||
@@ -10,6 +10,7 @@ import React, {
|
||||
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query.ts";
|
||||
import {
|
||||
ActionIcon,
|
||||
Divider,
|
||||
Group,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
@@ -51,6 +52,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
const tree = useMemo(() => new SimpleTree<SpaceTreeNode>(data), [data]);
|
||||
const createPageMutation = useCreatePageMutation();
|
||||
const emit = useQueryEmit();
|
||||
const isInCommentContext = props.isInCommentContext ?? false;
|
||||
|
||||
const { data: suggestion, isLoading } = useSearchSuggestionsQuery({
|
||||
query: props.query,
|
||||
@@ -58,6 +60,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
includePages: true,
|
||||
spaceId: space.id,
|
||||
limit: 10,
|
||||
preload: true,
|
||||
});
|
||||
|
||||
const createPageItem = (label: string) : MentionSuggestionItem => {
|
||||
@@ -102,7 +105,9 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
})),
|
||||
);
|
||||
}
|
||||
items.push(createPageItem(props.query));
|
||||
if (!isInCommentContext && props.query) {
|
||||
items.push(createPageItem(props.query));
|
||||
}
|
||||
|
||||
setRenderItems(items);
|
||||
// update editor storage
|
||||
@@ -250,35 +255,51 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
}
|
||||
}
|
||||
|
||||
// if no results and enter what to do?
|
||||
|
||||
useEffect(() => {
|
||||
viewportRef.current
|
||||
?.querySelector(`[data-item-index="${selectedIndex}"]`)
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
}, [selectedIndex]);
|
||||
|
||||
const popupWidth = isInCommentContext ? 280 : 320;
|
||||
|
||||
if (renderItems.length === 0) {
|
||||
return (
|
||||
<Paper shadow="md" p="xs" withBorder>
|
||||
{ t("No results") }
|
||||
<Paper id="mention" shadow="md" py="xs" withBorder radius="md">
|
||||
<Text c="dimmed" size="sm" px="sm">
|
||||
{ t("No results") }
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
const hasUsers = renderItems.some((item) => item.entityType === "user");
|
||||
const hasPages = renderItems.some((item) => item.entityType === "page" && item.id !== null);
|
||||
const createPageItemData = renderItems.find((item) => item.entityType === "page" && item.id === null);
|
||||
|
||||
return (
|
||||
<Paper id="mention" shadow="md" p="xs" withBorder>
|
||||
<Paper id="mention" shadow="md" withBorder radius="md" py={6}>
|
||||
<ScrollArea.Autosize
|
||||
viewportRef={viewportRef}
|
||||
mah={350}
|
||||
w={320}
|
||||
scrollbarSize={8}
|
||||
w={popupWidth}
|
||||
scrollbarSize={6}
|
||||
>
|
||||
{renderItems?.map((item, index) => {
|
||||
if (item.entityType === "header") {
|
||||
const isFirst = index === 0;
|
||||
return (
|
||||
<div key={`${item.label}-${index}`}>
|
||||
<Text c="dimmed" mb={4} tt="uppercase">
|
||||
{!isFirst && <Divider my={6} />}
|
||||
<Text
|
||||
c="dimmed"
|
||||
size="xs"
|
||||
fw={500}
|
||||
px="sm"
|
||||
pt={isFirst ? 2 : 4}
|
||||
pb={4}
|
||||
tt="uppercase"
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
</div>
|
||||
@@ -292,8 +313,9 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
className={clsx(classes.menuBtn, {
|
||||
[classes.selectedItem]: index === selectedIndex,
|
||||
})}
|
||||
px="sm"
|
||||
>
|
||||
<Group>
|
||||
<Group gap="sm">
|
||||
<CustomAvatar
|
||||
size={"sm"}
|
||||
avatarUrl={item.avatarUrl}
|
||||
@@ -308,7 +330,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
} else if (item.entityType === "page") {
|
||||
} else if (item.entityType === "page" && item.id !== null) {
|
||||
return (
|
||||
<UnstyledButton
|
||||
data-item-index={index}
|
||||
@@ -317,28 +339,24 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
className={clsx(classes.menuBtn, {
|
||||
[classes.selectedItem]: index === selectedIndex,
|
||||
})}
|
||||
px="sm"
|
||||
>
|
||||
<Group>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
variant="subtle"
|
||||
component="div"
|
||||
aria-label={item.label}
|
||||
color="gray"
|
||||
size="sm"
|
||||
>
|
||||
{item.icon || (
|
||||
<ActionIcon
|
||||
component="span"
|
||||
variant="transparent"
|
||||
color="gray"
|
||||
size={18}
|
||||
>
|
||||
{ (item.id) ? <IconFileDescription size={18} /> : <IconPlus size={18} /> }
|
||||
</ActionIcon>
|
||||
<IconFileDescription size={18} stroke={1.5} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text size="sm" fw={500}>
|
||||
{ (item.id) ? item.label : t("Create page") + ': ' + item.label }
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{item.label}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
@@ -348,6 +366,37 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
|
||||
{createPageItemData && !isInCommentContext && (
|
||||
<>
|
||||
{(hasUsers || hasPages) && <Divider my={6} />}
|
||||
<UnstyledButton
|
||||
data-item-index={renderItems.indexOf(createPageItemData)}
|
||||
onClick={() => selectItem(renderItems.indexOf(createPageItemData))}
|
||||
className={clsx(classes.menuBtn, {
|
||||
[classes.selectedItem]: renderItems.indexOf(createPageItemData) === selectedIndex,
|
||||
})}
|
||||
px="sm"
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
component="div"
|
||||
color="gray"
|
||||
size="sm"
|
||||
>
|
||||
<IconPlus size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{t("Create page")}: {createPageItemData.label}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</>
|
||||
)}
|
||||
</ScrollArea.Autosize>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
@@ -17,8 +17,13 @@ const mentionRenderItems = () => {
|
||||
let component: ReactRenderer | null = null;
|
||||
let activeClientRect: (() => DOMRect) | null = null;
|
||||
let updatePositionCleanup: (() => void) | null = null;
|
||||
let outsideClickHandler: ((e: MouseEvent) => void) | null = null;
|
||||
|
||||
const destroy = () => {
|
||||
if (outsideClickHandler) {
|
||||
document.removeEventListener("pointerdown", outsideClickHandler);
|
||||
outsideClickHandler = null;
|
||||
}
|
||||
updatePositionCleanup?.();
|
||||
updatePositionCleanup = null;
|
||||
component?.destroy();
|
||||
@@ -45,8 +50,14 @@ const mentionRenderItems = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const editorDom = props.editor?.view?.dom;
|
||||
const asideEl = editorDom?.closest(".mantine-AppShell-aside");
|
||||
const dialogEl = editorDom?.closest("[data-comment-dialog]");
|
||||
const isInCommentContext = !!(asideEl || dialogEl);
|
||||
// const isInCommentContext = !!asideEl;
|
||||
|
||||
component = new ReactRenderer(MentionList, {
|
||||
props,
|
||||
props: { ...props, isInCommentContext },
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
@@ -59,6 +70,18 @@ const mentionRenderItems = () => {
|
||||
const { element } = component;
|
||||
document.body.appendChild(element);
|
||||
|
||||
outsideClickHandler = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (element && !element.contains(target)) {
|
||||
destroy();
|
||||
}
|
||||
};
|
||||
document.addEventListener("pointerdown", outsideClickHandler);
|
||||
|
||||
const shiftMiddleware = asideEl
|
||||
? shift({ boundary: asideEl, crossAxis: true, padding: 8 })
|
||||
: shift();
|
||||
|
||||
updatePositionCleanup = autoUpdate(
|
||||
{
|
||||
getBoundingClientRect: () =>
|
||||
@@ -76,7 +99,7 @@ const mentionRenderItems = () => {
|
||||
element,
|
||||
{
|
||||
placement: "bottom-start",
|
||||
middleware: [offset(0), flip(), shift()],
|
||||
middleware: [offset(4), flip(), shiftMiddleware],
|
||||
},
|
||||
).then(({ x, y }) => {
|
||||
Object.assign(element.style, {
|
||||
|
||||
@@ -31,14 +31,14 @@
|
||||
|
||||
.menuBtn {
|
||||
width: 100%;
|
||||
padding: 4px;
|
||||
margin-bottom: 2px;
|
||||
padding: 6px 4px;
|
||||
margin-bottom: 1px;
|
||||
color: var(--mantine-color-text);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
|
||||
&:hover {
|
||||
@mixin light {
|
||||
background: var(--mantine-color-gray-2);
|
||||
background: var(--mantine-color-gray-1);
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
@@ -49,7 +49,7 @@
|
||||
|
||||
.selectedItem {
|
||||
@mixin light {
|
||||
background: var(--mantine-color-gray-2);
|
||||
background: var(--mantine-color-gray-1);
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface MentionListProps {
|
||||
range: Range;
|
||||
text: string;
|
||||
editor: Editor;
|
||||
isInCommentContext?: boolean;
|
||||
}
|
||||
|
||||
export type MentionSuggestionItem =
|
||||
|
||||
@@ -66,6 +66,7 @@ import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
import { searchSpotlight } from "@/features/search/constants.ts";
|
||||
import { useEditorScroll } from "./hooks/use-editor-scroll";
|
||||
import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu";
|
||||
|
||||
interface PageEditorProps {
|
||||
pageId: string;
|
||||
@@ -405,6 +406,7 @@ export default function PageEditor({
|
||||
|
||||
{editor && editorIsEditable && (
|
||||
<div>
|
||||
<EditorAiMenu editor={editor} />
|
||||
<EditorBubbleMenu editor={editor} />
|
||||
<TableMenu editor={editor} />
|
||||
<TableCellMenu editor={editor} appendTo={menuContainerRef} />
|
||||
|
||||
@@ -160,7 +160,7 @@ export function TitleEditor({
|
||||
// guard against Cannot access view['hasFocus'] error
|
||||
if (!titleEditor?.isInitialized) return;
|
||||
titleEditor?.commands?.focus("end");
|
||||
}, 500);
|
||||
}, 300);
|
||||
}, [titleEditor]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Group,
|
||||
Text,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconCheck,
|
||||
IconFileDescription,
|
||||
IconPointFilled,
|
||||
} from "@tabler/icons-react";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
||||
import { INotification } from "../types/notification.types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useState } from "react";
|
||||
import { useMarkReadMutation } from "../queries/notification-query";
|
||||
import { buildPageUrl } from "@/features/page/page.utils";
|
||||
import { formatRelativeTime } from "../notification.utils";
|
||||
import classes from "../notification.module.css";
|
||||
|
||||
type NotificationItemProps = {
|
||||
notification: INotification;
|
||||
onNavigate: () => void;
|
||||
};
|
||||
|
||||
export function NotificationItem({
|
||||
notification,
|
||||
onNavigate,
|
||||
}: NotificationItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const markRead = useMarkReadMutation();
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
const isUnread = !notification.readAt;
|
||||
|
||||
const getNotificationMessage = (): string => {
|
||||
switch (notification.type) {
|
||||
case "comment.user_mention":
|
||||
return t("mentioned you in a comment");
|
||||
case "comment.created":
|
||||
return t("commented on a page");
|
||||
case "comment.resolved":
|
||||
return t("resolved a comment");
|
||||
case "page.user_mention":
|
||||
return t("mentioned you on a page");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (notification.page && notification.space) {
|
||||
if (isUnread) {
|
||||
markRead.mutate([notification.id]);
|
||||
}
|
||||
navigate(
|
||||
buildPageUrl(
|
||||
notification.space.slug,
|
||||
notification.page.slugId,
|
||||
notification.page.title,
|
||||
),
|
||||
);
|
||||
onNavigate();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkRead = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (isUnread) {
|
||||
markRead.mutate([notification.id]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
onClick={handleClick}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
w="100%"
|
||||
className={classes.notificationItem}
|
||||
>
|
||||
<Group wrap="nowrap" align="flex-start" gap="sm">
|
||||
<CustomAvatar
|
||||
avatarUrl={notification.actor?.avatarUrl}
|
||||
name={notification.actor?.name || "?"}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" lineClamp={2}>
|
||||
<Text span fw={600}>
|
||||
{notification.actor?.name}
|
||||
</Text>{" "}
|
||||
{getNotificationMessage()}
|
||||
</Text>
|
||||
|
||||
{notification.page && (
|
||||
<Group gap={4} mt={2} wrap="nowrap">
|
||||
{notification.page.icon ? (
|
||||
<Text size="xs" style={{ flexShrink: 0 }}>
|
||||
{notification.page.icon}
|
||||
</Text>
|
||||
) : (
|
||||
<IconFileDescription
|
||||
size={14}
|
||||
stroke={1.5}
|
||||
style={{ flexShrink: 0, color: "var(--mantine-color-dimmed)" }}
|
||||
/>
|
||||
)}
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{notification.page.title || t("Untitled")}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Group gap={4} wrap="nowrap" align="center" style={{ flexShrink: 0 }}>
|
||||
{hovered && isUnread ? (
|
||||
<Tooltip label={t("Mark as read")} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={handleMarkRead}
|
||||
>
|
||||
<IconCheck size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
{formatRelativeTime(notification.createdAt)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{isUnread && (
|
||||
<IconPointFilled
|
||||
size={12}
|
||||
color="var(--mantine-color-blue-filled)"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Center, Divider, Loader, Stack, Text } from "@mantine/core";
|
||||
import { IconBellOff } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { NotificationItem } from "./notification-item";
|
||||
import { INotification, NotificationFilter } from "../types/notification.types";
|
||||
import { groupNotificationsByTime } from "../notification.utils";
|
||||
import { useNotificationsQuery } from "../queries/notification-query";
|
||||
import classes from "../notification.module.css";
|
||||
|
||||
type NotificationListProps = {
|
||||
filter: NotificationFilter;
|
||||
onNavigate: () => void;
|
||||
};
|
||||
|
||||
export function NotificationList({
|
||||
filter,
|
||||
onNavigate,
|
||||
}: NotificationListProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useNotificationsQuery();
|
||||
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
if (!sentinel) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const allNotifications =
|
||||
data?.pages.flatMap((page) => page.items) ?? [];
|
||||
|
||||
const filtered =
|
||||
filter === "unread"
|
||||
? allNotifications.filter((n) => !n.readAt)
|
||||
: allNotifications;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Stack align="center" gap="xs">
|
||||
<IconBellOff size={32} stroke={1.5} color="var(--mantine-color-dimmed)" />
|
||||
<Text size="sm" c="dimmed">
|
||||
{filter === "unread"
|
||||
? t("No unread notifications")
|
||||
: t("No notifications")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const timeGroupLabels = {
|
||||
today: t("Today"),
|
||||
yesterday: t("Yesterday"),
|
||||
this_week: t("This week"),
|
||||
older: t("Older"),
|
||||
};
|
||||
|
||||
const groups = groupNotificationsByTime(filtered, timeGroupLabels);
|
||||
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
{groups.map((group, groupIndex) => (
|
||||
<div key={group.key}>
|
||||
{groupIndex > 0 && <Divider className={classes.divider} />}
|
||||
<Text size="xs" fw={600} c="dimmed" px="md" pt="sm" pb={4}>
|
||||
{group.label}
|
||||
</Text>
|
||||
{group.notifications.map((notification: INotification) => (
|
||||
<NotificationItem
|
||||
key={notification.id}
|
||||
notification={notification}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div ref={sentinelRef} style={{ height: 1 }} />
|
||||
|
||||
{isFetchingNextPage && (
|
||||
<Center py="xs">
|
||||
<Loader size="xs" />
|
||||
</Center>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Group,
|
||||
Indicator,
|
||||
Menu,
|
||||
Popover,
|
||||
ScrollArea,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconBell,
|
||||
IconCheck,
|
||||
IconChecks,
|
||||
IconDots,
|
||||
IconFilter,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { NotificationList } from "./notification-list";
|
||||
import { NotificationFilter } from "../types/notification.types";
|
||||
import {
|
||||
useMarkAllReadMutation,
|
||||
useUnreadCountQuery,
|
||||
} from "../queries/notification-query";
|
||||
|
||||
export function NotificationPopover() {
|
||||
const { t } = useTranslation();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [filter, setFilter] = useState<NotificationFilter>("all");
|
||||
|
||||
const { data: unreadData } = useUnreadCountQuery();
|
||||
const markAllRead = useMarkAllReadMutation();
|
||||
|
||||
const unreadCount = unreadData?.count ?? 0;
|
||||
|
||||
const handleMarkAllRead = () => {
|
||||
markAllRead.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover
|
||||
position="bottom-end"
|
||||
shadow="lg"
|
||||
opened={opened}
|
||||
onChange={setOpened}
|
||||
withArrow
|
||||
>
|
||||
<Popover.Target>
|
||||
<Tooltip label={t("Notifications")} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
size="sm"
|
||||
onClick={() => setOpened((o) => !o)}
|
||||
>
|
||||
<Indicator
|
||||
offset={5}
|
||||
color="red"
|
||||
withBorder
|
||||
disabled={unreadCount === 0}
|
||||
>
|
||||
<IconBell size={20} />
|
||||
</Indicator>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Popover.Target>
|
||||
|
||||
<Popover.Dropdown
|
||||
p={0}
|
||||
style={{ width: "min(420px, calc(100vw - 24px))" }}
|
||||
>
|
||||
<Group justify="space-between" px="md" py="sm">
|
||||
<Text fw={600} size="sm">
|
||||
{t("Notifications")}
|
||||
</Text>
|
||||
<Group gap={4}>
|
||||
<Menu position="bottom-end" withArrow withinPortal={false}>
|
||||
<Menu.Target>
|
||||
<Tooltip label={t("Filter")} withArrow>
|
||||
<ActionIcon variant="subtle" color="dark" size="sm">
|
||||
<IconFilter size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>{t("Filter")}</Menu.Label>
|
||||
<Menu.Item
|
||||
onClick={() => setFilter("all")}
|
||||
rightSection={
|
||||
filter === "all" ? <IconCheck size={14} /> : null
|
||||
}
|
||||
>
|
||||
{t("All notifications")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={() => setFilter("unread")}
|
||||
rightSection={
|
||||
filter === "unread" ? <IconCheck size={14} /> : null
|
||||
}
|
||||
>
|
||||
{t("Unread only")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
<Menu position="bottom-end" withArrow withinPortal={false}>
|
||||
<Menu.Target>
|
||||
<Tooltip label={t("More options")} withArrow>
|
||||
<ActionIcon variant="subtle" color="dark" size="sm">
|
||||
<IconDots size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconChecks size={16} />}
|
||||
onClick={handleMarkAllRead}
|
||||
disabled={unreadCount === 0}
|
||||
>
|
||||
{t("Mark all as read")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<ScrollArea.Autosize
|
||||
mah={500}
|
||||
type="auto"
|
||||
offsetScrollbars
|
||||
scrollbarSize={6}
|
||||
>
|
||||
<NotificationList
|
||||
filter={filter}
|
||||
onNavigate={() => setOpened(false)}
|
||||
/>
|
||||
</ScrollArea.Autosize>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useEffect } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { socketAtom } from "@/features/websocket/atoms/socket-atom";
|
||||
import { NOTIFICATION_KEY } from "../queries/notification-query";
|
||||
|
||||
export function useNotificationSocket() {
|
||||
const queryClient = useQueryClient();
|
||||
const [socket] = useAtom(socketAtom);
|
||||
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handler = () => {
|
||||
queryClient.invalidateQueries({ queryKey: NOTIFICATION_KEY });
|
||||
};
|
||||
|
||||
socket.on("notification", handler);
|
||||
return () => {
|
||||
socket.off("notification", handler);
|
||||
};
|
||||
}, [socket, queryClient]);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
.notificationItem {
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.notificationItem:hover {
|
||||
background-color: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
.divider {
|
||||
border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { INotification } from "./types/notification.types";
|
||||
|
||||
export function formatRelativeTime(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60_000);
|
||||
const diffHours = Math.floor(diffMs / 3_600_000);
|
||||
const diffDays = Math.floor(diffMs / 86_400_000);
|
||||
|
||||
if (diffMin < 1) return "now";
|
||||
if (diffMin < 60) return `${diffMin}m`;
|
||||
if (diffHours < 24) return `${diffHours}h`;
|
||||
if (diffDays < 7) return `${diffDays}d`;
|
||||
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
type TimeGroup = "today" | "yesterday" | "this_week" | "older";
|
||||
|
||||
export function getTimeGroup(dateStr: string): TimeGroup {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
|
||||
const startOfToday = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
);
|
||||
const startOfYesterday = new Date(startOfToday);
|
||||
startOfYesterday.setDate(startOfYesterday.getDate() - 1);
|
||||
const startOfWeek = new Date(startOfToday);
|
||||
startOfWeek.setDate(startOfWeek.getDate() - 7);
|
||||
|
||||
if (date >= startOfToday) return "today";
|
||||
if (date >= startOfYesterday) return "yesterday";
|
||||
if (date >= startOfWeek) return "this_week";
|
||||
return "older";
|
||||
}
|
||||
|
||||
export type GroupedNotifications = {
|
||||
key: TimeGroup;
|
||||
label: string;
|
||||
notifications: INotification[];
|
||||
};
|
||||
|
||||
export function groupNotificationsByTime(
|
||||
notifications: INotification[],
|
||||
labels: Record<TimeGroup, string>,
|
||||
): GroupedNotifications[] {
|
||||
const groups: Record<TimeGroup, INotification[]> = {
|
||||
today: [],
|
||||
yesterday: [],
|
||||
this_week: [],
|
||||
older: [],
|
||||
};
|
||||
|
||||
for (const notification of notifications) {
|
||||
const group = getTimeGroup(notification.createdAt);
|
||||
groups[group].push(notification);
|
||||
}
|
||||
|
||||
const order: TimeGroup[] = ["today", "yesterday", "this_week", "older"];
|
||||
|
||||
return order
|
||||
.filter((key) => groups[key].length > 0)
|
||||
.map((key) => ({
|
||||
key,
|
||||
label: labels[key],
|
||||
notifications: groups[key],
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
keepPreviousData,
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
getNotifications,
|
||||
getUnreadCount,
|
||||
markNotificationsRead,
|
||||
markAllNotificationsRead,
|
||||
} from "../services/notification-service";
|
||||
|
||||
export const NOTIFICATION_KEY = ["notifications"];
|
||||
export const UNREAD_COUNT_KEY = ["notifications", "unread-count"];
|
||||
|
||||
export function useNotificationsQuery() {
|
||||
return useInfiniteQuery({
|
||||
queryKey: NOTIFICATION_KEY,
|
||||
queryFn: ({ pageParam }) => getNotifications({ cursor: pageParam }),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUnreadCountQuery() {
|
||||
return useQuery({
|
||||
queryKey: UNREAD_COUNT_KEY,
|
||||
queryFn: getUnreadCount,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarkReadMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (notificationIds: string[]) =>
|
||||
markNotificationsRead(notificationIds),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: NOTIFICATION_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarkAllReadMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: markAllNotificationsRead,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: NOTIFICATION_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import api from "@/lib/api-client";
|
||||
import { INotification } from "../types/notification.types";
|
||||
import { IPagination } from "@/lib/types";
|
||||
|
||||
export async function getNotifications(params: {
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
}): Promise<IPagination<INotification>> {
|
||||
const req = await api.post<IPagination<INotification>>(
|
||||
"/notifications",
|
||||
params,
|
||||
);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getUnreadCount(): Promise<{ count: number }> {
|
||||
const req = await api.post<{ count: number }>(
|
||||
"/notifications/unread-count",
|
||||
);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function markNotificationsRead(
|
||||
notificationIds: string[],
|
||||
): Promise<void> {
|
||||
await api.post("/notifications/mark-read", { notificationIds });
|
||||
}
|
||||
|
||||
export async function markAllNotificationsRead(): Promise<void> {
|
||||
await api.post("/notifications/mark-all-read");
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export type NotificationType =
|
||||
| "comment.user_mention"
|
||||
| "comment.created"
|
||||
| "comment.resolved"
|
||||
| "page.user_mention";
|
||||
|
||||
export type INotification = {
|
||||
id: string;
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
type: NotificationType;
|
||||
actorId: string | null;
|
||||
pageId: string | null;
|
||||
spaceId: string | null;
|
||||
commentId: string | null;
|
||||
data: Record<string, unknown> | null;
|
||||
readAt: string | null;
|
||||
emailedAt: string | null;
|
||||
archivedAt: string | null;
|
||||
createdAt: string;
|
||||
actor: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
} | null;
|
||||
page: {
|
||||
id: string;
|
||||
title: string;
|
||||
slugId: string;
|
||||
icon: string | null;
|
||||
} | null;
|
||||
space: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type NotificationFilter = "all" | "unread";
|
||||
@@ -82,8 +82,8 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
||||
|
||||
<Tooltip label={t("Comments")} openDelay={250} withArrow>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
style={{ border: "none" }}
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
onClick={() => toggleAside("comments")}
|
||||
>
|
||||
<IconMessage size={20} stroke={2} />
|
||||
@@ -92,8 +92,8 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
||||
|
||||
<Tooltip label={t("Table of contents")} openDelay={250} withArrow>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
style={{ border: "none" }}
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
onClick={() => toggleAside("toc")}
|
||||
>
|
||||
<IconList size={20} stroke={2} />
|
||||
@@ -169,7 +169,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
arrowPosition="center"
|
||||
>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="default" style={{ border: "none" }}>
|
||||
<ActionIcon variant="subtle" color="dark">
|
||||
<IconDots size={20} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
@@ -106,10 +106,16 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
||||
}
|
||||
}, sizeRef);
|
||||
const [isDataLoaded, setIsDataLoaded] = useState(false);
|
||||
const spaceIdRef = useRef(spaceId);
|
||||
spaceIdRef.current = spaceId;
|
||||
const { data: currentPage } = usePageQuery({
|
||||
pageId: extractPageSlugId(pageSlug),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setIsDataLoaded(false);
|
||||
}, [spaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasNextPage && !isFetching) {
|
||||
fetchNextPage();
|
||||
@@ -130,12 +136,15 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
||||
}
|
||||
|
||||
// same space; append only missing roots
|
||||
setIsDataLoaded(true);
|
||||
return mergeRootTrees(prev, treeData);
|
||||
});
|
||||
}
|
||||
}, [pagesData, hasNextPage]);
|
||||
}, [pagesData, hasNextPage, spaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
const effectSpaceId = spaceId;
|
||||
|
||||
const fetchData = async () => {
|
||||
if (isDataLoaded && currentPage) {
|
||||
// check if pageId node is present in the tree
|
||||
@@ -149,6 +158,8 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
||||
if (!currentPage.id) return;
|
||||
const ancestors = await getPageBreadcrumbs(currentPage.id);
|
||||
|
||||
if (spaceIdRef.current !== effectSpaceId) return;
|
||||
|
||||
if (ancestors && ancestors?.length > 1) {
|
||||
let flatTreeItems = [...buildTree(ancestors)];
|
||||
|
||||
@@ -176,22 +187,22 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
||||
|
||||
// Wait for all fetch operations to complete
|
||||
Promise.all(fetchPromises).then(() => {
|
||||
if (spaceIdRef.current !== effectSpaceId) return;
|
||||
|
||||
// build tree with children
|
||||
const ancestorsTree = buildTreeWithChildren(flatTreeItems);
|
||||
// child of root page we're attaching the built ancestors to
|
||||
const rootChild = ancestorsTree[0];
|
||||
|
||||
// attach built ancestors to tree
|
||||
const updatedTree = appendNodeChildren(
|
||||
data,
|
||||
rootChild.id,
|
||||
rootChild.children,
|
||||
// attach built ancestors to tree using functional updater
|
||||
// to avoid stale closure overwriting the current tree data
|
||||
setData((currentData) =>
|
||||
appendNodeChildren(currentData, rootChild.id, rootChild.children),
|
||||
);
|
||||
setData(updatedTree);
|
||||
|
||||
setTimeout(() => {
|
||||
// focus on node and open all parents
|
||||
treeApiRef.current.select(currentPage.id);
|
||||
treeApiRef.current?.select(currentPage.id);
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -44,8 +44,8 @@ export function SearchMobileControl({ onSearch }: SearchMobileControlProps) {
|
||||
return (
|
||||
<Tooltip label={t("Search")} withArrow>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
style={{ border: "none" }}
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
onClick={onSearch}
|
||||
size="sm"
|
||||
>
|
||||
|
||||
@@ -140,7 +140,7 @@ export function SearchSpotlightFilters({
|
||||
<Switch
|
||||
checked={isAiMode}
|
||||
onChange={(event) => onAskClick()}
|
||||
label={t("Ask AI")}
|
||||
label={t("AI Answers")}
|
||||
size="sm"
|
||||
color="blue"
|
||||
labelPosition="left"
|
||||
@@ -279,7 +279,7 @@ export function SearchSpotlightFilters({
|
||||
isAiMode &&
|
||||
option.value === "attachment" && (
|
||||
<Text size="xs" mt={4}>
|
||||
{t("Ask AI not available for attachments")}
|
||||
{t("AI Answers not available for attachments")}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -24,13 +24,14 @@ export function usePageSearchQuery(
|
||||
}
|
||||
|
||||
export function useSearchSuggestionsQuery(
|
||||
params: SearchSuggestionParams,
|
||||
params: SearchSuggestionParams & { preload?: boolean },
|
||||
): UseQueryResult<ISuggestionResult, Error> {
|
||||
const { preload, ...queryParams } = params;
|
||||
return useQuery({
|
||||
queryKey: ["search-suggestion", params.query],
|
||||
staleTime: 60 * 1000, // 1min
|
||||
queryFn: () => searchSuggestions(params),
|
||||
enabled: !!params.query,
|
||||
queryFn: () => searchSuggestions(queryParams),
|
||||
enabled: preload || !!params.query,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -45,8 +45,7 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
|
||||
const { isTrial } = useTrial();
|
||||
const [workspace] = useAtom(workspaceAtom);
|
||||
const { data: space } = useSpaceQuery(spaceSlug);
|
||||
const workspaceDisabled =
|
||||
workspace?.settings?.sharing?.disabled === true;
|
||||
const workspaceDisabled = workspace?.settings?.sharing?.disabled === true;
|
||||
const spaceDisabled = space?.settings?.sharing?.disabled === true;
|
||||
const sharingDisabled = workspaceDisabled || spaceDisabled;
|
||||
const createShareMutation = useCreateShareMutation();
|
||||
@@ -134,7 +133,6 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
|
||||
<Popover width={350} position="bottom" withArrow shadow="md">
|
||||
<Popover.Target>
|
||||
<Button
|
||||
style={{ border: "none" }}
|
||||
size="compact-sm"
|
||||
leftSection={
|
||||
<Indicator
|
||||
@@ -146,7 +144,8 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
|
||||
<IconWorld size={20} stroke={1.5} />
|
||||
</Indicator>
|
||||
}
|
||||
variant="default"
|
||||
color="dark"
|
||||
variant="subtle"
|
||||
>
|
||||
{t("Share")}
|
||||
</Button>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { io } from "socket.io-client";
|
||||
import { SOCKET_URL } from "@/features/websocket/types";
|
||||
import { useQuerySubscription } from "@/features/websocket/use-query-subscription.ts";
|
||||
import { useTreeSocket } from "@/features/websocket/use-tree-socket.ts";
|
||||
import { useNotificationSocket } from "@/features/notification/hooks/use-notification-socket.ts";
|
||||
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||
import { Error404 } from "@/components/ui/error-404.tsx";
|
||||
|
||||
@@ -44,6 +45,7 @@ export function UserProvider({ children }: React.PropsWithChildren) {
|
||||
|
||||
useQuerySubscription();
|
||||
useTreeSocket();
|
||||
useNotificationSocket();
|
||||
|
||||
useEffect(() => {
|
||||
if (data && data.user && data.workspace) {
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface IWorkspace {
|
||||
hasLicenseKey?: boolean;
|
||||
enforceMfa?: boolean;
|
||||
aiSearch?: boolean;
|
||||
generativeAi?: boolean;
|
||||
disablePublicSharing?: boolean;
|
||||
}
|
||||
|
||||
@@ -33,6 +34,7 @@ export interface IWorkspaceSettings {
|
||||
|
||||
export interface IWorkspaceAiSettings {
|
||||
search?: boolean;
|
||||
generative?: boolean;
|
||||
}
|
||||
|
||||
export interface IWorkspaceSharingSettings {
|
||||
|
||||
@@ -35,12 +35,35 @@ export const theme = createTheme({
|
||||
blue,
|
||||
red,
|
||||
},
|
||||
/***
|
||||
components: {
|
||||
ActionIcon: ActionIcon.extend({
|
||||
vars: (_theme, props) => {
|
||||
return {
|
||||
root: {
|
||||
...(props.variant === "subtle" &&
|
||||
props.color === "dark" && {
|
||||
"--ai-color": "var(--mantine-color-default-color)",
|
||||
"--ai-hover": "var(--mantine-color-default-hover)",
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
},
|
||||
***/
|
||||
});
|
||||
|
||||
export const mantineCssResolver: CSSVariablesResolver = (theme) => ({
|
||||
variables: {
|
||||
"--input-error-size": theme.fontSizes.sm,
|
||||
},
|
||||
light: {},
|
||||
dark: {},
|
||||
light: {
|
||||
"--mantine-color-dark-light-color": "#4e5359",
|
||||
"--mantine-color-dark-light-hover": "var(--mantine-color-gray-light-hover)",
|
||||
},
|
||||
dark: {
|
||||
"--mantine-color-dark-light-color": "var(--mantine-color-gray-4)",
|
||||
"--mantine-color-dark-light-hover": "var(--mantine-color-default-hover)",
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user