diff --git a/cypress/support/document.ts b/cypress/support/document.ts index b7dfe090..8c22a88e 100644 --- a/cypress/support/document.ts +++ b/cypress/support/document.ts @@ -6,7 +6,7 @@ import * as Y from 'yjs'; export interface FromBlockJSON { type: string; children: FromBlockJSON[]; - data: Record; + data: Record; text: Op[]; } diff --git a/src/@types/translations/en.json b/src/@types/translations/en.json index 38a8b99a..0574eee5 100644 --- a/src/@types/translations/en.json +++ b/src/@types/translations/en.json @@ -307,7 +307,7 @@ "openMenu": "Click to open menu", "dragRow": "Drag to reorder the row", "viewDataBase": "View database", - "referencePage": "This {name} is referenced", + "referencePage": "This {{name}} is referenced", "addBlockBelow": "Add a block below", "aiGenerate": "Generate" }, @@ -508,6 +508,8 @@ "namespaceLengthAtLeast2Characters": "The namespace must be at least 2 characters long", "onlyWorkspaceOwnerCanUpdateNamespace": "Only workspace owner can update the namespace", "onlyWorkspaceOwnerCanRemoveHomepage": "Only workspace owner can remove the homepage", + "onlyWorkspaceOwnerCanChangeHomepage": "Only workspace owner can change the homepage", + "onlyProCanSetHomepage": "Only Pro Plan workspace owner can set the homepage", "setHomepageFailed": "Failed to set homepage", "namespaceTooLong": "The namespace is too long, please try another one", "namespaceTooShort": "The namespace is too short, please try another one", @@ -1522,7 +1524,7 @@ "cannotFindCreatableField": "Cannot find a suitable field to sort by", "deleteAllSorts": "Delete all sorts", "addSort": "Add sort", - "sortsActive": "Cannot {intention} while sorting", + "sortsActive": "Cannot {{intention}} while sorting", "removeSorting": "Would you like to remove all the sorts in this view and continue?", "fieldInUse": "You are already sorting by this field" }, @@ -1732,7 +1734,7 @@ "toggleList": "Toggle list", "emptyToggleHeading": "Empty toggle h{}. Click to add content.", "emptyToggleList": "Empty toggle list. Click to add content.", - "emptyToggleHeadingWeb": "Empty toggle h{level}. Click to add content", + "emptyToggleHeadingWeb": "Empty toggle h{{level}}. Click to add content", "quoteList": "Quote list", "numberedList": "Numbered list", "bulletedList": "Bulleted list", @@ -2169,8 +2171,8 @@ }, "unSupportBlock": "The current version does not support this Block.", "views": { - "deleteContentTitle": "Are you sure want to delete the {pageType}?", - "deleteContentCaption": "if you delete this {pageType}, you can restore it from the trash." + "deleteContentTitle": "Are you sure want to delete the {{pageType}}?", + "deleteContentCaption": "if you delete this {{pageType}}, you can restore it from the trash." }, "colors": { "custom": "Custom", @@ -2676,8 +2678,8 @@ "signInError": "Sign in error", "login": "Sign up or log in", "fileBlock": { - "uploadedAt": "Uploaded on {time}", - "linkedAt": "Link added on {time}", + "uploadedAt": "Uploaded on {{time}}", + "linkedAt": "Link added on {{time}}", "empty": "Upload or embed a file", "uploadFailed": "Upload failed, please try again", "retry": "Retry" @@ -2745,7 +2747,7 @@ "featured": "PIN to Featured", "relatedTemplates": "Related Templates", "requiredField": "{field} is required", - "addCategory": "Add \"{category}\"", + "addCategory": "Add \"{{category}}\"", "addNewCategory": "Add new category", "addNewCreator": "Add new creator", "deleteCategory": "Delete category", @@ -2785,7 +2787,7 @@ "addRelatedTemplate": "Add related template", "removeRelatedTemplate": "Remove related template", "uploadAvatar": "Upload avatar", - "searchInCategory": "Search in {category}", + "searchInCategory": "Search in {{category}}", "label": "Templates" }, "fileDropzone": { @@ -2823,7 +2825,7 @@ "alreadyAccepted": "You've already accepted the invitation", "errorModal": { "title": "Something went wrong", - "description": "Your current account {email} may not have access to this workspace. Please log in with the correct account or contact the workspace owner for help.", + "description": "Your current account {{email}} may not have access to this workspace. Please log in with the correct account or contact the workspace owner for help.", "contactOwner": "Contact owner", "close": "Back to home", "changeAccount": "Change account" @@ -2865,7 +2867,7 @@ }, "upgradePlanModal": { "title": "Upgrade to Pro", - "message": "{name} has reached the free member limit. Upgrade to the Pro Plan to invite more members.", + "message": "{{name}} has reached the free member limit. Upgrade to the Pro Plan to invite more members.", "upgradeSteps": "How to upgrade your plan on AppFlowy:", "step1": "1. Go to Settings", "step2": "2. Click on 'Plan'", @@ -2945,7 +2947,7 @@ "upgrade": "upgrade", "addEmail": "email@example.com, email2@example.com...", "requestInvites": "Send invites", - "inviteAlready": "You've already invited this email: {email}", + "inviteAlready": "You've already invited this email: {{email}}", "inviteSuccess": "Invitation sent successfully", "description": "Input emails below with commas between them. Charges are based on member count.", "emails": "Email" @@ -3042,5 +3044,8 @@ "memberCount_many": "{{count}} members", "memberCount_other": "{{count}} members", "aiMatch": "AI match", - "titleMatch": "Title match" + "titleMatch": "Title match", + "namespace": "Namespace", + "manageNamespaceDescription": "Manage your namespace and homepage", + "homepage": "Homepage" } diff --git a/src/application/services/js-services/http/http_api.ts b/src/application/services/js-services/http/http_api.ts index bc03fa54..098ea50f 100644 --- a/src/application/services/js-services/http/http_api.ts +++ b/src/application/services/js-services/http/http_api.ts @@ -21,7 +21,13 @@ import { CreateSpacePayload, UpdateSpacePayload, Role, - WorkspaceMember, QuickNote, QuickNoteEditorData, CreateWorkspacePayload, UpdateWorkspacePayload, + WorkspaceMember, + QuickNote, + QuickNoteEditorData, + CreateWorkspacePayload, + UpdateWorkspacePayload, + PublishViewPayload, + UploadPublishNamespacePayload, } from '@/application/types'; import { GlobalComment, Reaction } from '@/application/comment.type'; import { initGrantService, refreshToken } from '@/application/services/js-services/http/gotrue'; @@ -328,6 +334,48 @@ export async function getUserWorkspaceInfo (): Promise<{ return Promise.reject(data); } +export async function publishView (workspaceId: string, viewId: string, payload?: PublishViewPayload) { + const url = `/api/workspace/${workspaceId}/page-view/${viewId}/publish`; + const response = await axiosInstance?.post<{ + code: number; + message: string; + }>(url, payload); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data); +} + +export async function unpublishView (workspaceId: string, viewId: string) { + const url = `/api/workspace/${workspaceId}/page-view/${viewId}/unpublish`; + const response = await axiosInstance?.post<{ + code: number; + message: string; + }>(url); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data); +} + +export async function updatePublishNamespace (workspaceId: string, payload: UploadPublishNamespacePayload) { + const url = `/api/workspace/${workspaceId}/publish-namespace`; + const response = await axiosInstance?.put<{ + code: number; + message: string; + }>(url, payload); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data); +} + export async function getPublishViewMeta (namespace: string, publishName: string) { const url = `/api/workspace/v1/published/${namespace}/${publishName}`; const response = await axiosInstance?.get<{ @@ -485,12 +533,15 @@ export async function getPublishView (publishNamespace: string, publishName: str } export async function getPublishInfoWithViewId (viewId: string) { - const url = `/api/workspace/published-info/${viewId}`; + const url = `/api/workspace/v1/published-info/${viewId}`; const response = await axiosInstance?.get<{ code: number; data?: { namespace: string; publish_name: string; + publisher_email: string; + view_id: string; + publish_timestamp: string; }; message: string; }>(url); @@ -596,6 +647,75 @@ export async function getView (workspaceId: string, viewId: string, depth: numbe return Promise.reject(data); } +export async function getPublishNamespace (workspaceId: string) { + const url = `/api/workspace/${workspaceId}/publish-namespace`; + const response = await axiosInstance?.get<{ + code: number; + data?: string; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + return data.data; + } + + return Promise.reject(data); +} + +export async function getPublishHomepage (workspaceId: string) { + const url = `/api/workspace/${workspaceId}/publish-default`; + const response = await axiosInstance?.get<{ + code: number; + data?: { + namespace: string; + publish_name: string; + publisher_email: string; + view_id: string; + }; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + return data.data; + } + + return Promise.reject(data); +} + +export async function updatePublishHomepage (workspaceId: string, viewId: string) { + const url = `/api/workspace/${workspaceId}/publish-default`; + const response = await axiosInstance?.put<{ + code: number; + message: string; + }>(url, { + view_id: viewId, + }); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data); +} + +export async function removePublishHomepage (workspaceId: string) { + const url = `/api/workspace/${workspaceId}/publish-default`; + const response = await axiosInstance?.delete<{ + code: number; + message: string; + }>(url); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data); +} + export async function getPublishOutline (publishNamespace: string) { const url = `/api/workspace/published-outline/${publishNamespace}`; const response = await axiosInstance?.get<{ diff --git a/src/application/services/js-services/index.ts b/src/application/services/js-services/index.ts index ffe1de58..a2560aa9 100644 --- a/src/application/services/js-services/index.ts +++ b/src/application/services/js-services/index.ts @@ -31,9 +31,9 @@ import { import { CreatePagePayload, CreateSpacePayload, CreateWorkspacePayload, DatabaseRelations, - DuplicatePublishView, QuickNoteEditorData, + DuplicatePublishView, PublishViewPayload, QuickNoteEditorData, SubscriptionInterval, SubscriptionPlan, - Types, UpdatePagePayload, UpdateSpacePayload, UpdateWorkspacePayload, WorkspaceMember, + Types, UpdatePagePayload, UpdateSpacePayload, UpdateWorkspacePayload, UploadPublishNamespacePayload, WorkspaceMember, YjsEditorKey, } from '@/application/types'; import { applyYDoc } from '@/application/ydoc/apply'; @@ -65,6 +65,43 @@ export class AFClientService implements AFService { return this.clientId; } + async publishView (workspaceId: string, viewId: string, payload?: PublishViewPayload) { + if (this.publishViewInfo.has(viewId)) { + this.publishViewInfo.delete(viewId); + } + + return APIService.publishView(workspaceId, viewId, payload); + } + + async unpublishView (workspaceId: string, viewId: string) { + if (this.publishViewInfo.has(viewId)) { + this.publishViewInfo.delete(viewId); + } + + return APIService.unpublishView(workspaceId, viewId); + } + + async updatePublishNamespace (workspaceId: string, payload: UploadPublishNamespacePayload) { + this.publishViewInfo.clear(); + return APIService.updatePublishNamespace(workspaceId, payload); + } + + async getPublishNamespace (workspaceId: string) { + return APIService.getPublishNamespace(workspaceId); + } + + async getPublishHomepage (workspaceId: string) { + return APIService.getPublishHomepage(workspaceId); + } + + async updatePublishHomepage (workspaceId: string, viewId: string) { + return APIService.updatePublishHomepage(workspaceId, viewId); + } + + async removePublishHomepage (workspaceId: string) { + return APIService.removePublishHomepage(workspaceId); + } + async getPublishViewMeta (namespace: string, publishName: string) { const name = `${namespace}_${publishName}`; @@ -165,6 +202,9 @@ export class AFClientService implements AFService { return this.publishViewInfo.get(viewId) as { namespace: string; publishName: string; + publisherEmail: string; + viewId: string; + publishedAt: string; }; } @@ -179,6 +219,9 @@ export class AFClientService implements AFService { const data = { namespace, publishName: info.publish_name, + publisherEmail: info.publisher_email, + viewId: info.view_id, + publishedAt: info.publish_timestamp, }; this.publishViewInfo.set(viewId, data); @@ -569,7 +612,7 @@ export class AFClientService implements AFService { return APIService.deleteQuickNote(workspaceId, id); } - searchWorkspace(workspaceId: string, query: string) { + searchWorkspace (workspaceId: string, query: string) { return APIService.searchWorkspace(workspaceId, query); } } diff --git a/src/application/services/services.type.ts b/src/application/services/services.type.ts index bfb168ac..5d910182 100644 --- a/src/application/services/services.type.ts +++ b/src/application/services/services.type.ts @@ -19,7 +19,12 @@ import { UpdateSpacePayload, WorkspaceMember, QuickNoteEditorData, - QuickNote, Subscription, CreateWorkspacePayload, UpdateWorkspacePayload, + QuickNote, + Subscription, + CreateWorkspacePayload, + UpdateWorkspacePayload, + PublishViewPayload, + UploadPublishNamespacePayload, } from '@/application/types'; import { GlobalComment, Reaction } from '@/application/comment.type'; import { ViewMeta } from '@/application/db/tables/view_metas'; @@ -138,11 +143,22 @@ export interface TemplateService { } export interface PublishService { - + publishView: (workspaceId: string, viewId: string, payload?: PublishViewPayload) => Promise; + unpublishView: (workspaceId: string, viewId: string) => Promise; + updatePublishNamespace: (workspaceId: string, payload: UploadPublishNamespacePayload) => Promise; getPublishViewMeta: (namespace: string, publishName: string) => Promise; getPublishView: (namespace: string, publishName: string) => Promise; getPublishRowDocument: (viewId: string) => Promise; - getPublishInfo: (viewId: string) => Promise<{ namespace: string; publishName: string }>; + getPublishInfo: (viewId: string) => Promise<{ + namespace: string; + publishName: string, + publisherEmail: string, + publishedAt: string + }>; + getPublishNamespace: (namespace: string) => Promise; + getPublishHomepage: (workspaceId: string) => Promise<{ view_id: string }>; + updatePublishHomepage: (workspaceId: string, viewId: string) => Promise; + removePublishHomepage: (workspaceId: string) => Promise; getPublishOutline (namespace: string): Promise; diff --git a/src/application/slate-yjs/command/index.ts b/src/application/slate-yjs/command/index.ts index 9bcf5caa..34e526cb 100644 --- a/src/application/slate-yjs/command/index.ts +++ b/src/application/slate-yjs/command/index.ts @@ -46,7 +46,7 @@ import { export const CustomEditor = { // Get the text content of a block node, including the text content of its children and formula nodes - getBlockTextContent(node: Node, depth: number = Infinity): string { + getBlockTextContent (node: Node, depth: number = Infinity): string { if (Text.isText(node)) { if (node.formula) { return node.formula; @@ -77,7 +77,7 @@ export const CustomEditor = { .join(''); }, - setBlockData(editor: YjsEditor, blockId: string, updateData: T, select?: boolean) { + setBlockData (editor: YjsEditor, blockId: string, updateData: T, select?: boolean) { if (editor.readOnly) { return; @@ -118,7 +118,7 @@ export const CustomEditor = { }, // Insert break line at the specified path - insertBreak(editor: YjsEditor, at?: BaseRange) { + insertBreak (editor: YjsEditor, at?: BaseRange) { const sharedRoot = getSharedRoot(editor); const newAt = getSelectionOrThrow(editor, at); @@ -132,7 +132,7 @@ export const CustomEditor = { }, - deleteBlockBackward(editor: YjsEditor, at?: BaseRange) { + deleteBlockBackward (editor: YjsEditor, at?: BaseRange) { console.trace('deleteBlockBackward', editor.selection, at); const sharedRoot = getSharedRoot(editor); @@ -172,7 +172,7 @@ export const CustomEditor = { } }, - deleteBlockForward(editor: YjsEditor, at?: BaseRange) { + deleteBlockForward (editor: YjsEditor, at?: BaseRange) { const sharedRoot = getSharedRoot(editor); const newAt = getSelectionOrThrow(editor, at); @@ -192,15 +192,15 @@ export const CustomEditor = { } }, - deleteEntireDocument(editor: YjsEditor) { + deleteEntireDocument (editor: YjsEditor) { handleDeleteEntireDocumentWithTxn(editor); }, - removeRange(editor: YjsEditor, at: BaseRange) { + removeRange (editor: YjsEditor, at: BaseRange) { removeRangeWithTxn(editor, getSharedRoot(editor), at); }, - tabEvent(editor: YjsEditor, event: KeyboardEvent) { + tabEvent (editor: YjsEditor, event: KeyboardEvent) { const type = event.shiftKey ? 'tabBackward' : 'tabForward'; const sharedRoot = getSharedRoot(editor); const { selection } = editor; @@ -286,7 +286,7 @@ export const CustomEditor = { }); }, - toggleToggleList(editor: YjsEditor, blockId: string) { + toggleToggleList (editor: YjsEditor, blockId: string) { const sharedRoot = getSharedRoot(editor); const data = dataStringTOJson(getBlock(blockId, sharedRoot).get(YjsEditorKey.block_data)) as ToggleListBlockData; const { selection } = editor; @@ -310,7 +310,7 @@ export const CustomEditor = { }, selected); }, - toggleTodoList(editor: YjsEditor, blockId: string, shiftKey: boolean) { + toggleTodoList (editor: YjsEditor, blockId: string, shiftKey: boolean) { const sharedRoot = getSharedRoot(editor); const block = getBlock(blockId, sharedRoot); const data = dataStringTOJson(block.get(YjsEditorKey.block_data)) as TodoListBlockData; @@ -344,7 +344,7 @@ export const CustomEditor = { }); }, - toggleMark(editor: ReactEditor, { + toggleMark (editor: ReactEditor, { key, value, }: { key: EditorMarkFormat, value: boolean | string @@ -358,11 +358,11 @@ export const CustomEditor = { } }, - getTextNodes(editor: ReactEditor) { + getTextNodes (editor: ReactEditor) { return getSelectionTexts(editor); }, - addMark(editor: ReactEditor, { + addMark (editor: ReactEditor, { key, value, }: { key: EditorMarkFormat, value: boolean | string | Mention @@ -370,11 +370,11 @@ export const CustomEditor = { editor.addMark(key, value); }, - removeMark(editor: ReactEditor, key: EditorMarkFormat) { + removeMark (editor: ReactEditor, key: EditorMarkFormat) { editor.removeMark(key); }, - turnToBlock(editor: YjsEditor, blockId: string, type: BlockType, data: T) { + turnToBlock (editor: YjsEditor, blockId: string, type: BlockType, data: T) { const operations: (() => void)[] = []; const sharedRoot = getSharedRoot(editor); const sourceBlock = getBlock(blockId, sharedRoot); @@ -395,7 +395,7 @@ export const CustomEditor = { return newBlockId; }, - isBlockActive(editor: YjsEditor, type: BlockType) { + isBlockActive (editor: YjsEditor, type: BlockType) { try { const [node] = getBlockEntry(editor); @@ -405,7 +405,7 @@ export const CustomEditor = { } }, - hasMark(editor: ReactEditor, key: string) { + hasMark (editor: ReactEditor, key: string) { const selection = editor.selection; if (!selection) return false; @@ -429,7 +429,7 @@ export const CustomEditor = { return marks ? !!marks[key] : false; }, - getAllMarks(editor: ReactEditor) { + getAllMarks (editor: ReactEditor) { const selection = editor.selection; if (!selection) return []; @@ -452,7 +452,7 @@ export const CustomEditor = { return [marks]; }, - isMarkActive(editor: ReactEditor, key: string) { + isMarkActive (editor: ReactEditor, key: string) { const selection = editor.selection; if (!selection) return false; @@ -476,7 +476,7 @@ export const CustomEditor = { return marks ? !!marks[key] : false; }, - addChildBlock(editor: YjsEditor, blockId: string, type: BlockType, data: BlockData) { + addChildBlock (editor: YjsEditor, blockId: string, type: BlockType, data: BlockData) { const sharedRoot = getSharedRoot(editor); const parent = getBlock(blockId, sharedRoot); @@ -510,7 +510,7 @@ export const CustomEditor = { } }, - addBlock(editor: YjsEditor, blockId: string, direction: 'below' | 'above', type: BlockType, data: BlockData) { + addBlock (editor: YjsEditor, blockId: string, direction: 'below' | 'above', type: BlockType, data: BlockData) { const parent = getParent(blockId, editor.sharedRoot); const index = getBlockIndex(blockId, editor.sharedRoot); @@ -540,15 +540,15 @@ export const CustomEditor = { } }, - addBelowBlock(editor: YjsEditor, blockId: string, type: BlockType, data: BlockData) { + addBelowBlock (editor: YjsEditor, blockId: string, type: BlockType, data: BlockData) { return CustomEditor.addBlock(editor, blockId, 'below', type, data); }, - addAboveBlock(editor: YjsEditor, blockId: string, type: BlockType, data: BlockData) { + addAboveBlock (editor: YjsEditor, blockId: string, type: BlockType, data: BlockData) { return CustomEditor.addBlock(editor, blockId, 'above', type, data); }, - deleteBlock(editor: YjsEditor, blockId: string) { + deleteBlock (editor: YjsEditor, blockId: string) { const sharedRoot = getSharedRoot(editor); const parent = getParent(blockId, sharedRoot); @@ -601,7 +601,7 @@ export const CustomEditor = { ReactEditor.focus(editor); }, - duplicateBlock(editor: YjsEditor, blockId: string, prevId?: string) { + duplicateBlock (editor: YjsEditor, blockId: string, prevId?: string) { const sharedRoot = getSharedRoot(editor); const block = getBlock(blockId, sharedRoot); @@ -631,7 +631,7 @@ export const CustomEditor = { return newBlockId; }, - pastedText(editor: YjsEditor, text: string) { + pastedText (editor: YjsEditor, text: string) { if (!beforePasted(editor)) return; @@ -640,7 +640,7 @@ export const CustomEditor = { Transforms.insertNodes(editor, { text }, { at: point, select: true, voids: false }); }, - highlight(editor: ReactEditor) { + highlight (editor: ReactEditor) { const selection = editor.selection; if (!selection) return; diff --git a/src/application/slate-yjs/utils/editor.ts b/src/application/slate-yjs/utils/editor.ts index 29654dc6..8a303129 100644 --- a/src/application/slate-yjs/utils/editor.ts +++ b/src/application/slate-yjs/utils/editor.ts @@ -51,7 +51,7 @@ import { updateBlockParent, } from '@/application/slate-yjs/utils/yjs'; -export function ensureBlockText(editor: YjsEditor) { +export function ensureBlockText (editor: YjsEditor) { const { selection } = editor; if (!selection) { @@ -98,7 +98,7 @@ export function ensureBlockText(editor: YjsEditor) { }); } -export function handleCollapsedBreakWithTxn(editor: YjsEditor, sharedRoot: YSharedRoot, at: BaseRange) { +export function handleCollapsedBreakWithTxn (editor: YjsEditor, sharedRoot: YSharedRoot, at: BaseRange) { const { startBlock, startOffset } = getBreakInfo(editor, sharedRoot, at); const [blockNode, path] = startBlock; const blockId = blockNode.blockId as string; @@ -164,7 +164,7 @@ export function handleCollapsedBreakWithTxn(editor: YjsEditor, sharedRoot: YShar } -export function removeRangeWithTxn(editor: YjsEditor, sharedRoot: YSharedRoot, range: Range) { +export function removeRangeWithTxn (editor: YjsEditor, sharedRoot: YSharedRoot, range: Range) { const { startBlock, endBlock, middleBlocks, startRange, endRange } = getAffectedBlocks(editor, range); const operations: (() => void)[] = []; const isSameBlock = startBlock[0].blockId === endBlock[0].blockId; @@ -186,7 +186,7 @@ export function removeRangeWithTxn(editor: YjsEditor, sharedRoot: YSharedRoot, r } -export function handleRangeBreak(editor: YjsEditor, sharedRoot: YSharedRoot, range: Range) { +export function handleRangeBreak (editor: YjsEditor, sharedRoot: YSharedRoot, range: Range) { removeRangeWithTxn(editor, sharedRoot, range); const selection = editor.selection; @@ -196,7 +196,7 @@ export function handleRangeBreak(editor: YjsEditor, sharedRoot: YSharedRoot, ran handleCollapsedBreakWithTxn(editor, sharedRoot, selection); } -function moveToNextLine(editor: Editor, block: YBlock, at: BaseRange, blockId: string) { +function moveToNextLine (editor: Editor, block: YBlock, at: BaseRange, blockId: string) { const { selection } = editor; if (!selection) return; @@ -232,7 +232,7 @@ function moveToNextLine(editor: Editor, block: YBlock, at: BaseRange, blockId: s Transforms.move(editor, { distance: 1, unit: 'line' }); } -export function getNextSiblingBlockPath(editor: Editor, blockId: string) { +export function getNextSiblingBlockPath (editor: Editor, blockId: string) { const [blockSlateNode] = editor.nodes({ match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId === blockId, }); @@ -253,7 +253,7 @@ export function getNextSiblingBlockPath(editor: Editor, blockId: string) { return nextPath; } -export function getBlockTextRange(editor: YjsEditor, entry: NodeEntry) { +export function getBlockTextRange (editor: YjsEditor, entry: NodeEntry) { const [node, path] = entry; const textId = TEXT_BLOCK_TYPES.includes(node.type as BlockType) ? (node.children[0] as Element)?.textId : null; @@ -266,7 +266,7 @@ export function getBlockTextRange(editor: YjsEditor, entry: NodeEntry) return [start, Editor.end(editor, [...path, 0])]; } -export function getAffectedBlocks(editor: YjsEditor, range: Range): { +export function getAffectedBlocks (editor: YjsEditor, range: Range): { startBlock: NodeEntry; endBlock: NodeEntry; middleBlocks: NodeEntry[]; @@ -318,11 +318,11 @@ export function getAffectedBlocks(editor: YjsEditor, range: Range): { return { startBlock, endBlock, middleBlocks, startRange, endRange }; } -function getTextIdFromSlateNode(node: Element) { +function getTextIdFromSlateNode (node: Element) { return node.textId ?? (node.children[0] as Element).textId; } -export function deleteSlateRangeInBlock(sharedRoot: YSharedRoot, editor: Editor, block: Element, range: BaseRange) { +export function deleteSlateRangeInBlock (sharedRoot: YSharedRoot, editor: Editor, block: Element, range: BaseRange) { const [start, end] = Editor.edges(editor, range); const relativeOffset = slatePointToRelativePosition(sharedRoot, editor, start); @@ -337,7 +337,7 @@ export function deleteSlateRangeInBlock(sharedRoot: YSharedRoot, editor: Editor, deleteRangeInBlock(sharedRoot, block, startPos.index, endPos.index); } -export function deleteRangeInBlock( +export function deleteRangeInBlock ( sharedRoot: YSharedRoot, block: Element, start: number, @@ -352,7 +352,7 @@ export function deleteRangeInBlock( } } -export function mergeBlocks( +export function mergeBlocks ( sharedRoot: YSharedRoot, sourceBlock: Element, targetBlock: Element, @@ -371,7 +371,7 @@ export function mergeBlocks( sourceOps.forEach((op) => { if (op.insert && typeof op.insert === 'string') { - targetYText.insert(targetYText.length, op.insert); + targetYText.insert(targetYText.length, op.insert, op.attributes); } }); @@ -382,7 +382,7 @@ export function mergeBlocks( deleteBlock(sharedRoot, sourceBlock.blockId as string); } -export function getBreakInfo(editor: YjsEditor, sharedRoot: YSharedRoot, at: BaseRange) { +export function getBreakInfo (editor: YjsEditor, sharedRoot: YSharedRoot, at: BaseRange) { const startPoint = Editor.start(editor, at); const doc = assertDocExists(sharedRoot); @@ -425,7 +425,7 @@ export function getBreakInfo(editor: YjsEditor, sharedRoot: YSharedRoot, at: Bas return { startBlock, startOffset }; } -export function isAtBlockStart(editor: Editor, point: Point) { +export function isAtBlockStart (editor: Editor, point: Point) { const entry = Editor.above(editor, { at: point, match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.textId !== undefined, @@ -439,7 +439,7 @@ export function isAtBlockStart(editor: Editor, point: Point) { return Point.equals(point, start); } -export function isAtBlockEnd(editor: Editor, point: Point) { +export function isAtBlockEnd (editor: Editor, point: Point) { const entry = Editor.above(editor, { at: point, match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.textId !== undefined, @@ -453,7 +453,7 @@ export function isAtBlockEnd(editor: Editor, point: Point) { return Point.equals(point, end); } -export function getSharedRoot(editor: YjsEditor) { +export function getSharedRoot (editor: YjsEditor) { if (!editor.sharedRoot || !editor.sharedRoot.doc) { throw new Error('Shared root not found'); } @@ -461,7 +461,7 @@ export function getSharedRoot(editor: YjsEditor) { return editor.sharedRoot; } -export function getSelectionOrThrow(editor: YjsEditor, at?: BaseRange) { +export function getSelectionOrThrow (editor: YjsEditor, at?: BaseRange) { const newAt = at || editor.selection; if (!newAt) { @@ -471,7 +471,7 @@ export function getSelectionOrThrow(editor: YjsEditor, at?: BaseRange) { return newAt; } -export function getBlockEntry(editor: YjsEditor, point?: Point) { +export function getBlockEntry (editor: YjsEditor, point?: Point) { const { selection } = editor; const at = point || (selection ? Editor.start(editor, selection) : null); @@ -491,7 +491,7 @@ export function getBlockEntry(editor: YjsEditor, point?: Point) { return blockEntry as NodeEntry; } -export function handleNonParagraphBlockBackspaceAndEnterWithTxn(editor: YjsEditor, sharedRoot: YSharedRoot, block: YBlock, point: BasePoint) { +export function handleNonParagraphBlockBackspaceAndEnterWithTxn (editor: YjsEditor, sharedRoot: YSharedRoot, block: YBlock, point: BasePoint) { const data = dataStringTOJson(block.get(YjsEditorKey.block_data)); const blockType = block.get(YjsEditorKey.block_type); @@ -515,7 +515,7 @@ export function handleNonParagraphBlockBackspaceAndEnterWithTxn(editor: YjsEdito executeOperations(sharedRoot, operations, 'turnToBlock'); } -export function handleLiftBlockOnBackspaceAndEnterWithTxn(editor: YjsEditor, sharedRoot: YSharedRoot, block: YBlock, point: Point) { +export function handleLiftBlockOnBackspaceAndEnterWithTxn (editor: YjsEditor, sharedRoot: YSharedRoot, block: YBlock, point: Point) { const operations: (() => void)[] = []; const parent = getBlock(block.get(YjsEditorKey.block_parent), sharedRoot); const parentChildren = getChildrenArray(parent.get(YjsEditorKey.block_children), sharedRoot); @@ -551,7 +551,7 @@ export function handleLiftBlockOnBackspaceAndEnterWithTxn(editor: YjsEditor, sha return false; } -export function handleMergeBlockBackwardWithTxn(editor: YjsEditor, node: Element, point: Point) { +export function handleMergeBlockBackwardWithTxn (editor: YjsEditor, node: Element, point: Point) { const operations: (() => void)[] = []; const sharedRoot = getSharedRoot(editor); @@ -599,7 +599,7 @@ export function handleMergeBlockBackwardWithTxn(editor: YjsEditor, node: Element executeOperations(sharedRoot, operations, 'deleteBlockBackward'); } -export function handleMergeBlockForwardWithTxn(editor: YjsEditor, node: Element, point: Point) { +export function handleMergeBlockForwardWithTxn (editor: YjsEditor, node: Element, point: Point) { const operations: (() => void)[] = []; const sharedRoot = getSharedRoot(editor); @@ -632,7 +632,7 @@ export function handleMergeBlockForwardWithTxn(editor: YjsEditor, node: Element, executeOperations(sharedRoot, operations, 'deleteBlockForward'); } -export function preventIndentNode(editor: YjsEditor, blockId: string) { +export function preventIndentNode (editor: YjsEditor, blockId: string) { const sharedRoot = getSharedRoot(editor); const block = getBlock(blockId, sharedRoot); const parent = getBlock(block.get(YjsEditorKey.block_parent), sharedRoot); @@ -659,7 +659,7 @@ export function preventIndentNode(editor: YjsEditor, blockId: string) { return false; } -export function findSlateEntryByBlockId(editor: Editor, blockId: string) { +export function findSlateEntryByBlockId (editor: Editor, blockId: string) { const [node] = Editor.nodes(editor, { match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId === blockId, at: [], @@ -670,7 +670,7 @@ export function findSlateEntryByBlockId(editor: Editor, blockId: string) { return node as NodeEntry; } -export function beforePasted(editor: ReactEditor) { +export function beforePasted (editor: ReactEditor) { const { selection } = editor; if (!selection) { @@ -688,7 +688,7 @@ export function beforePasted(editor: ReactEditor) { return true; } -export function getSelectedPaths(editor: ReactEditor) { +export function getSelectedPaths (editor: ReactEditor) { const { selection } = editor; if (!selection) { @@ -756,7 +756,7 @@ export function getSelectedPaths(editor: ReactEditor) { return blockEntries.map(([, path]) => path); } -export function filterValidNodes(editor: ReactEditor, selectedPaths: Path[]): [ +export function filterValidNodes (editor: ReactEditor, selectedPaths: Path[]): [ Element, Path, ][] { @@ -781,7 +781,7 @@ export function filterValidNodes(editor: ReactEditor, selectedPaths: Path[]): [ }); } -export function sortNodesByDepth(editor: Editor, selectedPaths: Path[]) { +export function sortNodesByDepth (editor: Editor, selectedPaths: Path[]) { const pathsWithDepth = selectedPaths.map(path => ({ path, depth: path.length, @@ -799,7 +799,7 @@ export function sortNodesByDepth(editor: Editor, selectedPaths: Path[]) { }); } -export function preventLiftNode(editor: YjsEditor, blockId: string) { +export function preventLiftNode (editor: YjsEditor, blockId: string) { const [, path] = findSlateEntryByBlockId(editor, blockId); const level = path.length; @@ -810,7 +810,7 @@ export function preventLiftNode(editor: YjsEditor, blockId: string) { return false; } -export function liftEditorNode(editor: YjsEditor, sharedRoot: YSharedRoot, block: YBlock, point: Point) { +export function liftEditorNode (editor: YjsEditor, sharedRoot: YSharedRoot, block: YBlock, point: Point) { if (preventLiftNode(editor, block.get(YjsEditorKey.block_id))) { return; } @@ -831,7 +831,7 @@ export function liftEditorNode(editor: YjsEditor, sharedRoot: YSharedRoot, block } -export function isEntireDocumentSelected(editor: YjsEditor) { +export function isEntireDocumentSelected (editor: YjsEditor) { const selection = getSelectionOrThrow(editor); const [start, end] = Editor.edges(editor, selection); const startEdge = Editor.start(editor, []); @@ -840,7 +840,7 @@ export function isEntireDocumentSelected(editor: YjsEditor) { return Point.equals(start, startEdge) && Point.equals(end, endEdge); } -export function handleDeleteEntireDocumentWithTxn(editor: YjsEditor) { +export function handleDeleteEntireDocumentWithTxn (editor: YjsEditor) { const sharedRoot = getSharedRoot(editor); const operations = [() => { editor.deselect(); @@ -861,7 +861,7 @@ export function handleDeleteEntireDocumentWithTxn(editor: YjsEditor) { Transforms.select(editor, Editor.start(editor, [0])); } -export function getNodeAtPath(children: Descendant[], path: Path): Descendant | null { +export function getNodeAtPath (children: Descendant[], path: Path): Descendant | null { let currentNode: Descendant | null = null; let currentChildren = children; @@ -887,7 +887,7 @@ export function getNodeAtPath(children: Descendant[], path: Path): Descendant | return currentNode; } -export function getSelectionTexts(editor: ReactEditor) { +export function getSelectionTexts (editor: ReactEditor) { const selection = editor.selection; if (!selection) return []; @@ -921,7 +921,7 @@ export function getSelectionTexts(editor: ReactEditor) { return texts; } -export function getOffsetPointFromSlateRange(editor: YjsEditor, point: BasePoint): { offset: number; textId: string } { +export function getOffsetPointFromSlateRange (editor: YjsEditor, point: BasePoint): { offset: number; textId: string } { const [node] = editor.nodes({ at: point, @@ -940,7 +940,7 @@ export function getOffsetPointFromSlateRange(editor: YjsEditor, point: BasePoint }; } -export function getSlatePointFromOffset(editor: YjsEditor, range: { offset: number; textId: string }): BasePoint { +export function getSlatePointFromOffset (editor: YjsEditor, range: { offset: number; textId: string }): BasePoint { const [node] = editor.nodes({ match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.textId === range.textId, }); @@ -956,7 +956,7 @@ export function getSlatePointFromOffset(editor: YjsEditor, range: { offset: numb return start; } -export function addBlock(editor: YjsEditor, { +export function addBlock (editor: YjsEditor, { ty, data, }: { diff --git a/src/application/types.ts b/src/application/types.ts index 629b0c06..65e97a22 100644 --- a/src/application/types.ts +++ b/src/application/types.ts @@ -684,6 +684,16 @@ export enum CollabOrigin { } +export interface PublishViewPayload { + publish_name?: string; + visible_database_view_ids?: string[]; +} + +export interface UploadPublishNamespacePayload { + old_namespace: string; + new_namespace: string; +} + export const layoutMap = { [ViewLayout.Document]: 'document', [ViewLayout.Grid]: 'grid', @@ -1020,4 +1030,11 @@ export interface CreateWorkspacePayload { export interface UpdateWorkspacePayload { workspace_name: string; +} + +export enum SettingMenuItem { + ACCOUNT = 'ACCOUNT', + WORKSPACE = 'WORKSPACE', + MEMBERS = 'MEMBERS', + SITES = 'SITES', } \ No newline at end of file diff --git a/src/assets/share.svg b/src/assets/share.svg index 90e62fc9..5590435a 100644 --- a/src/assets/share.svg +++ b/src/assets/share.svg @@ -1,8 +1,8 @@ + stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/> - + \ No newline at end of file diff --git a/src/components/_shared/emoji-picker/EmojiPickerHeader.tsx b/src/components/_shared/emoji-picker/EmojiPickerHeader.tsx index 97d76cf7..6a2b65ed 100644 --- a/src/components/_shared/emoji-picker/EmojiPickerHeader.tsx +++ b/src/components/_shared/emoji-picker/EmojiPickerHeader.tsx @@ -46,7 +46,7 @@ interface Props { hideRemove?: boolean; } -function EmojiPickerHeader({ hideRemove, onEmojiSelect, onSkinSelect, searchValue, onSearchChange, skin }: Props) { +function EmojiPickerHeader ({ hideRemove, onEmojiSelect, onSkinSelect, searchValue, onSearchChange, skin }: Props) { const { onOpen, ...popoverProps } = useSelectSkinPopoverProps(); const { t } = useTranslation(); @@ -74,14 +74,14 @@ function EmojiPickerHeader({ hideRemove, onEmojiSelect, onSkinSelect, searchValu ); }, - [] + [], ); return (
} + startAdornment={} value={searchValue} onChange={(e) => { onSearchChange(e.target.value); @@ -118,18 +118,21 @@ function EmojiPickerHeader({ hideRemove, onEmojiSelect, onSkinSelect, searchValu {hideRemove ? null : renderButton({ - onClick: () => { - onEmojiSelect(''); - }, - tooltip: t('emoji.remove'), - children: , - })} + onClick: () => { + onEmojiSelect(''); + }, + tooltip: t('emoji.remove'), + children: , + })}
{skinTones.map((skinTone) => ( -
+
@@ -200,7 +200,7 @@ function IconPicker({
-
+
void; onCancel?: () => void; danger?: boolean; @@ -17,6 +17,7 @@ export interface NormalModalProps extends DialogProps { cancelButtonProps?: ButtonProps; okLoading?: boolean; closable?: boolean; + overflowHidden?: boolean; } export function NormalModal ({ @@ -32,6 +33,7 @@ export function NormalModal ({ cancelButtonProps, okLoading, closable = true, + overflowHidden = false, ...dialogProps }: NormalModalProps) { const { t } = useTranslation(); @@ -51,7 +53,12 @@ export function NormalModal ({ }} {...dialogProps} > -
+
{title}
{closable &&
@@ -67,7 +74,12 @@ export function NormalModal ({
-
{children}
+
{children}
+ ; + } + + return ( +
+ + + + {homePage && + + { + e.stopPropagation(); + if (!isOwner) return; + setRemoveLoading(true); + try { + await onRemoveHomePage(); + } finally { + setRemoveLoading(false); + } + }} + size={'small'} + className={'ml-1'} + > + {removeLoading ? : + } + + } + setAnchorEl(null)} + classes={{ + paper: 'max-h-[500px] w-[320px] appflowy-scroller overflow-y-auto overflow-x-hidden', + }} + > +
+ setSearchText(e.target.value)} + size={'small'} + autoFocus={true} + startAdornment={} + inputProps={{ + className: 'px-2 py-1.5 text-sm', + }} + /> +
+
+ {views.map(view => ( + + ))} +
+
+
+ ); +} + +export default HomePageSetting; \ No newline at end of file diff --git a/src/components/app/publish-manage/PublishManage.tsx b/src/components/app/publish-manage/PublishManage.tsx new file mode 100644 index 00000000..6e1a131b --- /dev/null +++ b/src/components/app/publish-manage/PublishManage.tsx @@ -0,0 +1,296 @@ +import { SubscriptionPlan, View, ViewLayout } from '@/application/types'; +import { notify } from '@/components/_shared/notify'; +import { flattenViews } from '@/components/_shared/outline/utils'; +import { useAppHandlers, useUserWorkspaceInfo } from '@/components/app/app.hooks'; +import HomePageSetting from '@/components/app/publish-manage/HomePageSetting'; +import PublishedPages from '@/components/app/publish-manage/PublishedPages'; +import UpdateNamespace from '@/components/app/publish-manage/UpdateNamespace'; +import { useCurrentUser, useService } from '@/components/main/app.hooks'; +import { openUrl } from '@/utils/url'; +import { Button, Divider, IconButton, Tooltip } from '@mui/material'; +import React, { useCallback, useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as EditIcon } from '@/assets/edit.svg'; + +export function PublishManage ({ + onClose, +}: { + onClose?: () => void; +}) { + const { t } = useTranslation(); + const userWorkspaceInfo = useUserWorkspaceInfo(); + const currentUser = useCurrentUser(); + const isOwner = userWorkspaceInfo?.selectedWorkspace?.owner?.uid.toString() === currentUser?.uid.toString(); + const [loading, setLoading] = React.useState(false); + const service = useService(); + const workspaceId = userWorkspaceInfo?.selectedWorkspace?.id; + const [namespace, setNamespace] = React.useState(''); + const [homePageId, setHomePageId] = React.useState(''); + const [publishViews, setPublishViews] = React.useState([]); + const [updateOpen, setUpdateOpen] = React.useState(false); + const homePage = useMemo(() => { + return publishViews.find(view => view.view_id === homePageId); + }, [homePageId, publishViews]); + const loadPublishNamespace = useCallback(async () => { + if (!service || !workspaceId) return; + try { + const namespace = await service.getPublishNamespace(workspaceId); + + setNamespace(namespace); + + // eslint-disable-next-line + } catch (e: any) { + console.error(e); + } + }, [service, workspaceId]); + + const loadHomePageId = useCallback(async () => { + if (!service || !workspaceId) return; + try { + const { + view_id, + } = await service.getPublishHomepage(workspaceId); + + setHomePageId(view_id); + + // eslint-disable-next-line + } catch (e: any) { + console.error(e); + } + }, [service, workspaceId]); + + const loadPublishPages = useCallback(async () => { + if (!service || !namespace) return; + setLoading(true); + try { + const outline = await service.getPublishOutline(namespace); + + setPublishViews(flattenViews(outline).filter(item => item.is_published)); + // eslint-disable-next-line + } catch (e: any) { + console.error(e); + } + + setLoading(false); + }, [namespace, service]); + + const handleUpdateNamespace = useCallback(async (newNamespace: string) => { + if (!service || !workspaceId) return; + try { + await service.updatePublishNamespace(workspaceId, { + old_namespace: namespace, + new_namespace: newNamespace, + }); + + setNamespace(newNamespace); + notify.success(t('settings.sites.success.namespaceUpdated')); + // eslint-disable-next-line + } catch (e: any) { + notify.error(e.message); + throw e; + } + }, [namespace, service, t, workspaceId]); + + const handleUpdateHomePage = useCallback(async (newHomePageId: string) => { + if (!service || !workspaceId) return; + if (!isOwner) { + return; + } + + try { + await service.updatePublishHomepage(workspaceId, newHomePageId); + + setHomePageId(newHomePageId); + notify.success(t('settings.sites.success.setHomepageSuccess')); + // eslint-disable-next-line + } catch (e: any) { + notify.error(e.message); + throw e; + } + }, [isOwner, service, t, workspaceId]); + + const handleRemoveHomePage = useCallback(async () => { + if (!service || !workspaceId) return; + if (!isOwner) { + notify.error(t('settings.sites.error.onlyWorkspaceOwnerCanRemoveHomepage')); + return; + } + + try { + await service.removePublishHomepage(workspaceId); + + setHomePageId(''); + notify.success(t('settings.sites.success.removeHomePageSuccess')); + // eslint-disable-next-line + } catch (e: any) { + notify.error(e.message); + throw e; + } + + }, [isOwner, service, t, workspaceId]); + + const currentWorkspaceId = userWorkspaceInfo?.selectedWorkspace?.id; + const handlePublish = useCallback(async (view: View, publishName: string) => { + if (!service || !currentWorkspaceId) return; + const isDatabase = [ViewLayout.Board, ViewLayout.Grid, ViewLayout.Calendar].includes(view.layout); + const viewId = view.view_id; + + try { + await service.publishView(currentWorkspaceId, viewId, { + publish_name: publishName, + visible_database_view_ids: isDatabase ? view.children?.map((v) => v.view_id) : undefined, + }); + notify.success(t('publish.publishSuccessfully')); + // eslint-disable-next-line + } catch (e: any) { + notify.error(e.message); + } + }, [currentWorkspaceId, service, t]); + + const handleUnpublish = useCallback(async (viewId: string) => { + if (!service || !currentWorkspaceId) return; + + try { + await service.unpublishView(currentWorkspaceId, viewId); + void loadPublishPages(); + notify.success(t('publish.unpublishSuccessfully')); + // eslint-disable-next-line + } catch (e: any) { + notify.error(e.message); + } + }, [currentWorkspaceId, loadPublishPages, service, t]); + + const { + getSubscriptions, + } = useAppHandlers(); + const [activeSubscription, setActiveSubscription] = React.useState(null); + const loadSubscription = useCallback(async () => { + try { + const subscriptions = await getSubscriptions?.(); + + if (!subscriptions || subscriptions.length === 0) { + setActiveSubscription(SubscriptionPlan.Free); + return; + } + + const subscription = subscriptions[0]; + + setActiveSubscription(subscription?.plan || SubscriptionPlan.Free); + } catch (e) { + console.error(e); + } + + }, [getSubscriptions]); + + useEffect(() => { + void loadSubscription(); + }, [loadSubscription]); + + useEffect(() => { + void loadPublishNamespace(); + }, [loadPublishNamespace]); + + useEffect(() => { + void loadHomePageId(); + }, [loadHomePageId]); + + useEffect(() => { + void loadPublishPages(); + }, [loadPublishPages]); + const url = `${window.location.origin}/${namespace}`; + + return ( +
+
{t('namespace')}
+
{t('manageNamespaceDescription')}
+ +
+
{t('namespace')}
+
{t('homepage')}
+
+ + +
+
+ +
{t('shareAction.visitSite')}
+
{url}
+
+ } + > + + +
+
+ + + { + if (!isOwner || activeSubscription === SubscriptionPlan.Free) { + return; + } + + e.currentTarget.blur(); + setUpdateOpen(true); + }} + > + + + +
+
+ +
{t('settings.sites.publishedPage.title')}
+
{t('settings.sites.publishedPage.description')}
+ +
+
{t('settings.sites.publishedPage.page')}
+
+ {t('settings.sites.publishedPage.pathName')} +
+
{t('settings.sites.publishedPage.date')}
+
+ + + + + {updateOpen && setUpdateOpen(false)} + onUpdateNamespace={handleUpdateNamespace} + />} +
+ ); +} + +export default PublishManage; \ No newline at end of file diff --git a/src/components/app/publish-manage/PublishNameSetting.tsx b/src/components/app/publish-manage/PublishNameSetting.tsx new file mode 100644 index 00000000..c7fc922b --- /dev/null +++ b/src/components/app/publish-manage/PublishNameSetting.tsx @@ -0,0 +1,117 @@ +import { NormalModal } from '@/components/_shared/modal'; +import { notify } from '@/components/_shared/notify'; +import { openUrl } from '@/utils/url'; +import { Button, CircularProgress, InputBase } from '@mui/material'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +export function PublishNameSetting ({ defaultName, onClose, open, onUnPublish, onPublish, url }: { + onClose: () => void; + open: boolean; + defaultName: string; + onUnPublish: () => Promise; + onPublish: (publishName: string) => Promise; + url: string; +}) { + const [value, setValue] = React.useState(defaultName); + const { t } = useTranslation(); + const [publishLoading, setPublishLoading] = React.useState(false); + const [unPublishLoading, setUnPublishLoading] = React.useState(false); + + const handlePublish = async () => { + if (!value) { + notify.error(t('settings.sites.error.publishNameCannotBeEmpty')); + return; + } + + if (value.length > 100) { + notify.error(t('settings.sites.error.publishNameTooLong')); + return; + } + + if (value.includes(' ') || value.includes('/')) { + notify.error(t('settings.sites.error.publishNameContainsInvalidCharacters')); + return; + } + + if (value === defaultName) { + notify.error(t('settings.sites.error.publishNameAlreadyInUse')); + return; + } + + setPublishLoading(true); + + try { + await onPublish(value); + } finally { + setPublishLoading(false); + } + }; + + const handleUnPublish = async () => { + setUnPublishLoading(true); + try { + await onUnPublish(); + } finally { + setUnPublishLoading(false); + } + }; + + return { + if (e.key === 'Escape') { + onClose?.(); + } + + if (e.key === 'Enter') { + void handlePublish(); + } + }} + cancelText={ + unPublishLoading ? : t('shareAction.unPublish') + } + okText={t( + 'shareAction.visitSite', + )} + cancelButtonProps={{ + disabled: unPublishLoading, + }} + onCancel={handleUnPublish} + onOk={() => { + void openUrl(url, '_blank'); + }} + title={
{t('settings.sites.publishedPage.settings')}
} + open={open} + onClose={onClose} + > +
+
+ {t('settings.sites.publishedPage.pathName')} +
+
+
+ setValue(e.target.value)} + /> +
+ + +
+ +
+
; +} \ No newline at end of file diff --git a/src/components/app/publish-manage/PublishedPageItem.tsx b/src/components/app/publish-manage/PublishedPageItem.tsx new file mode 100644 index 00000000..38177a45 --- /dev/null +++ b/src/components/app/publish-manage/PublishedPageItem.tsx @@ -0,0 +1,232 @@ +import { View } from '@/application/types'; +import { notify } from '@/components/_shared/notify'; +import { Popover } from '@/components/_shared/popover'; +import PageIcon from '@/components/_shared/view-icon/PageIcon'; +import { useAppHandlers, useUserWorkspaceInfo } from '@/components/app/app.hooks'; +import { PublishNameSetting } from '@/components/app/publish-manage/PublishNameSetting'; +import { useCurrentUser, useService } from '@/components/main/app.hooks'; +import { copyTextToClipboard } from '@/utils/copy'; +import { openUrl } from '@/utils/url'; +import { Button, CircularProgress, IconButton, Tooltip } from '@mui/material'; +import dayjs from 'dayjs'; +import React, { useCallback, useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as MoreIcon } from '@/assets/more.svg'; +import { ReactComponent as GlobalIcon } from '@/assets/publish.svg'; +import { ReactComponent as CopyIcon } from '@/assets/copy.svg'; +import { ReactComponent as TrashIcon } from '@/assets/trash.svg'; +import { ReactComponent as SettingIcon } from '@/assets/settings.svg'; + +function PublishedPageItem ({ onClose, view, onUnPublish, onPublish }: { + view: View, + onClose?: () => void; + onUnPublish: (viewId: string) => Promise; + onPublish: (view: View, publishName: string) => Promise +}) { + const { t } = useTranslation(); + const [openSetting, setOpenSetting] = React.useState(false); + const [anchorEl, setAnchorEl] = React.useState(null); + const [publishInfo, setPublishInfo] = React.useState<{ + namespace: string, + publishName: string, + publisherEmail: string + publishedAt: string + } | undefined>(undefined); + const toView = useAppHandlers().toView; + const service = useService(); + const [unPublishLoading, setUnPublishLoading] = React.useState(false); + const userWorkspaceInfo = useUserWorkspaceInfo(); + const currentUser = useCurrentUser(); + const isOwner = userWorkspaceInfo?.selectedWorkspace?.owner?.uid.toString() === currentUser?.uid.toString(); + const isPublisher = publishInfo?.publisherEmail === currentUser?.email; + + const getPublishInfo = useCallback(async (viewId: string) => { + if (!service) return; + + try { + const res = await service?.getPublishInfo(viewId); + + setPublishInfo(res); + return res; + } catch (e) { + console.error(e); + } + }, [service]); + + const url = useMemo(() => { + return `${window.origin}/${publishInfo?.namespace}/${publishInfo?.publishName}`; + }, [publishInfo]); + + useEffect(() => { + void getPublishInfo(view.view_id); + }, [getPublishInfo, view.view_id]); + + const actions = useMemo(() => { + return [ + { + value: 'visit', + label: t('shareAction.visitSite'), + IconComponent: GlobalIcon, + onClick: () => { + void openUrl(url, '_blank'); + }, + }, + { + value: 'copy', + label: t('shareAction.copyLink'), + IconComponent: CopyIcon, + onClick: () => { + void copyTextToClipboard(url); + notify.success(t('shareAction.copyLinkSuccess')); + }, + }, + { + value: 'unpublish', + disabled: unPublishLoading, + label: t('shareAction.unPublish'), + tooltip: !(isOwner || isPublisher) ? t('settings.sites.error.publishPermissionDenied') : undefined, + IconComponent: unPublishLoading ? CircularProgress : TrashIcon, + onClick: async () => { + if (!(isOwner || isPublisher)) { + return; + } + + setUnPublishLoading(true); + try { + await onUnPublish(view.view_id); + } catch (e) { + console.error(e); + } finally { + setUnPublishLoading(false); + } + }, + }, + { + value: 'setting', + label: t('settings.title'), + tooltip: !(isOwner || isPublisher) ? t('settings.sites.error.publishPermissionDenied') : undefined, + + IconComponent: SettingIcon, + onClick: () => { + if (!(isOwner || isPublisher)) return; + setOpenSetting(true); + }, + }, + + ]; + }, [t, isOwner, isPublisher, unPublishLoading, url, onUnPublish, view.view_id]); + + return ( +
+
+ + Open {view.name || t('menuAppHeader.defaultNewPageName')} +
+ } + > + + +
+
+ + {`Open Page in New Tab \n${publishInfo?.publishName || ''}`} +
} + > + + +
+
+ {dayjs(publishInfo?.publishedAt).format('MMM D, YYYY')} + { + setAnchorEl(e.currentTarget); + }} + size={'small'} + > + + +
+ { + setAnchorEl(null); + }} + > +
+ {actions.map((action) => { + return + + ; + })} +
+
+ {openSetting && publishInfo && { + return onUnPublish(view.view_id); + }} + onPublish={async (publishName: string) => { + await onPublish(view, publishName); + void getPublishInfo(view.view_id); + }} + onClose={() => { + setOpenSetting(false); + }} + url={url} + open={openSetting} + defaultName={publishInfo.publishName} + />} +
+ ); +} + +export default PublishedPageItem; \ No newline at end of file diff --git a/src/components/app/publish-manage/PublishedPages.tsx b/src/components/app/publish-manage/PublishedPages.tsx new file mode 100644 index 00000000..bf05b977 --- /dev/null +++ b/src/components/app/publish-manage/PublishedPages.tsx @@ -0,0 +1,44 @@ +import { View } from '@/application/types'; + +import PublishedPageItem from '@/components/app/publish-manage/PublishedPageItem'; +import { CircularProgress, Divider } from '@mui/material'; + +import React from 'react'; + +function PublishedPages ({ + publishViews, + onUnPublish, + onPublish, + loading, + onClose, +}: { + publishViews: View[]; + loading: boolean; + onUnPublish: (viewId: string) => Promise; + onPublish: (view: View, publishName: string) => Promise; + onClose?: () => void +}) { + + return ( +
+ + {loading ?
+
+ : publishViews.map((view, index) => { + return + + {index !== publishViews.length - 1 && } + + ; + })} + +
+ ); +} + +export default PublishedPages; \ No newline at end of file diff --git a/src/components/app/publish-manage/UpdateNamespace.tsx b/src/components/app/publish-manage/UpdateNamespace.tsx new file mode 100644 index 00000000..c1d70361 --- /dev/null +++ b/src/components/app/publish-manage/UpdateNamespace.tsx @@ -0,0 +1,73 @@ +import { NormalModal } from '@/components/_shared/modal'; +import { IconButton, OutlinedInput, Tooltip } from '@mui/material'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as TipIcon } from '@/assets/warning.svg'; + +function UpdateNamespace ({ + namespace, + open, + onClose, + onUpdateNamespace, +}: { + namespace: string; + open: boolean; + onClose: () => void; + onUpdateNamespace: (namespace: string) => Promise; +}) { + const { t } = useTranslation(); + const [value, setValue] = React.useState(namespace); + const [loading, setLoading] = React.useState(false); + + const handleOk = async () => { + setLoading(true); + try { + await onUpdateNamespace(value); + onClose(); + } finally { + setLoading(false); + } + }; + + return ( + +
{t('settings.sites.namespace.updateExistingNamespace')}
+ + + + + +
} + > +
{t('settings.sites.namespace.description')}
+ setValue(e.target.value)} + size={'small'} + autoFocus={true} + inputProps={{ + className: 'px-2 py-1.5 text-sm', + }} + /> +
{window.location.host}/{value}
+ + ); +} + +export default UpdateNamespace; \ No newline at end of file diff --git a/src/components/app/publish-manage/index.tsx b/src/components/app/publish-manage/index.tsx new file mode 100644 index 00000000..2f673f0e --- /dev/null +++ b/src/components/app/publish-manage/index.tsx @@ -0,0 +1 @@ +export * from './PublishManage'; \ No newline at end of file diff --git a/src/components/app/settings/SettingMenu.tsx b/src/components/app/settings/SettingMenu.tsx new file mode 100644 index 00000000..63242e71 --- /dev/null +++ b/src/components/app/settings/SettingMenu.tsx @@ -0,0 +1,58 @@ +import { SettingMenuItem } from '@/application/types'; +import { ReactComponent as PersonIcon } from '@/assets/person.svg'; +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +interface SettingMenuProps { + selectedItem: SettingMenuItem; + onSelectItem: (item: SettingMenuItem) => void; +} + +function SettingMenu ({ + selectedItem, + onSelectItem, +}: SettingMenuProps) { + const { t } = useTranslation(); + + const options = useMemo(() => { + return [ + { + value: SettingMenuItem.ACCOUNT, + label: t('settings.accountPage.menuLabel'), + IconComponent: PersonIcon, + }, + { + value: SettingMenuItem.WORKSPACE, + label: t('settings.workspacePage.menuLabel'), + IconComponent: PersonIcon, + }, + { + value: SettingMenuItem.MEMBERS, + label: t('settings.appearance.members.label'), + IconComponent: PersonIcon, + }, + { + value: SettingMenuItem.SITES, + label: t('settings.sites.title'), + IconComponent: PersonIcon, + }, + ]; + }, [t]); + + return ( +
+ {options.map(option => ( +
onSelectItem(option.value)} + className={`flex items-center gap-3 p-2 rounded-[8px] hover:bg-fill-list-hover cursor-pointer ${option.value === selectedItem ? 'bg-fill-list-hover' : ''}`} + > + + {option.label} +
+ ))} +
+ ); +} + +export default SettingMenu; \ No newline at end of file diff --git a/src/components/app/settings/Settings.tsx b/src/components/app/settings/Settings.tsx new file mode 100644 index 00000000..d9bc88d0 --- /dev/null +++ b/src/components/app/settings/Settings.tsx @@ -0,0 +1,57 @@ +import { SettingMenuItem } from '@/application/types'; +import { ReactComponent as SettingsIcon } from '@/assets/settings.svg'; +import SettingMenu from '@/components/app/settings/SettingMenu'; +import { Button, Dialog } from '@mui/material'; +import React, { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSearchParams } from 'react-router-dom'; + +export function Settings () { + const { t } = useTranslation(); + const [open, setOpen] = React.useState(false); + const [search, setSearch] = useSearchParams(); + + const [selectedItem, setSelectedItem] = React.useState(SettingMenuItem.ACCOUNT); + + useEffect(() => { + const item = search.get('setting') as SettingMenuItem; + + if (item) { + setOpen(true); + setSelectedItem(item); + setSearch(prev => { + prev.delete('setting'); + return prev; + }); + } + }, [search, setSearch]); + + return ( + <> + + setOpen(false)} + > + + + + ); +} + +export default Settings; \ No newline at end of file diff --git a/src/components/app/settings/index.ts b/src/components/app/settings/index.ts new file mode 100644 index 00000000..2cca89bf --- /dev/null +++ b/src/components/app/settings/index.ts @@ -0,0 +1 @@ +export * from './Settings'; \ No newline at end of file diff --git a/src/components/app/share/PublishLinkPreview.tsx b/src/components/app/share/PublishLinkPreview.tsx new file mode 100644 index 00000000..b6b416c8 --- /dev/null +++ b/src/components/app/share/PublishLinkPreview.tsx @@ -0,0 +1,178 @@ +import { NormalModal } from '@/components/_shared/modal'; +import { PublishManage } from '@/components/app/publish-manage'; +import { PublishNameSetting } from '@/components/app/publish-manage/PublishNameSetting'; +import { CircularProgress, IconButton, InputBase, Tooltip } from '@mui/material'; +import { ReactComponent as LinkIcon } from '@/assets/link.svg'; +import { ReactComponent as DownIcon } from '@/assets/chevron_down.svg'; +import { ReactComponent as CheckIcon } from '@/assets/check.svg'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +function PublishLinkPreview ({ + publishInfo, + onUnPublish, + onPublish, + url, + isOwner, + isPublisher, +}: { + publishInfo: { namespace: string, publishName: string }; + onUnPublish: () => Promise; + onPublish: (publishName?: string) => Promise; + url: string; + isOwner: boolean; + isPublisher: boolean; +}) { + const [siteOpen, setSiteOpen] = React.useState(false); + const [renameOpen, setRenameOpen] = React.useState(false); + const { t } = useTranslation(); + const [publishName, setPublishName] = React.useState(publishInfo.publishName); + const [focused, setFocused] = React.useState(false); + const [loading, setLoading] = React.useState(false); + + const handlePublish = async () => { + if (loading) return; + if (publishName === publishInfo.publishName) return; + setLoading(true); + try { + await onPublish(publishName); + } finally { + setLoading(false); + } + }; + + return ( + <> +
+
+
{window.location.origin}
+ {'/'} +
+ + {publishInfo.namespace} + + + { + setSiteOpen(true); + }} + > + + + + +
+ {'/'} +
+ { + setFocused(true); + }} + onBlur={() => { + setFocused(false); + }} + onKeyDown={async (e) => { + if (e.key === 'Enter') { + void handlePublish(); + } + }} + size={'small'} + value={publishName} + onChange={e => { + setPublishName(e.target.value); + }} + className={'flex-1 truncate'} + /> + {(isOwner || isPublisher) && + e.preventDefault()} + onClick={(e) => { + e.stopPropagation(); + if (focused) { + void handlePublish(); + return; + } + + setRenameOpen(true); + }} + > + {loading ? : + focused ? : + } + + } + +
+
+
+ + + + + +
+ {renameOpen && { setRenameOpen(false); }} + open={renameOpen} + onUnPublish={onUnPublish} + onPublish={onPublish} + url={url} + />} + {siteOpen && { + setSiteOpen(false); + }} + scroll={'paper'} + open={siteOpen} + title={
{t('settings.sites.title')}
} + > +
+ { + setSiteOpen(false); + }} + /> +
+ +
} +
+ + + ); +} + +export default PublishLinkPreview; \ No newline at end of file diff --git a/src/components/app/share/PublishPanel.tsx b/src/components/app/share/PublishPanel.tsx index 26062de8..38a288fb 100644 --- a/src/components/app/share/PublishPanel.tsx +++ b/src/components/app/share/PublishPanel.tsx @@ -1,25 +1,82 @@ -import { useAppView, useCurrentWorkspaceId } from '@/components/app/app.hooks'; -import LinkPreview from '@/components/app/share/LinkPreview'; +import { ViewLayout } from '@/application/types'; +import { notify } from '@/components/_shared/notify'; +import { useCurrentWorkspaceId } from '@/components/app/app.hooks'; import { useLoadPublishInfo } from '@/components/app/share/publish.hooks'; -import { openOrDownload } from '@/utils/open_schema'; -import { openAppFlowySchema } from '@/utils/url'; -import { Button, Typography } from '@mui/material'; +import PublishLinkPreview from '@/components/app/share/PublishLinkPreview'; +import { useService } from '@/components/main/app.hooks'; +import { Button, CircularProgress, Typography } from '@mui/material'; import React, { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { ReactComponent as PublishIcon } from '@/assets/publish.svg'; function PublishPanel ({ viewId }: { viewId: string }) { - const view = useAppView(viewId); const currentWorkspaceId = useCurrentWorkspaceId(); const { t } = useTranslation(); const { url, + loadPublishInfo, + view, + publishInfo, + loading, + isOwner, + isPublisher, } = useLoadPublishInfo(viewId); + const service = useService(); + const handlePublish = useCallback(async (publishName?: string) => { + if (!service || !currentWorkspaceId || !view) return; + const isDatabase = [ViewLayout.Board, ViewLayout.Grid, ViewLayout.Calendar].includes(view.layout); + + try { + await service.publishView(currentWorkspaceId, viewId, { + publish_name: publishName, + visible_database_view_ids: isDatabase ? view.children?.map((v) => v.view_id) : undefined, + }); + await loadPublishInfo(); + notify.success(t('publish.publishSuccessfully')); + // eslint-disable-next-line + } catch (e: any) { + notify.error(e.message); + } + }, [currentWorkspaceId, loadPublishInfo, service, t, view, viewId]); + + const handleUnpublish = useCallback(async () => { + if (!service || !currentWorkspaceId || !view) return; + if (!isOwner && !isPublisher) { + notify.error(t('settings.sites.error.publishPermissionDenied')); + return; + } + + try { + await service.unpublishView(currentWorkspaceId, viewId); + await loadPublishInfo(); + notify.success(t('publish.unpublishSuccessfully')); + // eslint-disable-next-line + } catch (e: any) { + notify.error(e.message); + } + }, [currentWorkspaceId, isOwner, isPublisher, loadPublishInfo, service, t, view, viewId]); + const renderPublished = useCallback(() => { - return
- -
+ if (!publishInfo || !view) return null; + return
+ +
+
; - }, [t, url]); + }, [handlePublish, handleUnpublish, isOwner, isPublisher, publishInfo, t, url, view]); const renderUnpublished = useCallback(() => { return ; - }, [currentWorkspaceId, t, view?.view_id]); + >{t('shareAction.publish')}; + }, [handlePublish, t]); return ( -
+
{t('shareAction.publishToTheWebHint')} - {view?.is_published ? renderPublished() : renderUnpublished()} + {loading &&
} +
+ {view?.is_published ? renderPublished() : renderUnpublished()} +
+
); } diff --git a/src/components/app/share/publish.hooks.ts b/src/components/app/share/publish.hooks.ts index 7f7a96cd..7f2e9ead 100644 --- a/src/components/app/share/publish.hooks.ts +++ b/src/components/app/share/publish.hooks.ts @@ -1,24 +1,56 @@ -import { useAppView } from '@/components/app/app.hooks'; -import { useService } from '@/components/main/app.hooks'; +import { View } from '@/application/types'; +import { useCurrentWorkspaceId, useUserWorkspaceInfo } from '@/components/app/app.hooks'; +import { useCurrentUser, useService } from '@/components/main/app.hooks'; import React, { useCallback, useEffect, useMemo } from 'react'; export function useLoadPublishInfo (viewId: string) { - const view = useAppView(viewId); - const [publishInfo, setPublishInfo] = React.useState<{ namespace: string, publishName: string }>(); - + const [view, setView] = React.useState(); + const currentWorkspaceId = useCurrentWorkspaceId(); + const [publishInfo, setPublishInfo] = React.useState<{ + namespace: string, + publishName: string, + publisherEmail: string + }>(); + const [loading, setLoading] = React.useState(false); const service = useService(); + const userWorkspaceInfo = useUserWorkspaceInfo(); + const currentUser = useCurrentUser(); + const isOwner = userWorkspaceInfo?.selectedWorkspace?.owner?.uid.toString() === currentUser?.uid.toString(); + const isPublisher = publishInfo?.publisherEmail === currentUser?.email; + const loadView = useCallback(async () => { + if (!viewId || !service || !currentWorkspaceId) return; + try { + + const view = await service.getAppView(currentWorkspaceId, viewId); + + setView(view); + return view; + // eslint-disable-next-line + } catch (e: any) { + // do nothing + console.error(e); + } + }, [currentWorkspaceId, service, viewId]); const loadPublishInfo = useCallback(async () => { - if (!service || !view?.view_id) return; + if (!service) return; + setLoading(true); try { + const view = await loadView(); + + if (!view) return; + const res = await service.getPublishInfo(view?.view_id); setPublishInfo(res); + // eslint-disable-next-line } catch (e: any) { // do nothing } - }, [service, view?.view_id]); + + setLoading(false); + }, [loadView, service]); useEffect(() => { void loadPublishInfo(); @@ -28,5 +60,5 @@ export function useLoadPublishInfo (viewId: string) { return `${window.origin}/${publishInfo?.namespace}/${publishInfo?.publishName}`; }, [publishInfo]); - return { publishInfo, url }; + return { publishInfo, url, loadPublishInfo, view, loading, isPublisher, isOwner }; } \ No newline at end of file diff --git a/src/components/app/workspaces/InviteMember.tsx b/src/components/app/workspaces/InviteMember.tsx index 8f00ed90..4efc2c9f 100644 --- a/src/components/app/workspaces/InviteMember.tsx +++ b/src/components/app/workspaces/InviteMember.tsx @@ -5,12 +5,12 @@ import { useTranslation } from 'react-i18next'; import { NormalModal } from '@/components/_shared/modal'; import { notify } from '@/components/_shared/notify'; import { useCurrentUser, useService } from '@/components/main/app.hooks'; -import { Subscription, SubscriptionPlan, Workspace, WorkspaceMember } from '@/application/types'; +import { SubscriptionPlan, Workspace, WorkspaceMember } from '@/application/types'; import { useAppHandlers } from '@/components/app/app.hooks'; import { ReactComponent as TipIcon } from '@/assets/warning.svg'; import { useSearchParams } from 'react-router-dom'; -function InviteMember({ workspace, onClick }: { +function InviteMember ({ workspace, onClick }: { workspace: Workspace; onClick?: () => void; }) { @@ -40,33 +40,40 @@ function InviteMember({ workspace, onClick }: { } }, [currentWorkspaceId, service]); - const [activeSubscription, setActiveSubscription] = React.useState(null); + const [activeSubscriptionPlan, setActiveSubscriptionPaln] = React.useState(null); const loadSubscription = useCallback(async () => { try { const subscriptions = await getSubscriptions?.(); - if (!subscriptions || subscriptions.length === 0) return; + if (!subscriptions || subscriptions.length === 0) { + setActiveSubscriptionPaln(SubscriptionPlan.Free); + + return; + } + const subscription = subscriptions[0]; - setActiveSubscription(subscription); + setActiveSubscriptionPaln(subscription?.plan || SubscriptionPlan.Free); } catch (e) { + setActiveSubscriptionPaln(SubscriptionPlan.Free); console.error(e); } }, [getSubscriptions]); const isExceed = useMemo(() => { - if (!activeSubscription || activeSubscription.plan === SubscriptionPlan.Free) { + if (activeSubscriptionPlan === null) return false; + if (activeSubscriptionPlan === SubscriptionPlan.Free) { return memberCount >= 2; } - if (activeSubscription.plan === SubscriptionPlan.Pro) { + if (activeSubscriptionPlan === SubscriptionPlan.Pro) { return memberCount >= 10; } return false; - }, [activeSubscription, memberCount]); + }, [activeSubscriptionPlan, memberCount]); const handleOk = async () => { if (!service || !currentWorkspaceId) return; @@ -121,11 +128,11 @@ function InviteMember({ workspace, onClick }: { setOpen(true); onClick?.(); }} - startIcon={} + startIcon={} >{t('settings.appearance.members.inviteMembers')} } okText={t('inviteMember.requestInvites')} - onOk={handleOk}> - {isExceed &&
- + onOk={handleOk} + > +
+ {t('inviteMember.inviteFailedMemberLimit')} {t('inviteMember.upgrade')} -
} + className={'hover:underline cursor-pointer text-fill-default'} + >{t('inviteMember.upgrade')} +
{t('inviteMember.emails')}
setValue(e.target.value)} + fullWidth={true} + size={'small'} + value={value} + onChange={e => setValue(e.target.value)} placeholder={t('inviteMember.addEmail')} />
diff --git a/src/components/app/workspaces/Workspaces.tsx b/src/components/app/workspaces/Workspaces.tsx index 1390e5b7..5c35358a 100644 --- a/src/components/app/workspaces/Workspaces.tsx +++ b/src/components/app/workspaces/Workspaces.tsx @@ -125,7 +125,6 @@ export function Workspaces () { }} workspace={currentWorkspace} />} -
- {showToolbar && } + {showToolbar && }
); }), diff --git a/src/components/editor/components/blocks/code/MermaidChat.tsx b/src/components/editor/components/blocks/code/MermaidChat.tsx index b154ff36..093976d4 100644 --- a/src/components/editor/components/blocks/code/MermaidChat.tsx +++ b/src/components/editor/components/blocks/code/MermaidChat.tsx @@ -2,7 +2,8 @@ import { CustomEditor } from '@/application/slate-yjs/command'; import { CodeNode } from '@/components/editor/editor.type'; import { ThemeModeContext } from '@/components/main/useAppThemeMode'; import { Alert } from '@mui/material'; -import React, { useContext, useEffect, useRef } from 'react'; +import { debounce } from 'lodash-es'; +import React, { useCallback, useContext, useEffect, useMemo, useRef } from 'react'; import mermaid from 'mermaid'; const lightTheme = { @@ -68,36 +69,37 @@ function MermaidChat ({ node }: { const isDark = useContext(ThemeModeContext)?.isDark; const [error, setError] = React.useState(null); - useEffect(() => { - const element = ref.current; - - if (!element || !diagram) return; - - setError(null); - void (async () => { - const sanitizedDiagram = sanitizeDiagram(diagram); - const theme = isDark ? darkTheme : lightTheme; - + const updateMermaid = useCallback(async () => { + const sanitizedDiagram = sanitizeDiagram(diagram); + const theme = isDark ? darkTheme : lightTheme; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + mermaid.initialize({ + startOnLoad: true, + securityLevel: 'loose', + ...theme, + }); + try { + await mermaid.parse(sanitizedDiagram); + const { svg } = await mermaid.render(`mermaid-${id}`, diagram); + + setError(null); + setInnerHtml(svg); + } catch (e) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - mermaid.initialize({ - startOnLoad: true, - securityLevel: 'loose', - ...theme, - }); - try { - await mermaid.parse(sanitizedDiagram); - const { svg } = await mermaid.render(`mermaid-${id}`, diagram); + setError(e.message); + } + }, [diagram, id, isDark]); - setInnerHtml(svg); - } catch (e) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - setError(e.message); - } - })(); + const deboucenUpdateMermaid = useMemo(() => { + return debounce(updateMermaid, 300); + }, [updateMermaid]); - }, [diagram, id, isDark]); + useEffect(() => { + void deboucenUpdateMermaid(); + }, [deboucenUpdateMermaid]); if (error) { return ( diff --git a/src/components/editor/components/blocks/text/Placeholder.tsx b/src/components/editor/components/blocks/text/Placeholder.tsx index 00d9de7b..329b0c3d 100644 --- a/src/components/editor/components/blocks/text/Placeholder.tsx +++ b/src/components/editor/components/blocks/text/Placeholder.tsx @@ -1,7 +1,7 @@ import { BlockType, ToggleListBlockData } from '@/application/types'; import { HeadingNode, ToggleListNode } from '@/components/editor/editor.type'; import { useEditorContext } from '@/components/editor/EditorContext'; -import React, { CSSProperties, useEffect, useMemo, useState } from 'react'; +import React, { CSSProperties, useCallback, useEffect, useMemo, useState } from 'react'; import { ReactEditor, useFocused, useSelected, useSlate } from 'slate-react'; import { Editor, Element, Range } from 'slate'; import { useTranslation } from 'react-i18next'; @@ -15,7 +15,7 @@ function Placeholder ({ node, ...attributes }: { node: Element; className?: stri const [isComposing, setIsComposing] = useState(false); const selected = focused && blockSelected && editor.selection && Range.isCollapsed(editor.selection); - const block = useMemo(() => { + const getBlock = useCallback(() => { const path = ReactEditor.findPath(editor, node); const match = Editor.above(editor, { match: (n) => { @@ -29,6 +29,8 @@ function Placeholder ({ node, ...attributes }: { node: Element; className?: stri return match[0] as Element; }, [editor, node]); + const block = getBlock(); + const className = useMemo(() => { const classList = attributes.className?.split(' ') ?? []; @@ -101,6 +103,7 @@ function Placeholder ({ node, ...attributes }: { node: Element; className?: stri }, [block, t]); const selectedPlaceholder = useMemo(() => { + if (block?.type === BlockType.ToggleListBlock && (block?.data as ToggleListBlockData).level) { return unSelectedPlaceholder; } diff --git a/src/components/editor/components/blocks/toggle-list/ToggleIcon.tsx b/src/components/editor/components/blocks/toggle-list/ToggleIcon.tsx index 1a944cd1..fd2123e9 100644 --- a/src/components/editor/components/blocks/toggle-list/ToggleIcon.tsx +++ b/src/components/editor/components/blocks/toggle-list/ToggleIcon.tsx @@ -6,7 +6,7 @@ import { ReactComponent as ExpandSvg } from '@/assets/drop_menu_show.svg'; import { useReadOnly, useSlateStatic } from 'slate-react'; import { Element } from 'slate'; -function ToggleIcon({ block, className }: { block: ToggleListNode; className: string }) { +function ToggleIcon ({ block, className }: { block: ToggleListNode; className: string }) { const { collapsed } = block.data; const editor = useSlateStatic(); const readOnly = useReadOnly() || editor.isElementReadOnly(block as unknown as Element); @@ -23,16 +23,17 @@ function ToggleIcon({ block, className }: { block: ToggleListNode; className: st return ( { e.preventDefault(); + handleClick(e); }} className={`${className} ${readOnly ? '' : 'cursor-pointer hover:text-fill-default'} pr-1 toggle-icon`} > - {collapsed ? : } + {collapsed ? : } ); } diff --git a/src/components/editor/components/blocks/toggle-list/ToggleList.tsx b/src/components/editor/components/blocks/toggle-list/ToggleList.tsx index 030669e0..346decb7 100644 --- a/src/components/editor/components/blocks/toggle-list/ToggleList.tsx +++ b/src/components/editor/components/blocks/toggle-list/ToggleList.tsx @@ -45,9 +45,11 @@ export const ToggleList = memo( {children} {!readOnly && !collapsed && node.children.slice(1).length === 0 &&
{ + onMouseDown={e => { + e.preventDefault(); CustomEditor.addChildBlock(editor, blockId, BlockType.Paragraph, {}); }} + data-testid={'toggle-list-empty'} contentEditable={false} className={'text-text-caption select-none text-sm hover:bg-fill-list-hover rounded-[6px] cursor-pointer flex items-center h-[36px] px-[0.5em] ml-[1.45em]'} > diff --git a/src/components/editor/components/leaf/Leaf.tsx b/src/components/editor/components/leaf/Leaf.tsx index 40d2e355..c9ba8c22 100644 --- a/src/components/editor/components/leaf/Leaf.tsx +++ b/src/components/editor/components/leaf/Leaf.tsx @@ -7,7 +7,7 @@ import React, { CSSProperties } from 'react'; import { RenderLeafProps } from 'slate-react'; import { renderColor } from '@/utils/color'; -export function Leaf({ attributes, children, leaf, text }: RenderLeafProps) { +export function Leaf ({ attributes, children, leaf, text }: RenderLeafProps) { let newChildren = children; const classList = [leaf.prism_token, leaf.prism_token && 'token', leaf.class_name].filter(Boolean); @@ -40,11 +40,15 @@ export function Leaf({ attributes, children, leaf, text }: RenderLeafProps) { } if (leaf.bg_color) { + classList.push('bg-color'); style['backgroundColor'] = renderColor(leaf.bg_color); } if (leaf.href) { - newChildren = {newChildren}; + newChildren = {newChildren}; } if (leaf.font_family) { @@ -56,7 +60,7 @@ export function Leaf({ attributes, children, leaf, text }: RenderLeafProps) { if (leaf.mention) { style['display'] = 'inline-block'; } - + const node = leaf.mention ? 1 && (editor.children[0] as Element).blockId === node.blockId && currentLineText === '') { + CustomEditor.deleteBlock(yjsEditor, node.blockId as string); + } const before = Editor.before(editor, selection, { unit: 'offset' }); if (!before && Path.isAncestor([0, 0], path)) { - focusedFocusableElement(true); + e.preventDefault(); + focusedFocusableElement(false); } break; @@ -484,7 +490,7 @@ export function useShortcuts(editor: ReactEditor) { }; } -function findInlineTextNode(editor: Editor, point?: BasePoint) { +function findInlineTextNode (editor: Editor, point?: BasePoint) { const [node] = editor.nodes({ at: point, match: (n) => { diff --git a/src/components/quick-note/NoteHeader.tsx b/src/components/quick-note/NoteHeader.tsx index 16c5674e..1b59d540 100644 --- a/src/components/quick-note/NoteHeader.tsx +++ b/src/components/quick-note/NoteHeader.tsx @@ -10,7 +10,7 @@ import { useTranslation } from 'react-i18next'; import { QuickNote } from '@/application/types'; import { getTitle } from '@/components/quick-note/utils'; -function NoteHeader({ note, onBack, onClose, expand, onToggleExpand }: { +function NoteHeader ({ note, onBack, onClose, expand, onToggleExpand }: { onBack: () => void; onClose: () => void; expand?: boolean; @@ -25,20 +25,30 @@ function NoteHeader({ note, onBack, onClose, expand, onToggleExpand }: { return (
- - + +
{title}
- { - e.currentTarget.blur(); - onToggleExpand?.(); - }} size={'small'}> - {expand ? : } + e.preventDefault()} + onClick={e => { + e.currentTarget.blur(); + onToggleExpand?.(); + }} + size={'small'} + > + {expand ? : } - - + +
); diff --git a/src/components/quick-note/NoteListHeader.tsx b/src/components/quick-note/NoteListHeader.tsx index cc742352..4f226dc1 100644 --- a/src/components/quick-note/NoteListHeader.tsx +++ b/src/components/quick-note/NoteListHeader.tsx @@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next'; import { createHotkey, HOT_KEY_NAME } from '@/utils/hotkeys'; import { debounce } from 'lodash-es'; -function NoteListHeader({ +function NoteListHeader ({ onSearch, onClose, expand, @@ -32,7 +32,10 @@ function NoteListHeader({
- + { @@ -41,8 +44,10 @@ function NoteListHeader({ inputRef.current?.focus(); setActiveSearch(true); } - }} size={'small'}> - + }} + size={'small'} + > +
@@ -70,22 +75,30 @@ function NoteListHeader({ }
- { - e.currentTarget.blur(); - onToggleExpand?.(); - }} size={'small'}> - {expand ? : } + e.preventDefault()} + onClick={e => { + e.currentTarget.blur(); + onToggleExpand?.(); + }} + size={'small'} + > + {expand ? : } - { - if (activeSearch) { - setActiveSearch(false); - debounceSearch(''); - } else { - onClose(); - } - }} size={'small'}> - + { + if (activeSearch) { + setActiveSearch(false); + debounceSearch(''); + } else { + onClose(); + } + }} + size={'small'} + > +
); diff --git a/src/styles/app.scss b/src/styles/app.scss index 2258fa25..1cb12d98 100644 --- a/src/styles/app.scss +++ b/src/styles/app.scss @@ -29,7 +29,7 @@ body { ::selection { - @apply bg-content-blue-100; + @apply bg-bg-selection; } @apply bg-bg-body text-text-title; diff --git a/src/styles/variables/dark.variables.css b/src/styles/variables/dark.variables.css index a631bac8..e0f63582 100644 --- a/src/styles/variables/dark.variables.css +++ b/src/styles/variables/dark.variables.css @@ -1,5 +1,6 @@ :root[data-dark-mode=true] { + --bg-selection: #2383e247; --text-title: #e2e9f2; --text-caption: #87A0BF; --text-placeholder: #3c4557; diff --git a/src/styles/variables/light.variables.css b/src/styles/variables/light.variables.css index 884fdd26..519f0da6 100644 --- a/src/styles/variables/light.variables.css +++ b/src/styles/variables/light.variables.css @@ -1,5 +1,6 @@ :root { + --bg-selection: #00bcf026; --text-title: #333333; --text-caption: #828282; --text-placeholder: #bdbdbd; diff --git a/tailwind/box-shadow.cjs b/tailwind/box-shadow.cjs index 7e3990af..426954cd 100644 --- a/tailwind/box-shadow.cjs +++ b/tailwind/box-shadow.cjs @@ -1,7 +1,7 @@ /** * Do not edit directly -* Generated on Fri, 03 Jan 2025 08:02:02 GMT +* Generated on Tue, 07 Jan 2025 10:54:45 GMT * Generated from $pnpm css:variables */ diff --git a/tailwind/colors.cjs b/tailwind/colors.cjs index e9ca222d..ea16f6b3 100644 --- a/tailwind/colors.cjs +++ b/tailwind/colors.cjs @@ -1,12 +1,22 @@ /** * Do not edit directly -* Generated on Fri, 03 Jan 2025 08:02:02 GMT +* Generated on Tue, 07 Jan 2025 10:54:45 GMT * Generated from $pnpm css:variables */ module.exports = { + "bg": { + "selection": "var(--bg-selection)", + "body": "var(--bg-body)", + "base": "var(--bg-base)", + "mask": "var(--bg-mask)", + "tips": "var(--bg-tips)", + "brand": "var(--bg-brand)", + "header": "var(--bg-header)", + "footer": "var(--bg-footer)" + }, "text": { "title": "var(--text-title)", "caption": "var(--text-caption)", @@ -52,15 +62,6 @@ module.exports = { "on-fill": "var(--content-on-fill)", "on-tag": "var(--content-on-tag)" }, - "bg": { - "body": "var(--bg-body)", - "base": "var(--bg-base)", - "mask": "var(--bg-mask)", - "tips": "var(--bg-tips)", - "brand": "var(--bg-brand)", - "header": "var(--bg-header)", - "footer": "var(--bg-footer)" - }, "function": { "error": "var(--function-error)", "error-hover": "var(--function-error-hover)",