mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
feat: pages collaboration
This commit is contained in:
parent
6f190ea6ee
commit
17c539658b
@ -430,17 +430,17 @@ class PagesDescriptionViewSet(BaseViewSet):
|
|||||||
if page.description_yjs:
|
if page.description_yjs:
|
||||||
Y.apply_update(existing_doc, page.description_yjs)
|
Y.apply_update(existing_doc, page.description_yjs)
|
||||||
|
|
||||||
# Load the new data into a separate YDoc
|
# # Load the new data into a separate YDoc
|
||||||
new_doc = Y.YDoc()
|
# new_doc = Y.YDoc()
|
||||||
Y.apply_update(new_doc, new_binary_data)
|
Y.apply_update(existing_doc, new_binary_data)
|
||||||
|
|
||||||
# Merge the new data into the existing data
|
# # Merge the new data into the existing data
|
||||||
# This will automatically resolve any conflicts
|
# # This will automatically resolve any conflicts
|
||||||
new_state_vector = Y.encode_state_vector(new_doc)
|
# new_state_vector = Y.encode_state_vector(new_doc)
|
||||||
diff = Y.encode_state_as_update(existing_doc, new_state_vector)
|
# diff = Y.encode_state_as_update(existing_doc, new_state_vector)
|
||||||
Y.apply_update(existing_doc, diff)
|
# Y.apply_update(existing_doc, diff)
|
||||||
|
|
||||||
# Encode the updated state as binary data
|
# # Encode the updated state as binary data
|
||||||
updated_binary_data = Y.encode_state_as_update(existing_doc)
|
updated_binary_data = Y.encode_state_as_update(existing_doc)
|
||||||
|
|
||||||
# Store the updated binary data
|
# Store the updated binary data
|
||||||
|
@ -12,18 +12,17 @@ import { insertContentAtSavedSelection } from "src/helpers/insert-content-at-cur
|
|||||||
import { EditorMenuItemNames, getEditorMenuItems } from "src/ui/menus/menu-items";
|
import { EditorMenuItemNames, getEditorMenuItems } from "src/ui/menus/menu-items";
|
||||||
import { EditorRefApi } from "src/types/editor-ref-api";
|
import { EditorRefApi } from "src/types/editor-ref-api";
|
||||||
import { IMarking, scrollSummary } from "src/helpers/scroll-to-node";
|
import { IMarking, scrollSummary } from "src/helpers/scroll-to-node";
|
||||||
|
export interface CustomEditorProps {
|
||||||
interface CustomEditorProps {
|
|
||||||
id?: string;
|
id?: string;
|
||||||
uploadFile: UploadImage;
|
uploadFile: UploadImage;
|
||||||
restoreFile: RestoreImage;
|
restoreFile: RestoreImage;
|
||||||
deleteFile: DeleteImage;
|
deleteFile: DeleteImage;
|
||||||
cancelUploadImage?: () => void;
|
cancelUploadImage?: () => void;
|
||||||
initialValue: string;
|
initialValue?: string;
|
||||||
editorClassName: string;
|
editorClassName: string;
|
||||||
// undefined when prop is not passed, null if intentionally passed to stop
|
// undefined when prop is not passed, null if intentionally passed to stop
|
||||||
// swr syncing
|
// swr syncing
|
||||||
value: string | null | undefined;
|
value?: string | null | undefined;
|
||||||
onChange?: (json: object, html: string) => void;
|
onChange?: (json: object, html: string) => void;
|
||||||
extensions?: any;
|
extensions?: any;
|
||||||
editorProps?: EditorProps;
|
editorProps?: EditorProps;
|
||||||
|
@ -24,6 +24,7 @@ export * from "src/ui/menus/menu-items";
|
|||||||
export * from "src/lib/editor-commands";
|
export * from "src/lib/editor-commands";
|
||||||
|
|
||||||
// types
|
// types
|
||||||
|
export type { CustomEditorProps } from "src/hooks/use-editor";
|
||||||
export type { DeleteImage } from "src/types/delete-image";
|
export type { DeleteImage } from "src/types/delete-image";
|
||||||
export type { UploadImage } from "src/types/upload-image";
|
export type { UploadImage } from "src/types/upload-image";
|
||||||
export type { EditorRefApi, EditorReadOnlyRefApi } from "src/types/editor-ref-api";
|
export type { EditorRefApi, EditorReadOnlyRefApi } from "src/types/editor-ref-api";
|
||||||
|
@ -34,12 +34,17 @@
|
|||||||
"@plane/ui": "*",
|
"@plane/ui": "*",
|
||||||
"@tippyjs/react": "^4.2.6",
|
"@tippyjs/react": "^4.2.6",
|
||||||
"@tiptap/core": "^2.1.13",
|
"@tiptap/core": "^2.1.13",
|
||||||
|
"@tiptap/extension-collaboration": "^2.3.2",
|
||||||
"@tiptap/pm": "^2.1.13",
|
"@tiptap/pm": "^2.1.13",
|
||||||
"@tiptap/suggestion": "^2.1.13",
|
"@tiptap/suggestion": "^2.1.13",
|
||||||
"lucide-react": "^0.378.0",
|
"lucide-react": "^0.378.0",
|
||||||
"react-popper": "^2.3.0",
|
"react-popper": "^2.3.0",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
"uuid": "^9.0.1"
|
"uuid": "^9.0.1",
|
||||||
|
"y-indexeddb": "^9.0.12",
|
||||||
|
"y-prosemirror": "^1.2.5",
|
||||||
|
"y-protocols": "^1.0.6",
|
||||||
|
"yjs": "^13.6.15"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "18.15.3",
|
"@types/node": "18.15.3",
|
||||||
|
101
packages/editor/document-editor/src/hooks/use-document-editor.ts
Normal file
101
packages/editor/document-editor/src/hooks/use-document-editor.ts
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import { useLayoutEffect, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
DeleteImage,
|
||||||
|
EditorRefApi,
|
||||||
|
IMentionHighlight,
|
||||||
|
IMentionSuggestion,
|
||||||
|
RestoreImage,
|
||||||
|
UploadImage,
|
||||||
|
useEditor,
|
||||||
|
} from "@plane/editor-core";
|
||||||
|
import * as Y from "yjs";
|
||||||
|
import { CollaborationProvider } from "src/providers/collaboration-provider";
|
||||||
|
import { DocumentEditorExtensions } from "src/ui/extensions";
|
||||||
|
import { IndexeddbPersistence } from "y-indexeddb";
|
||||||
|
import { EditorProps } from "@tiptap/pm/view";
|
||||||
|
|
||||||
|
type DocumentEditorProps = {
|
||||||
|
id?: string;
|
||||||
|
uploadFile: UploadImage;
|
||||||
|
restoreFile: RestoreImage;
|
||||||
|
deleteFile: DeleteImage;
|
||||||
|
cancelUploadImage?: () => void;
|
||||||
|
value: Uint8Array;
|
||||||
|
editorClassName: string;
|
||||||
|
onChange: (binaryString: string, html: string) => void;
|
||||||
|
extensions?: any;
|
||||||
|
editorProps?: EditorProps;
|
||||||
|
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
||||||
|
mentionHandler: {
|
||||||
|
highlights: () => Promise<IMentionHighlight[]>;
|
||||||
|
suggestions?: () => Promise<IMentionSuggestion[]>;
|
||||||
|
};
|
||||||
|
handleEditorReady?: (value: boolean) => void;
|
||||||
|
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||||
|
setHideDragHandleFunction: (hideDragHandlerFromDragDrop: () => void) => void;
|
||||||
|
tabIndex?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDocumentEditor = ({
|
||||||
|
uploadFile,
|
||||||
|
id = "",
|
||||||
|
deleteFile,
|
||||||
|
cancelUploadImage,
|
||||||
|
editorProps = {},
|
||||||
|
value,
|
||||||
|
editorClassName,
|
||||||
|
onChange,
|
||||||
|
forwardedRef,
|
||||||
|
tabIndex,
|
||||||
|
restoreFile,
|
||||||
|
handleEditorReady,
|
||||||
|
mentionHandler,
|
||||||
|
placeholder,
|
||||||
|
setHideDragHandleFunction,
|
||||||
|
}: DocumentEditorProps) => {
|
||||||
|
const provider = useMemo(
|
||||||
|
() =>
|
||||||
|
new CollaborationProvider({
|
||||||
|
name: id,
|
||||||
|
onChange,
|
||||||
|
}),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const yDoc = useMemo(() => {
|
||||||
|
if (value.byteLength !== 0) Y.applyUpdate(provider.document, value);
|
||||||
|
return provider.document;
|
||||||
|
}, [value, provider.document]);
|
||||||
|
console.log("yDoc", yDoc);
|
||||||
|
|
||||||
|
// indexedDB provider
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const localProvider = new IndexeddbPersistence(id, provider.document);
|
||||||
|
return () => {
|
||||||
|
localProvider?.destroy();
|
||||||
|
};
|
||||||
|
}, [provider, id]);
|
||||||
|
|
||||||
|
const editor = useEditor({
|
||||||
|
id,
|
||||||
|
editorProps,
|
||||||
|
editorClassName,
|
||||||
|
restoreFile,
|
||||||
|
uploadFile,
|
||||||
|
deleteFile,
|
||||||
|
cancelUploadImage,
|
||||||
|
handleEditorReady,
|
||||||
|
forwardedRef,
|
||||||
|
mentionHandler,
|
||||||
|
extensions: DocumentEditorExtensions({
|
||||||
|
uploadFile,
|
||||||
|
setHideDragHandle: setHideDragHandleFunction,
|
||||||
|
provider,
|
||||||
|
}),
|
||||||
|
placeholder,
|
||||||
|
tabIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
return editor;
|
||||||
|
};
|
@ -0,0 +1,70 @@
|
|||||||
|
import * as Y from "yjs";
|
||||||
|
|
||||||
|
export interface CompleteCollaboratorProviderConfiguration {
|
||||||
|
/**
|
||||||
|
* The identifier/name of your document
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* The actual Y.js document
|
||||||
|
*/
|
||||||
|
document: Y.Doc;
|
||||||
|
/**
|
||||||
|
* onChange callback
|
||||||
|
*/
|
||||||
|
onChange: (binaryString: string, html: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CollaborationProviderConfiguration = Required<Pick<CompleteCollaboratorProviderConfiguration, "name">> &
|
||||||
|
Partial<CompleteCollaboratorProviderConfiguration>;
|
||||||
|
|
||||||
|
export class CollaborationProvider {
|
||||||
|
public configuration: CompleteCollaboratorProviderConfiguration = {
|
||||||
|
name: "",
|
||||||
|
// @ts-expect-error cannot be undefined
|
||||||
|
document: undefined,
|
||||||
|
onChange: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
intervals: any = {
|
||||||
|
forceSync: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
timeoutId: any;
|
||||||
|
|
||||||
|
constructor(configuration: CollaborationProviderConfiguration) {
|
||||||
|
this.setConfiguration(configuration);
|
||||||
|
|
||||||
|
this.timeoutId = null;
|
||||||
|
|
||||||
|
this.configuration.document = configuration.document ?? new Y.Doc();
|
||||||
|
this.document.on("update", this.documentUpdateHandler.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
public setConfiguration(configuration: Partial<CompleteCollaboratorProviderConfiguration> = {}): void {
|
||||||
|
this.configuration = {
|
||||||
|
...this.configuration,
|
||||||
|
...configuration,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
get document() {
|
||||||
|
return this.configuration.document;
|
||||||
|
}
|
||||||
|
|
||||||
|
documentUpdateHandler(update: Uint8Array, origin: any) {
|
||||||
|
if (origin === this) return;
|
||||||
|
|
||||||
|
// debounce onChange call
|
||||||
|
if (this.timeoutId !== null) clearTimeout(this.timeoutId);
|
||||||
|
|
||||||
|
this.timeoutId = setTimeout(() => {
|
||||||
|
const docAsUint8Array = Y.encodeStateAsUpdate(this.document);
|
||||||
|
const base64Doc = Buffer.from(docAsUint8Array).toString("base64");
|
||||||
|
// const base64Doc = Buffer.from(update).toString("base64");
|
||||||
|
|
||||||
|
this.configuration.onChange?.(base64Doc, "<p></p>");
|
||||||
|
this.timeoutId = null;
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
}
|
@ -2,14 +2,20 @@ import { IssueWidgetPlaceholder } from "src/ui/extensions/widgets/issue-embed-wi
|
|||||||
|
|
||||||
import { SlashCommand, DragAndDrop } from "@plane/editor-extensions";
|
import { SlashCommand, DragAndDrop } from "@plane/editor-extensions";
|
||||||
import { UploadImage } from "@plane/editor-core";
|
import { UploadImage } from "@plane/editor-core";
|
||||||
|
import { CollaborationProvider } from "src/providers/collaboration-provider";
|
||||||
|
import Collaboration from "@tiptap/extension-collaboration";
|
||||||
|
|
||||||
type TArguments = {
|
type TArguments = {
|
||||||
uploadFile: UploadImage;
|
uploadFile: UploadImage;
|
||||||
setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void;
|
setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void;
|
||||||
|
provider: CollaborationProvider;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentEditorExtensions = ({ uploadFile, setHideDragHandle }: TArguments) => [
|
export const DocumentEditorExtensions = ({ uploadFile, setHideDragHandle, provider }: TArguments) => [
|
||||||
SlashCommand(uploadFile),
|
SlashCommand(uploadFile),
|
||||||
DragAndDrop(setHideDragHandle),
|
DragAndDrop(setHideDragHandle),
|
||||||
IssueWidgetPlaceholder(),
|
IssueWidgetPlaceholder(),
|
||||||
|
Collaboration.configure({
|
||||||
|
document: provider.document,
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
@ -4,17 +4,16 @@ import {
|
|||||||
DeleteImage,
|
DeleteImage,
|
||||||
RestoreImage,
|
RestoreImage,
|
||||||
getEditorClassNames,
|
getEditorClassNames,
|
||||||
useEditor,
|
|
||||||
EditorRefApi,
|
EditorRefApi,
|
||||||
IMentionHighlight,
|
IMentionHighlight,
|
||||||
IMentionSuggestion,
|
IMentionSuggestion,
|
||||||
} from "@plane/editor-core";
|
} from "@plane/editor-core";
|
||||||
import { DocumentEditorExtensions } from "src/ui/extensions";
|
|
||||||
import { PageRenderer } from "src/ui/components/page-renderer";
|
import { PageRenderer } from "src/ui/components/page-renderer";
|
||||||
|
import { useDocumentEditor } from "src/hooks/use-document-editor";
|
||||||
|
|
||||||
interface IDocumentEditor {
|
interface IDocumentEditor {
|
||||||
initialValue: string;
|
id: string;
|
||||||
value?: string;
|
value: Uint8Array;
|
||||||
fileHandler: {
|
fileHandler: {
|
||||||
cancel: () => void;
|
cancel: () => void;
|
||||||
delete: DeleteImage;
|
delete: DeleteImage;
|
||||||
@ -24,7 +23,7 @@ interface IDocumentEditor {
|
|||||||
handleEditorReady?: (value: boolean) => void;
|
handleEditorReady?: (value: boolean) => void;
|
||||||
containerClassName?: string;
|
containerClassName?: string;
|
||||||
editorClassName?: string;
|
editorClassName?: string;
|
||||||
onChange: (json: object, html: string) => void;
|
onChange: (binaryString: string, html: string) => void;
|
||||||
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
||||||
mentionHandler: {
|
mentionHandler: {
|
||||||
highlights: () => Promise<IMentionHighlight[]>;
|
highlights: () => Promise<IMentionHighlight[]>;
|
||||||
@ -37,8 +36,9 @@ interface IDocumentEditor {
|
|||||||
const DocumentEditor = (props: IDocumentEditor) => {
|
const DocumentEditor = (props: IDocumentEditor) => {
|
||||||
const {
|
const {
|
||||||
onChange,
|
onChange,
|
||||||
initialValue,
|
id,
|
||||||
value,
|
value,
|
||||||
|
// value,
|
||||||
fileHandler,
|
fileHandler,
|
||||||
containerClassName,
|
containerClassName,
|
||||||
editorClassName = "",
|
editorClassName = "",
|
||||||
@ -56,26 +56,22 @@ const DocumentEditor = (props: IDocumentEditor) => {
|
|||||||
const setHideDragHandleFunction = (hideDragHandlerFromDragDrop: () => void) => {
|
const setHideDragHandleFunction = (hideDragHandlerFromDragDrop: () => void) => {
|
||||||
setHideDragHandleOnMouseLeave(() => hideDragHandlerFromDragDrop);
|
setHideDragHandleOnMouseLeave(() => hideDragHandlerFromDragDrop);
|
||||||
};
|
};
|
||||||
// use editor
|
|
||||||
const editor = useEditor({
|
// use document editor
|
||||||
onChange(json, html) {
|
const editor = useDocumentEditor({
|
||||||
onChange(json, html);
|
id,
|
||||||
},
|
|
||||||
editorClassName,
|
editorClassName,
|
||||||
restoreFile: fileHandler.restore,
|
restoreFile: fileHandler.restore,
|
||||||
uploadFile: fileHandler.upload,
|
uploadFile: fileHandler.upload,
|
||||||
deleteFile: fileHandler.delete,
|
deleteFile: fileHandler.delete,
|
||||||
cancelUploadImage: fileHandler.cancel,
|
cancelUploadImage: fileHandler.cancel,
|
||||||
initialValue,
|
|
||||||
value,
|
value,
|
||||||
|
onChange,
|
||||||
handleEditorReady,
|
handleEditorReady,
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
mentionHandler,
|
mentionHandler,
|
||||||
extensions: DocumentEditorExtensions({
|
|
||||||
uploadFile: fileHandler.upload,
|
|
||||||
setHideDragHandle: setHideDragHandleFunction,
|
|
||||||
}),
|
|
||||||
placeholder,
|
placeholder,
|
||||||
|
setHideDragHandleFunction,
|
||||||
tabIndex,
|
tabIndex,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { Control, Controller } from "react-hook-form";
|
import { Control, Controller } from "react-hook-form";
|
||||||
@ -22,6 +22,8 @@ import { usePageFilters } from "@/hooks/use-page-filters";
|
|||||||
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
|
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
|
||||||
// services
|
// services
|
||||||
import { FileService } from "@/services/file.service";
|
import { FileService } from "@/services/file.service";
|
||||||
|
import { PageService } from "@/services/page.service";
|
||||||
|
const pageService = new PageService();
|
||||||
// store
|
// store
|
||||||
import { IPageStore } from "@/store/pages/page.store";
|
import { IPageStore } from "@/store/pages/page.store";
|
||||||
|
|
||||||
@ -31,7 +33,6 @@ type Props = {
|
|||||||
control: Control<TPage, any>;
|
control: Control<TPage, any>;
|
||||||
editorRef: React.RefObject<EditorRefApi>;
|
editorRef: React.RefObject<EditorRefApi>;
|
||||||
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
|
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
|
||||||
swrPageDetails: TPage | undefined;
|
|
||||||
handleSubmit: () => void;
|
handleSubmit: () => void;
|
||||||
markings: IMarking[];
|
markings: IMarking[];
|
||||||
pageStore: IPageStore;
|
pageStore: IPageStore;
|
||||||
@ -49,12 +50,13 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
|||||||
editorRef,
|
editorRef,
|
||||||
markings,
|
markings,
|
||||||
readOnlyEditorRef,
|
readOnlyEditorRef,
|
||||||
handleSubmit,
|
// handleSubmit,
|
||||||
pageStore,
|
pageStore,
|
||||||
swrPageDetails,
|
|
||||||
sidePeekVisible,
|
sidePeekVisible,
|
||||||
updateMarkings,
|
updateMarkings,
|
||||||
} = props;
|
} = props;
|
||||||
|
// states
|
||||||
|
const [descriptionYJS, setDescriptionYJS] = useState<Uint8Array | null>(null);
|
||||||
// router
|
// router
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug, projectId } = router.query;
|
const { workspaceSlug, projectId } = router.query;
|
||||||
@ -67,6 +69,7 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
|||||||
} = useMember();
|
} = useMember();
|
||||||
// derived values
|
// derived values
|
||||||
const workspaceId = workspaceSlug ? getWorkspaceBySlug(workspaceSlug.toString())?.id ?? "" : "";
|
const workspaceId = workspaceSlug ? getWorkspaceBySlug(workspaceSlug.toString())?.id ?? "" : "";
|
||||||
|
const pageId = pageStore?.id ?? "";
|
||||||
const pageTitle = pageStore?.name ?? "";
|
const pageTitle = pageStore?.name ?? "";
|
||||||
const pageDescription = pageStore?.description_html;
|
const pageDescription = pageStore?.description_html;
|
||||||
const { description_html, isContentEditable, updateTitle, isSubmitting, setIsSubmitting } = pageStore;
|
const { description_html, isContentEditable, updateTitle, isSubmitting, setIsSubmitting } = pageStore;
|
||||||
@ -82,13 +85,70 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
|||||||
// page filters
|
// page filters
|
||||||
const { isFullWidth } = usePageFilters();
|
const { isFullWidth } = usePageFilters();
|
||||||
|
|
||||||
const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting");
|
useReloadConfirmations(isSubmitting === "submitting");
|
||||||
|
|
||||||
|
// const { data: pageDescriptionYJS } = useSWR(
|
||||||
|
// workspaceSlug && projectId && pageId ? `PAGE_DESCRIPTION_${workspaceSlug}_${projectId}_${pageId}` : null,
|
||||||
|
// workspaceSlug && projectId && pageId
|
||||||
|
// ? () => pageService.fetchDescriptionYJS(workspaceSlug.toString(), projectId.toString(), pageId.toString())
|
||||||
|
// : null
|
||||||
|
// );
|
||||||
|
|
||||||
|
const handleDescriptionChange = useCallback(
|
||||||
|
(binaryString: string, descriptionHTML: string) => {
|
||||||
|
if (!workspaceSlug || !projectId || !pageId) return;
|
||||||
|
pageService.updateDescriptionYJS(workspaceSlug.toString(), projectId.toString(), pageId.toString(), {
|
||||||
|
description_yjs: binaryString,
|
||||||
|
description_html: descriptionHTML,
|
||||||
|
});
|
||||||
|
// setIsSubmitting("submitting");
|
||||||
|
// setShowAlert(true);
|
||||||
|
// onChange(description_html);
|
||||||
|
// handleSubmit();
|
||||||
|
},
|
||||||
|
[pageId, projectId, workspaceSlug]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchDescription = async () => {
|
||||||
|
if (!workspaceSlug || !projectId || !pageId) return;
|
||||||
|
console.log("fetching...");
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`http://localhost:8000/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`,
|
||||||
|
{
|
||||||
|
credentials: "include",
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/octet-stream",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data = await response.arrayBuffer();
|
||||||
|
setDescriptionYJS(new Uint8Array(data));
|
||||||
|
// __AUTO_GENERATED_PRINT_VAR_START__
|
||||||
|
console.log("fetchById data: %s", data); // __AUTO_GENERATED_PRINT_VAR_END__
|
||||||
|
// if (data.byteLength === 0) {
|
||||||
|
// const yjs = await fetchByIdIfExists(workspaceSlug, projectId, pageId);
|
||||||
|
// if (yjs) {
|
||||||
|
// console.log("not found in db:", yjs, yjs instanceof Uint8Array);
|
||||||
|
// return yjs;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
};
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
fetchDescription();
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [pageId, projectId, workspaceSlug]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
updateMarkings(description_html ?? "<p></p>");
|
updateMarkings(description_html ?? "<p></p>");
|
||||||
}, [description_html, updateMarkings]);
|
}, [description_html, updateMarkings]);
|
||||||
|
|
||||||
if (pageDescription === undefined) return <PageContentLoader />;
|
if (pageDescription === undefined || pageId === undefined || !descriptionYJS) return <PageContentLoader />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center h-full w-full overflow-y-auto">
|
<div className="flex items-center h-full w-full overflow-y-auto">
|
||||||
@ -125,8 +185,9 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
|||||||
<Controller
|
<Controller
|
||||||
name="description_html"
|
name="description_html"
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field: { onChange } }) => (
|
render={() => (
|
||||||
<DocumentEditorWithRef
|
<DocumentEditorWithRef
|
||||||
|
id={pageId}
|
||||||
fileHandler={{
|
fileHandler={{
|
||||||
cancel: fileService.cancelUpload,
|
cancel: fileService.cancelUpload,
|
||||||
delete: fileService.getDeleteImageFunction(workspaceId),
|
delete: fileService.getDeleteImageFunction(workspaceId),
|
||||||
@ -134,17 +195,11 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
|||||||
upload: fileService.getUploadFileFunction(workspaceSlug as string, setIsSubmitting),
|
upload: fileService.getUploadFileFunction(workspaceSlug as string, setIsSubmitting),
|
||||||
}}
|
}}
|
||||||
handleEditorReady={handleEditorReady}
|
handleEditorReady={handleEditorReady}
|
||||||
initialValue={pageDescription ?? "<p></p>"}
|
value={descriptionYJS}
|
||||||
value={swrPageDetails?.description_html ?? "<p></p>"}
|
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
containerClassName="p-0 pb-64"
|
containerClassName="p-0 pb-64"
|
||||||
editorClassName="lg:px-10 pl-8"
|
editorClassName="lg:px-10 pl-8"
|
||||||
onChange={(_description_json, description_html) => {
|
onChange={handleDescriptionChange}
|
||||||
setIsSubmitting("submitting");
|
|
||||||
setShowAlert(true);
|
|
||||||
onChange(description_html);
|
|
||||||
handleSubmit();
|
|
||||||
}}
|
|
||||||
mentionHandler={{
|
mentionHandler={{
|
||||||
highlights: mentionHighlights,
|
highlights: mentionHighlights,
|
||||||
suggestions: mentionSuggestions,
|
suggestions: mentionSuggestions,
|
||||||
|
@ -50,7 +50,6 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
|
|||||||
|
|
||||||
// fetching page details
|
// fetching page details
|
||||||
const {
|
const {
|
||||||
data: swrPageDetails,
|
|
||||||
isValidating,
|
isValidating,
|
||||||
error: pageDetailsError,
|
error: pageDetailsError,
|
||||||
} = useSWR(pageId ? `PAGE_DETAILS_${pageId}` : null, pageId ? () => getPageById(pageId.toString()) : null, {
|
} = useSWR(pageId ? `PAGE_DETAILS_${pageId}` : null, pageId ? () => getPageById(pageId.toString()) : null, {
|
||||||
@ -145,7 +144,6 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<PageEditorBody
|
<PageEditorBody
|
||||||
swrPageDetails={swrPageDetails}
|
|
||||||
control={control}
|
control={control}
|
||||||
editorRef={editorRef}
|
editorRef={editorRef}
|
||||||
handleEditorReady={(val) => setEditorReady(val)}
|
handleEditorReady={(val) => setEditorReady(val)}
|
||||||
|
@ -31,8 +31,11 @@ export abstract class APIService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
get(url: string, params = {}) {
|
get(url: string, params = {}, config = {}) {
|
||||||
return this.axiosInstance.get(url, params);
|
return this.axiosInstance.get(url, {
|
||||||
|
...params,
|
||||||
|
...config,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
post(url: string, data = {}, config = {}) {
|
post(url: string, data = {}, config = {}) {
|
||||||
|
@ -119,4 +119,24 @@ export class PageService extends APIService {
|
|||||||
throw error?.response?.data;
|
throw error?.response?.data;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fetchDescriptionYJS(workspaceSlug: string, projectId: string, pageId: string): Promise<any> {
|
||||||
|
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/octet-stream",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((response) => response?.data)
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response?.data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateDescriptionYJS(workspaceSlug: string, projectId: string, pageId: string, data: any): Promise<any> {
|
||||||
|
return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`, data)
|
||||||
|
.then((response) => response?.data)
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response?.data;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
68
yarn.lock
68
yarn.lock
@ -2410,6 +2410,11 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-code/-/extension-code-2.3.1.tgz#cea919becab684688819b29481a5c43ee1ee9c52"
|
resolved "https://registry.yarnpkg.com/@tiptap/extension-code/-/extension-code-2.3.1.tgz#cea919becab684688819b29481a5c43ee1ee9c52"
|
||||||
integrity sha512-bVX0EnDZoRXnoA7dyoZe7w2gdRjxmFEcsatHLkcr3R3x4k9oSgZXLe1C2jGbjJWr4j32tYXZ1cpKte6f1WUKzg==
|
integrity sha512-bVX0EnDZoRXnoA7dyoZe7w2gdRjxmFEcsatHLkcr3R3x4k9oSgZXLe1C2jGbjJWr4j32tYXZ1cpKte6f1WUKzg==
|
||||||
|
|
||||||
|
"@tiptap/extension-collaboration@^2.3.2":
|
||||||
|
version "2.3.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@tiptap/extension-collaboration/-/extension-collaboration-2.3.2.tgz#0780eabbe2e72665ed83f86dc70790589d1d0ff1"
|
||||||
|
integrity sha512-1vN+crj5KgqoJhDV+CrfIrBWDIjfpVxiEWHBk+yQU/G2vmyQfbN/R/5gH6rOw5GT3mHqgWFtCDJo4+H/2Ete4w==
|
||||||
|
|
||||||
"@tiptap/extension-document@^2.3.1":
|
"@tiptap/extension-document@^2.3.1":
|
||||||
version "2.3.1"
|
version "2.3.1"
|
||||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-2.3.1.tgz#c2c3a1d1f87e262872012508555eda8227a3bc7a"
|
resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-2.3.1.tgz#c2c3a1d1f87e262872012508555eda8227a3bc7a"
|
||||||
@ -2757,7 +2762,7 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@types/react" "*"
|
"@types/react" "*"
|
||||||
|
|
||||||
"@types/react@*", "@types/react@18.2.48", "@types/react@^18.2.42", "@types/react@^18.2.48":
|
"@types/react@*", "@types/react@^18.2.42", "@types/react@^18.2.48":
|
||||||
version "18.2.48"
|
version "18.2.48"
|
||||||
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.48.tgz#11df5664642d0bd879c1f58bc1d37205b064e8f1"
|
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.48.tgz#11df5664642d0bd879c1f58bc1d37205b064e8f1"
|
||||||
integrity sha512-qboRCl6Ie70DQQG9hhNREz81jqC1cs9EVNcjQ1AU+jH6NFfSAhVVbrrY/+nSF+Bsk4AOwm9Qa61InvMCyV+H3w==
|
integrity sha512-qboRCl6Ie70DQQG9hhNREz81jqC1cs9EVNcjQ1AU+jH6NFfSAhVVbrrY/+nSF+Bsk4AOwm9Qa61InvMCyV+H3w==
|
||||||
@ -5635,6 +5640,11 @@ isexe@^2.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
|
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
|
||||||
integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
|
integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
|
||||||
|
|
||||||
|
isomorphic.js@^0.2.4:
|
||||||
|
version "0.2.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/isomorphic.js/-/isomorphic.js-0.2.5.tgz#13eecf36f2dba53e85d355e11bf9d4208c6f7f88"
|
||||||
|
integrity sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==
|
||||||
|
|
||||||
iterator.prototype@^1.1.2:
|
iterator.prototype@^1.1.2:
|
||||||
version "1.1.2"
|
version "1.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/iterator.prototype/-/iterator.prototype-1.1.2.tgz#5e29c8924f01916cb9335f1ff80619dcff22b0c0"
|
resolved "https://registry.yarnpkg.com/iterator.prototype/-/iterator.prototype-1.1.2.tgz#5e29c8924f01916cb9335f1ff80619dcff22b0c0"
|
||||||
@ -5845,6 +5855,13 @@ levn@^0.4.1:
|
|||||||
prelude-ls "^1.2.1"
|
prelude-ls "^1.2.1"
|
||||||
type-check "~0.4.0"
|
type-check "~0.4.0"
|
||||||
|
|
||||||
|
lib0@^0.2.42, lib0@^0.2.74, lib0@^0.2.85, lib0@^0.2.86:
|
||||||
|
version "0.2.93"
|
||||||
|
resolved "https://registry.yarnpkg.com/lib0/-/lib0-0.2.93.tgz#95487c2a97657313cb1d91fbcf9f6d64b7fcd062"
|
||||||
|
integrity sha512-M5IKsiFJYulS+8Eal8f+zAqf5ckm1vffW0fFDxfgxJ+uiVopvDdd3PxJmz0GsVi3YNO7QCFSq0nAsiDmNhLj9Q==
|
||||||
|
dependencies:
|
||||||
|
isomorphic.js "^0.2.4"
|
||||||
|
|
||||||
lie@3.1.1:
|
lie@3.1.1:
|
||||||
version "3.1.1"
|
version "3.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/lie/-/lie-3.1.1.tgz#9a436b2cc7746ca59de7a41fa469b3efb76bd87e"
|
resolved "https://registry.yarnpkg.com/lie/-/lie-3.1.1.tgz#9a436b2cc7746ca59de7a41fa469b3efb76bd87e"
|
||||||
@ -7954,16 +7971,8 @@ streamx@^2.15.0, streamx@^2.16.1:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
bare-events "^2.2.0"
|
bare-events "^2.2.0"
|
||||||
|
|
||||||
"string-width-cjs@npm:string-width@^4.2.0":
|
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0:
|
||||||
version "4.2.3"
|
name string-width-cjs
|
||||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
|
||||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
|
||||||
dependencies:
|
|
||||||
emoji-regex "^8.0.0"
|
|
||||||
is-fullwidth-code-point "^3.0.0"
|
|
||||||
strip-ansi "^6.0.1"
|
|
||||||
|
|
||||||
string-width@^4.1.0:
|
|
||||||
version "4.2.3"
|
version "4.2.3"
|
||||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||||
@ -8043,14 +8052,7 @@ stringify-object@^3.3.0:
|
|||||||
is-obj "^1.0.1"
|
is-obj "^1.0.1"
|
||||||
is-regexp "^1.0.0"
|
is-regexp "^1.0.0"
|
||||||
|
|
||||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
|
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||||
version "6.0.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
|
||||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
|
||||||
dependencies:
|
|
||||||
ansi-regex "^5.0.1"
|
|
||||||
|
|
||||||
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
|
||||||
version "6.0.1"
|
version "6.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||||
@ -9137,6 +9139,27 @@ wrappy@1:
|
|||||||
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
|
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
|
||||||
integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
|
integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
|
||||||
|
|
||||||
|
y-indexeddb@^9.0.12:
|
||||||
|
version "9.0.12"
|
||||||
|
resolved "https://registry.yarnpkg.com/y-indexeddb/-/y-indexeddb-9.0.12.tgz#73657f31d52886d7532256610babf5cca4ad5e58"
|
||||||
|
integrity sha512-9oCFRSPPzBK7/w5vOkJBaVCQZKHXB/v6SIT+WYhnJxlEC61juqG0hBrAf+y3gmSMLFLwICNH9nQ53uscuse6Hg==
|
||||||
|
dependencies:
|
||||||
|
lib0 "^0.2.74"
|
||||||
|
|
||||||
|
y-prosemirror@^1.2.5:
|
||||||
|
version "1.2.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/y-prosemirror/-/y-prosemirror-1.2.5.tgz#c448f80a6017190bc69a30a33f3930e9924fad3a"
|
||||||
|
integrity sha512-T/JATxC8P2Dbvq/dAiaiztD1a8KEwRP8oLRlT8YlaZdNlLGE1Ea0IJ8If25UlDYmk+4+uqLbqT/S+dzUmwwgbA==
|
||||||
|
dependencies:
|
||||||
|
lib0 "^0.2.42"
|
||||||
|
|
||||||
|
y-protocols@^1.0.6:
|
||||||
|
version "1.0.6"
|
||||||
|
resolved "https://registry.yarnpkg.com/y-protocols/-/y-protocols-1.0.6.tgz#66dad8a95752623443e8e28c0e923682d2c0d495"
|
||||||
|
integrity sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==
|
||||||
|
dependencies:
|
||||||
|
lib0 "^0.2.85"
|
||||||
|
|
||||||
yallist@^3.0.2:
|
yallist@^3.0.2:
|
||||||
version "3.1.1"
|
version "3.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd"
|
resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd"
|
||||||
@ -9152,6 +9175,13 @@ yaml@^2.3.4:
|
|||||||
resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.4.2.tgz#7a2b30f2243a5fc299e1f14ca58d475ed4bc5362"
|
resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.4.2.tgz#7a2b30f2243a5fc299e1f14ca58d475ed4bc5362"
|
||||||
integrity sha512-B3VqDZ+JAg1nZpaEmWtTXUlBneoGx6CPM9b0TENK6aoSu5t73dItudwdgmi6tHlIZZId4dZ9skcAQ2UbcyAeVA==
|
integrity sha512-B3VqDZ+JAg1nZpaEmWtTXUlBneoGx6CPM9b0TENK6aoSu5t73dItudwdgmi6tHlIZZId4dZ9skcAQ2UbcyAeVA==
|
||||||
|
|
||||||
|
yjs@^13.6.15:
|
||||||
|
version "13.6.15"
|
||||||
|
resolved "https://registry.yarnpkg.com/yjs/-/yjs-13.6.15.tgz#5a2402632aabf83e5baf56342b4c82fe40859306"
|
||||||
|
integrity sha512-moFv4uNYhp8BFxIk3AkpoAnnjts7gwdpiG8RtyFiKbMtxKCS0zVZ5wPaaGpwC3V2N/K8TK8MwtSI3+WO9CHWjQ==
|
||||||
|
dependencies:
|
||||||
|
lib0 "^0.2.86"
|
||||||
|
|
||||||
yocto-queue@^0.1.0:
|
yocto-queue@^0.1.0:
|
||||||
version "0.1.0"
|
version "0.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
|
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
|
||||||
|
Loading…
Reference in New Issue
Block a user