From 2402b4c6f1f047cb5ffa19610f091ba1de7b7371 Mon Sep 17 00:00:00 2001 From: "Kilu.He" <108015703+qinluhe@users.noreply.github.com> Date: Thu, 1 Aug 2024 12:59:04 +0800 Subject: [PATCH] feat: support global comment on publish (#5834) * feat: support duplicate UI on web * fix: replace google svg * fix: modified some copy * fix: adjust modal position * fix: upgrade wasm package * fix: text overflow * fix: global comments * fix: replace appflowy icon * fix: demond load outline * fix: lazy load * fix: close duplicate entry * fix: ci error * fix: modified comment styles * fix: adjust space * fix: easy find reply comment * fix: calendar scroll bugs * fix: image render * fix: replace loading * fix: issues of test session * fix: fixed adding comment * fix: database view name --- frontend/appflowy_web_app/index.html | 6 +- frontend/appflowy_web_app/package.json | 3 +- frontend/appflowy_web_app/pnpm-lock.yaml | 16 +- frontend/appflowy_web_app/public/appflowy.svg | 19 +- .../src/application/comment.type.ts | 22 ++ .../application/services/js-services/index.ts | 69 ++++ .../services/js-services/wasm/client_api.ts | 121 +++++- .../src/application/services/services.type.ts | 13 + .../services/tauri-services/index.ts | 52 ++- .../src/application/session/sign_in.ts | 1 - .../appflowy_web_app/src/application/types.ts | 40 ++ .../src/assets/add_reaction.svg | 4 + .../src/assets/check_circle.svg | 7 + .../src/assets/clock_alarm.svg | 7 + .../appflowy_web_app/src/assets/close.svg | 6 + .../appflowy_web_app/src/assets/collapse.svg | 4 + .../src/assets/corner_left_top.svg | 3 + .../src/assets/double_arrow.svg | 8 + .../appflowy_web_app/src/assets/error.svg | 4 + .../src/assets/error_outline.svg | 7 + .../appflowy_web_app/src/assets/expand.svg | 4 + .../src/assets/images/empty.png | Bin 0 -> 50773 bytes frontend/appflowy_web_app/src/assets/lock.svg | 5 + .../src/assets/login/google.svg | 19 +- .../appflowy_web_app/src/assets/reply.svg | 3 + .../appflowy_web_app/src/assets/selected.svg | 3 + .../appflowy_web_app/src/assets/shuffle.svg | 12 + .../appflowy_web_app/src/assets/trash.svg | 14 + .../src/assets/warning_amber.svg | 7 + .../_shared/emoji-picker/EmojiPicker.hooks.ts | 175 +++++++++ .../_shared/emoji-picker/EmojiPicker.tsx | 37 ++ .../emoji-picker/EmojiPickerCategories.tsx | 354 ++++++++++++++++++ .../emoji-picker/EmojiPickerHeader.tsx | 156 ++++++++ .../components/_shared/emoji-picker/const.ts | 3 + .../components/_shared/emoji-picker/index.ts | 3 + .../_shared/image-render/ImageRender.tsx | 50 +++ .../components/_shared/katex-math/index.ts | 3 + .../components/_shared/modal/NormalModal.tsx | 82 ++++ .../src/components/_shared/modal/index.ts | 1 + .../_shared/notify/InfoSnackbar.tsx | 122 ++++++ .../src/components/_shared/notify/index.ts | 18 +- .../components/_shared/popover/Popover.tsx | 2 + .../src/components/app/AppConfig.tsx | 49 ++- .../src/components/app/AppTheme.tsx | 29 ++ .../src/components/app/withAppWrapper.tsx | 4 + .../components/database/calendar/Calendar.tsx | 5 +- .../database/calendar/calendar.scss | 21 +- .../components/board/column/Column.tsx | 1 - .../components/calendar/toolbar/Toolbar.tsx | 2 +- .../components/cell/date/DateTimeCell.tsx | 2 +- .../database/components/field/CardField.tsx | 4 +- .../components/grid/grid-table/GridTable.tsx | 2 +- .../components/header/DatabaseHeader.tsx | 2 +- .../database/components/tabs/DatabaseTabs.tsx | 29 +- .../src/components/document/Document.tsx | 4 +- .../components/document/DocumentSkeleton.tsx | 17 + .../components/blocks/code/Code.hooks.ts | 50 ++- .../editor/components/blocks/code/utils.ts | 7 +- .../components/blocks/image/ImageRender.tsx | 25 +- .../blocks/math-equation/MathEquation.tsx | 2 +- .../components/blocks/math-equation/index.ts | 4 +- .../components/leaf/formula/Formula.tsx | 2 +- .../editor/components/leaf/formula/index.ts | 6 +- .../components/leaf/mention/MentionDate.tsx | 2 +- .../components/global-comment/CommentList.tsx | 39 ++ .../global-comment/GlobalComment.hooks.tsx | 246 ++++++++++++ .../global-comment/GlobalComment.tsx | 36 ++ .../global-comment/GlobalCommentProvider.tsx | 60 +++ .../global-comment/ReplyComment.tsx | 47 +++ .../global-comment/actions/CommentActions.tsx | 17 + .../global-comment/actions/MoreActions.tsx | 147 ++++++++ .../global-comment/actions/ReactAction.tsx | 62 +++ .../global-comment/actions/ReplyAction.tsx | 28 ++ .../global-comment/add-comment/AddComment.tsx | 175 +++++++++ .../add-comment/AddCommentWrapper.tsx | 68 ++++ .../global-comment/add-comment/index.ts | 1 + .../global-comment/comment/Comment.tsx | 106 ++++++ .../global-comment/comment/CommentWrap.tsx | 82 ++++ .../global-comment/comment/index.ts | 1 + .../src/components/global-comment/index.ts | 1 + .../global-comment/reactions/Reaction.tsx | 101 +++++ .../global-comment/reactions/Reactions.tsx | 31 ++ .../global-comment/reactions/index.ts | 1 + .../src/components/global-comment/utils.ts | 11 + .../src/components/login/Login.tsx | 18 +- .../src/components/login/LoginModal.tsx | 21 ++ .../src/components/login/LoginProvider.tsx | 4 +- .../src/components/login/MagicLink.tsx | 10 +- .../src/components/login/index.ts | 2 + .../src/components/publish/DatabaseView.tsx | 2 +- .../src/components/publish/PublishView.tsx | 13 +- .../components/publish/header/MoreActions.tsx | 110 +++--- .../publish/header/PublishViewHeader.tsx | 8 +- .../publish/header/duplicate/Duplicate.tsx | 31 ++ .../header/duplicate/DuplicateModal.tsx | 129 +++++++ .../header/duplicate/SelectWorkspace.tsx | 143 +++++++ .../publish/header/duplicate/SpaceList.tsx | 95 +++++ .../publish/header/duplicate/index.ts | 1 + .../publish/header/duplicate/useDuplicate.ts | 131 +++++++ .../publish/outline/OutlineDrawer.tsx | 2 +- .../publish/outline/OutlinePopover.tsx | 2 +- .../src/components/publish/outline/index.ts | 1 + .../src/components/publish/useViewMeta.ts | 9 +- .../src/components/view-meta/ViewCover.tsx | 7 +- .../appflowy_web_app/src/pages/LoginPage.tsx | 15 +- frontend/appflowy_web_app/src/styles/app.scss | 48 +++ .../appflowy_web_app/src/styles/mixin.scss | 11 + .../src/styles/variables/dark.variables.css | 6 + .../src/styles/variables/light.variables.css | 6 + frontend/appflowy_web_app/src/utils/color.ts | 31 ++ frontend/appflowy_web_app/src/utils/emoji.ts | 14 + frontend/appflowy_web_app/src/utils/font.ts | 22 +- .../appflowy_web_app/src/utils/position.ts | 20 + frontend/appflowy_web_app/src/vite-env.d.ts | 3 +- frontend/appflowy_web_app/vite.config.ts | 9 +- frontend/resources/translations/en.json | 87 ++++- 116 files changed, 3789 insertions(+), 236 deletions(-) create mode 100644 frontend/appflowy_web_app/src/application/comment.type.ts create mode 100644 frontend/appflowy_web_app/src/application/types.ts create mode 100644 frontend/appflowy_web_app/src/assets/add_reaction.svg create mode 100644 frontend/appflowy_web_app/src/assets/check_circle.svg create mode 100644 frontend/appflowy_web_app/src/assets/clock_alarm.svg create mode 100644 frontend/appflowy_web_app/src/assets/close.svg create mode 100644 frontend/appflowy_web_app/src/assets/collapse.svg create mode 100644 frontend/appflowy_web_app/src/assets/corner_left_top.svg create mode 100644 frontend/appflowy_web_app/src/assets/double_arrow.svg create mode 100644 frontend/appflowy_web_app/src/assets/error.svg create mode 100644 frontend/appflowy_web_app/src/assets/error_outline.svg create mode 100644 frontend/appflowy_web_app/src/assets/expand.svg create mode 100644 frontend/appflowy_web_app/src/assets/images/empty.png create mode 100644 frontend/appflowy_web_app/src/assets/lock.svg create mode 100644 frontend/appflowy_web_app/src/assets/reply.svg create mode 100644 frontend/appflowy_web_app/src/assets/selected.svg create mode 100644 frontend/appflowy_web_app/src/assets/shuffle.svg create mode 100644 frontend/appflowy_web_app/src/assets/trash.svg create mode 100644 frontend/appflowy_web_app/src/assets/warning_amber.svg create mode 100644 frontend/appflowy_web_app/src/components/_shared/emoji-picker/EmojiPicker.hooks.ts create mode 100644 frontend/appflowy_web_app/src/components/_shared/emoji-picker/EmojiPicker.tsx create mode 100644 frontend/appflowy_web_app/src/components/_shared/emoji-picker/EmojiPickerCategories.tsx create mode 100644 frontend/appflowy_web_app/src/components/_shared/emoji-picker/EmojiPickerHeader.tsx create mode 100644 frontend/appflowy_web_app/src/components/_shared/emoji-picker/const.ts create mode 100644 frontend/appflowy_web_app/src/components/_shared/emoji-picker/index.ts create mode 100644 frontend/appflowy_web_app/src/components/_shared/image-render/ImageRender.tsx create mode 100644 frontend/appflowy_web_app/src/components/_shared/katex-math/index.ts create mode 100644 frontend/appflowy_web_app/src/components/_shared/modal/NormalModal.tsx create mode 100644 frontend/appflowy_web_app/src/components/_shared/modal/index.ts create mode 100644 frontend/appflowy_web_app/src/components/_shared/notify/InfoSnackbar.tsx create mode 100644 frontend/appflowy_web_app/src/components/document/DocumentSkeleton.tsx create mode 100644 frontend/appflowy_web_app/src/components/global-comment/CommentList.tsx create mode 100644 frontend/appflowy_web_app/src/components/global-comment/GlobalComment.hooks.tsx create mode 100644 frontend/appflowy_web_app/src/components/global-comment/GlobalComment.tsx create mode 100644 frontend/appflowy_web_app/src/components/global-comment/GlobalCommentProvider.tsx create mode 100644 frontend/appflowy_web_app/src/components/global-comment/ReplyComment.tsx create mode 100644 frontend/appflowy_web_app/src/components/global-comment/actions/CommentActions.tsx create mode 100644 frontend/appflowy_web_app/src/components/global-comment/actions/MoreActions.tsx create mode 100644 frontend/appflowy_web_app/src/components/global-comment/actions/ReactAction.tsx create mode 100644 frontend/appflowy_web_app/src/components/global-comment/actions/ReplyAction.tsx create mode 100644 frontend/appflowy_web_app/src/components/global-comment/add-comment/AddComment.tsx create mode 100644 frontend/appflowy_web_app/src/components/global-comment/add-comment/AddCommentWrapper.tsx create mode 100644 frontend/appflowy_web_app/src/components/global-comment/add-comment/index.ts create mode 100644 frontend/appflowy_web_app/src/components/global-comment/comment/Comment.tsx create mode 100644 frontend/appflowy_web_app/src/components/global-comment/comment/CommentWrap.tsx create mode 100644 frontend/appflowy_web_app/src/components/global-comment/comment/index.ts create mode 100644 frontend/appflowy_web_app/src/components/global-comment/index.ts create mode 100644 frontend/appflowy_web_app/src/components/global-comment/reactions/Reaction.tsx create mode 100644 frontend/appflowy_web_app/src/components/global-comment/reactions/Reactions.tsx create mode 100644 frontend/appflowy_web_app/src/components/global-comment/reactions/index.ts create mode 100644 frontend/appflowy_web_app/src/components/global-comment/utils.ts create mode 100644 frontend/appflowy_web_app/src/components/login/LoginModal.tsx create mode 100644 frontend/appflowy_web_app/src/components/publish/header/duplicate/Duplicate.tsx create mode 100644 frontend/appflowy_web_app/src/components/publish/header/duplicate/DuplicateModal.tsx create mode 100644 frontend/appflowy_web_app/src/components/publish/header/duplicate/SelectWorkspace.tsx create mode 100644 frontend/appflowy_web_app/src/components/publish/header/duplicate/SpaceList.tsx create mode 100644 frontend/appflowy_web_app/src/components/publish/header/duplicate/index.ts create mode 100644 frontend/appflowy_web_app/src/components/publish/header/duplicate/useDuplicate.ts create mode 100644 frontend/appflowy_web_app/src/utils/emoji.ts create mode 100644 frontend/appflowy_web_app/src/utils/position.ts diff --git a/frontend/appflowy_web_app/index.html b/frontend/appflowy_web_app/index.html index 49615f8cb0..4c8514b049 100644 --- a/frontend/appflowy_web_app/index.html +++ b/frontend/appflowy_web_app/index.html @@ -31,7 +31,6 @@
- - - - + + diff --git a/frontend/appflowy_web_app/package.json b/frontend/appflowy_web_app/package.json index 22bf0cb631..04c3f259ae 100644 --- a/frontend/appflowy_web_app/package.json +++ b/frontend/appflowy_web_app/package.json @@ -24,7 +24,7 @@ "coverage": "pnpm run test:unit && pnpm run test:components" }, "dependencies": { - "@appflowyinc/client-api-wasm": "0.1.2", + "@appflowyinc/client-api-wasm": "0.1.4", "@atlaskit/primitives": "^5.5.3", "@emoji-mart/data": "^1.1.2", "@emoji-mart/react": "^1.1.1", @@ -49,6 +49,7 @@ "emoji-regex": "^10.2.1", "events": "^3.3.0", "google-protobuf": "^3.15.12", + "highlight.js": "^11.10.0", "i18next": "^22.4.10", "i18next-browser-languagedetector": "^7.0.1", "i18next-resources-to-backend": "^1.1.4", diff --git a/frontend/appflowy_web_app/pnpm-lock.yaml b/frontend/appflowy_web_app/pnpm-lock.yaml index 584d748c79..db5d7e333a 100644 --- a/frontend/appflowy_web_app/pnpm-lock.yaml +++ b/frontend/appflowy_web_app/pnpm-lock.yaml @@ -6,8 +6,8 @@ settings: dependencies: '@appflowyinc/client-api-wasm': - specifier: 0.1.2 - version: 0.1.2 + specifier: 0.1.4 + version: 0.1.4 '@atlaskit/primitives': specifier: ^5.5.3 version: 5.7.0(@types/react@18.2.66)(react@18.2.0) @@ -80,6 +80,9 @@ dependencies: google-protobuf: specifier: ^3.15.12 version: 3.21.2 + highlight.js: + specifier: ^11.10.0 + version: 11.10.0 i18next: specifier: ^22.4.10 version: 22.5.1 @@ -451,8 +454,8 @@ packages: '@jridgewell/gen-mapping': 0.3.5 '@jridgewell/trace-mapping': 0.3.25 - /@appflowyinc/client-api-wasm@0.1.2: - resolution: {integrity: sha512-+v0hs7/7BVKtgev/Bcbr0u2HLDhUuw4ZvZTaMddI+06HK8vt5S52dMaZKUcMvh1eUjVX8hjC6Mfe0X/yHqvFgA==} + /@appflowyinc/client-api-wasm@0.1.4: + resolution: {integrity: sha512-3uBpy3n+aIG0fapPAroMfL8JLdAPtqPAkpV+LOxlRnMW4Au2JQcW8TW0P3K1YAe16tDZ62ZIZPoG6Bi40RDRoQ==} dev: false /@atlaskit/analytics-next-stable-react-context@1.0.1(react@18.2.0): @@ -7068,6 +7071,11 @@ packages: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} dev: true + /highlight.js@11.10.0: + resolution: {integrity: sha512-SYVnVFswQER+zu1laSya563s+F8VDGt7o35d4utbamowvUNLLMovFqwCLSocpZTz3MgaSRA1IbqRWZv97dtErQ==} + engines: {node: '>=12.0.0'} + dev: false + /hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} dependencies: diff --git a/frontend/appflowy_web_app/public/appflowy.svg b/frontend/appflowy_web_app/public/appflowy.svg index a3e0c6491d..e8ad422794 100644 --- a/frontend/appflowy_web_app/public/appflowy.svg +++ b/frontend/appflowy_web_app/public/appflowy.svg @@ -1,12 +1,9 @@ - - - - - - - - - - - + + + + + + + + diff --git a/frontend/appflowy_web_app/src/application/comment.type.ts b/frontend/appflowy_web_app/src/application/comment.type.ts new file mode 100644 index 0000000000..b4c81e40a1 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/comment.type.ts @@ -0,0 +1,22 @@ +export interface CommentUser { + uuid: string; + name: string; + avatarUrl: string | null; +} + +export interface GlobalComment { + commentId: string; + user: CommentUser | null; + content: string; + createdAt: string; + lastUpdatedAt: string; + replyCommentId: string | null; + isDeleted: boolean; + canDeleted: boolean; +} + +export interface Reaction { + reactionType: string; + reactUsers: CommentUser[]; + commentId: string; +} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/index.ts b/frontend/appflowy_web_app/src/application/services/js-services/index.ts index 7ac7cbc256..93e1cc7993 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/index.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/index.ts @@ -1,4 +1,5 @@ import { YDoc } from '@/application/collab.type'; +import { GlobalComment, Reaction } from '@/application/comment.type'; import { deleteView, getPublishView, @@ -14,12 +15,23 @@ import { signInGithub, signInDiscord, signInWithUrl, + createGlobalCommentOnPublishView, + deleteGlobalCommentOnPublishView, + getPublishViewComments, + getWorkspaces, + getWorkspaceFolder, + getCurrentUser, + duplicatePublishView, + getReactions, + addReaction, + removeReaction, } from '@/application/services/js-services/wasm/client_api'; import { AFService, AFServiceConfig } from '@/application/services/services.type'; import { emit, EventType } from '@/application/session'; import { afterAuth, AUTH_CALLBACK_URL, withSignIn } from '@/application/session/sign_in'; import { nanoid } from 'nanoid'; import * as Y from 'yjs'; +import { DuplicatePublishView } from '@/application/types'; export class AFClientService implements AFService { private deviceId: string = nanoid(8); @@ -199,4 +211,61 @@ export class AFClientService implements AFService { async signInDiscord(_: { redirectTo: string }) { return await signInDiscord(AUTH_CALLBACK_URL); } + + async getWorkspaces() { + const data = getWorkspaces(); + + return data; + } + + async getWorkspaceFolder(workspaceId: string) { + const data = await getWorkspaceFolder(workspaceId); + + return data; + } + + async getCurrentUser() { + const data = await getCurrentUser(); + + return { + uid: data.uid, + email: data.email, + name: data.name, + avatar: data.icon_url, + uuid: data.uuid, + }; + } + + async duplicatePublishView(params: DuplicatePublishView) { + return duplicatePublishView({ + workspace_id: params.workspaceId, + dest_view_id: params.spaceViewId, + published_view_id: params.viewId, + published_collab_type: params.collabType, + }); + } + + createCommentOnPublishView(viewId: string, content: string, replyCommentId: string | undefined): Promise { + return createGlobalCommentOnPublishView(viewId, content, replyCommentId); + } + + deleteCommentOnPublishView(viewId: string, commentId: string): Promise { + return deleteGlobalCommentOnPublishView(viewId, commentId); + } + + getPublishViewGlobalComments(viewId: string): Promise { + return getPublishViewComments(viewId); + } + + getPublishViewReactions(viewId: string, commentId?: string): Promise> { + return getReactions(viewId, commentId); + } + + addPublishViewReaction(viewId: string, commentId: string, reactionType: string): Promise { + return addReaction(viewId, commentId, reactionType); + } + + removePublishViewReaction(viewId: string, commentId: string, reactionType: string): Promise { + return removeReaction(viewId, commentId, reactionType); + } } diff --git a/frontend/appflowy_web_app/src/application/services/js-services/wasm/client_api.ts b/frontend/appflowy_web_app/src/application/services/js-services/wasm/client_api.ts index 852559c3aa..94075ade54 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/wasm/client_api.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/wasm/client_api.ts @@ -1,7 +1,9 @@ import { getToken, invalidToken, isTokenValid, refreshToken } from '@/application/session/token'; -import { ClientAPI } from '@appflowyinc/client-api-wasm'; +import { ClientAPI, WorkspaceFolder, DuplicatePublishViewPayload } from '@appflowyinc/client-api-wasm'; import { AFCloudConfig } from '@/application/services/services.type'; import { DatabaseId, PublishViewMetaData, RowId, ViewId, ViewLayout } from '@/application/collab.type'; +import { FolderView } from '@/application/types'; +import { GlobalComment, Reaction } from '@/application/comment.type'; let client: ClientAPI; @@ -115,3 +117,120 @@ export async function signInGithub(redirectTo: string) { export async function signInDiscord(redirectTo: string) { return signInProvider('discord', redirectTo); } + +export async function getWorkspaces() { + try { + const { data } = await client.get_workspaces(); + + return data.map((workspace) => ({ + id: workspace.workspace_id, + name: workspace.workspace_name, + icon: workspace.icon, + memberCount: workspace.member_count || 0, + })); + } catch (e) { + return Promise.reject(e); + } +} + +export async function getWorkspaceFolder(workspaceId: string): Promise { + try { + const data = await client.get_folder(workspaceId); + + // eslint-disable-next-line no-inner-declarations + function iterateFolder(folder: WorkspaceFolder): FolderView { + return { + id: folder.view_id, + name: folder.name, + icon: folder.icon, + isSpace: folder.is_space, + extra: folder.extra, + isPrivate: folder.is_private, + children: folder.children.map((child: WorkspaceFolder) => { + return iterateFolder(child); + }), + }; + } + + return iterateFolder(data); + } catch (e) { + return Promise.reject(e); + } +} + +export function getCurrentUser() { + return client.get_user(); +} + +export function duplicatePublishView(payload: DuplicatePublishViewPayload) { + return client.duplicate_publish_view(payload); +} + +export async function getPublishViewComments(viewId: string): Promise { + try { + const { comments } = await client.get_publish_view_comments(viewId); + + return comments.map((comment) => { + return { + commentId: comment.comment_id, + user: { + uuid: comment.user?.uuid || '', + name: comment.user?.name || '', + avatarUrl: comment.user?.avatar_url || null, + }, + content: comment.content, + createdAt: comment.created_at, + lastUpdatedAt: comment.last_updated_at, + replyCommentId: comment.reply_comment_id, + isDeleted: comment.is_deleted, + canDeleted: comment.can_be_deleted, + }; + }); + } catch (e) { + return Promise.reject(e); + } +} + +export async function createGlobalCommentOnPublishView(viewId: string, content: string, replyCommentId?: string) { + return client.create_comment_on_publish_view(viewId, content, replyCommentId); +} + +export async function deleteGlobalCommentOnPublishView(viewId: string, commentId: string) { + return client.delete_comment_on_publish_view(viewId, commentId); +} + +export async function getReactions(viewId: string, commentId?: string): Promise> { + try { + const { reactions } = await client.get_reactions(viewId, commentId); + + const reactionsMap: Record = {}; + + for (const reaction of reactions) { + if (!reactionsMap[reaction.comment_id]) { + reactionsMap[reaction.comment_id] = []; + } + + reactionsMap[reaction.comment_id].push({ + reactionType: reaction.reaction_type, + commentId: reaction.comment_id, + reactUsers: reaction.react_users.map((user) => ({ + uuid: user.uuid, + name: user.name, + avatarUrl: user.avatar_url, + })), + }); + } + + return reactionsMap; + } catch (e) { + return Promise.reject(e); + } +} + +export async function addReaction(viewId: string, commentId: string, reactionType: string) { + return client.create_reaction(viewId, commentId, reactionType); +} + +export async function removeReaction(viewId: string, commentId: string, reactionType: string) { + return client.delete_reaction(viewId, commentId, reactionType); +} diff --git a/frontend/appflowy_web_app/src/application/services/services.type.ts b/frontend/appflowy_web_app/src/application/services/services.type.ts index 919cbf5306..77b7086fa3 100644 --- a/frontend/appflowy_web_app/src/application/services/services.type.ts +++ b/frontend/appflowy_web_app/src/application/services/services.type.ts @@ -1,6 +1,8 @@ import { YDoc } from '@/application/collab.type'; +import { GlobalComment, Reaction } from '@/application/comment.type'; import { ViewMeta } from '@/application/db/tables/view_metas'; import * as Y from 'yjs'; +import { DuplicatePublishView, FolderView, User, Workspace } from '@/application/types'; export type AFService = PublishService; @@ -27,10 +29,21 @@ export interface PublishService { rows: Y.Map; destroy: () => void; }>; + getPublishViewGlobalComments: (viewId: string) => Promise; + createCommentOnPublishView: (viewId: string, content: string, replyCommentId?: string) => Promise; + deleteCommentOnPublishView: (viewId: string, commentId: string) => Promise; + getPublishViewReactions: (viewId: string, commentId?: string) => Promise>; + addPublishViewReaction: (viewId: string, commentId: string, reactionType: string) => Promise; + removePublishViewReaction: (viewId: string, commentId: string, reactionType: string) => Promise; loginAuth: (url: string) => Promise; signInMagicLink: (params: { email: string; redirectTo: string }) => Promise; signInGoogle: (params: { redirectTo: string }) => Promise; signInGithub: (params: { redirectTo: string }) => Promise; signInDiscord: (params: { redirectTo: string }) => Promise; + + getWorkspaces: () => Promise; + getWorkspaceFolder: (workspaceId: string) => Promise; + getCurrentUser: () => Promise; + duplicatePublishView: (params: DuplicatePublishView) => Promise; } diff --git a/frontend/appflowy_web_app/src/application/services/tauri-services/index.ts b/frontend/appflowy_web_app/src/application/services/tauri-services/index.ts index e5b970e74b..e1e6ff19be 100644 --- a/frontend/appflowy_web_app/src/application/services/tauri-services/index.ts +++ b/frontend/appflowy_web_app/src/application/services/tauri-services/index.ts @@ -1,7 +1,9 @@ import { YDoc } from '@/application/collab.type'; +import { GlobalComment, Reaction } from '@/application/comment.type'; import { AFService } from '@/application/services/services.type'; import { nanoid } from 'nanoid'; import { YMap } from 'yjs/dist/src/types/YMap'; +import { DuplicatePublishView, FolderView, User, Workspace } from '@/application/types'; export class AFClientService implements AFService { private deviceId: string = nanoid(8); @@ -25,23 +27,23 @@ export class AFClientService implements AFService { } loginAuth(_: string): Promise { - return Promise.resolve(undefined); + return Promise.reject('Method not implemented'); } signInDiscord(_params: { redirectTo: string }): Promise { - return Promise.resolve(undefined); + return Promise.reject('Method not implemented'); } signInGithub(_params: { redirectTo: string }): Promise { - return Promise.resolve(undefined); + return Promise.reject('Method not implemented'); } signInGoogle(_params: { redirectTo: string }): Promise { - return Promise.resolve(undefined); + return Promise.reject('Method not implemented'); } signInMagicLink(_params: { email: string; redirectTo: string }): Promise { - return Promise.resolve(undefined); + return Promise.reject('Method not implemented'); } getPublishDatabaseViewRows( @@ -53,4 +55,44 @@ export class AFClientService implements AFService { }> { return Promise.reject('Method not implemented'); } + + duplicatePublishView(_params: DuplicatePublishView): Promise { + return Promise.reject('Method not implemented'); + } + + getCurrentUser(): Promise { + return Promise.reject('Method not implemented'); + } + + getWorkspaceFolder(_workspaceId: string): Promise { + return Promise.reject('Method not implemented'); + } + + getWorkspaces(): Promise { + return Promise.reject('Method not implemented'); + } + + addPublishViewReaction(_viewId: string, _commentId: string, _reactionType: string): Promise { + return Promise.reject('Method not implemented'); + } + + createCommentOnPublishView(_viewId: string, _content: string, _replyCommentId: string | undefined): Promise { + return Promise.reject('Method not implemented'); + } + + deleteCommentOnPublishView(_viewId: string, _commentId: string): Promise { + return Promise.reject('Method not implemented'); + } + + getPublishViewGlobalComments(_viewId: string): Promise { + return Promise.resolve([]); + } + + getPublishViewReactions(_viewId: string, _commentId: string | undefined): Promise> { + return Promise.reject('Method not implemented'); + } + + removePublishViewReaction(_viewId: string, _commentId: string, _reactionType: string): Promise { + return Promise.reject('Method not implemented'); + } } diff --git a/frontend/appflowy_web_app/src/application/session/sign_in.ts b/frontend/appflowy_web_app/src/application/session/sign_in.ts index a78c3229a6..49f817788b 100644 --- a/frontend/appflowy_web_app/src/application/session/sign_in.ts +++ b/frontend/appflowy_web_app/src/application/session/sign_in.ts @@ -28,7 +28,6 @@ export function withSignIn() { saveRedirectTo(redirectTo); - console.log('=====saveRedirectTo', redirectTo); try { await originalMethod.apply(this, [args]); } catch (e) { diff --git a/frontend/appflowy_web_app/src/application/types.ts b/frontend/appflowy_web_app/src/application/types.ts new file mode 100644 index 0000000000..1549df084b --- /dev/null +++ b/frontend/appflowy_web_app/src/application/types.ts @@ -0,0 +1,40 @@ +import { CollabType } from '@/application/collab.type'; + +export interface Workspace { + icon: string; + id: string; + name: string; + memberCount: number; +} + +export interface SpaceView { + id: string; + extra: string | null; + name: string; + isPrivate: boolean; +} + +export interface FolderView { + id: string; + icon: string | null; + extra: string | null; + name: string; + isSpace: boolean; + isPrivate: boolean; + children: FolderView[]; +} + +export interface User { + email: string | null; + name: string | null; + uid: string; + avatar: string | null; + uuid: string; +} + +export interface DuplicatePublishView { + workspaceId: string; + spaceViewId: string; + collabType: CollabType; + viewId: string; +} diff --git a/frontend/appflowy_web_app/src/assets/add_reaction.svg b/frontend/appflowy_web_app/src/assets/add_reaction.svg new file mode 100644 index 0000000000..03e3ef9f22 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/add_reaction.svg @@ -0,0 +1,4 @@ + + + diff --git a/frontend/appflowy_web_app/src/assets/check_circle.svg b/frontend/appflowy_web_app/src/assets/check_circle.svg new file mode 100644 index 0000000000..ca1aba673f --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/check_circle.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/frontend/appflowy_web_app/src/assets/clock_alarm.svg b/frontend/appflowy_web_app/src/assets/clock_alarm.svg new file mode 100644 index 0000000000..8e58bc8c42 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/clock_alarm.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/frontend/appflowy_web_app/src/assets/close.svg b/frontend/appflowy_web_app/src/assets/close.svg new file mode 100644 index 0000000000..6eb7ce67e9 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/close.svg @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/assets/collapse.svg b/frontend/appflowy_web_app/src/assets/collapse.svg new file mode 100644 index 0000000000..b04e9d656f --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/collapse.svg @@ -0,0 +1,4 @@ + + + diff --git a/frontend/appflowy_web_app/src/assets/corner_left_top.svg b/frontend/appflowy_web_app/src/assets/corner_left_top.svg new file mode 100644 index 0000000000..755bc16a8a --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/corner_left_top.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/src/assets/double_arrow.svg b/frontend/appflowy_web_app/src/assets/double_arrow.svg new file mode 100644 index 0000000000..816e93e042 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/double_arrow.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/appflowy_web_app/src/assets/error.svg b/frontend/appflowy_web_app/src/assets/error.svg new file mode 100644 index 0000000000..2c48438535 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/error.svg @@ -0,0 +1,4 @@ + + + diff --git a/frontend/appflowy_web_app/src/assets/error_outline.svg b/frontend/appflowy_web_app/src/assets/error_outline.svg new file mode 100644 index 0000000000..6028640e6e --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/error_outline.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/frontend/appflowy_web_app/src/assets/expand.svg b/frontend/appflowy_web_app/src/assets/expand.svg new file mode 100644 index 0000000000..2af0b760ea --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/expand.svg @@ -0,0 +1,4 @@ + + + diff --git a/frontend/appflowy_web_app/src/assets/images/empty.png b/frontend/appflowy_web_app/src/assets/images/empty.png new file mode 100644 index 0000000000000000000000000000000000000000..c0a589f480e44bfa49e99295f83b09825ffb3554 GIT binary patch literal 50773 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

g@AM%9|Mk)GF++)0PpWqx4=ne=MO3+J>) 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 literal 0 HcmV?d00001 diff --git a/frontend/appflowy_web_app/src/assets/lock.svg b/frontend/appflowy_web_app/src/assets/lock.svg new file mode 100644 index 0000000000..2371767324 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/lock.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/assets/login/google.svg b/frontend/appflowy_web_app/src/assets/login/google.svg index 84bee691e0..3ffa2aabbd 100644 --- a/frontend/appflowy_web_app/src/assets/login/google.svg +++ b/frontend/appflowy_web_app/src/assets/login/google.svg @@ -1,17 +1,2 @@ - - - - - - - - - - - - - \ No newline at end of file + + \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/assets/reply.svg b/frontend/appflowy_web_app/src/assets/reply.svg new file mode 100644 index 0000000000..d9022ddd68 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/reply.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/src/assets/selected.svg b/frontend/appflowy_web_app/src/assets/selected.svg new file mode 100644 index 0000000000..159ff31122 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/selected.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/assets/shuffle.svg b/frontend/appflowy_web_app/src/assets/shuffle.svg new file mode 100644 index 0000000000..cfaae93ad3 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/shuffle.svg @@ -0,0 +1,12 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/assets/trash.svg b/frontend/appflowy_web_app/src/assets/trash.svg new file mode 100644 index 0000000000..2dddd911cc --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/trash.svg @@ -0,0 +1,14 @@ + + + + + + + + + \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/assets/warning_amber.svg b/frontend/appflowy_web_app/src/assets/warning_amber.svg new file mode 100644 index 0000000000..73599edf29 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/warning_amber.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/frontend/appflowy_web_app/src/components/_shared/emoji-picker/EmojiPicker.hooks.ts b/frontend/appflowy_web_app/src/components/_shared/emoji-picker/EmojiPicker.hooks.ts new file mode 100644 index 0000000000..2158061708 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/emoji-picker/EmojiPicker.hooks.ts @@ -0,0 +1,175 @@ +import { MAX_FREQUENTLY_ROW_COUNT, PER_ROW_EMOJI_COUNT } from '@/components/_shared/emoji-picker/const'; +import { loadEmojiData } from '@/utils/emoji'; +import { EmojiMartData } from '@emoji-mart/data'; +import { PopoverProps } from '@mui/material/Popover'; +import { PopoverOrigin } from '@mui/material/Popover/Popover'; +import { FrequentlyUsed, getEmojiDataFromNative, init, Store } from 'emoji-mart'; +import chunk from 'lodash-es/chunk'; +import React, { useCallback, useEffect, useState } from 'react'; + +export interface EmojiCategory { + id: string; + emojis: Emoji[]; +} + +interface Emoji { + id: string; + name: string; + native: string; +} + +export function useLoadEmojiData({ onEmojiSelect }: { onEmojiSelect: (emoji: string) => void }) { + const [searchValue, setSearchValue] = useState(''); + const [emojiCategories, setEmojiCategories] = useState([]); + const [skin, setSkin] = useState(() => { + return Number(Store.get('skin')) || 0; + }); + + const onSkinChange = useCallback((val: number) => { + setSkin(val); + Store.set('skin', String(val)); + }, []); + + const searchEmojiData = useCallback( + async (searchVal?: string) => { + const emojiData = await loadEmojiData(); + + const { emojis, categories } = emojiData as EmojiMartData; + + const filteredCategories = categories + .map((category) => { + const { id, emojis: categoryEmojis } = category; + + return { + id, + emojis: categoryEmojis + .filter((emojiId) => { + const emoji = emojis[emojiId]; + + if (!searchVal) return true; + return filterSearchValue(emoji, searchVal); + }) + .map((emojiId) => { + const emoji = emojis[emojiId]; + const { name, skins } = emoji; + + return { + id: emojiId, + name, + native: skins[skin] ? skins[skin].native : skins[0].native, + }; + }), + }; + }) + .filter((category) => category.emojis.length > 0); + + setEmojiCategories(filteredCategories); + }, + [skin] + ); + + useEffect(() => { + void (async () => { + await init({ + maxFrequentRows: MAX_FREQUENTLY_ROW_COUNT, + perLine: PER_ROW_EMOJI_COUNT, + }); + await searchEmojiData(); + })(); + }, [searchEmojiData]); + + useEffect(() => { + void searchEmojiData(searchValue); + }, [searchEmojiData, searchValue]); + + const onSelect = useCallback( + async (native: string) => { + onEmojiSelect(native); + if (!native) { + return; + } + + try { + const data = await getEmojiDataFromNative(native); + + FrequentlyUsed.add(data); + } catch (e) { + // do nothing + } + }, + [onEmojiSelect] + ); + + return { + emojiCategories, + setSearchValue, + searchValue, + onSelect, + onSkinChange, + skin, + }; +} + +export function useSelectSkinPopoverProps(): PopoverProps & { + onOpen: (event: React.MouseEvent) => void; + onClose: () => void; +} { + const [anchorEl, setAnchorEl] = useState(undefined); + const onOpen = useCallback((event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }, []); + const onClose = useCallback(() => { + setAnchorEl(undefined); + }, []); + const open = Boolean(anchorEl); + const anchorOrigin = { vertical: 'bottom', horizontal: 'center' } as PopoverOrigin; + const transformOrigin = { vertical: 'top', horizontal: 'center' } as PopoverOrigin; + + return { + anchorEl, + onOpen, + onClose, + open, + anchorOrigin, + transformOrigin, + }; +} + +function filterSearchValue( + emoji: { + name: string; + keywords?: string[]; + }, + searchValue: string +) { + const { name, keywords } = emoji; + const searchValueLowerCase = searchValue.toLowerCase(); + + return ( + name.toLowerCase().includes(searchValueLowerCase) || + (keywords && keywords.some((keyword) => keyword.toLowerCase().includes(searchValueLowerCase))) + ); +} + +export function getRowsWithCategories(emojiCategories: EmojiCategory[], rowSize: number) { + const rows: { + id: string; + type: 'category' | 'emojis'; + emojis?: Emoji[]; + }[] = []; + + emojiCategories.forEach((category) => { + rows.push({ + id: category.id, + type: 'category', + }); + chunk(category.emojis, rowSize).forEach((chunk, index) => { + rows.push({ + type: 'emojis', + emojis: chunk, + id: `${category.id}-${index}`, + }); + }); + }); + return rows; +} diff --git a/frontend/appflowy_web_app/src/components/_shared/emoji-picker/EmojiPicker.tsx b/frontend/appflowy_web_app/src/components/_shared/emoji-picker/EmojiPicker.tsx new file mode 100644 index 0000000000..d5d15fa966 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/emoji-picker/EmojiPicker.tsx @@ -0,0 +1,37 @@ +import React from 'react'; + +import { useLoadEmojiData } from './EmojiPicker.hooks'; +import EmojiPickerHeader from './EmojiPickerHeader'; +import EmojiPickerCategories from './EmojiPickerCategories'; + +interface Props { + onEmojiSelect: (emoji: string) => void; + onEscape?: () => void; + defaultEmoji?: string; + hideRemove?: boolean; +} + +export function EmojiPicker({ defaultEmoji, onEscape, ...props }: Props) { + const { skin, onSkinChange, emojiCategories, setSearchValue, searchValue, onSelect } = useLoadEmojiData(props); + + return ( +

+ + +
+ ); +} + +export default EmojiPicker; diff --git a/frontend/appflowy_web_app/src/components/_shared/emoji-picker/EmojiPickerCategories.tsx b/frontend/appflowy_web_app/src/components/_shared/emoji-picker/EmojiPickerCategories.tsx new file mode 100644 index 0000000000..8cc1ee5947 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/emoji-picker/EmojiPickerCategories.tsx @@ -0,0 +1,354 @@ +import { EMOJI_SIZE, PER_ROW_EMOJI_COUNT } from '@/components/_shared/emoji-picker/const'; +import { AFScroller } from '@/components/_shared/scroller'; +import { getDistanceEdge, inView } from '@/utils/position'; +import { Tooltip } from '@mui/material'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import AutoSizer from 'react-virtualized-auto-sizer'; +import { FixedSizeList } from 'react-window'; +import { EmojiCategory, getRowsWithCategories } from './EmojiPicker.hooks'; + +function EmojiPickerCategories({ + emojiCategories, + onEmojiSelect, + onEscape, + defaultEmoji, +}: { + emojiCategories: EmojiCategory[]; + onEmojiSelect: (emoji: string) => void; + onEscape?: () => void; + defaultEmoji?: string; +}) { + const scrollRef = React.useRef(null); + const { t } = useTranslation(); + const [selectCell, setSelectCell] = React.useState({ + row: 1, + column: 0, + }); + const rows = useMemo(() => { + return getRowsWithCategories(emojiCategories, PER_ROW_EMOJI_COUNT); + }, [emojiCategories]); + const mouseY = useRef(null); + const mouseX = useRef(null); + + const ref = React.useRef(null); + + const getCategoryName = useCallback( + (id: string) => { + const i18nName: Record = { + frequent: t('emoji.categories.frequentlyUsed'), + people: t('emoji.categories.people'), + nature: t('emoji.categories.nature'), + foods: t('emoji.categories.food'), + activity: t('emoji.categories.activities'), + places: t('emoji.categories.places'), + objects: t('emoji.categories.objects'), + symbols: t('emoji.categories.symbols'), + flags: t('emoji.categories.flags'), + }; + + return i18nName[id]; + }, + [t] + ); + + useEffect(() => { + scrollRef.current?.scrollTo({ + top: 0, + }); + + setSelectCell({ + row: 1, + column: 0, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rows]); + + const renderRow = useCallback( + ({ index, style }: { index: number; style: React.CSSProperties }) => { + const item = rows[index]; + + return ( +
+ {item.type === 'category' ? ( +
{getCategoryName(item.id)}
+ ) : null} +
+ {item.emojis?.map((emoji, columnIndex) => { + const isSelected = selectCell.row === index && selectCell.column === columnIndex; + + const isDefaultEmoji = defaultEmoji === emoji.native; + + return ( + +
{ + onEmojiSelect(emoji.native); + }} + onMouseMove={(e) => { + mouseY.current = e.clientY; + mouseX.current = e.clientX; + }} + onMouseEnter={(e) => { + if (mouseY.current === null || mouseY.current !== e.clientY || mouseX.current !== e.clientX) { + setSelectCell({ + row: index, + column: columnIndex, + }); + } + + mouseX.current = e.clientX; + mouseY.current = e.clientY; + }} + className={`flex cursor-pointer items-center justify-center rounded text-[20px] hover:bg-fill-list-hover ${ + isSelected ? 'bg-fill-list-hover' : 'hover:bg-transparent' + } ${isDefaultEmoji ? 'bg-fill-list-active' : ''}`} + > + {emoji.native} +
+
+ ); + })} +
+
+ ); + }, + [defaultEmoji, getCategoryName, onEmojiSelect, rows, selectCell.column, selectCell.row] + ); + + const getNewColumnIndex = useCallback( + (rowIndex: number, columnIndex: number): number => { + const row = rows[rowIndex]; + const length = row.emojis?.length; + let newColumnIndex = columnIndex; + + if (length && length <= columnIndex) { + newColumnIndex = length - 1 || 0; + } + + return newColumnIndex; + }, + [rows] + ); + + const findNextRow = useCallback( + (rowIndex: number, columnIndex: number): { row: number; column: number } => { + const rowLength = rows.length; + let nextRowIndex = rowIndex + 1; + + if (nextRowIndex >= rowLength - 1) { + nextRowIndex = rowLength - 1; + } else if (rows[nextRowIndex].type === 'category') { + nextRowIndex = findNextRow(nextRowIndex, columnIndex).row; + } + + const newColumnIndex = getNewColumnIndex(nextRowIndex, columnIndex); + + return { + row: nextRowIndex, + column: newColumnIndex, + }; + }, + [getNewColumnIndex, rows] + ); + + const findPrevRow = useCallback( + (rowIndex: number, columnIndex: number): { row: number; column: number } => { + let prevRowIndex = rowIndex - 1; + + if (prevRowIndex < 1) { + prevRowIndex = 1; + } else if (rows[prevRowIndex].type === 'category') { + prevRowIndex = findPrevRow(prevRowIndex, columnIndex).row; + } + + const newColumnIndex = getNewColumnIndex(prevRowIndex, columnIndex); + + return { + row: prevRowIndex, + column: newColumnIndex, + }; + }, + [getNewColumnIndex, rows] + ); + + const findPrevCell = useCallback( + (row: number, column: number): { row: number; column: number } => { + const prevColumn = column - 1; + + if (prevColumn < 0) { + const prevRow = findPrevRow(row, column).row; + + if (prevRow === row) return { row, column }; + const length = rows[prevRow].emojis?.length || 0; + + return { + row: prevRow, + column: length > 0 ? length - 1 : 0, + }; + } + + return { + row, + column: prevColumn, + }; + }, + [findPrevRow, rows] + ); + + const findNextCell = useCallback( + (row: number, column: number): { row: number; column: number } => { + const nextColumn = column + 1; + + const rowLength = rows[row].emojis?.length || 0; + + if (nextColumn >= rowLength) { + const nextRow = findNextRow(row, column).row; + + if (nextRow === row) return { row, column }; + return { + row: nextRow, + column: 0, + }; + } + + return { + row, + column: nextColumn, + }; + }, + [findNextRow, rows] + ); + + useEffect(() => { + if (!selectCell || !scrollRef.current) return; + const emojiKey = rows[selectCell.row]?.emojis?.[selectCell.column]?.id; + const emojiDom = document.querySelector(`[data-key="${emojiKey}"]`); + + if (emojiDom && !inView(emojiDom as HTMLElement, scrollRef.current as HTMLElement)) { + const distance = getDistanceEdge(emojiDom as HTMLElement, scrollRef.current as HTMLElement); + + scrollRef.current?.scrollTo({ + top: scrollRef.current?.scrollTop + distance, + }); + } + }, [selectCell, rows]); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + e.stopPropagation(); + + switch (e.key) { + case 'Escape': + e.preventDefault(); + onEscape?.(); + break; + case 'ArrowUp': { + e.preventDefault(); + + setSelectCell(findPrevRow(selectCell.row, selectCell.column)); + + break; + } + + case 'ArrowDown': { + e.preventDefault(); + + setSelectCell(findNextRow(selectCell.row, selectCell.column)); + + break; + } + + case 'ArrowLeft': { + e.preventDefault(); + + const prevCell = findPrevCell(selectCell.row, selectCell.column); + + setSelectCell(prevCell); + break; + } + + case 'ArrowRight': { + e.preventDefault(); + + const nextCell = findNextCell(selectCell.row, selectCell.column); + + setSelectCell(nextCell); + break; + } + + case 'Enter': { + e.preventDefault(); + const currentRow = rows[selectCell.row]; + const emoji = currentRow.emojis?.[selectCell.column]; + + if (emoji) { + onEmojiSelect(emoji.native); + } + + break; + } + + default: + break; + } + }, + [ + findNextCell, + findPrevCell, + findPrevRow, + findNextRow, + onEmojiSelect, + onEscape, + rows, + selectCell.column, + selectCell.row, + ] + ); + + useEffect(() => { + const focusElement = document.querySelector('.emoji-picker .search-emoji-input') as HTMLInputElement; + + const parentElement = ref.current?.parentElement; + + focusElement?.addEventListener('keydown', handleKeyDown); + parentElement?.addEventListener('keydown', handleKeyDown); + return () => { + focusElement?.removeEventListener('keydown', handleKeyDown); + parentElement?.removeEventListener('keydown', handleKeyDown); + }; + }, [handleKeyDown]); + + return ( +
+ + {({ height, width }: { height: number; width: number }) => ( + + {renderRow} + + )} + +
+ ); +} + +export default EmojiPickerCategories; diff --git a/frontend/appflowy_web_app/src/components/_shared/emoji-picker/EmojiPickerHeader.tsx b/frontend/appflowy_web_app/src/components/_shared/emoji-picker/EmojiPickerHeader.tsx new file mode 100644 index 0000000000..97d76cf7b5 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/emoji-picker/EmojiPickerHeader.tsx @@ -0,0 +1,156 @@ +import { useSelectSkinPopoverProps } from './EmojiPicker.hooks'; +import React, { useCallback } from 'react'; +import { Button, OutlinedInput } from '@mui/material'; + +import Tooltip from '@mui/material/Tooltip'; +import { randomEmoji } from '@/utils/emoji'; +import { ReactComponent as ShuffleIcon } from '@/assets/shuffle.svg'; +import Popover from '@mui/material/Popover'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as DeleteOutlineRounded } from '@/assets/trash.svg'; +import { ReactComponent as SearchOutlined } from '@/assets/search.svg'; + +const skinTones = [ + { + value: 0, + icon: '👋', + }, + { + value: 1, + icon: '👋🏻', + }, + { + value: 2, + icon: '👋🏼', + }, + { + value: 3, + icon: '👋🏽', + }, + { + value: 4, + icon: '👋🏾', + }, + { + value: 5, + icon: '👋🏿', + }, +]; + +interface Props { + onEmojiSelect: (emoji: string) => void; + skin: number; + onSkinSelect: (skin: number) => void; + searchValue: string; + onSearchChange: (value: string) => void; + hideRemove?: boolean; +} + +function EmojiPickerHeader({ hideRemove, onEmojiSelect, onSkinSelect, searchValue, onSearchChange, skin }: Props) { + const { onOpen, ...popoverProps } = useSelectSkinPopoverProps(); + const { t } = useTranslation(); + + const renderButton = useCallback( + ({ + onClick, + tooltip, + children, + }: { + onClick: (e: React.MouseEvent) => void; + tooltip: string; + children: React.ReactNode; + }) => { + return ( + + + + ); + }, + [] + ); + + return ( +
+
+ } + value={searchValue} + onChange={(e) => { + onSearchChange(e.target.value); + }} + autoFocus={true} + fullWidth={true} + size={'small'} + autoCorrect={'off'} + autoComplete={'off'} + spellCheck={false} + inputProps={{ + className: 'px-2 py-1.5 text-base', + }} + className={'search-emoji-input'} + placeholder={t('search.label')} + /> +
+ {renderButton({ + onClick: async () => { + const emoji = await randomEmoji(); + + onEmojiSelect(emoji); + }, + tooltip: t('emoji.random'), + children: , + })} + + {renderButton({ + onClick: onOpen, + tooltip: t('emoji.selectSkinTone'), + children: {skinTones[skin].icon}, + })} + + {hideRemove + ? null + : renderButton({ + onClick: () => { + onEmojiSelect(''); + }, + tooltip: t('emoji.remove'), + children: , + })} +
+
+ +
+ {skinTones.map((skinTone) => ( +
+ +
+ ))} +
+
+
+ ); +} + +export default EmojiPickerHeader; diff --git a/frontend/appflowy_web_app/src/components/_shared/emoji-picker/const.ts b/frontend/appflowy_web_app/src/components/_shared/emoji-picker/const.ts new file mode 100644 index 0000000000..151c93fa20 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/emoji-picker/const.ts @@ -0,0 +1,3 @@ +export const EMOJI_SIZE = 38; +export const PER_ROW_EMOJI_COUNT = 9; +export const MAX_FREQUENTLY_ROW_COUNT = 2; diff --git a/frontend/appflowy_web_app/src/components/_shared/emoji-picker/index.ts b/frontend/appflowy_web_app/src/components/_shared/emoji-picker/index.ts new file mode 100644 index 0000000000..2e8188b1cc --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/emoji-picker/index.ts @@ -0,0 +1,3 @@ +import { lazy } from 'react'; + +export const EmojiPicker = lazy(() => import('./EmojiPicker')); diff --git a/frontend/appflowy_web_app/src/components/_shared/image-render/ImageRender.tsx b/frontend/appflowy_web_app/src/components/_shared/image-render/ImageRender.tsx new file mode 100644 index 0000000000..4cd2aafa6f --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/image-render/ImageRender.tsx @@ -0,0 +1,50 @@ +import { Skeleton } from '@mui/material'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as ErrorOutline } from '@/assets/error.svg'; + +interface ImageRenderProps extends React.HTMLAttributes { + src: string; + alt?: string; +} + +export function ImageRender({ src, ...props }: ImageRenderProps) { + const { t } = useTranslation(); + const [loading, setLoading] = useState(true); + const [hasError, setHasError] = useState(false); + + return ( + <> + {hasError ? ( +
+ +
{t('editor.imageLoadFailed')}
+
+ ) : loading ? ( + + ) : null} + { + props.onLoad?.(e); + setLoading(false); + setHasError(false); + }} + onError={(e) => { + props.onError?.(e); + setHasError(true); + setLoading(false); + }} + /> + + ); +} + +export default ImageRender; diff --git a/frontend/appflowy_web_app/src/components/_shared/katex-math/index.ts b/frontend/appflowy_web_app/src/components/_shared/katex-math/index.ts new file mode 100644 index 0000000000..b9833620d9 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/katex-math/index.ts @@ -0,0 +1,3 @@ +import { lazy } from 'react'; + +export const KatexMath = lazy(() => import('./KatexMath')); diff --git a/frontend/appflowy_web_app/src/components/_shared/modal/NormalModal.tsx b/frontend/appflowy_web_app/src/components/_shared/modal/NormalModal.tsx new file mode 100644 index 0000000000..5698837a27 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/modal/NormalModal.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, ButtonProps, CircularProgress, Dialog, DialogProps, IconButton } from '@mui/material'; +import { ReactComponent as CloseIcon } from '@/assets/close.svg'; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +export interface NormalModalProps extends DialogProps { + okText?: string; + cancelText?: string; + onOk?: () => void; + onCancel?: () => void; + danger?: boolean; + onClose?: () => void; + title: string | React.ReactNode; + okButtonProps?: ButtonProps; + cancelButtonProps?: ButtonProps; + okLoading?: boolean; +} + +export function NormalModal({ + okText, + title, + cancelText, + onOk, + onCancel, + danger, + onClose, + children, + okButtonProps, + cancelButtonProps, + okLoading, + ...dialogProps +}: NormalModalProps) { + const { t } = useTranslation(); + const modalOkText = okText || t('button.ok'); + const modalCancelText = cancelText || t('button.cancel'); + const buttonColor = danger ? 'var(--function-error)' : undefined; + + return ( + { + if (e.key === 'Escape') { + onClose?.(); + } + }} + {...dialogProps} + > +
+
+
{title}
+
+ + + +
+
+ +
{children}
+
+ + +
+
+
+ ); +} + +export default NormalModal; diff --git a/frontend/appflowy_web_app/src/components/_shared/modal/index.ts b/frontend/appflowy_web_app/src/components/_shared/modal/index.ts new file mode 100644 index 0000000000..d6ea76cb2d --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/modal/index.ts @@ -0,0 +1 @@ +export * from './NormalModal'; diff --git a/frontend/appflowy_web_app/src/components/_shared/notify/InfoSnackbar.tsx b/frontend/appflowy_web_app/src/components/_shared/notify/InfoSnackbar.tsx new file mode 100644 index 0000000000..a5c36318fb --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/notify/InfoSnackbar.tsx @@ -0,0 +1,122 @@ +import { notify } from '@/components/_shared/notify/index'; +import React, { forwardRef } from 'react'; +import { Button, IconButton, Paper } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as CloseIcon } from '@/assets/close.svg'; +import { CustomContentProps, SnackbarContent } from 'notistack'; +import { ReactComponent as CheckCircle } from '@/assets/check_circle.svg'; +import { ReactComponent as ErrorOutline } from '@/assets/error_outline.svg'; +import { ReactComponent as WarningAmber } from '@/assets/warning_amber.svg'; + +export interface InfoProps { + onOk?: () => void; + okText?: string; + title?: string; + message?: JSX.Element | string; + onClose?: () => void; + autoHideDuration?: number | null; + type?: 'success' | 'info' | 'warning' | 'error'; + showActions?: boolean; +} + +export type InfoSnackbarProps = InfoProps & CustomContentProps; + +const InfoSnackbar = forwardRef( + ({ showActions = true, type = 'info', onOk, okText, title, message, onClose }, ref) => { + const { t } = useTranslation(); + + const handleClose = () => { + onClose?.(); + notify.clear(); + }; + + return ( + + +
+
+ {getIcon(type)} +
{title}
+
+
+ + + +
+
+ +
{message}
+ {showActions && ( +
+ +
+ )} +
+
+ ); + } +); + +export default InfoSnackbar; + +function getIcon(type: 'success' | 'info' | 'warning' | 'error') { + switch (type) { + case 'success': + return ; + case 'info': + return ''; + case 'warning': + return ; + case 'error': + return ; + } +} + +function getButtonBgColor(type: 'success' | 'info' | 'warning' | 'error') { + switch (type) { + case 'success': + return 'bg-[var(--function-success)]'; + case 'info': + return ''; + case 'warning': + return 'bg-[var(--function-warning)]'; + case 'error': + return 'bg-[var(--function-error)]'; + } +} + +function getButtonHoverBgColor(type: 'success' | 'info' | 'warning' | 'error') { + switch (type) { + case 'success': + return 'hover:bg-[var(--function-success-hover)]'; + case 'info': + return ''; + case 'warning': + return 'hover:bg-[var(--function-warning-hover)]'; + case 'error': + return 'hover:bg-[var(--function-error-hover)]'; + } +} + +function getBorderColor(type: 'success' | 'info' | 'warning' | 'error') { + switch (type) { + case 'success': + return 'border-[var(--function-success)]'; + case 'info': + return 'border-transparent'; + case 'warning': + return 'border-[var(--function-warning)]'; + case 'error': + return 'border-[var(--function-error)]'; + } +} diff --git a/frontend/appflowy_web_app/src/components/_shared/notify/index.ts b/frontend/appflowy_web_app/src/components/_shared/notify/index.ts index 9b6e888f2e..127d89dbac 100644 --- a/frontend/appflowy_web_app/src/components/_shared/notify/index.ts +++ b/frontend/appflowy_web_app/src/components/_shared/notify/index.ts @@ -1,3 +1,8 @@ +import { InfoProps } from '@/components/_shared/notify/InfoSnackbar'; +import { lazy } from 'react'; + +export const InfoSnackbar = lazy(() => import('./InfoSnackbar')); + export const notify = { success: (message: string) => { window.toast.success(message); @@ -11,10 +16,19 @@ export const notify = { warning: (message: string) => { window.toast.warning(message); }, - info: (message: string) => { - window.toast.info(message); + info: (props: InfoProps) => { + window.toast.info({ + ...props, + variant: 'info', + anchorOrigin: { + vertical: 'bottom', + horizontal: 'center', + }, + }); }, clear: () => { window.toast.clear(); }, }; + +export * from './InfoSnackbar'; diff --git a/frontend/appflowy_web_app/src/components/_shared/popover/Popover.tsx b/frontend/appflowy_web_app/src/components/_shared/popover/Popover.tsx index f91ac8284e..90819ed57b 100644 --- a/frontend/appflowy_web_app/src/components/_shared/popover/Popover.tsx +++ b/frontend/appflowy_web_app/src/components/_shared/popover/Popover.tsx @@ -17,3 +17,5 @@ export function Popover({ children, ...props }: PopoverComponentProps) { ); } + +export default Popover; diff --git a/frontend/appflowy_web_app/src/components/app/AppConfig.tsx b/frontend/appflowy_web_app/src/components/app/AppConfig.tsx index b4ef0e60d4..44d00cdb67 100644 --- a/frontend/appflowy_web_app/src/components/app/AppConfig.tsx +++ b/frontend/appflowy_web_app/src/components/app/AppConfig.tsx @@ -2,10 +2,13 @@ import { clearData } from '@/application/db'; import { EventType, on } from '@/application/session'; import { isTokenValid } from '@/application/session/token'; import { useAppLanguage } from '@/components/app/useAppLanguage'; +import { LoginModal } from '@/components/login'; import { useSnackbar } from 'notistack'; -import React, { createContext, useEffect, useState } from 'react'; +import React, { createContext, useCallback, useEffect, useState } from 'react'; import { AFService, AFServiceConfig } from '@/application/services/services.type'; import { getService } from '@/application/services'; +import { InfoSnackbarProps } from '@/components/_shared/notify'; +import { User } from '@/application/types'; const baseURL = import.meta.env.AF_BASE_URL || 'https://test.appflowy.cloud'; const gotrueURL = import.meta.env.AF_GOTRUE_URL || 'https://test.appflowy.cloud/gotrue'; @@ -23,6 +26,8 @@ export const AFConfigContext = createContext< | { service: AFService | undefined; isAuthenticated: boolean; + currentUser?: User; + openLoginModal: (redirectTo?: string) => void; } | undefined >(undefined); @@ -31,6 +36,14 @@ function AppConfig({ children }: { children: React.ReactNode }) { const [appConfig] = useState(defaultConfig); const [service, setService] = useState(); const [isAuthenticated, setIsAuthenticated] = React.useState(isTokenValid()); + const [currentUser, setCurrentUser] = React.useState(); + const [loginOpen, setLoginOpen] = React.useState(false); + const [loginCompletedRedirectTo, setLoginCompletedRedirectTo] = React.useState(''); + + const openLoginModal = useCallback((redirectTo?: string) => { + setLoginOpen(true); + setLoginCompletedRedirectTo(redirectTo || ''); + }, []); useEffect(() => { return on(EventType.SESSION_VALID, () => { @@ -38,6 +51,24 @@ function AppConfig({ children }: { children: React.ReactNode }) { }); }, []); + useEffect(() => { + if (!isAuthenticated) { + setCurrentUser(undefined); + return; + } + + void (async () => { + if (!service) return; + try { + const user = await service.getCurrentUser(); + + setCurrentUser(user); + } catch (e) { + console.error(e); + } + })(); + }, [isAuthenticated, service]); + useEffect(() => { const handleStorageChange = (event: StorageEvent) => { if (event.key === 'token') setIsAuthenticated(isTokenValid()); @@ -79,8 +110,9 @@ function AppConfig({ children }: { children: React.ReactNode }) { default: (message: string) => { enqueueSnackbar(message, { variant: 'default' }); }, - info: (message: string) => { - enqueueSnackbar(message, { variant: 'info' }); + + info: (props: InfoSnackbarProps) => { + enqueueSnackbar(props.message, props); }, clear: () => { @@ -111,9 +143,20 @@ function AppConfig({ children }: { children: React.ReactNode }) { value={{ service, isAuthenticated, + currentUser, + openLoginModal, }} > {children} + {loginOpen && ( + { + setLoginOpen(false); + }} + /> + )} ); } diff --git a/frontend/appflowy_web_app/src/components/app/AppTheme.tsx b/frontend/appflowy_web_app/src/components/app/AppTheme.tsx index 5b81148b0b..627a1f6ef5 100644 --- a/frontend/appflowy_web_app/src/components/app/AppTheme.tsx +++ b/frontend/appflowy_web_app/src/components/app/AppTheme.tsx @@ -41,20 +41,42 @@ function AppTheme({ children }: { children: React.ReactNode }) { }, borderRadius: '4px', padding: '2px', + '&.MuiIconButton-colorInherit': { + color: 'var(--icon-primary)', + }, }, }, }, MuiButton: { styleOverrides: { + text: { + borderRadius: '8px', + '&:hover': { + backgroundColor: 'var(--fill-list-hover)', + }, + }, contained: { color: 'var(--content-on-fill)', boxShadow: 'none', '&:hover': { backgroundColor: 'var(--content-blue-600)', }, + borderRadius: '8px', + '&.Mui-disabled': { + backgroundColor: 'var(--content-blue-400)', + opacity: 0.3, + color: 'var(--content-on-fill)', + }, + }, + outlined: { + '&.MuiButton-outlinedInherit': { + borderColor: 'var(--line-divider)', + }, + borderRadius: '8px', }, }, }, + MuiButtonBase: { styleOverrides: { root: { @@ -78,10 +100,16 @@ function AppTheme({ children }: { children: React.ReactNode }) { root: { backgroundImage: 'none', boxShadow: 'var(--shadow)', + borderRadius: '10px', }, }, }, MuiDialog: { + styleOverrides: { + paper: { + borderRadius: '12px', + }, + }, defaultProps: { sx: { '& .MuiBackdrop-root': { @@ -112,6 +140,7 @@ function AppTheme({ children }: { children: React.ReactNode }) { color: 'var(--text-caption)', WebkitTextFillColor: 'var(--text-caption) !important', }, + borderRadius: '8px', }, }, styleOverrides: { diff --git a/frontend/appflowy_web_app/src/components/app/withAppWrapper.tsx b/frontend/appflowy_web_app/src/components/app/withAppWrapper.tsx index 0fddafd2a5..e296637a0f 100644 --- a/frontend/appflowy_web_app/src/components/app/withAppWrapper.tsx +++ b/frontend/appflowy_web_app/src/components/app/withAppWrapper.tsx @@ -5,6 +5,7 @@ import AppConfig from '@/components/app/AppConfig'; import { Suspense } from 'react'; import { SnackbarProvider } from 'notistack'; import { styled } from '@mui/material'; +import { InfoSnackbar } from '../_shared/notify'; const StyledSnackbarProvider = styled(SnackbarProvider)` &.notistack-MuiContent-default { @@ -39,6 +40,9 @@ export default function withAppWrapper(Component: React.FC): React.FC { horizontal: 'center', }} preventDuplicate + Components={{ + info: InfoSnackbar, + }} > diff --git a/frontend/appflowy_web_app/src/components/database/calendar/Calendar.tsx b/frontend/appflowy_web_app/src/components/database/calendar/Calendar.tsx index 96e8238c6b..d1933056aa 100644 --- a/frontend/appflowy_web_app/src/components/database/calendar/Calendar.tsx +++ b/frontend/appflowy_web_app/src/components/database/calendar/Calendar.tsx @@ -8,15 +8,12 @@ export function Calendar() { const { dayPropGetter, localizer, formats, events, emptyEvents } = useCalendarSetup(); return ( -
+
, eventWrapper: Event, }} - style={{ - marginBottom: '24px', - }} events={events} views={['month']} localizer={localizer} diff --git a/frontend/appflowy_web_app/src/components/database/calendar/calendar.scss b/frontend/appflowy_web_app/src/components/database/calendar/calendar.scss index eacc97482a..debf2d67d9 100644 --- a/frontend/appflowy_web_app/src/components/database/calendar/calendar.scss +++ b/frontend/appflowy_web_app/src/components/database/calendar/calendar.scss @@ -27,17 +27,24 @@ $today-highlight-bg: transparent; } } +.rbc-calendar { + height: fit-content; + @apply w-full overflow-x-scroll; + @include mixin.scrollbar-style; + +} + .rbc-month-view { border: none; - @apply h-full overflow-auto; + height: fit-content; .rbc-month-row { border: 1px solid var(--line-divider); border-top: none; - min-width: 700px; + min-width: 1200px; + @apply max-sm:w-[650vw]; } - @include mixin.scrollbar-style; } @@ -51,7 +58,9 @@ $today-highlight-bg: transparent; top: 0; background: var(--bg-body); z-index: 50; - min-width: 700px; + min-width: 1200px; + + @apply max-sm:w-[650vw]; .rbc-header { border: none; @@ -79,10 +88,6 @@ $today-highlight-bg: transparent; height: fit-content; table-layout: fixed; width: 100%; - - &:last-child { - margin-bottom: 150px; - } } .event-properties { diff --git a/frontend/appflowy_web_app/src/components/database/components/board/column/Column.tsx b/frontend/appflowy_web_app/src/components/database/components/board/column/Column.tsx index bedf5088d0..6ac53aa7b2 100644 --- a/frontend/appflowy_web_app/src/components/database/components/board/column/Column.tsx +++ b/frontend/appflowy_web_app/src/components/database/components/board/column/Column.tsx @@ -79,7 +79,6 @@ export const Column = memo( return ( +
{dateStr}
onNavigate('PREV')}> diff --git a/frontend/appflowy_web_app/src/components/database/components/cell/date/DateTimeCell.tsx b/frontend/appflowy_web_app/src/components/database/components/cell/date/DateTimeCell.tsx index 8585b47a20..ccfd773c0a 100644 --- a/frontend/appflowy_web_app/src/components/database/components/cell/date/DateTimeCell.tsx +++ b/frontend/appflowy_web_app/src/components/database/components/cell/date/DateTimeCell.tsx @@ -2,7 +2,7 @@ import { FieldType } from '@/application/database-yjs'; import { useDateTypeCellDispatcher } from '@/components/database/components/cell/Cell.hooks'; import { CellProps, DateTimeCell as DateTimeCellType } from '@/application/database-yjs/cell.type'; import React, { useMemo } from 'react'; -import { ReactComponent as ReminderSvg } from '$icons/16x/clock_alarm.svg'; +import { ReactComponent as ReminderSvg } from '@/assets/clock_alarm.svg'; export function DateTimeCell({ cell, fieldId, style, placeholder }: CellProps) { const { getDateTimeStr } = useDateTypeCellDispatcher(fieldId); diff --git a/frontend/appflowy_web_app/src/components/database/components/field/CardField.tsx b/frontend/appflowy_web_app/src/components/database/components/field/CardField.tsx index 6f98f7579d..85aa3edb9a 100644 --- a/frontend/appflowy_web_app/src/components/database/components/field/CardField.tsx +++ b/frontend/appflowy_web_app/src/components/database/components/field/CardField.tsx @@ -4,7 +4,7 @@ import Cell from '@/components/database/components/cell/Cell'; import React, { CSSProperties, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -function CardField({ rowId, fieldId }: { rowId: string; fieldId: string; index: number }) { +export function CardField({ rowId, fieldId }: { rowId: string; fieldId: string; index: number }) { const { t } = useTranslation(); const { field } = useFieldSelector(fieldId); const cell = useCellSelector({ @@ -21,7 +21,7 @@ function CardField({ rowId, fieldId }: { rowId: string; fieldId: string; index: textAlign: 'left', }; - if ([FieldType.Relation, FieldType.SingleSelect, FieldType.MultiSelect].includes(Number(type))) { + if (isPrimary || [FieldType.Relation, FieldType.SingleSelect, FieldType.MultiSelect].includes(Number(type))) { Object.assign(styleProperties, { breakWord: 'break-word', whiteSpace: 'normal', diff --git a/frontend/appflowy_web_app/src/components/database/components/grid/grid-table/GridTable.tsx b/frontend/appflowy_web_app/src/components/database/components/grid/grid-table/GridTable.tsx index 8cd294d234..01ed814c8e 100644 --- a/frontend/appflowy_web_app/src/components/database/components/grid/grid-table/GridTable.tsx +++ b/frontend/appflowy_web_app/src/components/database/components/grid/grid-table/GridTable.tsx @@ -126,7 +126,7 @@ export const GridTable = ({ scrollLeft, columnWidth, columns, onScrollLeft }: Gr columnCount={columns.length} columnWidth={(index) => columnWidth(index, width)} rowHeight={rowHeight} - className={'grid-table pb-[150px]'} + className={'grid-table'} overscanRowCount={5} overscanColumnCount={5} style={{ diff --git a/frontend/appflowy_web_app/src/components/database/components/header/DatabaseHeader.tsx b/frontend/appflowy_web_app/src/components/database/components/header/DatabaseHeader.tsx index 24b3b5bf20..a98c434dd2 100644 --- a/frontend/appflowy_web_app/src/components/database/components/header/DatabaseHeader.tsx +++ b/frontend/appflowy_web_app/src/components/database/components/header/DatabaseHeader.tsx @@ -18,7 +18,7 @@ function DatabaseHeader({ return (
diff --git a/frontend/appflowy_web_app/src/components/database/components/tabs/DatabaseTabs.tsx b/frontend/appflowy_web_app/src/components/database/components/tabs/DatabaseTabs.tsx index c74165fa66..b8892bc75e 100644 --- a/frontend/appflowy_web_app/src/components/database/components/tabs/DatabaseTabs.tsx +++ b/frontend/appflowy_web_app/src/components/database/components/tabs/DatabaseTabs.tsx @@ -1,8 +1,9 @@ import { DatabaseViewLayout, YDatabaseView, YjsDatabaseKey } from '@/application/collab.type'; -import { useDatabase, useDatabaseView } from '@/application/database-yjs'; +import { DatabaseContext, useDatabase, useDatabaseView } from '@/application/database-yjs'; +import { ViewMeta } from '@/application/db/tables/view_metas'; import { DatabaseActions } from '@/components/database/components/conditions'; import { Tooltip } from '@mui/material'; -import { forwardRef, FunctionComponent, SVGProps, useMemo } from 'react'; +import { forwardRef, FunctionComponent, SVGProps, useContext, useEffect, useMemo, useState } from 'react'; import { ViewTabs, ViewTab } from './ViewTabs'; import { useTranslation } from 'react-i18next'; @@ -32,12 +33,28 @@ export const DatabaseTabs = forwardRef( const { t } = useTranslation(); const view = useDatabaseView(); const views = useDatabase().get(YjsDatabaseKey.views); + const loadViewMeta = useContext(DatabaseContext)?.loadViewMeta; + const [meta, setMeta] = useState(null); const layout = Number(view?.get(YjsDatabaseKey.layout)) as DatabaseViewLayout; const handleChange = (_: React.SyntheticEvent, newValue: string) => { setSelectedViewId?.(newValue); }; + useEffect(() => { + void (async () => { + if (loadViewMeta) { + try { + const meta = await loadViewMeta(iidIndex, setMeta); + + setMeta(meta); + } catch (e) { + // do nothing + } + } + })(); + }, [loadViewMeta, iidIndex]); + const className = useMemo(() => { const classList = ['-mb-[0.5px] flex items-center overflow-hidden border-line-divider text-text-title']; @@ -48,12 +65,14 @@ export const DatabaseTabs = forwardRef( return classList.join(' '); }, [layout]); + const showActions = !hideConditions && layout !== DatabaseViewLayout.Calendar; + if (viewIds.length === 0) return null; return (
@@ -70,7 +89,7 @@ export const DatabaseTabs = forwardRef( if (!view) return null; const layout = Number(view.get(YjsDatabaseKey.layout)) as DatabaseViewLayout; const Icon = DatabaseIcons[layout]; - const name = viewId === iidIndex ? viewName : view.get(YjsDatabaseKey.name); + const name = viewId === iidIndex ? viewName : meta?.child_views?.find((v) => v.view_id === viewId)?.name; return ( ( })}
- {!hideConditions && layout !== DatabaseViewLayout.Calendar ? : null} + {showActions ? : null}
); } diff --git a/frontend/appflowy_web_app/src/components/document/Document.tsx b/frontend/appflowy_web_app/src/components/document/Document.tsx index 2285237c21..ed54c3144d 100644 --- a/frontend/appflowy_web_app/src/components/document/Document.tsx +++ b/frontend/appflowy_web_app/src/components/document/Document.tsx @@ -1,5 +1,5 @@ import { GetViewRowsMap, LoadView, LoadViewMeta, YDoc } from '@/application/collab.type'; -import ComponentLoading from '@/components/_shared/progress/ComponentLoading'; +import DocumentSkeleton from '@/components/document/DocumentSkeleton'; import { Editor } from '@/components/editor'; import React, { Suspense } from 'react'; import ViewMetaPreview, { ViewMetaProps } from '@/components/view-meta/ViewMetaPreview'; @@ -17,7 +17,7 @@ export const Document = ({ doc, loadView, navigateToView, loadViewMeta, getViewR return (
- }> + }>
+ +
+ ); +} + +export default DocumentSkeleton; diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/code/Code.hooks.ts b/frontend/appflowy_web_app/src/components/editor/components/blocks/code/Code.hooks.ts index 8f3b3f99de..232e60cd63 100644 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/code/Code.hooks.ts +++ b/frontend/appflowy_web_app/src/components/editor/components/blocks/code/Code.hooks.ts @@ -3,39 +3,49 @@ import { useEditorContext } from '@/components/editor/EditorContext'; import { useCallback, useEffect } from 'react'; import { ReactEditor, useSlateStatic } from 'slate-react'; import { Element as SlateElement, Transforms } from 'slate'; - -const Prism = window.Prism; -const hljs = window.hljs; +import Prism from 'prismjs'; export function useCodeBlock(node: CodeNode) { const language = node.data.language; const editor = useSlateStatic() as ReactEditor; - const addCodeGrammars = useEditorContext().addCodeGrammars; useEffect(() => { - const path = ReactEditor.findPath(editor, node); - let detectedLanguage = language; + void (async () => { + const path = ReactEditor.findPath(editor, node); + let detectedLanguage = language; - if (!language) { - const codeSnippet = editor.string(path); + if (!language) { + const codeSnippet = editor.string(path); + const script = document.createElement('script'); - detectedLanguage = hljs.highlightAuto(codeSnippet).language; - } + script.src = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.5.1/highlight.min.js'; + document.body.appendChild(script); + const promise = new Promise((resolve) => { + script.onload = () => { + resolve(true); + }; + }); - const prismLanguage = Prism.languages[detectedLanguage.toLowerCase()]; + await promise; - if (!prismLanguage) { - const script = document.createElement('script'); + detectedLanguage = window.hljs.highlightAuto(codeSnippet).language || 'plaintext'; + } - script.src = `https://cdnjs.cloudflare.com/ajax/libs/prism/1.26.0/components/prism-${detectedLanguage.toLowerCase()}.min.js`; - document.head.appendChild(script); - script.onload = () => { + const prismLanguage = Prism.languages[detectedLanguage.toLowerCase()]; + + if (!prismLanguage) { + const script = document.createElement('script'); + + script.src = `https://cdnjs.cloudflare.com/ajax/libs/prism/1.26.0/components/prism-${detectedLanguage.toLowerCase()}.min.js`; + document.body.appendChild(script); + script.onload = () => { + addCodeGrammars?.(node.blockId, detectedLanguage); + }; + } else { addCodeGrammars?.(node.blockId, detectedLanguage); - }; - } else { - addCodeGrammars?.(node.blockId, detectedLanguage); - } + } + })(); }, [addCodeGrammars, editor, language, node]); const handleChangeLanguage = useCallback( diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/code/utils.ts b/frontend/appflowy_web_app/src/components/editor/components/blocks/code/utils.ts index 12d5070c7b..4750b0c229 100644 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/code/utils.ts +++ b/frontend/appflowy_web_app/src/components/editor/components/blocks/code/utils.ts @@ -1,8 +1,5 @@ import { BaseRange, NodeEntry, Text, Path } from 'slate'; - -const Prism = window.Prism; - -Prism.plugins.autoloader.languages_path = 'https://cdnjs.cloudflare.com/ajax/libs/prism/1.26.0/components/'; +import Prism, { Grammar } from 'prismjs'; const push_string = ( token: string | Prism.Token, @@ -86,7 +83,7 @@ export const decorateCode = ([node, path]: NodeEntry, language: string) => { return ranges; } - const highlightCode = (code: string, language: string) => { + const highlightCode = (code: string, language: Grammar) => { try { const tokens = Prism.tokenize(code, language); diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/image/ImageRender.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/image/ImageRender.tsx index 9ace998260..1e529549df 100644 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/image/ImageRender.tsx +++ b/frontend/appflowy_web_app/src/components/editor/components/blocks/image/ImageRender.tsx @@ -4,8 +4,8 @@ import { ImageBlockNode } from '@/components/editor/editor.type'; import { copyTextToClipboard } from '@/utils/copy'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { CircularProgress } from '@mui/material'; -import { ErrorOutline } from '@mui/icons-material'; +import { Skeleton } from '@mui/material'; +import { ReactComponent as ErrorOutline } from '@/assets/error.svg'; const MIN_WIDTH = 100; @@ -35,7 +35,11 @@ function ImageRender({ }, [hasError, initialWidth, loading]); const imageProps: React.ImgHTMLAttributes = useMemo(() => { return { - style: { width: loading || hasError ? '0' : newWidth ?? '100%', opacity: selected ? 0.8 : 1 }, + style: { + width: loading || hasError ? '0' : newWidth ?? '100%', + opacity: selected ? 0.8 : 1, + height: hasError ? 0 : 'auto', + }, className: 'object-cover', ref: imgRef, src: url, @@ -54,7 +58,9 @@ function ImageRender({ const renderErrorNode = useCallback(() => { return (
{t('editor.imageLoadFailed')}
@@ -68,7 +74,7 @@ function ImageRender({
@@ -86,14 +92,7 @@ function ImageRender({ }} /> )} - {hasError ? ( - renderErrorNode() - ) : loading ? ( -
- -
{t('editor.loading')}
-
- ) : null} + {hasError ? renderErrorNode() : loading ? : null}
); } diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/math-equation/MathEquation.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/math-equation/MathEquation.tsx index 045780c432..7a11ef8814 100644 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/math-equation/MathEquation.tsx +++ b/frontend/appflowy_web_app/src/components/editor/components/blocks/math-equation/MathEquation.tsx @@ -1,4 +1,4 @@ -import KatexMath from '@/components/_shared/katex-math/KatexMath'; +import { KatexMath } from '@/components/_shared/katex-math'; import { notify } from '@/components/_shared/notify'; import RightTopActionsToolbar from '@/components/editor/components/block-actions/RightTopActionsToolbar'; import { EditorElementProps, MathEquationNode } from '@/components/editor/editor.type'; diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/math-equation/index.ts b/frontend/appflowy_web_app/src/components/editor/components/blocks/math-equation/index.ts index d10b172020..ae6eb70209 100644 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/math-equation/index.ts +++ b/frontend/appflowy_web_app/src/components/editor/components/blocks/math-equation/index.ts @@ -1,3 +1 @@ -import { lazy } from 'react'; - -export const MathEquation = lazy(() => import('./MathEquation')); +export * from './MathEquation'; diff --git a/frontend/appflowy_web_app/src/components/editor/components/leaf/formula/Formula.tsx b/frontend/appflowy_web_app/src/components/editor/components/leaf/formula/Formula.tsx index 8efd1b0345..fd1a4766ff 100644 --- a/frontend/appflowy_web_app/src/components/editor/components/leaf/formula/Formula.tsx +++ b/frontend/appflowy_web_app/src/components/editor/components/leaf/formula/Formula.tsx @@ -1,4 +1,4 @@ -import KatexMath from '@/components/_shared/katex-math/KatexMath'; +import { KatexMath } from '@/components/_shared/katex-math'; import { EditorElementProps, FormulaNode } from '@/components/editor/editor.type'; import React, { memo, forwardRef } from 'react'; import { useSelected } from 'slate-react'; diff --git a/frontend/appflowy_web_app/src/components/editor/components/leaf/formula/index.ts b/frontend/appflowy_web_app/src/components/editor/components/leaf/formula/index.ts index 1c01fca07e..dc4ad2cd03 100644 --- a/frontend/appflowy_web_app/src/components/editor/components/leaf/formula/index.ts +++ b/frontend/appflowy_web_app/src/components/editor/components/leaf/formula/index.ts @@ -1,5 +1 @@ -import { lazy } from 'react'; - -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-expect-error -export const Formula = lazy(() => import('./Formula?chunkName=formula')); +export * from './Formula'; diff --git a/frontend/appflowy_web_app/src/components/editor/components/leaf/mention/MentionDate.tsx b/frontend/appflowy_web_app/src/components/editor/components/leaf/mention/MentionDate.tsx index fd167985af..89ec879784 100644 --- a/frontend/appflowy_web_app/src/components/editor/components/leaf/mention/MentionDate.tsx +++ b/frontend/appflowy_web_app/src/components/editor/components/leaf/mention/MentionDate.tsx @@ -1,7 +1,7 @@ import { renderDate } from '@/utils/time'; import React, { useMemo } from 'react'; import { ReactComponent as DateSvg } from '@/assets/date.svg'; -import { ReactComponent as ReminderSvg } from '$icons/16x/clock_alarm.svg'; +import { ReactComponent as ReminderSvg } from '@/assets/clock_alarm.svg'; function MentionDate({ date, reminder }: { date: string; reminder?: { id: string; option: string } }) { const dateFormat = useMemo(() => { diff --git a/frontend/appflowy_web_app/src/components/global-comment/CommentList.tsx b/frontend/appflowy_web_app/src/components/global-comment/CommentList.tsx new file mode 100644 index 0000000000..41f1cf1e0b --- /dev/null +++ b/frontend/appflowy_web_app/src/components/global-comment/CommentList.tsx @@ -0,0 +1,39 @@ +import { CommentWrap } from '@/components/global-comment/comment'; +import { useGlobalCommentContext } from '@/components/global-comment/GlobalComment.hooks'; + +import React, { memo } from 'react'; + +function CommentList() { + const { comments, highLightCommentId } = useGlobalCommentContext(); + + const isEmpty = !comments || comments.length === 0; + + const [hoverId, setHoverId] = React.useState(null); + + if (isEmpty) { + return null; + } + + return ( +
{ + setHoverId(null); + }} + className={'flex w-full flex-col gap-2'} + > + {comments?.map((comment) => ( + { + setHoverId(comment.commentId); + }} + isHighLight={comment.commentId === highLightCommentId} + key={comment.commentId} + commentId={comment.commentId} + /> + ))} +
+ ); +} + +export default memo(CommentList); diff --git a/frontend/appflowy_web_app/src/components/global-comment/GlobalComment.hooks.tsx b/frontend/appflowy_web_app/src/components/global-comment/GlobalComment.hooks.tsx new file mode 100644 index 0000000000..f1529773f6 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/global-comment/GlobalComment.hooks.tsx @@ -0,0 +1,246 @@ +import { CommentUser, GlobalComment, Reaction } from '@/application/comment.type'; +import { PublishContext } from '@/application/publish'; +import { AFConfigContext } from '@/components/app/AppConfig'; +import { stringAvatar } from '@/utils/color'; +import dayjs from 'dayjs'; +import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +export const GlobalCommentContext = React.createContext<{ + reload: () => Promise; + getComment: (commentId: string) => GlobalComment | undefined; + loading: boolean; + comments: GlobalComment[] | null; + replyComment: (commentId: string | null) => void; + replyCommentId: string | null; + reactions: Record | null; + toggleReaction: (commentId: string, reactionType: string) => void; + setHighLightCommentId: (commentId: string | null) => void; + highLightCommentId: string | null; +}>({ + reload: () => Promise.resolve(), + getComment: () => undefined, + loading: false, + comments: null, + replyComment: () => undefined, + replyCommentId: null, + reactions: null, + toggleReaction: () => undefined, + setHighLightCommentId: () => undefined, + highLightCommentId: null, +}); + +export function useGlobalCommentContext() { + return useContext(GlobalCommentContext); +} + +export function useLoadReactions() { + const viewId = useContext(PublishContext)?.viewMeta?.view_id; + const service = useContext(AFConfigContext)?.service; + const currentUser = useContext(AFConfigContext)?.currentUser; + const [reactions, setReactions] = useState | null>(null); + const fetchReactions = useCallback(async () => { + if (!viewId || !service) return; + + try { + const reactions = await service.getPublishViewReactions(viewId); + + setReactions(reactions); + } catch (e) { + console.error(e); + } + }, [service, viewId]); + + useEffect(() => { + void fetchReactions(); + }, [fetchReactions]); + + const toggleReaction = useCallback( + async (commentId: string, reactionType: string) => { + try { + if (!service || !viewId) return; + let isAdded = true; + + setReactions((prev) => { + const commentReactions = prev?.[commentId] || []; + const reaction = commentReactions.find((reaction) => reaction.reactionType === reactionType); + const reactUsers = reaction?.reactUsers || []; + const hasReacted = reactUsers.some((user) => user.uuid === currentUser?.uuid); + let newReaction: Reaction | null = null; + let index = -1; + const reactUser = { + uuid: currentUser?.uuid || '', + name: currentUser?.name || '', + avatarUrl: currentUser?.avatar || null, + }; + + // If the reaction does not exist, create a new reaction. + if (!reaction) { + index = commentReactions.length; + newReaction = { + reactionType, + reactUsers: [reactUser], + commentId, + }; + } else { + let newReactUsers: CommentUser[] = []; + + // If the user has not reacted, add the user to the reaction. + if (!hasReacted) { + newReactUsers = [...reactUsers, reactUser]; + + // If the user has reacted, remove the user from the reaction. + } else { + isAdded = false; + newReactUsers = reactUsers.filter((user) => user.uuid !== currentUser?.uuid); + } + + newReaction = { + reactionType, + reactUsers: newReactUsers, + commentId, + }; + index = commentReactions.findIndex((reaction) => reaction.reactionType === reactionType); + } + + const newReactions = [...commentReactions]; + + if (!newReaction) return prev; + // If the reaction does not exist, add the reaction to the list. + if (index === -1) { + newReactions.push(newReaction); + // If the reaction exists, update the reaction. + } else { + newReactions.splice(index, 1, newReaction); + } + + return { + ...prev, + [commentId]: newReactions, + }; + }); + + try { + if (isAdded) { + await service.addPublishViewReaction(viewId, commentId, reactionType); + } else { + await service.removePublishViewReaction(viewId, commentId, reactionType); + } + } catch (e) { + console.error(e); + } + } catch (e) { + console.error(e); + } + }, + [currentUser, service, viewId] + ); + + return { reactions, toggleReaction }; +} + +export function useLoadComments() { + const viewId = useContext(PublishContext)?.viewMeta?.view_id; + const service = useContext(AFConfigContext)?.service; + + const [comments, setComments] = useState(null); + const [loading, setLoading] = useState(false); + + const fetchComments = useCallback(async () => { + if (!viewId || !service) return; + + setLoading(true); + try { + const comments = await service.getPublishViewGlobalComments(viewId); + + setComments(comments); + } catch (e) { + console.error(e); + } finally { + setLoading(false); + } + }, [viewId, service]); + + useEffect(() => { + void fetchComments(); + }, [fetchComments]); + + return { comments, loading, reload: fetchComments }; +} + +export function getAvatar(comment: GlobalComment) { + if (comment.user?.avatarUrl) { + return { + children: {comment.user.avatarUrl}, + }; + } + + return stringAvatar(comment.user?.name || ''); +} + +export function useCommentRender(comment: GlobalComment) { + const { t } = useTranslation(); + const avatar = useMemo(() => { + return getAvatar(comment); + }, [comment]); + + const timeFormat = useMemo(() => { + const time = dayjs.unix(Number(comment.lastUpdatedAt)); + + return time.format('YYYY-MM-DD HH:mm:ss'); + }, [comment.lastUpdatedAt]); + + const time = useMemo(() => { + if (!comment.lastUpdatedAt) return ''; + const now = dayjs(); + const past = dayjs.unix(Number(comment.lastUpdatedAt)); + const diffSec = now.diff(past, 'second'); + const diffMin = now.diff(past, 'minute'); + const diffHour = now.diff(past, 'hour'); + const diffDay = now.diff(past, 'day'); + const diffMonth = now.diff(past, 'month'); + const diffYear = now.diff(past, 'year'); + + if (diffSec < 5) { + return t('globalComment.showSeconds', { + count: 0, + }); + } + + if (diffMin < 1) { + return t('globalComment.showSeconds', { + count: diffSec, + }); + } + + if (diffHour < 1) { + return t('globalComment.showMinutes', { + count: diffMin, + }); + } + + if (diffDay < 1) { + return t('globalComment.showHours', { + count: diffHour, + }); + } + + if (diffMonth < 1) { + return t('globalComment.showDays', { + count: diffDay, + }); + } + + if (diffYear < 1) { + return t('globalComment.showMonths', { + count: diffMonth, + }); + } + + return t('globalComment.showYears', { + count: diffYear, + }); + }, [t, comment]); + + return { avatar, time, timeFormat }; +} diff --git a/frontend/appflowy_web_app/src/components/global-comment/GlobalComment.tsx b/frontend/appflowy_web_app/src/components/global-comment/GlobalComment.tsx new file mode 100644 index 0000000000..2b27aeb5e4 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/global-comment/GlobalComment.tsx @@ -0,0 +1,36 @@ +import { AddCommentWrapper } from '@/components/global-comment/add-comment'; +import CommentList from '@/components/global-comment/CommentList'; +import { useGlobalCommentContext } from '@/components/global-comment/GlobalComment.hooks'; +import { Divider } from '@mui/material'; +import CircularProgress from '@mui/material/CircularProgress'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +function GlobalComment() { + const { t } = useTranslation(); + const { loading, comments } = useGlobalCommentContext(); + + return ( +
+
+
{t('globalComment.comments')}
+ + + + {loading && !comments?.length ? ( +
+ +
+ ) : ( + + )} +
+
+ ); +} + +export default GlobalComment; diff --git a/frontend/appflowy_web_app/src/components/global-comment/GlobalCommentProvider.tsx b/frontend/appflowy_web_app/src/components/global-comment/GlobalCommentProvider.tsx new file mode 100644 index 0000000000..aeb6a73e62 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/global-comment/GlobalCommentProvider.tsx @@ -0,0 +1,60 @@ +import GlobalComment from '@/components/global-comment/GlobalComment'; +import { + GlobalCommentContext, + useLoadComments, + useLoadReactions, +} from '@/components/global-comment/GlobalComment.hooks'; +import { debounce } from 'lodash-es'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; + +export function GlobalCommentProvider() { + const { comments, loading, reload } = useLoadComments(); + const { reactions, toggleReaction } = useLoadReactions(); + const [replyCommentId, setReplyCommentId] = useState(null); + const [highLightCommentId, setHighLightCommentId] = useState(null); + const getComment = useCallback( + (commentId: string) => { + return comments?.find((comment) => comment.commentId === commentId); + }, + [comments] + ); + + const replyComment = useCallback((commentId: string | null) => { + setReplyCommentId(commentId); + }, []); + + const debounceClearHighLightCommentId = useMemo(() => { + return debounce(() => { + setHighLightCommentId(null); + }, 5000); + }, []); + + useEffect(() => { + if (highLightCommentId) { + debounceClearHighLightCommentId(); + } else { + debounceClearHighLightCommentId.cancel(); + } + }, [highLightCommentId, debounceClearHighLightCommentId]); + + return ( + + + + ); +} + +export default GlobalCommentProvider; diff --git a/frontend/appflowy_web_app/src/components/global-comment/ReplyComment.tsx b/frontend/appflowy_web_app/src/components/global-comment/ReplyComment.tsx new file mode 100644 index 0000000000..dc6c448e2a --- /dev/null +++ b/frontend/appflowy_web_app/src/components/global-comment/ReplyComment.tsx @@ -0,0 +1,47 @@ +import { getAvatar, useGlobalCommentContext } from '@/components/global-comment/GlobalComment.hooks'; +import { Avatar } from '@mui/material'; +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import smoothScrollIntoViewIfNeeded from 'smooth-scroll-into-view-if-needed'; + +function ReplyComment({ commentId }: { commentId?: string | null }) { + const { getComment, setHighLightCommentId } = useGlobalCommentContext(); + const { t } = useTranslation(); + const replyComment = useMemo(() => { + if (!commentId) return; + return getComment(commentId); + }, [commentId, getComment]); + + const avatar = useMemo(() => (replyComment ? getAvatar(replyComment) : null), [replyComment]); + + const handleClick = () => { + if (commentId) { + const element = document.querySelector('[data-comment-id="' + commentId + '"]'); + + if (element) { + void smoothScrollIntoViewIfNeeded(element, { + behavior: 'smooth', + block: 'center', + }); + setHighLightCommentId(commentId); + } + } + }; + + if (!replyComment) return null; + return ( +
+ +
@{replyComment.user?.name}
+
+ {replyComment.isDeleted ? ( + {`[${t('globalComment.hasBeenDeleted')}]`} + ) : ( + replyComment.content + )} +
+
+ ); +} + +export default ReplyComment; diff --git a/frontend/appflowy_web_app/src/components/global-comment/actions/CommentActions.tsx b/frontend/appflowy_web_app/src/components/global-comment/actions/CommentActions.tsx new file mode 100644 index 0000000000..3148a64841 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/global-comment/actions/CommentActions.tsx @@ -0,0 +1,17 @@ +import { GlobalComment } from '@/application/comment.type'; +import MoreActions from '@/components/global-comment/actions/MoreActions'; +import ReactAction from '@/components/global-comment/actions/ReactAction'; +import ReplyAction from '@/components/global-comment/actions/ReplyAction'; +import React, { memo } from 'react'; + +function CommentActions({ comment }: { comment: GlobalComment }) { + return ( + <> + + + + + ); +} + +export default memo(CommentActions); diff --git a/frontend/appflowy_web_app/src/components/global-comment/actions/MoreActions.tsx b/frontend/appflowy_web_app/src/components/global-comment/actions/MoreActions.tsx new file mode 100644 index 0000000000..5501a07ee8 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/global-comment/actions/MoreActions.tsx @@ -0,0 +1,147 @@ +import { GlobalComment } from '@/application/comment.type'; +import { PublishContext } from '@/application/publish'; +import { NormalModal } from '@/components/_shared/modal'; +import { notify } from '@/components/_shared/notify'; +import { Popover } from '@/components/_shared/popover'; +import { AFConfigContext } from '@/components/app/AppConfig'; +import { useGlobalCommentContext } from '@/components/global-comment/GlobalComment.hooks'; +import { Button, IconButton, Tooltip, TooltipProps } from '@mui/material'; +import React, { memo, useCallback, useContext, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as MoreIcon } from '@/assets/more.svg'; +import { ReactComponent as TrashIcon } from '@/assets/trash.svg'; + +interface Item { + Icon: React.FC>; + label: string; + disabled: boolean; + onClick: () => void; + danger?: boolean; + tooltip?: TooltipProps; +} + +function MoreActions({ comment }: { comment: GlobalComment }) { + const { reload } = useGlobalCommentContext(); + const canDeleted = comment.canDeleted; + + const ref = React.useRef(null); + const [open, setOpen] = React.useState(false); + const [deleteModalOpen, setDeleteModalOpen] = React.useState(false); + const handleClose = useCallback(() => { + setOpen(false); + }, []); + + const handleOpen = () => { + setOpen(true); + }; + + const { t } = useTranslation(); + const service = useContext(AFConfigContext)?.service; + const viewId = useContext(PublishContext)?.viewMeta?.view_id; + + const handleDeleteAction = useCallback(async () => { + if (!viewId || !service) return; + try { + await service?.deleteCommentOnPublishView(viewId, comment.commentId); + await reload(); + } catch (e) { + console.error(e); + notify.error('Failed to delete comment'); + } finally { + setDeleteModalOpen(false); + } + }, [comment.commentId, reload, service, viewId]); + + const actions = useMemo(() => { + return [ + { + Icon: TrashIcon, + label: t('button.delete'), + disabled: !canDeleted, + tooltip: canDeleted + ? undefined + : { + title:
{t('globalComment.noAccessDeleteComment')}
, + placement: 'top', + }, + onClick: () => { + setDeleteModalOpen(true); + }, + danger: true, + }, + ] as Item[]; + }, [t, canDeleted]); + + const renderItem = useCallback((action: Item) => { + return ( + + ); + }, []); + + return ( + <> + + + + +
+ {actions.map((action, index) => { + if (action.tooltip) { + return ( + +
{renderItem(action)}
+
+ ); + } + + return ( +
+ {renderItem(action)} +
+ ); + })} +
+
+ + {deleteModalOpen && ( + { + setDeleteModalOpen(false); + }} + onClose={() => setDeleteModalOpen(false)} + open={deleteModalOpen} + title={
{t('globalComment.deleteComment')}
} + > +
+ {t('globalComment.confirmDeleteDescription')} +
+
+ )} + + ); +} + +export default memo(MoreActions); diff --git a/frontend/appflowy_web_app/src/components/global-comment/actions/ReactAction.tsx b/frontend/appflowy_web_app/src/components/global-comment/actions/ReactAction.tsx new file mode 100644 index 0000000000..6ab0ff412e --- /dev/null +++ b/frontend/appflowy_web_app/src/components/global-comment/actions/ReactAction.tsx @@ -0,0 +1,62 @@ +import { GlobalComment } from '@/application/comment.type'; +import { EmojiPicker } from '@/components/_shared/emoji-picker'; +import { EMOJI_SIZE, PER_ROW_EMOJI_COUNT } from '@/components/_shared/emoji-picker/const'; +import { Popover } from '@/components/_shared/popover'; +import ComponentLoading from '@/components/_shared/progress/ComponentLoading'; +import { useGlobalCommentContext } from '@/components/global-comment/GlobalComment.hooks'; +import { ReactComponent as AddReactionRounded } from '@/assets/add_reaction.svg'; +import { IconButton, Tooltip } from '@mui/material'; +import React, { memo, Suspense, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +function ReactAction({ comment }: { comment: GlobalComment }) { + const { toggleReaction } = useGlobalCommentContext(); + const { t } = useTranslation(); + const ref = React.useRef(null); + const [open, setOpen] = React.useState(false); + const handleClose = useCallback(() => { + setOpen(false); + }, []); + + const handleOpen = () => { + setOpen(true); + }; + + const handlePickEmoji = useCallback( + (emoji: string) => { + toggleReaction(comment.commentId, emoji); + handleClose(); + }, + [comment.commentId, handleClose, toggleReaction] + ); + + return ( + <> + + + + + + {open && ( + + }> + + + + )} + + ); +} + +export default memo(ReactAction); diff --git a/frontend/appflowy_web_app/src/components/global-comment/actions/ReplyAction.tsx b/frontend/appflowy_web_app/src/components/global-comment/actions/ReplyAction.tsx new file mode 100644 index 0000000000..5b1ec45d03 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/global-comment/actions/ReplyAction.tsx @@ -0,0 +1,28 @@ +import { GlobalComment } from '@/application/comment.type'; +import { useGlobalCommentContext } from '@/components/global-comment/GlobalComment.hooks'; +import { ReactComponent as ReplyOutlined } from '@/assets/reply.svg'; +import { Tooltip } from '@mui/material'; +import React, { memo } from 'react'; +import { useTranslation } from 'react-i18next'; +import IconButton from '@mui/material/IconButton'; + +function ReplyAction({ comment }: { comment: GlobalComment }) { + const { t } = useTranslation(); + const replyComment = useGlobalCommentContext().replyComment; + + return ( + + { + replyComment(comment.commentId); + }} + size='small' + className={'h-full'} + > + + + + ); +} + +export default memo(ReplyAction); diff --git a/frontend/appflowy_web_app/src/components/global-comment/add-comment/AddComment.tsx b/frontend/appflowy_web_app/src/components/global-comment/add-comment/AddComment.tsx new file mode 100644 index 0000000000..3a12168a2d --- /dev/null +++ b/frontend/appflowy_web_app/src/components/global-comment/add-comment/AddComment.tsx @@ -0,0 +1,175 @@ +import { PublishContext } from '@/application/publish'; +import { notify } from '@/components/_shared/notify'; +import { AFConfigContext } from '@/components/app/AppConfig'; +import { useGlobalCommentContext } from '@/components/global-comment/GlobalComment.hooks'; +import ReplyComment from '@/components/global-comment/ReplyComment'; + +import { Button, TextareaAutosize } from '@mui/material'; +import CircularProgress from '@mui/material/CircularProgress'; +import React, { memo, useCallback, useContext, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as CloseIcon } from '@/assets/close.svg'; +import smoothScrollIntoViewIfNeeded from 'smooth-scroll-into-view-if-needed'; + +interface AddCommentProps { + content: string; + setContent: (content: string) => void; + focus: boolean; + setFocus: (focus: boolean) => void; +} + +function AddComment({ content, setContent, focus, setFocus }: AddCommentProps) { + const { reload, replyCommentId, replyComment: setReplyCommentId } = useGlobalCommentContext(); + + const { t } = useTranslation(); + const isAuthenticated = useContext(AFConfigContext)?.isAuthenticated; + const openLoginModal = useContext(AFConfigContext)?.openLoginModal; + const createCommentOnPublishView = useContext(AFConfigContext)?.service?.createCommentOnPublishView; + const viewId = useContext(PublishContext)?.viewMeta?.view_id; + const [loading, setLoading] = React.useState(false); + const url = window.location.href + '#addComment'; + + const ref = useRef(null); + const inputRef = useRef(null); + + const handleOnFocus = () => { + setFocus(true); + }; + + const handleSubmit = useCallback(async () => { + if (!createCommentOnPublishView || !viewId || loading) { + return; + } + + if (!content || content.trim().length === 0) return; + + setLoading(true); + try { + await createCommentOnPublishView(viewId, content, replyCommentId || undefined); + await reload(); + setContent(''); + + setReplyCommentId(null); + notify.info({ + type: 'success', + title: t('globalComment.commentAddedSuccessfully'), + message: t('globalComment.askForViewComment'), + okText: t('button.yes'), + onOk: () => { + const element = document.getElementById('addComment') as HTMLElement; + + if (!element) return; + void smoothScrollIntoViewIfNeeded(element, { behavior: 'smooth', scrollMode: 'if-needed', block: 'start' }); + }, + }); + } catch (_e) { + notify.error(t('globalComment.failedToAddComment')); + } finally { + setLoading(false); + } + }, [createCommentOnPublishView, viewId, loading, content, replyCommentId, reload, setReplyCommentId, t, setContent]); + + return ( +
+
+
+ {replyCommentId && ( +
+ {t('globalComment.replyingTo')} +
{}
+ +
+ setReplyCommentId(null)} /> +
+
+ )} + +
+
+ { + if (!isAuthenticated && openLoginModal) { + openLoginModal(url); + } + }} + readOnly={!isAuthenticated} + onFocus={handleOnFocus} + onBlur={() => setFocus(false)} + value={content} + className={'w-full resize-none'} + onChange={(e) => { + setContent(e.target.value); + }} + placeholder={t('globalComment.addComment')} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey) { + e.preventDefault(); + void handleSubmit(); + } + + if (e.key === 'Escape') { + setContent(''); + setReplyCommentId(null); + } + }} + /> +
+
+
+
+ + {!!content && ( +
+ + +
+ )} +
+ ); +} + +export default memo(AddComment); diff --git a/frontend/appflowy_web_app/src/components/global-comment/add-comment/AddCommentWrapper.tsx b/frontend/appflowy_web_app/src/components/global-comment/add-comment/AddCommentWrapper.tsx new file mode 100644 index 0000000000..10d3df5237 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/global-comment/add-comment/AddCommentWrapper.tsx @@ -0,0 +1,68 @@ +import { useGlobalCommentContext } from '@/components/global-comment/GlobalComment.hooks'; +import { getScrollParent } from '@/components/global-comment/utils'; +import { HEADER_HEIGHT } from '@/components/publish/header'; +import React, { useEffect, useRef, useState } from 'react'; +import AddComment from './AddComment'; +import { Portal } from '@mui/material'; + +export function AddCommentWrapper() { + const { replyCommentId } = useGlobalCommentContext(); + const addCommentRef = useRef(null); + const [showFixedAddComment, setShowFixedAddComment] = useState(false); + const [focus, setFocus] = useState(false); + const [content, setContent] = useState(''); + + useEffect(() => { + if (replyCommentId) { + setFocus(true); + } + }, [replyCommentId]); + + useEffect(() => { + const element = addCommentRef.current; + + if (!element) return; + const scrollContainer = getScrollParent(element); + + if (!scrollContainer) return; + + const handleScroll = () => { + const isIntersecting = element.getBoundingClientRect().top < HEADER_HEIGHT; + + if (isIntersecting) { + setShowFixedAddComment(true); + } else { + setShowFixedAddComment(false); + } + }; + + scrollContainer.addEventListener('scroll', handleScroll); + return () => { + scrollContainer.removeEventListener('scroll', handleScroll); + }; + }, []); + + return ( + <> +
+ +
+ {showFixedAddComment && ( + +
+
+ +
+
+
+ )} + + ); +} + +export default AddCommentWrapper; diff --git a/frontend/appflowy_web_app/src/components/global-comment/add-comment/index.ts b/frontend/appflowy_web_app/src/components/global-comment/add-comment/index.ts new file mode 100644 index 0000000000..90a8580ad7 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/global-comment/add-comment/index.ts @@ -0,0 +1 @@ +export * from './AddCommentWrapper'; diff --git a/frontend/appflowy_web_app/src/components/global-comment/comment/Comment.tsx b/frontend/appflowy_web_app/src/components/global-comment/comment/Comment.tsx new file mode 100644 index 0000000000..63c18c280c --- /dev/null +++ b/frontend/appflowy_web_app/src/components/global-comment/comment/Comment.tsx @@ -0,0 +1,106 @@ +import { GlobalComment } from '@/application/comment.type'; +import { useCommentRender } from '@/components/global-comment/GlobalComment.hooks'; +import { Reactions } from '@/components/global-comment/reactions'; +import { Avatar, Divider, Tooltip } from '@mui/material'; +import React, { memo, useCallback, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as BulletedListIcon } from '@/assets/bulleted_list_icon_1.svg'; +import { ReactComponent as DoubleArrow } from '@/assets/double_arrow.svg'; +import smoothScrollIntoViewIfNeeded from 'smooth-scroll-into-view-if-needed'; + +interface CommentProps { + comment: GlobalComment; +} + +const MAX_HEIGHT = 320; + +function Comment({ comment }: CommentProps) { + const { avatar, time, timeFormat } = useCommentRender(comment); + const { t } = useTranslation(); + const ref = React.useRef(null); + const contentRef = React.useRef(null); + const [showExpand, setShowExpand] = React.useState(false); + const [isExpand, setIsExpand] = React.useState(false); + + useEffect(() => { + const contentEl = contentRef.current; + + if (!contentEl) return; + const contentHeight = contentEl.offsetHeight; + + setShowExpand(contentHeight > MAX_HEIGHT); + }, []); + + const toggleExpand = useCallback(() => { + setIsExpand((prev) => { + return !prev; + }); + }, []); + + return ( +
+
+
+ +
{comment.user?.name}
+
+ +
+ +
{time}
+
+
+
+
+ {comment.isDeleted ? ( + {`[${t('globalComment.hasBeenDeleted')}]`} + ) : ( +
+ + {comment.content} + +
+ )} + {showExpand && ( + <> + +
{ + const originalExpand = isExpand; + + toggleExpand(); + + if (originalExpand && ref.current) { + void smoothScrollIntoViewIfNeeded(ref.current, { + behavior: 'smooth', + block: 'start', + }); + } + }} + className={ + 'relative flex cursor-pointer items-center justify-center gap-2 bg-transparent text-text-caption hover:text-content-blue-400' + } + > + + + +
+
+ + )} + {!comment.isDeleted && } +
+
+ ); +} + +export default memo(Comment); diff --git a/frontend/appflowy_web_app/src/components/global-comment/comment/CommentWrap.tsx b/frontend/appflowy_web_app/src/components/global-comment/comment/CommentWrap.tsx new file mode 100644 index 0000000000..bec4c5cd82 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/global-comment/comment/CommentWrap.tsx @@ -0,0 +1,82 @@ +import { AFConfigContext } from '@/components/app/AppConfig'; +import CommentActions from '@/components/global-comment/actions/CommentActions'; +import Comment from './Comment'; +import { useGlobalCommentContext } from '@/components/global-comment/GlobalComment.hooks'; +import ReplyComment from '@/components/global-comment/ReplyComment'; +import React, { useCallback, useContext, useEffect, useMemo } from 'react'; +import smoothScrollIntoViewIfNeeded from 'smooth-scroll-into-view-if-needed'; + +export interface CommentWrapProps { + commentId: string; + isHovered: boolean; + onHovered: () => void; + isHighLight: boolean; +} + +export function CommentWrap({ commentId, isHighLight, isHovered, onHovered }: CommentWrapProps) { + const { getComment, setHighLightCommentId } = useGlobalCommentContext(); + const comment = useMemo(() => getComment(commentId), [commentId, getComment]); + const ref = React.useRef(null); + const isAuthenticated = useContext(AFConfigContext)?.isAuthenticated; + + useEffect(() => { + const hashHasComment = window.location.hash.includes(`#comment-${commentId}`); + + if (!hashHasComment) return; + const element = ref.current; + + if (!element) return; + let timeout: NodeJS.Timeout | null = null; + + void (async () => { + window.location.hash = ''; + + timeout = setTimeout(() => { + void smoothScrollIntoViewIfNeeded(element, { + behavior: 'smooth', + block: 'center', + }); + setHighLightCommentId(commentId); + }, 500); + })(); + + return () => { + timeout && clearTimeout(timeout); + }; + }, [commentId, setHighLightCommentId]); + + const renderReplyComment = useCallback((replyCommentId: string) => { + return ( +
+
+
{}
+
+ ); + }, []); + + if (!comment) { + return null; + } + + return ( +
+ {comment.replyCommentId && renderReplyComment(comment.replyCommentId)} +
{ + onHovered(); + }} + > + + {isHovered && isAuthenticated && !comment.isDeleted && ( +
+ +
+ )} +
+
+ ); +} + +export default CommentWrap; diff --git a/frontend/appflowy_web_app/src/components/global-comment/comment/index.ts b/frontend/appflowy_web_app/src/components/global-comment/comment/index.ts new file mode 100644 index 0000000000..5751181871 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/global-comment/comment/index.ts @@ -0,0 +1 @@ +export * from './CommentWrap'; diff --git a/frontend/appflowy_web_app/src/components/global-comment/index.ts b/frontend/appflowy_web_app/src/components/global-comment/index.ts new file mode 100644 index 0000000000..0f422a88f5 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/global-comment/index.ts @@ -0,0 +1 @@ +export * from './GlobalCommentProvider'; diff --git a/frontend/appflowy_web_app/src/components/global-comment/reactions/Reaction.tsx b/frontend/appflowy_web_app/src/components/global-comment/reactions/Reaction.tsx new file mode 100644 index 0000000000..b3ae81c6d9 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/global-comment/reactions/Reaction.tsx @@ -0,0 +1,101 @@ +import { Reaction as ReactionType } from '@/application/comment.type'; +import { AFConfigContext } from '@/components/app/AppConfig'; +import { getPlatform } from '@/utils/platform'; +import { Tooltip } from '@mui/material'; +import React, { memo, useContext, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +function Reaction({ reaction, onClick }: { reaction: ReactionType; onClick: (reaction: ReactionType) => void }) { + const { t } = useTranslation(); + const isAuthenticated = useContext(AFConfigContext)?.isAuthenticated; + const openLoginModal = useContext(AFConfigContext)?.openLoginModal; + const url = window.location.href + '#comment-' + reaction.commentId; + const reactCount = useMemo(() => { + return reaction.reactUsers.length; + }, [reaction.reactUsers]); + const userNames = useMemo(() => { + let sliceOffset = reactCount; + let suffix = ''; + + if (reactCount > 20) { + sliceOffset = 20; + suffix = ` ${t('globalComment.reactedByMore', { count: reactCount - 20 })}`; + } + + return ( + reaction.reactUsers + .slice(0, sliceOffset) + .map((user) => user.name) + .join(', ') + suffix + ); + }, [reaction.reactUsers, t, reactCount]); + const currentUser = useContext(AFConfigContext)?.currentUser; + const currentUid = currentUser?.uuid; + + const isCurrentUserReacted = useMemo(() => { + return reaction.reactUsers.some((user) => user.uuid === currentUid); + }, [currentUid, reaction.reactUsers]); + + const [hover, setHover] = React.useState(false); + const style = useMemo(() => { + const styleProperties: React.CSSProperties = {}; + + if (hover) { + Object.assign(styleProperties, { + borderColor: 'var(--line-border)', + backgroundColor: 'var(--bg-body)', + }); + } else if (isCurrentUserReacted) { + Object.assign(styleProperties, { + borderColor: 'var(--content-blue-400)', + backgroundColor: 'var(--content-blue-100)', + }); + } + + return styleProperties; + }, [hover, isCurrentUserReacted]); + + const handleSelected = () => { + if (!isAuthenticated && openLoginModal) { + openLoginModal(url); + return; + } + + onClick(reaction); + }; + + const isMobile = useMemo(() => { + return getPlatform().isMobile; + }, []); + + return ( + + {t('globalComment.reactedBy')} + {` `} + {userNames} +
+ } + > +
{ + if (isMobile) return; + setHover(true); + }} + onMouseLeave={() => setHover(false)} + className={ + 'flex cursor-pointer items-center gap-1 rounded-full border border-transparent bg-fill-list-hover px-1 py-0.5 text-sm' + } + > + {reaction.reactionType} + {
{reactCount}
} +
+ + ); +} + +export default memo(Reaction); diff --git a/frontend/appflowy_web_app/src/components/global-comment/reactions/Reactions.tsx b/frontend/appflowy_web_app/src/components/global-comment/reactions/Reactions.tsx new file mode 100644 index 0000000000..1406bbb350 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/global-comment/reactions/Reactions.tsx @@ -0,0 +1,31 @@ +import { GlobalComment, Reaction as ReactionType } from '@/application/comment.type'; +import ReactAction from '@/components/global-comment/actions/ReactAction'; +import { useGlobalCommentContext } from '@/components/global-comment/GlobalComment.hooks'; +import Reaction from '@/components/global-comment/reactions/Reaction'; +import React, { memo, useCallback, useMemo } from 'react'; + +export function Reactions({ comment }: { comment: GlobalComment }) { + const { reactions, toggleReaction } = useGlobalCommentContext(); + const commentReactions = useMemo(() => { + return reactions?.[comment.commentId]?.filter((reaction) => reaction.reactUsers.length > 0) || []; + }, [reactions, comment.commentId]); + + const handleReactionClick = useCallback( + (reaction: ReactionType) => { + toggleReaction(comment.commentId, reaction.reactionType); + }, + [comment.commentId, toggleReaction] + ); + + if (!commentReactions.length) return null; + return ( +
+ {commentReactions.map((reaction) => { + return ; + })} + +
+ ); +} + +export default memo(Reactions); diff --git a/frontend/appflowy_web_app/src/components/global-comment/reactions/index.ts b/frontend/appflowy_web_app/src/components/global-comment/reactions/index.ts new file mode 100644 index 0000000000..1ef7c4f1af --- /dev/null +++ b/frontend/appflowy_web_app/src/components/global-comment/reactions/index.ts @@ -0,0 +1 @@ +export * from './Reactions'; diff --git a/frontend/appflowy_web_app/src/components/global-comment/utils.ts b/frontend/appflowy_web_app/src/components/global-comment/utils.ts new file mode 100644 index 0000000000..b6d15d34be --- /dev/null +++ b/frontend/appflowy_web_app/src/components/global-comment/utils.ts @@ -0,0 +1,11 @@ +export function getScrollParent(node: Element | null): Element | null { + if (node === null) { + return null; + } + + if (node.scrollHeight > node.clientHeight) { + return node; + } else { + return getScrollParent(node.parentElement); + } +} diff --git a/frontend/appflowy_web_app/src/components/login/Login.tsx b/frontend/appflowy_web_app/src/components/login/Login.tsx index bd8c166ff6..ff565756c8 100644 --- a/frontend/appflowy_web_app/src/components/login/Login.tsx +++ b/frontend/appflowy_web_app/src/components/login/Login.tsx @@ -1,28 +1,18 @@ -import { AFConfigContext } from '@/components/app/AppConfig'; import LoginProvider from '@/components/login/LoginProvider'; import MagicLink from '@/components/login/MagicLink'; import { Divider } from '@mui/material'; -import React, { useContext, useEffect } from 'react'; +import React from 'react'; import { ReactComponent as Logo } from '@/assets/logo.svg'; import { useTranslation } from 'react-i18next'; -import { useSearchParams } from 'react-router-dom'; -export function Login() { +export function Login({ redirectTo }: { redirectTo: string }) { const { t } = useTranslation(); - const [search] = useSearchParams(); - const redirectTo = search.get('redirectTo') || ''; - const isAuthenticated = useContext(AFConfigContext)?.isAuthenticated || false; - useEffect(() => { - if (isAuthenticated && redirectTo && encodeURIComponent(redirectTo) !== window.location.href) { - window.location.href = redirectTo; - } - }, [isAuthenticated, redirectTo]); return (
-
+
-
{t('welcomeTo')} AppFlowy
+
{t('welcomeTo')} AppFlowy
diff --git a/frontend/appflowy_web_app/src/components/login/LoginModal.tsx b/frontend/appflowy_web_app/src/components/login/LoginModal.tsx new file mode 100644 index 0000000000..af86862b3b --- /dev/null +++ b/frontend/appflowy_web_app/src/components/login/LoginModal.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { Dialog, IconButton } from '@mui/material'; +import { Login } from './Login'; +import { ReactComponent as CloseIcon } from '@/assets/close.svg'; + +export function LoginModal({ redirectTo, open, onClose }: { redirectTo: string; open: boolean; onClose: () => void }) { + return ( + +
+ +
+ + + +
+
+
+ ); +} + +export default LoginModal; diff --git a/frontend/appflowy_web_app/src/components/login/LoginProvider.tsx b/frontend/appflowy_web_app/src/components/login/LoginProvider.tsx index 9e531f2f24..ca94d554ef 100644 --- a/frontend/appflowy_web_app/src/components/login/LoginProvider.tsx +++ b/frontend/appflowy_web_app/src/components/login/LoginProvider.tsx @@ -50,7 +50,7 @@ function LoginProvider({ redirectTo }: { redirectTo: string }) { }; return ( -
+
{options.map((option) => ( + ))} +
{ - action.onClick(); - handleClose(); + window.open('https://appflowy.io', '_blank'); }} - key={index} className={ - 'flex items-center gap-2 rounded-[8px] p-1.5 text-sm hover:bg-content-blue-50 focus:bg-content-blue-50 focus:outline-none' + 'flex w-full cursor-pointer items-center justify-center py-2 text-sm text-text-title opacity-50' } > - - {action.label} - - ))} -
{ - window.open('https://appflowy.io', '_blank'); - }} - className={'flex w-full cursor-pointer items-center justify-center py-2 text-sm text-text-title opacity-50'} - > - Powered by - - + Powered by + + +
-
- + + )} ); } diff --git a/frontend/appflowy_web_app/src/components/publish/header/PublishViewHeader.tsx b/frontend/appflowy_web_app/src/components/publish/header/PublishViewHeader.tsx index 9075ba162c..90923e5dfd 100644 --- a/frontend/appflowy_web_app/src/components/publish/header/PublishViewHeader.tsx +++ b/frontend/appflowy_web_app/src/components/publish/header/PublishViewHeader.tsx @@ -3,12 +3,13 @@ import { openOrDownload } from '@/components/publish/header/utils'; import { Divider, IconButton, Tooltip } from '@mui/material'; import { debounce } from 'lodash-es'; import React, { useCallback, useMemo } from 'react'; -import OutlinePopover from '@/components/publish/outline/OutlinePopover'; +import { OutlinePopover } from '@/components/publish/outline'; import { useTranslation } from 'react-i18next'; import Breadcrumb from './Breadcrumb'; import { ReactComponent as Logo } from '@/assets/logo.svg'; import MoreActions from './MoreActions'; import { ReactComponent as SideOutlined } from '@/assets/side_outlined.svg'; +// import { Duplicate } from './duplicate'; export const HEADER_HEIGHT = 48; @@ -65,7 +66,7 @@ export function PublishViewHeader({ onOpenDrawer, openDrawer }: { onOpenDrawer: className={'appflowy-top-bar sticky top-0 z-10 flex px-5'} >
- {!openDrawer && ( + {!openDrawer && openPopover && ( + {/**/} + {/* */} + {/**/} + + {duplicateOpen && } + + ); +} + +export default Duplicate; diff --git a/frontend/appflowy_web_app/src/components/publish/header/duplicate/DuplicateModal.tsx b/frontend/appflowy_web_app/src/components/publish/header/duplicate/DuplicateModal.tsx new file mode 100644 index 0000000000..dd3500d9df --- /dev/null +++ b/frontend/appflowy_web_app/src/components/publish/header/duplicate/DuplicateModal.tsx @@ -0,0 +1,129 @@ +import React, { useCallback, useContext, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { NormalModal } from '@/components/_shared/modal'; +import SelectWorkspace from '@/components/publish/header/duplicate/SelectWorkspace'; +import { useLoadWorkspaces } from '@/components/publish/header/duplicate/useDuplicate'; +import SpaceList from '@/components/publish/header/duplicate/SpaceList'; +import { downloadPage, openAppFlowySchema } from '@/utils/url'; +import { AFConfigContext } from '@/components/app/AppConfig'; +import { PublishContext } from '@/application/publish'; +import { CollabType, ViewLayout } from '@/application/collab.type'; +import { notify } from '@/components/_shared/notify'; + +function getCollabTypeFromViewLayout(layout: ViewLayout) { + switch (layout) { + case ViewLayout.Document: + return CollabType.Document; + case ViewLayout.Grid: + case ViewLayout.Board: + case ViewLayout.Calendar: + return CollabType.Database; + default: + return null; + } +} + +function DuplicateModal({ open, onClose }: { open: boolean; onClose: () => void }) { + const { t } = useTranslation(); + const service = useContext(AFConfigContext)?.service; + const viewMeta = useContext(PublishContext)?.viewMeta; + const viewId = viewMeta?.view_id; + const layout = viewMeta?.layout as ViewLayout; + const [loading, setLoading] = React.useState(false); + const [successModalOpen, setSuccessModalOpen] = React.useState(false); + const { + workspaceList, + spaceList, + setSelectedSpaceId, + setSelectedWorkspaceId, + selectedWorkspaceId, + selectedSpaceId, + workspaceLoading, + spaceLoading, + } = useLoadWorkspaces(); + + useEffect(() => { + if (!open) { + setSelectedWorkspaceId(workspaceList[0]?.id || ''); + setSelectedSpaceId(''); + } + }, [open, setSelectedSpaceId, setSelectedWorkspaceId, workspaceList]); + + const handleDuplicate = useCallback(async () => { + if (!viewId) return; + const collabType = getCollabTypeFromViewLayout(layout); + + if (collabType === null) return; + + setLoading(true); + try { + await service?.duplicatePublishView({ + workspaceId: selectedWorkspaceId, + spaceViewId: selectedSpaceId, + viewId, + collabType, + }); + onClose(); + setSuccessModalOpen(true); + } catch (e) { + notify.error(t('publish.duplicateFailed')); + } finally { + setLoading(false); + } + }, [viewId, layout, service, selectedWorkspaceId, selectedSpaceId, onClose, t]); + + return ( + <> + +
+ + +
+
+ window.open(openAppFlowySchema, '_self')} + onCancel={() => { + window.open(downloadPage, '_blank'); + }} + onClose={() => setSuccessModalOpen(false)} + open={successModalOpen} + title={
{t('publish.duplicateSuccessfully')}
} + > +
+ {t('publish.duplicateSuccessfullyDescription')} +
+
+ + ); +} + +export default DuplicateModal; diff --git a/frontend/appflowy_web_app/src/components/publish/header/duplicate/SelectWorkspace.tsx b/frontend/appflowy_web_app/src/components/publish/header/duplicate/SelectWorkspace.tsx new file mode 100644 index 0000000000..1e61b35d72 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/publish/header/duplicate/SelectWorkspace.tsx @@ -0,0 +1,143 @@ +import React, { useCallback, useContext, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Avatar, Button, CircularProgress, Divider, Tooltip } from '@mui/material'; +import { Workspace } from '@/application/types'; +import { ReactComponent as RightIcon } from '@/assets/arrow_right.svg'; +import { ReactComponent as CheckIcon } from '@/assets/selected.svg'; +import { Popover } from '@/components/_shared/popover'; +import { stringToColor } from '@/utils/color'; +import { AFConfigContext } from '@/components/app/AppConfig'; + +export interface SelectWorkspaceProps { + value: string; + onChange?: (value: string) => void; + workspaceList: Workspace[]; + loading?: boolean; +} + +function stringAvatar(name: string) { + return { + sx: { + bgcolor: stringToColor(name), + }, + children: `${name.split('')[0]}`, + }; +} + +function SelectWorkspace({ loading, value, onChange, workspaceList }: SelectWorkspaceProps) { + const { t } = useTranslation(); + const email = useContext(AFConfigContext)?.currentUser?.email || ''; + const selectedWorkspace = useMemo(() => { + return workspaceList.find((workspace) => workspace.id === value); + }, [value, workspaceList]); + const ref = useRef(null); + const [selectOpen, setSelectOpen] = useState(false); + + const renderWorkspace = useCallback( + (workspace: Workspace) => { + return ( +
+ {workspace.icon ? ( +
{workspace.icon}
+ ) : ( + + )} +
+
{workspace.name}
+
+ {t('publish.membersCount', { + count: workspace.memberCount || 0, + })} +
+
+
+ ); + }, + [t] + ); + + return ( +
+
{t('publish.selectWorkspace')}
+ + { + setSelectOpen(false); + }} + > +
+
{email}
+ +
+ {workspaceList.map((workspace) => { + const isSelected = workspace.id === selectedWorkspace?.id; + + return ( + + + + ); + })} +
+
+
+
+ ); +} + +export default SelectWorkspace; diff --git a/frontend/appflowy_web_app/src/components/publish/header/duplicate/SpaceList.tsx b/frontend/appflowy_web_app/src/components/publish/header/duplicate/SpaceList.tsx new file mode 100644 index 0000000000..8979c0e6f0 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/publish/header/duplicate/SpaceList.tsx @@ -0,0 +1,95 @@ +import React, { useCallback } from 'react'; +import { SpaceView } from '@/application/types'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as CheckIcon } from '@/assets/selected.svg'; +import { renderColor } from '@/utils/color'; +import SpaceIcon from '@/components/publish/header/SpaceIcon'; +import { Button, CircularProgress, Tooltip } from '@mui/material'; +import { ReactComponent as LockSvg } from '@/assets/lock.svg'; + +export interface SpaceListProps { + value: string; + onChange?: (value: string) => void; + spaceList: SpaceView[]; + loading?: boolean; +} + +function SpaceList({ loading, spaceList, value, onChange }: SpaceListProps) { + const { t } = useTranslation(); + + const getExtraObj = useCallback((extra: string) => { + try { + return extra + ? (JSON.parse(extra) as { + is_space?: boolean; + space_icon?: string; + space_icon_color?: string; + }) + : {}; + } catch (e) { + return {}; + } + }, []); + + const renderSpace = useCallback( + (space: SpaceView) => { + const extraObj = getExtraObj(space.extra || ''); + + return ( +
+ + + +
+ {space.name} + {space.isPrivate && } +
+
+ ); + }, + [getExtraObj] + ); + + return ( +
+
{t('publish.addTo')}
+ {loading ? ( +
+ +
+ ) : ( +
+ {spaceList.map((space) => { + const isSelected = value === space.id; + + return ( + + + + ); + })} +
+ )} +
+ ); +} + +export default SpaceList; diff --git a/frontend/appflowy_web_app/src/components/publish/header/duplicate/index.ts b/frontend/appflowy_web_app/src/components/publish/header/duplicate/index.ts new file mode 100644 index 0000000000..28659ab13f --- /dev/null +++ b/frontend/appflowy_web_app/src/components/publish/header/duplicate/index.ts @@ -0,0 +1 @@ +export * from './Duplicate'; diff --git a/frontend/appflowy_web_app/src/components/publish/header/duplicate/useDuplicate.ts b/frontend/appflowy_web_app/src/components/publish/header/duplicate/useDuplicate.ts new file mode 100644 index 0000000000..ab93ad253d --- /dev/null +++ b/frontend/appflowy_web_app/src/components/publish/header/duplicate/useDuplicate.ts @@ -0,0 +1,131 @@ +import React, { useCallback, useContext, useEffect, useState } from 'react'; +import { AFConfigContext } from '@/components/app/AppConfig'; +import { useSearchParams } from 'react-router-dom'; +import { SpaceView, Workspace } from '@/application/types'; +import { notify } from '@/components/_shared/notify'; + +export function useDuplicate() { + const isAuthenticated = useContext(AFConfigContext)?.isAuthenticated || false; + const [search, setSearch] = useSearchParams(); + const [loginOpen, setLoginOpen] = React.useState(false); + const [duplicateOpen, setDuplicateOpen] = React.useState(false); + + useEffect(() => { + const isDuplicate = search.get('action') === 'duplicate'; + + if (!isDuplicate) return; + + setLoginOpen(!isAuthenticated); + setDuplicateOpen(isAuthenticated); + }, [isAuthenticated, search, setSearch]); + + const url = window.location.href; + + const handleLoginClose = useCallback(() => { + setLoginOpen(false); + setSearch((prev) => { + prev.delete('action'); + return prev; + }); + }, [setSearch]); + + const handleDuplicateClose = useCallback(() => { + setDuplicateOpen(false); + setSearch((prev) => { + prev.delete('action'); + return prev; + }); + }, [setSearch]); + + return { + loginOpen, + handleLoginClose, + url, + duplicateOpen, + handleDuplicateClose, + }; +} + +export function useLoadWorkspaces() { + const [spaceLoading, setSpaceLoading] = useState(false); + const [workspaceLoading, setWorkspaceLoading] = useState(false); + const [selectedWorkspaceId, setSelectedWorkspaceId] = useState('1'); + const [selectedSpaceId, setSelectedSpaceId] = useState('1'); + + const [workspaceList, setWorkspaceList] = useState([]); + + const [spaceList, setSpaceList] = useState([]); + + const service = useContext(AFConfigContext)?.service; + + useEffect(() => { + void (async () => { + setWorkspaceLoading(true); + try { + const workspaces = await service?.getWorkspaces(); + + if (workspaces) { + setWorkspaceList(workspaces); + setSelectedWorkspaceId(workspaces[0].id); + } else { + setWorkspaceList([]); + setSelectedWorkspaceId(''); + } + } catch (e) { + notify.error('Failed to load workspaces'); + } finally { + setWorkspaceLoading(false); + } + })(); + }, [service]); + + useEffect(() => { + if (workspaceList.length === 0 || !selectedWorkspaceId || workspaceLoading) { + setSpaceList([]); + setSelectedSpaceId(''); + return; + } + + setSpaceLoading(true); + void (async () => { + try { + const folder = await service?.getWorkspaceFolder(selectedWorkspaceId); + + if (folder) { + const spaces = []; + + for (const child of folder.children) { + if (child.isSpace) { + spaces.push({ + id: child.id, + name: child.name, + isPrivate: child.isPrivate, + extra: child.extra, + }); + } + } + + setSpaceList(spaces); + } else { + setSpaceList([]); + } + } catch (e) { + notify.error('Failed to load spaces'); + } finally { + setSelectedSpaceId(''); + setSpaceLoading(false); + } + })(); + }, [selectedWorkspaceId, service, workspaceList.length, workspaceLoading]); + + return { + workspaceList, + spaceList, + selectedWorkspaceId, + setSelectedWorkspaceId, + selectedSpaceId, + setSelectedSpaceId, + workspaceLoading, + spaceLoading, + }; +} diff --git a/frontend/appflowy_web_app/src/components/publish/outline/OutlineDrawer.tsx b/frontend/appflowy_web_app/src/components/publish/outline/OutlineDrawer.tsx index 2376db484d..c25a447f14 100644 --- a/frontend/appflowy_web_app/src/components/publish/outline/OutlineDrawer.tsx +++ b/frontend/appflowy_web_app/src/components/publish/outline/OutlineDrawer.tsx @@ -7,7 +7,7 @@ import { createHotKeyLabel, HOT_KEY_NAME } from '@/utils/hotkeys'; import { Drawer, IconButton, Tooltip } from '@mui/material'; import { useTranslation } from 'react-i18next'; -function OutlineDrawer({ open, width, onClose }: { open: boolean; width: number; onClose: () => void }) { +export function OutlineDrawer({ open, width, onClose }: { open: boolean; width: number; onClose: () => void }) { const { t } = useTranslation(); const viewMeta = usePublishContext()?.viewMeta; diff --git a/frontend/appflowy_web_app/src/components/publish/outline/OutlinePopover.tsx b/frontend/appflowy_web_app/src/components/publish/outline/OutlinePopover.tsx index 0812adbcc0..b876d857e2 100644 --- a/frontend/appflowy_web_app/src/components/publish/outline/OutlinePopover.tsx +++ b/frontend/appflowy_web_app/src/components/publish/outline/OutlinePopover.tsx @@ -2,7 +2,7 @@ import { usePublishContext } from '@/application/publish'; import Outline from '@/components/publish/outline/Outline'; import { Divider, PopperPlacementType } from '@mui/material'; import React, { ReactElement, useMemo } from 'react'; -import RichTooltip from 'src/components/_shared/popover/RichTooltip'; +import { RichTooltip } from '@/components/_shared/popover'; import { ReactComponent as Logo } from '@/assets/logo.svg'; import { ReactComponent as AppflowyLogo } from '@/assets/appflowy.svg'; diff --git a/frontend/appflowy_web_app/src/components/publish/outline/index.ts b/frontend/appflowy_web_app/src/components/publish/outline/index.ts index 626a625ee5..dea4da9c45 100644 --- a/frontend/appflowy_web_app/src/components/publish/outline/index.ts +++ b/frontend/appflowy_web_app/src/components/publish/outline/index.ts @@ -1 +1,2 @@ export * from './OutlinePopover'; +export * from './OutlineDrawer'; diff --git a/frontend/appflowy_web_app/src/components/publish/useViewMeta.ts b/frontend/appflowy_web_app/src/components/publish/useViewMeta.ts index 18122ef445..f17afc1d9f 100644 --- a/frontend/appflowy_web_app/src/components/publish/useViewMeta.ts +++ b/frontend/appflowy_web_app/src/components/publish/useViewMeta.ts @@ -1,6 +1,7 @@ import { usePublishContext } from '@/application/publish'; import { EditorLayoutStyle } from '@/components/editor/EditorContext'; import { ViewMetaCover } from '@/components/view-meta'; +import { getFontFamily } from '@/utils/font'; import { useEffect, useMemo } from 'react'; export function useViewMeta() { @@ -21,7 +22,7 @@ export function useViewMeta() { lineHeightLayout: extra?.lineHeightLayout, }; }, [extra]); - + const layout = viewMeta?.layout; const style = useMemo(() => { const fontSizeMap = { @@ -56,11 +57,7 @@ export function useViewMeta() { useEffect(() => { if (!layoutStyle.font) return; - void window.WebFont?.load({ - google: { - families: [layoutStyle.font], - }, - }); + void getFontFamily(layoutStyle.font); }, [layoutStyle.font]); const icon = viewMeta?.icon || undefined; diff --git a/frontend/appflowy_web_app/src/components/view-meta/ViewCover.tsx b/frontend/appflowy_web_app/src/components/view-meta/ViewCover.tsx index a22a3f6ab7..b87f1cbd5c 100644 --- a/frontend/appflowy_web_app/src/components/view-meta/ViewCover.tsx +++ b/frontend/appflowy_web_app/src/components/view-meta/ViewCover.tsx @@ -1,3 +1,4 @@ +import ImageRender from '@/components/_shared/image-render/ImageRender'; import { renderColor } from '@/utils/color'; import React, { useCallback } from 'react'; @@ -14,7 +15,11 @@ function ViewCover({ coverValue, coverType }: { coverValue?: string; coverType?: }, []); const renderCoverImage = useCallback((url: string) => { - return {''}; + return ( + <> + + + ); }, []); if (!coverType || !coverValue) { diff --git a/frontend/appflowy_web_app/src/pages/LoginPage.tsx b/frontend/appflowy_web_app/src/pages/LoginPage.tsx index 52bfd9d963..ca4e4ca4f4 100644 --- a/frontend/appflowy_web_app/src/pages/LoginPage.tsx +++ b/frontend/appflowy_web_app/src/pages/LoginPage.tsx @@ -1,10 +1,21 @@ import { Login } from '@/components/login'; -import React from 'react'; +import React, { useContext, useEffect } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { AFConfigContext } from '@/components/app/AppConfig'; function LoginPage() { + const [search] = useSearchParams(); + const redirectTo = search.get('redirectTo') || ''; + const isAuthenticated = useContext(AFConfigContext)?.isAuthenticated || false; + + useEffect(() => { + if (isAuthenticated && redirectTo && encodeURIComponent(redirectTo) !== window.location.href) { + window.location.href = redirectTo; + } + }, [isAuthenticated, redirectTo]); return (
- +
); } diff --git a/frontend/appflowy_web_app/src/styles/app.scss b/frontend/appflowy_web_app/src/styles/app.scss index f69936e2e6..8983ddde5f 100644 --- a/frontend/appflowy_web_app/src/styles/app.scss +++ b/frontend/appflowy_web_app/src/styles/app.scss @@ -24,6 +24,7 @@ body { } @apply bg-bg-body text-text-title; + &[data-os="windows"]:not([data-browser="firefox"]) { .appflowy-custom-scroller { @include mixin.hidden-scrollbar @@ -45,12 +46,23 @@ body { } +.appflowy-scroller { + @include mixin.scrollbar-style; + +} + + .appflowy-scrollbar-thumb-horizontal, .appflowy-scrollbar-thumb-vertical { background-color: var(--scrollbar-thumb); border-radius: 4px; opacity: 60%; } +.appflowy-scrollbar-track-horizontal, .appflowy-scrollbar-track-vertical { + background-color: var(--scrollbar-track); + +} + .icon { font-family: 'Apple Color Emoji', 'Noto Color Emoji', 'Segoe UI Emoji', 'Twemoji Mozilla', sans-serif; } @@ -111,3 +123,39 @@ body { @apply flex-wrap py-2; } } + +@keyframes blink { + 0%, 100% { + background-color: var(--content-blue-100); + } + 50% { + background-color: unset; + } +} + +.blink { + animation: blink 2s linear infinite; +} + +.reply-line { + width: 28px; + height: 16px; + border-left: 2px solid; + border-top: 2px solid; + border-color: var(--line-border); + border-top-left-radius: 6px; +} + +.read-more { + box-shadow: 0px -15px 20px 12px var(--bg-body); + background-color: transparent; +} + +.comment { + scroll-margin-top: 100px; +} + +#addComment { + scroll-margin-top: 60px; +} + diff --git a/frontend/appflowy_web_app/src/styles/mixin.scss b/frontend/appflowy_web_app/src/styles/mixin.scss index 50ef277fe2..6881ea3c00 100644 --- a/frontend/appflowy_web_app/src/styles/mixin.scss +++ b/frontend/appflowy_web_app/src/styles/mixin.scss @@ -1,3 +1,13 @@ + +::-webkit-scrollbar-thumb:hover { + border-radius: 4px; + background-color: var(--scrollbar-thumb); +} + +::-webkit-scrollbar-track:hover { + background-color: var(--scrollbar-track); +} + @mixin hidden-scrollbar { &::-webkit-scrollbar { display: none; @@ -7,6 +17,7 @@ scrollbar-width: none; // For Firefox } + @mixin scrollbar-style { ::-webkit-scrollbar, &::-webkit-scrollbar { width: 8px; diff --git a/frontend/appflowy_web_app/src/styles/variables/dark.variables.css b/frontend/appflowy_web_app/src/styles/variables/dark.variables.css index 373ef8ee79..ac37847ec6 100644 --- a/frontend/appflowy_web_app/src/styles/variables/dark.variables.css +++ b/frontend/appflowy_web_app/src/styles/variables/dark.variables.css @@ -33,8 +33,14 @@ --bg-tips: #005174; --bg-brand: #2c144b; --function-error: #d32772; + --function-error-hover: #d36f8b; + --function-error-opacity: rgba(211, 39, 114, 0.1); --function-warning: #e9b320; + --function-warning-hover: #e9c94b; + --function-warning-opacity: rgba(233, 179, 32, 0.1); --function-success: #3ba856; + --function-success-hover: #5dbb7a; + --function-success-opacity: rgba(59, 168, 86, 0.1); --function-info: #2e9dbb; --tint-red: #56363F; --tint-green: #3C5133; diff --git a/frontend/appflowy_web_app/src/styles/variables/light.variables.css b/frontend/appflowy_web_app/src/styles/variables/light.variables.css index 613577ef71..054aa19d12 100644 --- a/frontend/appflowy_web_app/src/styles/variables/light.variables.css +++ b/frontend/appflowy_web_app/src/styles/variables/light.variables.css @@ -36,8 +36,14 @@ --bg-tips: #e0f8ff; --bg-brand: #2c144b; --function-error: #fb006d; + --function-error-hover: #ff4f8a; + --function-error-opacity: rgba(251, 0, 109, 0.1); --function-waring: #ffd667; + --function-waring-hover: #ffdf8f; + --function-waring-opacity: rgba(255, 214, 103, 0.1); --function-success: #66cf80; + --function-success-hover: #66cf00; + --function-success-opacity: rgba(102, 207, 128, 0.1); --function-info: #00bcf0; --tint-purple: #e8e0ff; --tint-pink: #ffe7ee; diff --git a/frontend/appflowy_web_app/src/utils/color.ts b/frontend/appflowy_web_app/src/utils/color.ts index 9de9da1dca..5d7e0544c0 100644 --- a/frontend/appflowy_web_app/src/utils/color.ts +++ b/frontend/appflowy_web_app/src/utils/color.ts @@ -72,3 +72,34 @@ export function renderColor(color: string) { return argbToRgba(color); } + + +export function stringToColor(string: string) { + let hash = 0; + let i; + + /* eslint-disable no-bitwise */ + for (i = 0; i < string.length; i += 1) { + hash = string.charCodeAt(i) + ((hash << 5) - hash); + } + + let color = '#'; + + for (i = 0; i < 3; i += 1) { + const value = (hash >> (i * 8)) & 0xff; + + color += `00${value.toString(16)}`.slice(-2); + } + /* eslint-enable no-bitwise */ + + return color; +} + +export function stringAvatar(name: string) { + return { + sx: { + bgcolor: stringToColor(name), + }, + children: `${name.split('')[0]}`, + }; +} diff --git a/frontend/appflowy_web_app/src/utils/emoji.ts b/frontend/appflowy_web_app/src/utils/emoji.ts new file mode 100644 index 0000000000..8ebd76fc3d --- /dev/null +++ b/frontend/appflowy_web_app/src/utils/emoji.ts @@ -0,0 +1,14 @@ +import { EmojiMartData } from '@emoji-mart/data'; + +export async function randomEmoji(skin = 0) { + const emojiData = await loadEmojiData(); + const emojis = (emojiData as EmojiMartData).emojis; + const keys = Object.keys(emojis); + const randomKey = keys[Math.floor(Math.random() * keys.length)]; + + return emojis[randomKey].skins[skin].native; +} + +export async function loadEmojiData() { + return import('@emoji-mart/data/sets/15/native.json'); +} diff --git a/frontend/appflowy_web_app/src/utils/font.ts b/frontend/appflowy_web_app/src/utils/font.ts index 645340d958..b4a41698fa 100644 --- a/frontend/appflowy_web_app/src/utils/font.ts +++ b/frontend/appflowy_web_app/src/utils/font.ts @@ -7,10 +7,22 @@ export function getFontFamily(attribute: string) { return fontFamily; } - window.WebFont?.load({ - google: { - families: [fontFamily], - }, - }); + void (async () => { + const script = document.createElement('script'); + + script.src = 'https://ajax.googleapis.com/ajax/libs/webfont/1.6.26/webfont.js'; + document.body.appendChild(script); + await new Promise((resolve) => { + script.onload = () => { + resolve(true); + }; + }); + window.WebFont?.load({ + google: { + families: [fontFamily], + }, + }); + })(); + return fontFamily; } diff --git a/frontend/appflowy_web_app/src/utils/position.ts b/frontend/appflowy_web_app/src/utils/position.ts new file mode 100644 index 0000000000..cb28fe58e3 --- /dev/null +++ b/frontend/appflowy_web_app/src/utils/position.ts @@ -0,0 +1,20 @@ +export function inView(dom: HTMLElement, container: HTMLElement) { + const domRect = dom.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + + if (!domRect || !containerRect) return true; + + return domRect?.bottom <= containerRect?.bottom && domRect?.top >= containerRect?.top; +} + +export function getDistanceEdge(dom: HTMLElement, container: HTMLElement) { + const domRect = dom.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + + if (!domRect || !containerRect) return 0; + + const distanceTop = domRect?.top - containerRect?.top; + const distanceBottom = domRect?.bottom - containerRect?.bottom; + + return Math.abs(distanceTop) < Math.abs(distanceBottom) ? distanceTop : distanceBottom; +} diff --git a/frontend/appflowy_web_app/src/vite-env.d.ts b/frontend/appflowy_web_app/src/vite-env.d.ts index 2ec03900db..8ecd467d45 100644 --- a/frontend/appflowy_web_app/src/vite-env.d.ts +++ b/frontend/appflowy_web_app/src/vite-env.d.ts @@ -13,7 +13,8 @@ interface Window { toast: { success: (message: string) => void; error: (message: string) => void; - info: (message: string) => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + info: (props: any) => void; clear: () => void; default: (message: string) => void; warning: (message: string) => void; diff --git a/frontend/appflowy_web_app/vite.config.ts b/frontend/appflowy_web_app/vite.config.ts index 451fe002fb..939ce54d73 100644 --- a/frontend/appflowy_web_app/vite.config.ts +++ b/frontend/appflowy_web_app/vite.config.ts @@ -112,8 +112,12 @@ export default defineConfig({ id.includes('/react-is@') || id.includes('/yjs@') || id.includes('/y-indexeddb@') || + id.includes('/dexie') || id.includes('/redux') || - id.includes('/react-custom-scrollbars') + id.includes('/react-custom-scrollbars') || + id.includes('/dayjs') || + id.includes('/smooth-scroll-into-view-if-needed') || + id.includes('/react-virtualized-auto-sizer') ) { return 'common'; } @@ -140,9 +144,6 @@ export default defineConfig({ include: [ 'react', 'react-dom', - '@mui/icons-material/ErrorOutline', - '@mui/icons-material/CheckCircleOutline', - '@mui/icons-material/FunctionsOutlined', 'react-katex', // 'react-custom-scrollbars-2', // 'react-window', diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index c05adaf3e1..1cb6c326db 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -1881,16 +1881,16 @@ "remove": "Remove emoji", "categories": { "smileys": "Smileys & Emotion", - "people": "People & Body", - "animals": "Animals & Nature", - "food": "Food & Drink", - "activities": "Activities", - "places": "Travel & Places", - "objects": "Objects", - "symbols": "Symbols", - "flags": "Flags", - "nature": "Nature", - "frequentlyUsed": "Frequently Used" + "people": "people", + "animals": "nature", + "food": "foods", + "activities": "activities", + "places": "places", + "objects": "objects", + "symbols": "symbols", + "flags": "flags", + "nature": "nature", + "frequentlyUsed": "frequently Used" }, "skinTone": { "default": "Default", @@ -2287,7 +2287,22 @@ }, "mustSelectPrimaryDatabase": "The primary view must be selected", "noDatabaseSelected": "No database selected, please select at least one database.", - "unableToDeselectPrimaryDatabase": "Unable to deselect primary database" + "unableToDeselectPrimaryDatabase": "Unable to deselect primary database", + "saveThisPage": "Save this page", + "duplicateTitle": "Where would you like to add", + "selectWorkspace": "Select a workspace", + "addTo": "Add to", + "duplicateSuccessfully": "Duplicated success. Want to view documents?", + "duplicateSuccessfullyDescription": "Don't have the app? Your download will begin automatically after clicking the 'Download'.", + "downloadIt": "Download", + "openApp": "Open in app", + "duplicateFailed": "Duplicated failed", + "membersCount": { + "zero": "No members", + "one": "1 member", + "many": "{count} members", + "other": "{count} members" + } }, "web": { "continue": "Continue", @@ -2301,5 +2316,55 @@ "privacyPolicy": "Privacy Policy", "signInError": "Sign in error", "login": "Sign up or log in" + }, + "globalComment": { + "comments": "Comments", + "addComment": "Add a comment", + "reactedBy": "reacted by", + "addReaction": "Add reaction", + "reactedByMore": "and {count} others", + "showSeconds": { + "one": "1 second ago", + "other": "{count} seconds ago", + "zero": "Just now", + "many": "{count} seconds ago" + }, + "showMinutes": { + "one": "1 minute ago", + "other": "{count} minutes ago", + "many": "{count} minutes ago" + }, + "showHours": { + "one": "1 hour ago", + "other": "{count} hours ago", + "many": "{count} hours ago" + }, + "showDays": { + "one": "1 day ago", + "other": "{count} days ago", + "many": "{count} days ago" + }, + "showMonths": { + "one": "1 month ago", + "other": "{count} months ago", + "many": "{count} months ago" + }, + "showYears": { + "one": "1 year ago", + "other": "{count} years ago", + "many": "{count} years ago" + }, + "reply": "Reply", + "deleteComment": "Delete comment", + "youAreNotOwner": "You are not the owner of this comment", + "confirmDeleteDescription": "Are you sure you want to delete this comment?", + "hasBeenDeleted": "Deleted", + "replyingTo": "Replying to", + "noAccessDeleteComment": "You're not allowed to delete this comment", + "collapse": "Collapse", + "readMore": "Read more", + "failedToAddComment": "Failed to add comment", + "commentAddedSuccessfully": "Comment added successfully.", + "askForViewComment": "Do you want to view the comment?" } } \ No newline at end of file