mirror of
https://github.com/docmost/docmost.git
synced 2026-05-17 14:54:05 +08:00
Merge branch 'main' into perm-x
This commit is contained in:
@@ -84,9 +84,14 @@ const CommentEditor = forwardRef(
|
||||
autofocus: (autofocus && "end") || false,
|
||||
});
|
||||
|
||||
// Sync content from props for read-only editors (e.g. when updated via
|
||||
// websocket on another browser). Skip for editable editors to avoid
|
||||
// resetting the cursor position on every keystroke.
|
||||
useEffect(() => {
|
||||
commentEditor.commands.setContent(defaultContent);
|
||||
}, [defaultContent]);
|
||||
if (!editable && commentEditor && defaultContent) {
|
||||
commentEditor.commands.setContent(defaultContent);
|
||||
}
|
||||
}, [defaultContent, editable, commentEditor]);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Group, Text, Box, Badge } from "@mantine/core";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import classes from "./comment.module.css";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { timeAgo } from "@/lib/time";
|
||||
@@ -40,6 +40,7 @@ function CommentListItem({
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const editor = useAtomValue(pageEditorAtom);
|
||||
const [content, setContent] = useState<string>(comment.content);
|
||||
const editContentRef = useRef<any>(null);
|
||||
const updateCommentMutation = useUpdateCommentMutation();
|
||||
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
||||
const resolveCommentMutation = useResolveCommentMutation();
|
||||
@@ -56,9 +57,13 @@ function CommentListItem({
|
||||
setIsLoading(true);
|
||||
const commentToUpdate = {
|
||||
commentId: comment.id,
|
||||
content: JSON.stringify(content),
|
||||
content: JSON.stringify(editContentRef.current ?? content),
|
||||
};
|
||||
await updateCommentMutation.mutateAsync(commentToUpdate);
|
||||
if (editContentRef.current) {
|
||||
setContent(editContentRef.current);
|
||||
editContentRef.current = null;
|
||||
}
|
||||
setIsEditing(false);
|
||||
|
||||
emit({
|
||||
@@ -128,6 +133,7 @@ function CommentListItem({
|
||||
setIsEditing(true);
|
||||
}
|
||||
function cancelEdit() {
|
||||
editContentRef.current = null;
|
||||
setIsEditing(false);
|
||||
}
|
||||
|
||||
@@ -194,7 +200,7 @@ function CommentListItem({
|
||||
<CommentEditor
|
||||
defaultContent={content}
|
||||
editable={true}
|
||||
onUpdate={(newContent: any) => setContent(newContent)}
|
||||
onUpdate={(newContent: any) => { editContentRef.current = newContent; }}
|
||||
onSave={handleUpdateComment}
|
||||
autofocus={true}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { ActionIcon, CopyButton, Group, Select, Tooltip } from "@mantine/core";
|
||||
import { ActionIcon, Group, Select, Tooltip } from "@mantine/core";
|
||||
import { CopyButton } from "@/components/common/copy-button";
|
||||
import { useEffect, useState } from "react";
|
||||
import { IconCheck, IconCopy } from "@tabler/icons-react";
|
||||
import classes from "./code-block.module.css";
|
||||
|
||||
@@ -170,6 +170,8 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
input.type = "file";
|
||||
input.accept = "image/*";
|
||||
input.multiple = true;
|
||||
input.style.display = "none";
|
||||
document.body.appendChild(input);
|
||||
input.onchange = async () => {
|
||||
if (input.files?.length) {
|
||||
for (const file of input.files) {
|
||||
@@ -179,8 +181,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the input value to allow uploading the same file again if needed
|
||||
input.value = "";
|
||||
input.remove();
|
||||
};
|
||||
input.click();
|
||||
},
|
||||
@@ -202,6 +203,8 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
input.type = "file";
|
||||
input.accept = "video/*";
|
||||
input.multiple = true;
|
||||
input.style.display = "none";
|
||||
document.body.appendChild(input);
|
||||
input.onchange = async () => {
|
||||
if (input.files?.length) {
|
||||
for (const file of input.files) {
|
||||
@@ -211,8 +214,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the input value to allow uploading the same file again if needed
|
||||
input.value = "";
|
||||
input.remove();
|
||||
};
|
||||
input.click();
|
||||
},
|
||||
@@ -234,6 +236,8 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
input.type = "file";
|
||||
input.accept = "";
|
||||
input.multiple = true;
|
||||
input.style.display = "none";
|
||||
document.body.appendChild(input);
|
||||
input.onchange = async () => {
|
||||
if (input.files?.length) {
|
||||
for (const file of input.files) {
|
||||
@@ -243,8 +247,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the input value to allow uploading the same file again if needed
|
||||
input.value = "";
|
||||
input.remove();
|
||||
};
|
||||
input.click();
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { EditorProvider } from "@tiptap/react";
|
||||
import { mainExtensions } from "@/features/editor/extensions/extensions";
|
||||
import { Document } from "@tiptap/extension-document";
|
||||
import { Heading, generateNodeId, UniqueID } from "@docmost/editor-ext";
|
||||
import { Heading, UniqueID } from "@docmost/editor-ext";
|
||||
import { Text } from "@tiptap/extension-text";
|
||||
import { Placeholder } from "@tiptap/extension-placeholder";
|
||||
import { useAtom } from "jotai";
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
}
|
||||
|
||||
.mantine-AppShell-main {
|
||||
padding-top: 0 !important;
|
||||
padding: 0 !important;
|
||||
min-height: auto !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -157,7 +157,9 @@ export function TitleEditor({
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
titleEditor?.commands.focus("end");
|
||||
// guard against Cannot access view['hasFocus'] error
|
||||
if (!titleEditor?.isInitialized) return;
|
||||
titleEditor?.commands?.focus("end");
|
||||
}, 500);
|
||||
}, [titleEditor]);
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { atom } from "jotai";
|
||||
|
||||
export const historyAtoms = atom<boolean>(false);
|
||||
export const activeHistoryIdAtom = atom<string>('');
|
||||
export const activeHistoryIdAtom = atom<string>("");
|
||||
export const activeHistoryPrevIdAtom = atom<string>("");
|
||||
export const highlightChangesAtom = atom<boolean>(true);
|
||||
|
||||
export type DiffCounts = { added: number; deleted: number; total: number };
|
||||
export const diffCountsAtom = atom<DiffCounts | null>(null);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 60px);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.selectorWrapper {
|
||||
padding: var(--mantine-spacing-sm);
|
||||
border-bottom: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.selector {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
|
||||
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5));
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
max-height: rem(300px);
|
||||
}
|
||||
|
||||
.option {
|
||||
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
|
||||
|
||||
&[data-combobox-selected] {
|
||||
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
|
||||
}
|
||||
}
|
||||
|
||||
.editorArea {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.editorContent {
|
||||
padding: var(--mantine-spacing-md);
|
||||
padding-bottom: rem(60px);
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
|
||||
padding-bottom: rem(70px);
|
||||
border-top: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.floatingBar {
|
||||
position: fixed;
|
||||
bottom: var(--mantine-spacing-md);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 100;
|
||||
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
.history {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--mantine-spacing-md);
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
|
||||
|
||||
@mixin hover {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-2),
|
||||
var(--mantine-color-dark-8)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.historyEditor {
|
||||
:global(.ProseMirror) {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
& :global(.history-diff-added) {
|
||||
background: light-dark(#e1f3f2, #01654a) !important;
|
||||
color: light-dark(#007b69, #cafff7) !important;
|
||||
-webkit-box-decoration-break: clone;
|
||||
box-decoration-break: clone;
|
||||
}
|
||||
|
||||
& :global(.history-diff-deleted) {
|
||||
text-decoration: line-through;
|
||||
color: light-dark(var(--mantine-color-red-7), var(--mantine-color-red-4));
|
||||
background: light-dark(var(--mantine-color-red-0), rgba(255, 0, 0, 0.1));
|
||||
border-radius: rem(2px);
|
||||
padding: 0 rem(2px);
|
||||
}
|
||||
|
||||
& :global(.history-diff-node-added) {
|
||||
outline: rem(2px) solid
|
||||
light-dark(var(--mantine-color-teal-5), var(--mantine-color-teal-7));
|
||||
outline-offset: rem(2px);
|
||||
border-radius: rem(4px);
|
||||
}
|
||||
|
||||
& :global(.history-diff-node-deleted) {
|
||||
opacity: 0.5;
|
||||
outline: rem(2px) dashed
|
||||
light-dark(var(--mantine-color-red-4), var(--mantine-color-red-6));
|
||||
outline-offset: rem(4px);
|
||||
border-radius: rem(4px);
|
||||
}
|
||||
}
|
||||
|
||||
.active {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-2),
|
||||
var(--mantine-color-dark-8)
|
||||
);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
max-height: rem(700px);
|
||||
width: rem(250px);
|
||||
padding: var(--mantine-spacing-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: rem(1px) solid
|
||||
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
}
|
||||
|
||||
.sidebarFlex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.sidebarMain {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebarRightSection {
|
||||
flex: 1;
|
||||
padding: rem(16px) rem(40px);
|
||||
}
|
||||
@@ -1,36 +1,203 @@
|
||||
import "@/features/editor/styles/index.css";
|
||||
import React, { useEffect } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { EditorContent, useEditor } from "@tiptap/react";
|
||||
import { mainExtensions } from "@/features/editor/extensions/extensions";
|
||||
import { Title } from "@mantine/core";
|
||||
import classes from "./history.module.css";
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
import historyClasses from "./css/history.module.css";
|
||||
import { recreateTransform } from "@docmost/editor-ext";
|
||||
import { DOMSerializer, Node } from "@tiptap/pm/model";
|
||||
import { ChangeSet, simplifyChanges } from "@tiptap/pm/changeset";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
diffCountsAtom,
|
||||
highlightChangesAtom,
|
||||
} from "@/features/page-history/atoms/history-atoms";
|
||||
|
||||
export interface HistoryEditorProps {
|
||||
title: string;
|
||||
content: any;
|
||||
previousContent?: any;
|
||||
}
|
||||
|
||||
export function HistoryEditor({ title, content }: HistoryEditorProps) {
|
||||
export function HistoryEditor({
|
||||
title,
|
||||
content,
|
||||
previousContent,
|
||||
}: HistoryEditorProps) {
|
||||
const [highlightChanges] = useAtom(highlightChangesAtom);
|
||||
const [, setDiffCounts] = useAtom(diffCountsAtom);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: mainExtensions,
|
||||
editable: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editor && content) {
|
||||
if (!editor || !content) return;
|
||||
|
||||
let decorationSet = DecorationSet.empty;
|
||||
let addedCount = 0;
|
||||
let deletedCount = 0;
|
||||
|
||||
if (previousContent) {
|
||||
try {
|
||||
const schema = editor.schema;
|
||||
const oldContent = Node.fromJSON(schema, previousContent);
|
||||
const newContent = Node.fromJSON(schema, content);
|
||||
|
||||
const tr = recreateTransform(oldContent, newContent, {
|
||||
complexSteps: false,
|
||||
wordDiffs: true,
|
||||
simplifyDiff: true,
|
||||
});
|
||||
|
||||
const changeSet = ChangeSet.create(oldContent).addSteps(
|
||||
tr.doc,
|
||||
tr.mapping.maps,
|
||||
[],
|
||||
);
|
||||
const changes = simplifyChanges(changeSet.changes, newContent);
|
||||
|
||||
editor.commands.setContent(content);
|
||||
|
||||
const specialNodeTypes = new Set([
|
||||
"image",
|
||||
"attachment",
|
||||
"video",
|
||||
"excalidraw",
|
||||
"drawio",
|
||||
"mermaid",
|
||||
"mathBlock",
|
||||
"mathInline",
|
||||
"table",
|
||||
"details",
|
||||
"callout",
|
||||
]);
|
||||
|
||||
const decorations: Decoration[] = [];
|
||||
let changeIndex = 0;
|
||||
|
||||
for (const change of changes) {
|
||||
if (change.toB > change.fromB) {
|
||||
changeIndex++;
|
||||
const currentIndex = changeIndex;
|
||||
let foundSpecialNode: { node: Node; pos: number } | null = null;
|
||||
newContent.nodesBetween(change.fromB, change.toB, (node, pos) => {
|
||||
if (specialNodeTypes.has(node.type.name)) {
|
||||
const nodeEnd = pos + node.nodeSize;
|
||||
if (change.fromB <= pos && change.toB >= nodeEnd) {
|
||||
foundSpecialNode = { node, pos };
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (foundSpecialNode) {
|
||||
const nodeEnd =
|
||||
foundSpecialNode.pos + foundSpecialNode.node.nodeSize;
|
||||
decorations.push(
|
||||
Decoration.node(foundSpecialNode.pos, nodeEnd, {
|
||||
class: "history-diff-node-added",
|
||||
"data-diff-index": String(currentIndex),
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
decorations.push(
|
||||
Decoration.inline(change.fromB, change.toB, {
|
||||
class: "history-diff-added",
|
||||
"data-diff-index": String(currentIndex),
|
||||
}),
|
||||
);
|
||||
}
|
||||
addedCount += 1;
|
||||
}
|
||||
if (change.toA > change.fromA) {
|
||||
changeIndex++;
|
||||
const currentIndex = changeIndex;
|
||||
let foundDeletedNode: { node: Node; pos: number } | null = null;
|
||||
oldContent.nodesBetween(change.fromA, change.toA, (node, pos) => {
|
||||
if (specialNodeTypes.has(node.type.name)) {
|
||||
const nodeEnd = pos + node.nodeSize;
|
||||
if (change.fromA <= pos && change.toA >= nodeEnd) {
|
||||
foundDeletedNode = { node, pos };
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (foundDeletedNode) {
|
||||
decorations.push(
|
||||
Decoration.widget(change.fromB, () => {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "history-diff-node-deleted";
|
||||
wrapper.setAttribute("data-diff-index", String(currentIndex));
|
||||
const serializer = DOMSerializer.fromSchema(schema);
|
||||
const dom = serializer.serializeNode(foundDeletedNode!.node);
|
||||
wrapper.appendChild(dom);
|
||||
return wrapper;
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
const deletedText = oldContent.textBetween(
|
||||
change.fromA,
|
||||
change.toA,
|
||||
"",
|
||||
);
|
||||
if (deletedText) {
|
||||
decorations.push(
|
||||
Decoration.widget(change.fromB, () => {
|
||||
const span = document.createElement("span");
|
||||
span.className = "history-diff-deleted";
|
||||
span.setAttribute("data-diff-index", String(currentIndex));
|
||||
span.textContent = deletedText;
|
||||
return span;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
deletedCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
decorationSet = DecorationSet.create(newContent, decorations);
|
||||
} catch (e) {
|
||||
console.error("History diff failed:", e);
|
||||
editor.commands.setContent(content);
|
||||
}
|
||||
} else {
|
||||
editor.commands.setContent(content);
|
||||
}
|
||||
}, [title, content, editor]);
|
||||
|
||||
const total = addedCount + deletedCount;
|
||||
// @ts-ignore
|
||||
setDiffCounts({ added: addedCount, deleted: deletedCount, total });
|
||||
|
||||
editor.setOptions({
|
||||
editorProps: {
|
||||
...editor.options.editorProps,
|
||||
decorations: () =>
|
||||
highlightChanges ? decorationSet : DecorationSet.empty,
|
||||
},
|
||||
});
|
||||
}, [
|
||||
title,
|
||||
content,
|
||||
editor,
|
||||
previousContent,
|
||||
highlightChanges,
|
||||
setDiffCounts,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Title order={1}>{title}</Title>
|
||||
|
||||
{editor && (
|
||||
<EditorContent editor={editor} className={classes.historyEditor} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
<div>
|
||||
<Title order={1}>{title}</Title>
|
||||
{editor && (
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
className={historyClasses.historyEditor}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,44 +1,100 @@
|
||||
import { Text, Group, UnstyledButton } from "@mantine/core";
|
||||
import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { formattedDate } from "@/lib/time";
|
||||
import classes from "./history.module.css";
|
||||
import classes from "./css/history.module.css";
|
||||
import clsx from "clsx";
|
||||
import { IPageHistory } from "@/features/page-history/types/page.types";
|
||||
import { memo, useCallback } from "react";
|
||||
|
||||
const MAX_VISIBLE_AVATARS = 5;
|
||||
|
||||
interface HistoryItemProps {
|
||||
historyItem: any;
|
||||
onSelect: (id: string) => void;
|
||||
historyItem: IPageHistory;
|
||||
index: number;
|
||||
onSelect: (id: string, index: number) => void;
|
||||
onHover?: (id: string, index: number) => void;
|
||||
onHoverEnd?: () => void;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
function HistoryItem({ historyItem, onSelect, isActive }: HistoryItemProps) {
|
||||
const HistoryItem = memo(function HistoryItem({
|
||||
historyItem,
|
||||
index,
|
||||
onSelect,
|
||||
onHover,
|
||||
onHoverEnd,
|
||||
isActive,
|
||||
}: HistoryItemProps) {
|
||||
const handleClick = useCallback(() => {
|
||||
onSelect(historyItem.id, index);
|
||||
}, [onSelect, historyItem.id, index]);
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
onHover?.(historyItem.id, index);
|
||||
}, [onHover, historyItem.id, index]);
|
||||
|
||||
const contributors = historyItem.contributors;
|
||||
const hasContributors = contributors && contributors.length > 0;
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
p="xs"
|
||||
onClick={() => onSelect(historyItem.id)}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={onHoverEnd}
|
||||
className={clsx(classes.history, { [classes.active]: isActive })}
|
||||
>
|
||||
<Group wrap="nowrap">
|
||||
<div>
|
||||
<Text size="sm">
|
||||
{formattedDate(new Date(historyItem.createdAt))}
|
||||
</Text>
|
||||
<Text size="sm">{formattedDate(new Date(historyItem.createdAt))}</Text>
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<CustomAvatar
|
||||
size="sm"
|
||||
avatarUrl={historyItem.lastUpdatedBy.avatarUrl}
|
||||
name={historyItem.lastUpdatedBy.name}
|
||||
/>
|
||||
<Group gap={6} wrap="nowrap" mt={4}>
|
||||
{hasContributors ? (
|
||||
<>
|
||||
<Tooltip.Group openDelay={300} closeDelay={100}>
|
||||
<Avatar.Group spacing={8}>
|
||||
{contributors.slice(0, MAX_VISIBLE_AVATARS).map((contributor) => (
|
||||
<Tooltip key={contributor.id} label={contributor.name} withArrow>
|
||||
<CustomAvatar
|
||||
size="sm"
|
||||
avatarUrl={contributor.avatarUrl}
|
||||
name={contributor.name}
|
||||
/>
|
||||
</Tooltip>
|
||||
))}
|
||||
{contributors.length > MAX_VISIBLE_AVATARS && (
|
||||
<Tooltip
|
||||
withArrow
|
||||
label={contributors.slice(MAX_VISIBLE_AVATARS).map((c) => (
|
||||
<div key={c.id}>{c.name}</div>
|
||||
))}
|
||||
>
|
||||
<Avatar size="sm" color="gray">
|
||||
+{contributors.length - MAX_VISIBLE_AVATARS}
|
||||
</Avatar>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Avatar.Group>
|
||||
</Tooltip.Group>
|
||||
{contributors.length === 1 && (
|
||||
<Text size="sm" c="dimmed" lineClamp={1}>
|
||||
{historyItem.lastUpdatedBy.name}
|
||||
{contributors[0].name}
|
||||
</Text>
|
||||
</Group>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CustomAvatar
|
||||
size="sm"
|
||||
avatarUrl={historyItem.lastUpdatedBy?.avatarUrl}
|
||||
name={historyItem.lastUpdatedBy?.name}
|
||||
/>
|
||||
<Text size="sm" c="dimmed" lineClamp={1}>
|
||||
{historyItem.lastUpdatedBy?.name}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default HistoryItem;
|
||||
|
||||
@@ -1,29 +1,27 @@
|
||||
import {
|
||||
usePageHistoryListQuery,
|
||||
usePageHistoryQuery,
|
||||
prefetchPageHistory,
|
||||
} from "@/features/page-history/queries/page-history-query";
|
||||
import HistoryItem from "@/features/page-history/components/history-item";
|
||||
import {
|
||||
activeHistoryIdAtom,
|
||||
activeHistoryPrevIdAtom,
|
||||
historyAtoms,
|
||||
} from "@/features/page-history/atoms/history-atoms";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { Button, ScrollArea, Group, Divider, Text } from "@mantine/core";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import {
|
||||
pageEditorAtom,
|
||||
titleEditorAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
Button,
|
||||
ScrollArea,
|
||||
Group,
|
||||
Divider,
|
||||
Loader,
|
||||
Center,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
||||
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from "@/features/space/permissions/permissions.type.ts";
|
||||
import { useHistoryRestore } from "@/features/page-history/hooks";
|
||||
|
||||
const PREFETCH_DELAY_MS = 150;
|
||||
|
||||
interface Props {
|
||||
pageId: string;
|
||||
@@ -32,62 +30,89 @@ interface Props {
|
||||
function HistoryList({ pageId }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
|
||||
const setActiveHistoryPrevId = useSetAtom(activeHistoryPrevIdAtom);
|
||||
const setHistoryModalOpen = useSetAtom(historyAtoms);
|
||||
|
||||
const {
|
||||
data: pageHistoryList,
|
||||
data: pageHistoryData,
|
||||
isLoading,
|
||||
isError,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = usePageHistoryListQuery(pageId);
|
||||
const { data: activeHistoryData } = usePageHistoryQuery(activeHistoryId);
|
||||
|
||||
const [mainEditor] = useAtom(pageEditorAtom);
|
||||
const [mainEditorTitle] = useAtom(titleEditorAtom);
|
||||
const [, setHistoryModalOpen] = useAtom(historyAtoms);
|
||||
const historyItems = useMemo(
|
||||
() => pageHistoryData?.pages.flatMap((page) => page.items) ?? [],
|
||||
[pageHistoryData],
|
||||
);
|
||||
|
||||
const { spaceSlug } = useParams();
|
||||
const { data: space } = useSpaceQuery(spaceSlug);
|
||||
const spaceRules = space?.membership?.permissions;
|
||||
const spaceAbility = useSpaceAbility(spaceRules);
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||
const prefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const confirmModal = () =>
|
||||
modals.openConfirmModal({
|
||||
title: t("Please confirm your action"),
|
||||
children: (
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"Are you sure you want to restore this version? Any changes not versioned will be lost.",
|
||||
)}
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
|
||||
onConfirm: handleRestore,
|
||||
});
|
||||
const { canRestore, confirmRestore } = useHistoryRestore();
|
||||
|
||||
const handleRestore = useCallback(() => {
|
||||
if (activeHistoryData) {
|
||||
mainEditorTitle
|
||||
.chain()
|
||||
.clearContent()
|
||||
.setContent(activeHistoryData.title, { emitUpdate: true })
|
||||
.run();
|
||||
mainEditor
|
||||
.chain()
|
||||
.clearContent()
|
||||
.setContent(activeHistoryData.content)
|
||||
.run();
|
||||
setHistoryModalOpen(false);
|
||||
notifications.show({ message: t("Successfully restored") });
|
||||
const clearPrefetchTimeout = useCallback(() => {
|
||||
if (prefetchTimeoutRef.current) {
|
||||
clearTimeout(prefetchTimeoutRef.current);
|
||||
prefetchTimeoutRef.current = null;
|
||||
}
|
||||
}, [activeHistoryData]);
|
||||
}, []);
|
||||
|
||||
const handleHover = useCallback(
|
||||
(historyId: string, index: number) => {
|
||||
clearPrefetchTimeout();
|
||||
prefetchTimeoutRef.current = setTimeout(() => {
|
||||
prefetchPageHistory(historyId);
|
||||
const prevId = historyItems[index + 1]?.id;
|
||||
if (prevId) {
|
||||
prefetchPageHistory(prevId);
|
||||
}
|
||||
}, PREFETCH_DELAY_MS);
|
||||
},
|
||||
[clearPrefetchTimeout, historyItems],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
pageHistoryList &&
|
||||
pageHistoryList.items.length > 0 &&
|
||||
!activeHistoryId
|
||||
) {
|
||||
setActiveHistoryId(pageHistoryList.items[0].id);
|
||||
return clearPrefetchTimeout;
|
||||
}, [clearPrefetchTimeout]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(id: string, index: number) => {
|
||||
setActiveHistoryId(id);
|
||||
setActiveHistoryPrevId(historyItems[index + 1]?.id ?? "");
|
||||
},
|
||||
[historyItems, setActiveHistoryId, setActiveHistoryPrevId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (historyItems.length > 0 && !activeHistoryId) {
|
||||
setActiveHistoryId(historyItems[0].id);
|
||||
setActiveHistoryPrevId(historyItems[1]?.id ?? "");
|
||||
}
|
||||
}, [pageHistoryList]);
|
||||
}, [
|
||||
historyItems,
|
||||
activeHistoryId,
|
||||
setActiveHistoryId,
|
||||
setActiveHistoryPrevId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const sentinel = loadMoreRef.current;
|
||||
if (!sentinel || !hasNextPage) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
||||
|
||||
if (isLoading) {
|
||||
return <></>;
|
||||
@@ -97,34 +122,36 @@ function HistoryList({ pageId }: Props) {
|
||||
return <div>{t("Error loading page history.")}</div>;
|
||||
}
|
||||
|
||||
if (!pageHistoryList || pageHistoryList.items.length === 0) {
|
||||
if (historyItems.length === 0) {
|
||||
return <>{t("No page history saved yet.")}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ScrollArea h={620} w="100%" type="scroll" scrollbarSize={5}>
|
||||
{pageHistoryList &&
|
||||
pageHistoryList.items.map((historyItem, index) => (
|
||||
<HistoryItem
|
||||
key={index}
|
||||
historyItem={historyItem}
|
||||
onSelect={setActiveHistoryId}
|
||||
isActive={historyItem.id === activeHistoryId}
|
||||
/>
|
||||
))}
|
||||
{historyItems.map((historyItem, index) => (
|
||||
<HistoryItem
|
||||
key={historyItem.id}
|
||||
historyItem={historyItem}
|
||||
index={index}
|
||||
onSelect={handleSelect}
|
||||
onHover={handleHover}
|
||||
onHoverEnd={clearPrefetchTimeout}
|
||||
isActive={historyItem.id === activeHistoryId}
|
||||
/>
|
||||
))}
|
||||
{hasNextPage && <div ref={loadMoreRef} style={{ height: 1 }} />}
|
||||
{isFetchingNextPage && (
|
||||
<Center py="sm">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{spaceAbility.cannot(
|
||||
SpaceCaslAction.Manage,
|
||||
SpaceCaslSubject.Page,
|
||||
) ? null : (
|
||||
{canRestore && (
|
||||
<>
|
||||
<Divider />
|
||||
<Group p="xs" wrap="nowrap">
|
||||
<Button size="compact-md" onClick={confirmModal}>
|
||||
{t("Restore")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="compact-md"
|
||||
@@ -132,6 +159,9 @@ function HistoryList({ pageId }: Props) {
|
||||
>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button size="compact-md" onClick={confirmRestore}>
|
||||
{t("Restore")}
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,21 +1,45 @@
|
||||
import { ScrollArea } from "@mantine/core";
|
||||
import {
|
||||
ActionIcon,
|
||||
Group,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Switch,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import HistoryList from "@/features/page-history/components/history-list";
|
||||
import classes from "./history.module.css";
|
||||
import { useAtom } from "jotai";
|
||||
import { activeHistoryIdAtom } from "@/features/page-history/atoms/history-atoms";
|
||||
import classes from "./css/history.module.css";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import {
|
||||
activeHistoryIdAtom,
|
||||
activeHistoryPrevIdAtom,
|
||||
diffCountsAtom,
|
||||
highlightChangesAtom,
|
||||
} from "@/features/page-history/atoms/history-atoms";
|
||||
import HistoryView from "@/features/page-history/components/history-view";
|
||||
import { useEffect } from "react";
|
||||
import { useRef } from "react";
|
||||
import { IconChevronUp, IconChevronDown } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useDiffNavigation,
|
||||
useHistoryReset,
|
||||
} from "@/features/page-history/hooks";
|
||||
|
||||
interface Props {
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
export default function HistoryModalBody({ pageId }: Props) {
|
||||
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
|
||||
const { t } = useTranslation();
|
||||
const scrollViewportRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveHistoryId("");
|
||||
}, [pageId]);
|
||||
const activeHistoryId = useAtomValue(activeHistoryIdAtom);
|
||||
const activeHistoryPrevId = useAtomValue(activeHistoryPrevIdAtom);
|
||||
const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom);
|
||||
const diffCounts = useAtomValue(diffCountsAtom);
|
||||
|
||||
useHistoryReset(pageId);
|
||||
const { currentChangeIndex, handlePrevChange, handleNextChange } =
|
||||
useDiffNavigation(scrollViewportRef);
|
||||
|
||||
return (
|
||||
<div className={classes.sidebarFlex}>
|
||||
@@ -25,11 +49,63 @@ export default function HistoryModalBody({ pageId }: Props) {
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<ScrollArea h="650" w="100%" scrollbarSize={5}>
|
||||
<div className={classes.sidebarRightSection}>
|
||||
{activeHistoryId && <HistoryView historyId={activeHistoryId} />}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div style={{ position: "relative", flex: 1 }}>
|
||||
<ScrollArea
|
||||
h={650}
|
||||
w="100%"
|
||||
scrollbarSize={5}
|
||||
viewportRef={scrollViewportRef}
|
||||
>
|
||||
<div className={classes.sidebarRightSection}>
|
||||
{activeHistoryId && <HistoryView />}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{activeHistoryId && activeHistoryPrevId && (
|
||||
<Paper
|
||||
shadow="md"
|
||||
radius="xl"
|
||||
px="md"
|
||||
py="xs"
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 16,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
}}
|
||||
>
|
||||
<Group gap="md" wrap="nowrap">
|
||||
<Switch
|
||||
label={t("Highlight changes")}
|
||||
checked={highlightChanges}
|
||||
onChange={(e) => setHighlightChanges(e.currentTarget.checked)}
|
||||
styles={{ label: { userSelect: "none", whiteSpace: "nowrap" } }}
|
||||
/>
|
||||
{highlightChanges && diffCounts && diffCounts.total > 0 && (
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
{currentChangeIndex} of {diffCounts.total}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={handlePrevChange}
|
||||
>
|
||||
<IconChevronUp size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={handleNextChange}
|
||||
>
|
||||
<IconChevronDown size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Select,
|
||||
Switch,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import {
|
||||
activeHistoryIdAtom,
|
||||
activeHistoryPrevIdAtom,
|
||||
diffCountsAtom,
|
||||
highlightChangesAtom,
|
||||
historyAtoms,
|
||||
} from "@/features/page-history/atoms/history-atoms";
|
||||
import HistoryView from "@/features/page-history/components/history-view";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { IconCheck, IconChevronDown, IconChevronUp } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePageHistoryListQuery } from "@/features/page-history/queries/page-history-query";
|
||||
import { formattedDate } from "@/lib/time";
|
||||
import {
|
||||
useDiffNavigation,
|
||||
useHistoryReset,
|
||||
useHistoryRestore,
|
||||
} from "@/features/page-history/hooks";
|
||||
import classes from "./css/history-mobile.module.css";
|
||||
|
||||
interface Props {
|
||||
pageId: string;
|
||||
pageTitle?: string;
|
||||
}
|
||||
|
||||
export default function HistoryModalMobile({ pageId, pageTitle }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
|
||||
const setActiveHistoryPrevId = useSetAtom(activeHistoryPrevIdAtom);
|
||||
const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom);
|
||||
const diffCounts = useAtomValue(diffCountsAtom);
|
||||
const setHistoryModalOpen = useSetAtom(historyAtoms);
|
||||
|
||||
const scrollViewportRef = useRef<HTMLDivElement>(null);
|
||||
const dropdownViewportRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const {
|
||||
data: pageHistoryData,
|
||||
isLoading,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = usePageHistoryListQuery(pageId);
|
||||
|
||||
const historyItems = useMemo(
|
||||
() => pageHistoryData?.pages.flatMap((page) => page.items) ?? [],
|
||||
[pageHistoryData],
|
||||
);
|
||||
|
||||
const selectData = useMemo(
|
||||
() =>
|
||||
historyItems.map((item) => {
|
||||
const contributors = item.contributors;
|
||||
const hasContributors = contributors && contributors.length > 0;
|
||||
const names = hasContributors
|
||||
? contributors.map((c) => c.name).join(", ")
|
||||
: item.lastUpdatedBy?.name;
|
||||
return {
|
||||
value: item.id,
|
||||
label: formattedDate(new Date(item.createdAt)),
|
||||
userName: names,
|
||||
};
|
||||
}),
|
||||
[historyItems],
|
||||
);
|
||||
|
||||
useHistoryReset(pageId);
|
||||
const { canRestore, confirmRestore } = useHistoryRestore();
|
||||
const { currentChangeIndex, handlePrevChange, handleNextChange } =
|
||||
useDiffNavigation(scrollViewportRef);
|
||||
|
||||
useEffect(() => {
|
||||
if (historyItems.length > 0 && !activeHistoryId) {
|
||||
setActiveHistoryId(historyItems[0].id);
|
||||
setActiveHistoryPrevId(historyItems[1]?.id ?? "");
|
||||
}
|
||||
}, [
|
||||
historyItems,
|
||||
activeHistoryId,
|
||||
setActiveHistoryId,
|
||||
setActiveHistoryPrevId,
|
||||
]);
|
||||
|
||||
const handleDropdownScroll = useCallback(() => {
|
||||
const viewport = dropdownViewportRef.current;
|
||||
if (!viewport || !hasNextPage || isFetchingNextPage) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = viewport;
|
||||
const isNearBottom = scrollTop + clientHeight >= scrollHeight - 50;
|
||||
|
||||
if (isNearBottom) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
||||
|
||||
const handleSelectVersion = useCallback(
|
||||
(value: string | null) => {
|
||||
if (!value) return;
|
||||
const index = historyItems.findIndex((item) => item.id === value);
|
||||
if (index >= 0) {
|
||||
setActiveHistoryId(value);
|
||||
setActiveHistoryPrevId(historyItems[index + 1]?.id ?? "");
|
||||
}
|
||||
},
|
||||
[historyItems, setActiveHistoryId, setActiveHistoryPrevId],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className={classes.container}>
|
||||
<Box className={classes.selectorWrapper}>
|
||||
<Select
|
||||
data={selectData}
|
||||
value={activeHistoryId}
|
||||
onChange={handleSelectVersion}
|
||||
placeholder={t("Select version")}
|
||||
checkIconPosition="right"
|
||||
maxDropdownHeight={300}
|
||||
renderOption={({ option, checked }) => (
|
||||
<Group justify="space-between" wrap="nowrap" w="100%">
|
||||
<div>
|
||||
<Text size="sm">{option.label}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{(option as { userName?: string }).userName}
|
||||
</Text>
|
||||
</div>
|
||||
{checked && <IconCheck size={16} />}
|
||||
</Group>
|
||||
)}
|
||||
comboboxProps={{ withinPortal: false }}
|
||||
scrollAreaProps={{
|
||||
viewportRef: dropdownViewportRef,
|
||||
onScrollPositionChange: handleDropdownScroll,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<ScrollArea
|
||||
className={classes.editorArea}
|
||||
viewportRef={scrollViewportRef}
|
||||
scrollbarSize={5}
|
||||
>
|
||||
<Box className={classes.editorContent}>
|
||||
{activeHistoryId && <HistoryView />}
|
||||
</Box>
|
||||
</ScrollArea>
|
||||
|
||||
{canRestore && (
|
||||
<Group className={classes.actionButtons} justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={() => setHistoryModalOpen(false)}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button onClick={confirmRestore}>{t("Restore")}</Button>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{activeHistoryId && (
|
||||
<Paper
|
||||
shadow="sm"
|
||||
radius="xl"
|
||||
px="md"
|
||||
py="xs"
|
||||
className={classes.floatingBar}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Switch
|
||||
label={t("Highlight changes")}
|
||||
checked={highlightChanges}
|
||||
onChange={(e) => setHighlightChanges(e.currentTarget.checked)}
|
||||
size="sm"
|
||||
styles={{ label: { userSelect: "none", whiteSpace: "nowrap" } }}
|
||||
/>
|
||||
{highlightChanges && diffCounts && diffCounts.total > 0 && (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
{currentChangeIndex} of {diffCounts.total}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={handlePrevChange}
|
||||
>
|
||||
<IconChevronUp size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={handleNextChange}
|
||||
>
|
||||
<IconChevronDown size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -2,21 +2,26 @@ import { Modal, Text } from "@mantine/core";
|
||||
import { useAtom } from "jotai";
|
||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms";
|
||||
import HistoryModalBody from "@/features/page-history/components/history-modal-body";
|
||||
import HistoryModalMobile from "@/features/page-history/components/history-modal-mobile";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
|
||||
interface Props {
|
||||
pageId: string;
|
||||
pageTitle?: string;
|
||||
}
|
||||
export default function HistoryModal({ pageId }: Props) {
|
||||
|
||||
export default function HistoryModal({ pageId, pageTitle }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [isModalOpen, setModalOpen] = useAtom(historyAtoms);
|
||||
const isMobile = useMediaQuery("(max-width: 800px)");
|
||||
|
||||
return (
|
||||
<>
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Modal.Root
|
||||
size={1200}
|
||||
opened={isModalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
fullScreen
|
||||
>
|
||||
<Modal.Overlay />
|
||||
<Modal.Content style={{ overflow: "hidden" }}>
|
||||
@@ -28,11 +33,37 @@ export default function HistoryModal({ pageId }: Props) {
|
||||
</Modal.Title>
|
||||
<Modal.CloseButton />
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<HistoryModalBody pageId={pageId} />
|
||||
<Modal.Body
|
||||
p={0}
|
||||
style={{ height: "calc(100vh - 60px)", overflow: "hidden" }}
|
||||
>
|
||||
<HistoryModalMobile pageId={pageId} pageTitle={pageTitle} />
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal.Root
|
||||
size={1400}
|
||||
opened={isModalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
>
|
||||
<Modal.Overlay />
|
||||
<Modal.Content style={{ overflow: "hidden" }}>
|
||||
<Modal.Header>
|
||||
<Modal.Title>
|
||||
<Text size="md" fw={500}>
|
||||
{t("Page history")}
|
||||
</Text>
|
||||
</Modal.Title>
|
||||
<Modal.CloseButton />
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<HistoryModalBody pageId={pageId} />
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,44 @@
|
||||
import { usePageHistoryQuery } from "@/features/page-history/queries/page-history-query";
|
||||
import { HistoryEditor } from "@/features/page-history/components/history-editor";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
activeHistoryIdAtom,
|
||||
activeHistoryPrevIdAtom,
|
||||
} from "@/features/page-history/atoms/history-atoms";
|
||||
|
||||
interface HistoryProps {
|
||||
historyId: string;
|
||||
}
|
||||
|
||||
function HistoryView({ historyId }: HistoryProps) {
|
||||
function HistoryView() {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isError } = usePageHistoryQuery(historyId);
|
||||
const historyId = useAtomValue(activeHistoryIdAtom);
|
||||
const prevHistoryId = useAtomValue(activeHistoryPrevIdAtom);
|
||||
|
||||
if (isLoading) {
|
||||
const {
|
||||
data,
|
||||
isLoading: isLoadingCurrent,
|
||||
isError: isErrorCurrent,
|
||||
} = usePageHistoryQuery(historyId);
|
||||
const {
|
||||
data: prevData,
|
||||
isLoading: isLoadingPrev,
|
||||
isError: isErrorPrev,
|
||||
} = usePageHistoryQuery(prevHistoryId);
|
||||
|
||||
if (isLoadingCurrent || isLoadingPrev) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
if (isErrorCurrent || !data) {
|
||||
return <div>{t("Error fetching page data.")}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
data && (
|
||||
<div>
|
||||
<HistoryEditor content={data.content} title={data.title} />
|
||||
</div>
|
||||
)
|
||||
<div>
|
||||
<HistoryEditor
|
||||
content={data.content}
|
||||
title={data.title}
|
||||
previousContent={!isErrorPrev ? prevData?.content : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
.history {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--mantine-spacing-md);
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
|
||||
|
||||
@mixin hover {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-2),
|
||||
var(--mantine-color-dark-8)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.historyEditor {
|
||||
:global(.ProseMirror) {
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.active {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-2),
|
||||
var(--mantine-color-dark-8)
|
||||
);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
max-height: rem(700px);
|
||||
width: rem(250px);
|
||||
padding: var(--mantine-spacing-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: rem(1px) solid
|
||||
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
}
|
||||
|
||||
.sidebarFlex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.sidebarMain {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebarRightSection {
|
||||
flex: 1;
|
||||
padding: rem(16px) rem(40px);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { useDiffNavigation } from "./use-diff-navigation";
|
||||
export { useHistoryRestore } from "./use-history-restore";
|
||||
export { useHistoryReset } from "./use-history-reset";
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useAtomValue } from "jotai";
|
||||
import { RefObject, useCallback, useEffect, useState } from "react";
|
||||
import { diffCountsAtom } from "@/features/page-history/atoms/history-atoms";
|
||||
|
||||
/**
|
||||
* Manages navigation between diff changes in the history view.
|
||||
* Provides prev/next handlers and auto-scrolls to the current change.
|
||||
*/
|
||||
export function useDiffNavigation(
|
||||
scrollViewportRef: RefObject<HTMLDivElement>,
|
||||
) {
|
||||
const diffCounts = useAtomValue(diffCountsAtom);
|
||||
const [currentChangeIndex, setCurrentChangeIndex] = useState(0);
|
||||
|
||||
const scrollToChangeIndex = useCallback(
|
||||
(index: number) => {
|
||||
const viewport = scrollViewportRef.current;
|
||||
if (!viewport || index < 1) return;
|
||||
|
||||
const element = viewport.querySelector(`[data-diff-index="${index}"]`);
|
||||
if (element instanceof HTMLElement) {
|
||||
const elementTop = element.offsetTop;
|
||||
const viewportHeight = viewport.clientHeight;
|
||||
const scrollTarget =
|
||||
elementTop - viewportHeight / 2 + element.offsetHeight / 2;
|
||||
viewport.scrollTo({ top: scrollTarget, behavior: "smooth" });
|
||||
}
|
||||
},
|
||||
[scrollViewportRef],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (diffCounts && diffCounts.total > 0) {
|
||||
setCurrentChangeIndex(1);
|
||||
requestAnimationFrame(() => scrollToChangeIndex(1));
|
||||
} else {
|
||||
setCurrentChangeIndex(0);
|
||||
}
|
||||
}, [diffCounts, scrollToChangeIndex]);
|
||||
|
||||
const handlePrevChange = useCallback(() => {
|
||||
if (!diffCounts || diffCounts.total === 0) return;
|
||||
const newIndex =
|
||||
currentChangeIndex <= 1 ? diffCounts.total : currentChangeIndex - 1;
|
||||
setCurrentChangeIndex(newIndex);
|
||||
scrollToChangeIndex(newIndex);
|
||||
}, [diffCounts, currentChangeIndex, scrollToChangeIndex]);
|
||||
|
||||
const handleNextChange = useCallback(() => {
|
||||
if (!diffCounts || diffCounts.total === 0) return;
|
||||
const newIndex =
|
||||
currentChangeIndex >= diffCounts.total ? 1 : currentChangeIndex + 1;
|
||||
setCurrentChangeIndex(newIndex);
|
||||
scrollToChangeIndex(newIndex);
|
||||
}, [diffCounts, currentChangeIndex, scrollToChangeIndex]);
|
||||
|
||||
return { currentChangeIndex, handlePrevChange, handleNextChange };
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
activeHistoryIdAtom,
|
||||
activeHistoryPrevIdAtom,
|
||||
diffCountsAtom,
|
||||
} from "@/features/page-history/atoms/history-atoms";
|
||||
|
||||
/**
|
||||
* Resets history state when pageId changes.
|
||||
* Clears active selection and diff counts.
|
||||
*/
|
||||
export function useHistoryReset(pageId: string) {
|
||||
const [, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
|
||||
const [, setActiveHistoryPrevId] = useAtom(activeHistoryPrevIdAtom);
|
||||
const [, setDiffCounts] = useAtom(diffCountsAtom);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveHistoryId("");
|
||||
setActiveHistoryPrevId("");
|
||||
// @ts-ignore
|
||||
setDiffCounts(null);
|
||||
}, [pageId, setActiveHistoryId, setActiveHistoryPrevId, setDiffCounts]);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text } from "@mantine/core";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {
|
||||
activeHistoryIdAtom,
|
||||
historyAtoms,
|
||||
} from "@/features/page-history/atoms/history-atoms";
|
||||
import { usePageHistoryQuery } from "@/features/page-history/queries/page-history-query";
|
||||
import {
|
||||
pageEditorAtom,
|
||||
titleEditorAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms";
|
||||
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability";
|
||||
import { useSpaceQuery } from "@/features/space/queries/space-query";
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from "@/features/space/permissions/permissions.type";
|
||||
|
||||
export function useHistoryRestore() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const activeHistoryId = useAtomValue(activeHistoryIdAtom);
|
||||
const { data: activeHistoryData } = usePageHistoryQuery(activeHistoryId);
|
||||
|
||||
const mainEditor = useAtomValue(pageEditorAtom);
|
||||
const mainEditorTitle = useAtomValue(titleEditorAtom);
|
||||
const setHistoryModalOpen = useSetAtom(historyAtoms);
|
||||
|
||||
const { spaceSlug } = useParams();
|
||||
const { data: space } = useSpaceQuery(spaceSlug);
|
||||
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
|
||||
|
||||
const canRestore = spaceAbility.can(
|
||||
SpaceCaslAction.Manage,
|
||||
SpaceCaslSubject.Page,
|
||||
);
|
||||
|
||||
const handleRestore = useCallback(() => {
|
||||
if (!activeHistoryData) return;
|
||||
|
||||
mainEditorTitle
|
||||
.chain()
|
||||
.clearContent()
|
||||
.setContent(activeHistoryData.title, { emitUpdate: true })
|
||||
.run();
|
||||
|
||||
mainEditor
|
||||
.chain()
|
||||
.clearContent()
|
||||
.setContent(activeHistoryData.content)
|
||||
.run();
|
||||
|
||||
setHistoryModalOpen(false);
|
||||
notifications.show({ message: t("Successfully restored") });
|
||||
}, [activeHistoryData, mainEditor, mainEditorTitle, setHistoryModalOpen, t]);
|
||||
|
||||
const confirmRestore = useCallback(() => {
|
||||
modals.openConfirmModal({
|
||||
title: t("Please confirm your action"),
|
||||
children: (
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"Are you sure you want to restore this version? Any changes not versioned will be lost.",
|
||||
)}
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
|
||||
onConfirm: handleRestore,
|
||||
});
|
||||
}, [t, handleRestore]);
|
||||
|
||||
return { canRestore, confirmRestore };
|
||||
}
|
||||
@@ -1,19 +1,38 @@
|
||||
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||
import {
|
||||
InfiniteData,
|
||||
useInfiniteQuery,
|
||||
UseInfiniteQueryResult,
|
||||
useQuery,
|
||||
UseQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
getPageHistoryById,
|
||||
getPageHistoryList,
|
||||
} from "@/features/page-history/services/page-history-service";
|
||||
import { IPageHistory } from "@/features/page-history/types/page.types";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
import { queryClient } from "@/main";
|
||||
|
||||
const HISTORY_STALE_TIME = 60 * 60 * 1000;
|
||||
|
||||
export function prefetchPageHistory(historyId: string) {
|
||||
return queryClient.prefetchQuery({
|
||||
queryKey: ["page-history", historyId],
|
||||
queryFn: () => getPageHistoryById(historyId),
|
||||
staleTime: HISTORY_STALE_TIME,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePageHistoryListQuery(
|
||||
pageId: string,
|
||||
): UseQueryResult<IPagination<IPageHistory>, Error> {
|
||||
return useQuery({
|
||||
): UseInfiniteQueryResult<InfiniteData<IPagination<IPageHistory>, unknown>> {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["page-history-list", pageId],
|
||||
queryFn: () => getPageHistoryList(pageId),
|
||||
queryFn: ({ pageParam }) => getPageHistoryList(pageId, pageParam),
|
||||
enabled: !!pageId,
|
||||
gcTime: 0,
|
||||
initialPageParam: undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.meta?.nextCursor ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,6 +43,6 @@ export function usePageHistoryQuery(
|
||||
queryKey: ["page-history", historyId],
|
||||
queryFn: () => getPageHistoryById(historyId),
|
||||
enabled: !!historyId,
|
||||
staleTime: 10 * 60 * 1000,
|
||||
staleTime: HISTORY_STALE_TIME,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ import { IPagination } from "@/lib/types.ts";
|
||||
|
||||
export async function getPageHistoryList(
|
||||
pageId: string,
|
||||
cursor?: string,
|
||||
): Promise<IPagination<IPageHistory>> {
|
||||
const req = await api.post("/pages/history", {
|
||||
pageId,
|
||||
cursor,
|
||||
});
|
||||
return req.data;
|
||||
}
|
||||
|
||||
@@ -18,4 +18,5 @@ export interface IPageHistory {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastUpdatedBy: IPageHistoryUser;
|
||||
contributors?: IPageHistoryUser[];
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ import React, { useEffect, useRef, useState } from "react";
|
||||
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||
import { useClipboard, useDisclosure, useHotkeys } from "@mantine/hooks";
|
||||
import { useDisclosure, useHotkeys } from "@mantine/hooks";
|
||||
import { useClipboard } from "@/hooks/use-clipboard";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
IconBrandNotion,
|
||||
IconCheck,
|
||||
IconFileCode,
|
||||
IconFileTypeDocx,
|
||||
IconFileTypeZip,
|
||||
IconMarkdown,
|
||||
IconX,
|
||||
@@ -86,11 +87,13 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
||||
|
||||
const markdownFileRef = useRef<() => void>(null);
|
||||
const htmlFileRef = useRef<() => void>(null);
|
||||
const docxFileRef = useRef<() => void>(null);
|
||||
const notionFileRef = useRef<() => void>(null);
|
||||
const confluenceFileRef = useRef<() => void>(null);
|
||||
const zipFileRef = useRef<() => void>(null);
|
||||
|
||||
const canUseConfluence = isCloud() || workspace?.hasLicenseKey;
|
||||
const canUseDocx = isCloud() || workspace?.hasLicenseKey;
|
||||
|
||||
const handleZipUpload = async (selectedFile: File, source: string) => {
|
||||
if (!selectedFile) {
|
||||
@@ -265,6 +268,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
||||
// Reset file inputs after successful upload
|
||||
if (markdownFileRef.current) markdownFileRef.current();
|
||||
if (htmlFileRef.current) htmlFileRef.current();
|
||||
if (docxFileRef.current) docxFileRef.current();
|
||||
|
||||
const pageCountText =
|
||||
pageCount === 1 ? `1 ${t("page")}` : `${pageCount} ${t("pages")}`;
|
||||
@@ -321,6 +325,30 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
||||
)}
|
||||
</FileButton>
|
||||
|
||||
<FileButton
|
||||
onChange={handleFileUpload}
|
||||
accept=".docx"
|
||||
multiple
|
||||
resetRef={docxFileRef}
|
||||
>
|
||||
{(props) => (
|
||||
<Tooltip
|
||||
label={t("Available in enterprise edition")}
|
||||
disabled={canUseDocx}
|
||||
>
|
||||
<Button
|
||||
disabled={!canUseDocx}
|
||||
justify="start"
|
||||
variant="default"
|
||||
leftSection={<IconFileTypeDocx size={18} />}
|
||||
{...props}
|
||||
>
|
||||
Word (DOCX)
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</FileButton>
|
||||
|
||||
<FileButton
|
||||
onChange={(file) => handleZipUpload(file, "notion")}
|
||||
accept="application/zip"
|
||||
|
||||
@@ -54,11 +54,11 @@ import { IPage, SidebarPagesParams } from "@/features/page/types/page.types.ts";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { OpenMap } from "react-arborist/dist/main/state/open-slice";
|
||||
import {
|
||||
useClipboard,
|
||||
useDisclosure,
|
||||
useElementSize,
|
||||
useMergedRef,
|
||||
} from "@mantine/hooks";
|
||||
import { useClipboard } from "@/hooks/use-clipboard";
|
||||
import { dfs } from "react-arborist/dist/module/utils";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
buildPageUrl,
|
||||
buildSharedPageUrl,
|
||||
} from "@/features/page/page.utils.ts";
|
||||
import { useClipboard } from "@mantine/hooks";
|
||||
import { useClipboard } from "@/hooks/use-clipboard";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useDeleteShareMutation } from "@/features/share/queries/share-query.ts";
|
||||
|
||||
@@ -26,6 +26,9 @@ import { getAppUrl, isCloud } from "@/lib/config.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import classes from "@/features/share/components/share.module.css";
|
||||
import useTrial from "@/ee/hooks/use-trial.tsx";
|
||||
import { useAtom } from "jotai";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
|
||||
|
||||
interface ShareModalProps {
|
||||
readOnly: boolean;
|
||||
@@ -40,6 +43,12 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
|
||||
const { data: share } = useShareForPageQuery(pageId);
|
||||
const { spaceSlug } = useParams();
|
||||
const { isTrial } = useTrial();
|
||||
const [workspace] = useAtom(workspaceAtom);
|
||||
const { data: space } = useSpaceQuery(spaceSlug);
|
||||
const workspaceDisabled =
|
||||
workspace?.settings?.sharing?.disabled === true;
|
||||
const spaceDisabled = space?.settings?.sharing?.disabled === true;
|
||||
const sharingDisabled = workspaceDisabled || spaceDisabled;
|
||||
const createShareMutation = useCreateShareMutation();
|
||||
const updateShareMutation = useUpdateShareMutation();
|
||||
const deleteShareMutation = useDeleteShareMutation();
|
||||
@@ -164,6 +173,20 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
|
||||
{t("Upgrade Plan")}
|
||||
</Button>
|
||||
</>
|
||||
) : sharingDisabled ? (
|
||||
<>
|
||||
<Group justify="center" mb="sm">
|
||||
<IconLock size={20} stroke={1.5} />
|
||||
</Group>
|
||||
<Text size="sm" ta="center" fw={500} mb="xs">
|
||||
{t("Public sharing is disabled")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{workspaceDisabled
|
||||
? t("Public sharing has been disabled at the workspace level.")
|
||||
: t("Public sharing has been disabled for this space.")}
|
||||
</Text>
|
||||
</>
|
||||
) : isDescendantShared ? (
|
||||
<>
|
||||
<Text size="sm">{t("Inherits public sharing from")}</Text>
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
ResponsiveSettingsControl,
|
||||
ResponsiveSettingsRow,
|
||||
} from "@/components/ui/responsive-settings-row.tsx";
|
||||
import SpacePublicSharingToggle from "@/ee/security/components/space-public-sharing-toggle.tsx";
|
||||
import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx";
|
||||
|
||||
interface SpaceDetailsProps {
|
||||
spaceId: string;
|
||||
@@ -26,6 +28,8 @@ interface SpaceDetailsProps {
|
||||
export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: space, isLoading, refetch } = useSpaceQuery(spaceId);
|
||||
const hasEnterpriseAccess = useEnterpriseAccess();
|
||||
const showSharingToggle = !readOnly && hasEnterpriseAccess;
|
||||
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||
useDisclosure(false);
|
||||
const [isIconUploading, setIsIconUploading] = useState(false);
|
||||
@@ -77,7 +81,6 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
|
||||
fallbackName={space.name}
|
||||
size={"60px"}
|
||||
variant="filled"
|
||||
|
||||
type={AvatarIconType.SPACE_ICON}
|
||||
onUpload={handleIconUpload}
|
||||
onRemove={handleIconRemove}
|
||||
@@ -88,6 +91,13 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
|
||||
|
||||
<EditSpaceForm space={space} readOnly={readOnly} />
|
||||
|
||||
{showSharingToggle && (
|
||||
<>
|
||||
<Divider my="lg" />
|
||||
<SpacePublicSharingToggle space={space} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{!readOnly && (
|
||||
<>
|
||||
<Divider my="lg" />
|
||||
|
||||
@@ -5,6 +5,14 @@ import {
|
||||
} from "@/features/space/permissions/permissions.type.ts";
|
||||
import { ExportFormat } from "@/features/page/types/page.types.ts";
|
||||
|
||||
export interface ISpaceSharingSettings {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ISpaceSettings {
|
||||
sharing?: ISpaceSharingSettings;
|
||||
}
|
||||
|
||||
export interface ISpace {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -18,6 +26,9 @@ export interface ISpace {
|
||||
memberCount?: number;
|
||||
spaceId?: string;
|
||||
membership?: IMembership;
|
||||
settings?: ISpaceSettings;
|
||||
// for updates
|
||||
disablePublicSharing?: boolean;
|
||||
}
|
||||
|
||||
interface IMembership {
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ import {
|
||||
} from "@/features/workspace/queries/workspace-query.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useClipboard } from "@mantine/hooks";
|
||||
import { useClipboard } from "@/hooks/use-clipboard";
|
||||
import { getInviteLink } from "@/features/workspace/services/workspace-service.ts";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import { isCloud } from "@/lib/config.ts";
|
||||
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
import { useAtom } from "jotai";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, CopyButton, Group, Text, TextInput } from "@mantine/core";
|
||||
import { Button, Group, Text, TextInput } from "@mantine/core";
|
||||
import { CopyButton } from "@/components/common/copy-button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function WorkspaceInviteSection() {
|
||||
|
||||
@@ -42,7 +42,7 @@ export async function deleteWorkspaceMember(data: {
|
||||
await api.post("/workspace/members/delete", data);
|
||||
}
|
||||
|
||||
export async function updateWorkspace(data: Partial<IWorkspace> & { aiSearch?: boolean }) {
|
||||
export async function updateWorkspace(data: Partial<IWorkspace>) {
|
||||
const req = await api.post<IWorkspace>("/workspace/update", data);
|
||||
return req.data;
|
||||
}
|
||||
@@ -66,7 +66,9 @@ export async function createInvitation(data: ICreateInvite) {
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function acceptInvitation(data: IAcceptInvite): Promise<{ requiresLogin?: boolean; }> {
|
||||
export async function acceptInvitation(
|
||||
data: IAcceptInvite,
|
||||
): Promise<{ requiresLogin?: boolean }> {
|
||||
const req = await api.post("/workspace/invites/accept", data);
|
||||
return req.data;
|
||||
}
|
||||
@@ -108,4 +110,3 @@ export async function getAppVersion(): Promise<IVersion> {
|
||||
const req = await api.post("/version");
|
||||
return req.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,16 +22,23 @@ export interface IWorkspace {
|
||||
plan?: string;
|
||||
hasLicenseKey?: boolean;
|
||||
enforceMfa?: boolean;
|
||||
aiSearch?: boolean;
|
||||
disablePublicSharing?: boolean;
|
||||
}
|
||||
|
||||
export interface IWorkspaceSettings {
|
||||
ai?: IWorkspaceAiSettings;
|
||||
sharing?: IWorkspaceSharingSettings;
|
||||
}
|
||||
|
||||
export interface IWorkspaceAiSettings {
|
||||
search?: boolean;
|
||||
}
|
||||
|
||||
export interface IWorkspaceSharingSettings {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ICreateInvite {
|
||||
role: string;
|
||||
emails: string[];
|
||||
|
||||
Reference in New Issue
Block a user