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 0000000000..c0a589f480 Binary files /dev/null and b/frontend/appflowy_web_app/src/assets/images/empty.png differ 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