fix: global comments
@ -24,7 +24,7 @@
|
|||||||
"coverage": "pnpm run test:unit && pnpm run test:components"
|
"coverage": "pnpm run test:unit && pnpm run test:components"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@appflowyinc/client-api-wasm": "0.1.3",
|
"@appflowyinc/client-api-wasm": "0.1.4",
|
||||||
"@atlaskit/primitives": "^5.5.3",
|
"@atlaskit/primitives": "^5.5.3",
|
||||||
"@emoji-mart/data": "^1.1.2",
|
"@emoji-mart/data": "^1.1.2",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
|
@ -1,9 +1,13 @@
|
|||||||
lockfileVersion: '6.0'
|
lockfileVersion: '6.0'
|
||||||
|
|
||||||
|
settings:
|
||||||
|
autoInstallPeers: true
|
||||||
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
'@appflowyinc/client-api-wasm':
|
'@appflowyinc/client-api-wasm':
|
||||||
specifier: 0.1.3
|
specifier: 0.1.4
|
||||||
version: 0.1.3
|
version: 0.1.4
|
||||||
'@atlaskit/primitives':
|
'@atlaskit/primitives':
|
||||||
specifier: ^5.5.3
|
specifier: ^5.5.3
|
||||||
version: 5.7.0(@types/react@18.2.66)(react@18.2.0)
|
version: 5.7.0(@types/react@18.2.66)(react@18.2.0)
|
||||||
@ -447,8 +451,8 @@ packages:
|
|||||||
'@jridgewell/gen-mapping': 0.3.5
|
'@jridgewell/gen-mapping': 0.3.5
|
||||||
'@jridgewell/trace-mapping': 0.3.25
|
'@jridgewell/trace-mapping': 0.3.25
|
||||||
|
|
||||||
/@appflowyinc/client-api-wasm@0.1.3:
|
/@appflowyinc/client-api-wasm@0.1.4:
|
||||||
resolution: {integrity: sha512-M603RIBocCjDlwDx5O53j4tH2M/y6uKZSdpnBq3nCMBPwTGEhTFKBDD3tMmjSIHo8nnGx1t8gsKei55LlhtoNQ==}
|
resolution: {integrity: sha512-3uBpy3n+aIG0fapPAroMfL8JLdAPtqPAkpV+LOxlRnMW4Au2JQcW8TW0P3K1YAe16tDZ62ZIZPoG6Bi40RDRoQ==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/@atlaskit/analytics-next-stable-react-context@1.0.1(react@18.2.0):
|
/@atlaskit/analytics-next-stable-react-context@1.0.1(react@18.2.0):
|
||||||
@ -11662,7 +11666,3 @@ packages:
|
|||||||
resolution: {integrity: sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==}
|
resolution: {integrity: sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==}
|
||||||
engines: {node: '>=12.20'}
|
engines: {node: '>=12.20'}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
settings:
|
|
||||||
autoInstallPeers: true
|
|
||||||
excludeLinksFromLockfile: false
|
|
||||||
|
22
frontend/appflowy_web_app/src/application/comment.type.ts
Normal file
@ -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;
|
||||||
|
}
|
@ -1,4 +1,5 @@
|
|||||||
import { YDoc } from '@/application/collab.type';
|
import { YDoc } from '@/application/collab.type';
|
||||||
|
import { GlobalComment, Reaction } from '@/application/comment.type';
|
||||||
import {
|
import {
|
||||||
deleteView,
|
deleteView,
|
||||||
getPublishView,
|
getPublishView,
|
||||||
@ -14,10 +15,16 @@ import {
|
|||||||
signInGithub,
|
signInGithub,
|
||||||
signInDiscord,
|
signInDiscord,
|
||||||
signInWithUrl,
|
signInWithUrl,
|
||||||
|
createGlobalCommentOnPublishView,
|
||||||
|
deleteGlobalCommentOnPublishView,
|
||||||
|
getPublishViewComments,
|
||||||
getWorkspaces,
|
getWorkspaces,
|
||||||
getWorkspaceFolder,
|
getWorkspaceFolder,
|
||||||
getCurrentUser,
|
getCurrentUser,
|
||||||
duplicatePublishView,
|
duplicatePublishView,
|
||||||
|
getReactions,
|
||||||
|
addReaction,
|
||||||
|
removeReaction,
|
||||||
} from '@/application/services/js-services/wasm/client_api';
|
} from '@/application/services/js-services/wasm/client_api';
|
||||||
import { AFService, AFServiceConfig } from '@/application/services/services.type';
|
import { AFService, AFServiceConfig } from '@/application/services/services.type';
|
||||||
import { emit, EventType } from '@/application/session';
|
import { emit, EventType } from '@/application/session';
|
||||||
@ -225,6 +232,7 @@ export class AFClientService implements AFService {
|
|||||||
email: data.email,
|
email: data.email,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
avatar: data.icon_url,
|
avatar: data.icon_url,
|
||||||
|
uuid: data.uuid,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -236,4 +244,28 @@ export class AFClientService implements AFService {
|
|||||||
published_collab_type: params.collabType,
|
published_collab_type: params.collabType,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createCommentOnPublishView(viewId: string, content: string, replyCommentId: string | undefined): Promise<void> {
|
||||||
|
return createGlobalCommentOnPublishView(viewId, content, replyCommentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteCommentOnPublishView(viewId: string, commentId: string): Promise<void> {
|
||||||
|
return deleteGlobalCommentOnPublishView(viewId, commentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getPublishViewGlobalComments(viewId: string): Promise<GlobalComment[]> {
|
||||||
|
return getPublishViewComments(viewId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getPublishViewReactions(viewId: string, commentId?: string): Promise<Record<string, Reaction[]>> {
|
||||||
|
return getReactions(viewId, commentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
addPublishViewReaction(viewId: string, commentId: string, reactionType: string): Promise<void> {
|
||||||
|
return addReaction(viewId, commentId, reactionType);
|
||||||
|
}
|
||||||
|
|
||||||
|
removePublishViewReaction(viewId: string, commentId: string, reactionType: string): Promise<void> {
|
||||||
|
return removeReaction(viewId, commentId, reactionType);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,7 +2,8 @@ import { getToken, invalidToken, isTokenValid, refreshToken } from '@/applicatio
|
|||||||
import { ClientAPI, WorkspaceFolder, DuplicatePublishViewPayload } from '@appflowyinc/client-api-wasm';
|
import { ClientAPI, WorkspaceFolder, DuplicatePublishViewPayload } from '@appflowyinc/client-api-wasm';
|
||||||
import { AFCloudConfig } from '@/application/services/services.type';
|
import { AFCloudConfig } from '@/application/services/services.type';
|
||||||
import { DatabaseId, PublishViewMetaData, RowId, ViewId, ViewLayout } from '@/application/collab.type';
|
import { DatabaseId, PublishViewMetaData, RowId, ViewId, ViewLayout } from '@/application/collab.type';
|
||||||
import { FolderView, Workspace } from '@/application/types';
|
import { FolderView } from '@/application/types';
|
||||||
|
import { GlobalComment, Reaction } from '@/application/comment.type';
|
||||||
|
|
||||||
let client: ClientAPI;
|
let client: ClientAPI;
|
||||||
|
|
||||||
@ -120,20 +121,13 @@ export async function signInDiscord(redirectTo: string) {
|
|||||||
export async function getWorkspaces() {
|
export async function getWorkspaces() {
|
||||||
try {
|
try {
|
||||||
const { data } = await client.get_workspaces();
|
const { data } = await client.get_workspaces();
|
||||||
const res: Workspace[] = [];
|
|
||||||
|
|
||||||
for (const workspace of data) {
|
return data.map((workspace) => ({
|
||||||
const members = await client.get_workspace_members(workspace.workspace_id);
|
|
||||||
|
|
||||||
res.push({
|
|
||||||
id: workspace.workspace_id,
|
id: workspace.workspace_id,
|
||||||
name: workspace.workspace_name,
|
name: workspace.workspace_name,
|
||||||
icon: workspace.icon,
|
icon: workspace.icon,
|
||||||
memberCount: members.data.length,
|
memberCount: workspace.member_count || 0,
|
||||||
});
|
}));
|
||||||
}
|
|
||||||
|
|
||||||
return res;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return Promise.reject(e);
|
return Promise.reject(e);
|
||||||
}
|
}
|
||||||
@ -171,3 +165,72 @@ export function getCurrentUser() {
|
|||||||
export function duplicatePublishView(payload: DuplicatePublishViewPayload) {
|
export function duplicatePublishView(payload: DuplicatePublishViewPayload) {
|
||||||
return client.duplicate_publish_view(payload);
|
return client.duplicate_publish_view(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getPublishViewComments(viewId: string): Promise<GlobalComment[]> {
|
||||||
|
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<Record<string, Reaction[]>> {
|
||||||
|
try {
|
||||||
|
const { reactions } = await client.get_reactions(viewId, commentId);
|
||||||
|
|
||||||
|
const reactionsMap: Record<string, Reaction[]> = {};
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { YDoc } from '@/application/collab.type';
|
import { YDoc } from '@/application/collab.type';
|
||||||
|
import { GlobalComment, Reaction } from '@/application/comment.type';
|
||||||
import { ViewMeta } from '@/application/db/tables/view_metas';
|
import { ViewMeta } from '@/application/db/tables/view_metas';
|
||||||
import * as Y from 'yjs';
|
import * as Y from 'yjs';
|
||||||
import { DuplicatePublishView, FolderView, User, Workspace } from '@/application/types';
|
import { DuplicatePublishView, FolderView, User, Workspace } from '@/application/types';
|
||||||
@ -28,6 +29,12 @@ export interface PublishService {
|
|||||||
rows: Y.Map<YDoc>;
|
rows: Y.Map<YDoc>;
|
||||||
destroy: () => void;
|
destroy: () => void;
|
||||||
}>;
|
}>;
|
||||||
|
getPublishViewGlobalComments: (viewId: string) => Promise<GlobalComment[]>;
|
||||||
|
createCommentOnPublishView: (viewId: string, content: string, replyCommentId?: string) => Promise<void>;
|
||||||
|
deleteCommentOnPublishView: (viewId: string, commentId: string) => Promise<void>;
|
||||||
|
getPublishViewReactions: (viewId: string, commentId?: string) => Promise<Record<string, Reaction[]>>;
|
||||||
|
addPublishViewReaction: (viewId: string, commentId: string, reactionType: string) => Promise<void>;
|
||||||
|
removePublishViewReaction: (viewId: string, commentId: string, reactionType: string) => Promise<void>;
|
||||||
|
|
||||||
loginAuth: (url: string) => Promise<void>;
|
loginAuth: (url: string) => Promise<void>;
|
||||||
signInMagicLink: (params: { email: string; redirectTo: string }) => Promise<void>;
|
signInMagicLink: (params: { email: string; redirectTo: string }) => Promise<void>;
|
||||||
|
@ -29,6 +29,7 @@ export interface User {
|
|||||||
name: string | null;
|
name: string | null;
|
||||||
uid: string;
|
uid: string;
|
||||||
avatar: string | null;
|
avatar: string | null;
|
||||||
|
uuid: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DuplicatePublishView {
|
export interface DuplicatePublishView {
|
||||||
|
4
frontend/appflowy_web_app/src/assets/add_reaction.svg
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M6 10C6 9.17 6.67 8.5 7.5 8.5C8.33 8.5 9 9.17 9 10C9 10.83 8.33 11.5 7.5 11.5C6.67 11.5 6 10.83 6 10ZM11 18C13.33 18 15.31 16.54 16.11 14.5H5.89C6.69 16.54 8.67 18 11 18ZM14.5 11.5C15.33 11.5 16 10.83 16 10C16 9.17 15.33 8.5 14.5 8.5C13.67 8.5 13 9.17 13 10C13 10.83 13.67 11.5 14.5 11.5ZM21 1.5H19V3.5H17V5.5H19V7.5H21V5.5H23V3.5H21V1.5ZM19 12.5C19 16.92 15.42 20.5 11 20.5C6.58 20.5 3 16.92 3 12.5C3 8.08 6.58 4.5 11 4.5C12.46 4.5 13.82 4.9 15 5.58V3.34C13.77 2.8 12.42 2.5 10.99 2.5C5.47 2.5 1 6.98 1 12.5C1 18.02 5.47 22.5 10.99 22.5C16.52 22.5 21 18.02 21 12.5C21 11.45 20.83 10.45 20.53 9.5H18.4C18.78 10.43 19 11.44 19 12.5Z"
|
||||||
|
fill="currentColor"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 781 B |
3
frontend/appflowy_web_app/src/assets/corner_left_top.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg width="32" height="16" viewBox="0 0 32 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.8774 3.10363H15.9L28 3.0908C28.2761 3.0908 28.5 3.31796 28.5 3.5941C28.5 3.87024 28.2761 4.0908 28 4.0908L15.9 4.10363C14.7716 4.10363 13.9554 4.10402 13.3135 4.15647C12.6774 4.20844 12.2566 4.30902 11.911 4.48511C11.2525 4.82066 10.717 5.3561 10.3815 6.01466C10.2054 6.36025 10.1048 6.78107 10.0528 7.41715C10.0004 8.05908 10 8.87527 10 10.0036V11.6036C10 11.8797 9.77614 12.1036 9.5 12.1036C9.22386 12.1036 9 11.8797 9 11.6036V10.0036V9.981C9 8.88004 9 8.02311 9.05616 7.33572C9.11318 6.63779 9.23058 6.07073 9.49047 5.56067C9.9219 4.71394 10.6103 4.02553 11.457 3.5941C11.9671 3.33421 12.5342 3.21681 13.2321 3.15979C13.9195 3.10363 14.7764 3.10363 15.8774 3.10363Z" fill="black"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 841 B |
4
frontend/appflowy_web_app/src/assets/error.svg
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M11 15H13V17H11V15ZM11 7H13V13H11V7ZM11.99 2C6.47 2 2 6.48 2 12C2 17.52 6.47 22 11.99 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 11.99 2ZM12 20C7.58 20 4 16.42 4 12C4 7.58 7.58 4 12 4C16.42 4 20 7.58 20 12C20 16.42 16.42 20 12 20Z"
|
||||||
|
fill="currentColor"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 380 B |
BIN
frontend/appflowy_web_app/src/assets/images/empty.png
Normal file
After Width: | Height: | Size: 50 KiB |
3
frontend/appflowy_web_app/src/assets/reply.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M10 8.5V4.5L3 11.5L10 18.5V14.4C15 14.4 18.5 16 21 19.5C20 14.5 17 9.5 10 8.5Z" fill="currentColor"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 218 B |
4
frontend/appflowy_web_app/src/assets/shuffle.svg
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M10.59 9.17L5.41 4L4 5.41L9.17 10.58L10.59 9.17ZM14.5 4L16.54 6.04L4 18.59L5.41 20L17.96 7.46L20 9.5V4H14.5ZM14.83 13.41L13.42 14.82L16.55 17.95L14.5 20H20V14.5L17.96 16.54L14.83 13.41Z"
|
||||||
|
fill="currentColor"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 335 B |
14
frontend/appflowy_web_app/src/assets/trash.svg
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<svg width="19" height="18" viewBox="0 0 19 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g opacity="0.6">
|
||||||
|
<path d="M16.5 4.48535C14.0025 4.23785 11.49 4.11035 8.985 4.11035C7.5 4.11035 6.015 4.18535 4.53 4.33535L3 4.48535"
|
||||||
|
stroke="currentColor" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M7.125 3.7275L7.29 2.745C7.41 2.0325 7.5 1.5 8.7675 1.5H10.7325C12 1.5 12.0975 2.0625 12.21 2.7525L12.375 3.7275"
|
||||||
|
stroke="currentColor" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M14.8844 6.85449L14.3969 14.407C14.3144 15.5845 14.2469 16.4995 12.1544 16.4995H7.33937C5.24687 16.4995 5.17938 15.5845 5.09688 14.407L4.60938 6.85449"
|
||||||
|
stroke="currentColor" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M8.49609 12.375H10.9936" stroke="currentColor" stroke-width="1.125" stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"/>
|
||||||
|
<path d="M7.875 9.375H11.625" stroke="currentColor" stroke-width="1.125" stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
@ -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<EmojiCategory[]>([]);
|
||||||
|
const [skin, setSkin] = useState<number>(() => {
|
||||||
|
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<HTMLButtonElement>) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
} {
|
||||||
|
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | undefined>(undefined);
|
||||||
|
const onOpen = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
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;
|
||||||
|
}
|
@ -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 (
|
||||||
|
<div tabIndex={0} className={'emoji-picker flex h-[360px] max-h-[70vh] flex-col p-4 pt-2'}>
|
||||||
|
<EmojiPickerHeader
|
||||||
|
onEmojiSelect={onSelect}
|
||||||
|
skin={skin}
|
||||||
|
hideRemove={props.hideRemove}
|
||||||
|
onSkinSelect={onSkinChange}
|
||||||
|
searchValue={searchValue}
|
||||||
|
onSearchChange={setSearchValue}
|
||||||
|
/>
|
||||||
|
<EmojiPickerCategories
|
||||||
|
defaultEmoji={defaultEmoji}
|
||||||
|
onEscape={onEscape}
|
||||||
|
onEmojiSelect={onSelect}
|
||||||
|
emojiCategories={emojiCategories}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EmojiPicker;
|
@ -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<HTMLDivElement>(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<number | null>(null);
|
||||||
|
const mouseX = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const ref = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const getCategoryName = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
const i18nName: Record<string, string> = {
|
||||||
|
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 (
|
||||||
|
<div style={style} data-index={index}>
|
||||||
|
{item.type === 'category' ? (
|
||||||
|
<div className={'pt-2 text-xs font-medium text-text-caption'}>{getCategoryName(item.id)}</div>
|
||||||
|
) : null}
|
||||||
|
<div className={'flex'}>
|
||||||
|
{item.emojis?.map((emoji, columnIndex) => {
|
||||||
|
const isSelected = selectCell.row === index && selectCell.column === columnIndex;
|
||||||
|
|
||||||
|
const isDefaultEmoji = defaultEmoji === emoji.native;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip key={emoji.id} title={emoji.name} placement={'top'} enterDelay={500}>
|
||||||
|
<div
|
||||||
|
data-key={emoji.id}
|
||||||
|
style={{
|
||||||
|
width: EMOJI_SIZE,
|
||||||
|
height: EMOJI_SIZE,
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
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={`icon flex cursor-pointer items-center justify-center rounded hover:bg-fill-list-hover ${
|
||||||
|
isSelected ? 'bg-fill-list-hover' : 'hover:bg-transparent'
|
||||||
|
} ${isDefaultEmoji ? 'bg-fill-list-active' : ''}`}
|
||||||
|
>
|
||||||
|
{emoji.native}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[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 (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={`mt-2 w-[${
|
||||||
|
EMOJI_SIZE * PER_ROW_EMOJI_COUNT
|
||||||
|
}px] flex-1 transform items-center justify-center overflow-y-auto overflow-x-hidden`}
|
||||||
|
>
|
||||||
|
<AutoSizer>
|
||||||
|
{({ height, width }: { height: number; width: number }) => (
|
||||||
|
<FixedSizeList
|
||||||
|
overscanCount={10}
|
||||||
|
height={height}
|
||||||
|
width={width}
|
||||||
|
outerRef={scrollRef}
|
||||||
|
itemCount={rows.length}
|
||||||
|
itemSize={EMOJI_SIZE}
|
||||||
|
itemData={rows}
|
||||||
|
outerElementType={AFScroller}
|
||||||
|
>
|
||||||
|
{renderRow}
|
||||||
|
</FixedSizeList>
|
||||||
|
)}
|
||||||
|
</AutoSizer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EmojiPickerCategories;
|
@ -0,0 +1,139 @@
|
|||||||
|
import { useSelectSkinPopoverProps } from './EmojiPicker.hooks';
|
||||||
|
import React from 'react';
|
||||||
|
import { Box, IconButton } from '@mui/material';
|
||||||
|
import { Circle } from '@mui/icons-material';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
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,
|
||||||
|
color: '#ffc93a',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: '#ffdab7',
|
||||||
|
value: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: '#e7b98f',
|
||||||
|
value: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: '#c88c61',
|
||||||
|
value: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: '#a46134',
|
||||||
|
value: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: '#5d4437',
|
||||||
|
value: 5,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={'px-0.5 py-2'}>
|
||||||
|
<div className={'search-input flex items-end justify-between gap-2'}>
|
||||||
|
<Box className={'mr-1 flex flex-1 items-center gap-2'}>
|
||||||
|
<SearchOutlined className={'h-5 h-5'} />
|
||||||
|
<TextField
|
||||||
|
value={searchValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
onSearchChange(e.target.value);
|
||||||
|
}}
|
||||||
|
autoFocus={true}
|
||||||
|
fullWidth={true}
|
||||||
|
autoCorrect={'off'}
|
||||||
|
autoComplete={'off'}
|
||||||
|
spellCheck={false}
|
||||||
|
className={'search-emoji-input'}
|
||||||
|
placeholder={t('search.label')}
|
||||||
|
variant='standard'
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<div className={'flex gap-1'}>
|
||||||
|
<Tooltip title={t('emoji.random')}>
|
||||||
|
<IconButton
|
||||||
|
size={'small'}
|
||||||
|
onClick={async () => {
|
||||||
|
const emoji = await randomEmoji();
|
||||||
|
|
||||||
|
onEmojiSelect(emoji);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ShuffleIcon className={'h-5 h-5'} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={t('emoji.selectSkinTone')}>
|
||||||
|
<IconButton size={'small'} className={'h-[25px] w-[25px]'} onClick={onOpen}>
|
||||||
|
<Circle
|
||||||
|
style={{
|
||||||
|
fill: skinTones[skin].color,
|
||||||
|
}}
|
||||||
|
className={'h-5 h-5'}
|
||||||
|
/>
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
{hideRemove ? null : (
|
||||||
|
<Tooltip title={t('emoji.remove')}>
|
||||||
|
<IconButton
|
||||||
|
size={'small'}
|
||||||
|
onClick={() => {
|
||||||
|
onEmojiSelect('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteOutlineRounded className={'h-5 h-5'} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Popover {...popoverProps}>
|
||||||
|
<div className={'flex items-center p-2'}>
|
||||||
|
{skinTones.map((skinTone) => (
|
||||||
|
<div className={'mx-0.5'} key={skinTone.value}>
|
||||||
|
<IconButton
|
||||||
|
style={{
|
||||||
|
backgroundColor: skinTone.value === skin ? 'var(--fill-list-hover)' : undefined,
|
||||||
|
}}
|
||||||
|
size={'small'}
|
||||||
|
onClick={() => {
|
||||||
|
onSkinSelect(skinTone.value);
|
||||||
|
popoverProps.onClose?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Circle
|
||||||
|
style={{
|
||||||
|
fill: skinTone.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EmojiPickerHeader;
|
@ -0,0 +1,3 @@
|
|||||||
|
export const EMOJI_SIZE = 32;
|
||||||
|
export const PER_ROW_EMOJI_COUNT = 13;
|
||||||
|
export const MAX_FREQUENTLY_ROW_COUNT = 2;
|
@ -0,0 +1,3 @@
|
|||||||
|
import { lazy } from 'react';
|
||||||
|
|
||||||
|
export const EmojiPicker = lazy(() => import('./EmojiPicker'));
|
@ -0,0 +1,3 @@
|
|||||||
|
import { lazy } from 'react';
|
||||||
|
|
||||||
|
export const KatexMath = lazy(() => import('./KatexMath'));
|
@ -38,7 +38,14 @@ export function NormalModal({
|
|||||||
const buttonColor = danger ? 'var(--function-error)' : undefined;
|
const buttonColor = danger ? 'var(--function-error)' : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog {...dialogProps}>
|
<Dialog
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onClose?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
{...dialogProps}
|
||||||
|
>
|
||||||
<div className={'relative flex flex-col gap-4 p-5'}>
|
<div className={'relative flex flex-col gap-4 p-5'}>
|
||||||
<div className={'flex w-full items-center justify-between text-base font-medium'}>
|
<div className={'flex w-full items-center justify-between text-base font-medium'}>
|
||||||
<div className={'flex-1 text-center '}>{title}</div>
|
<div className={'flex-1 text-center '}>{title}</div>
|
||||||
|
@ -15,8 +15,7 @@ export interface InfoProps {
|
|||||||
|
|
||||||
export type InfoSnackbarProps = InfoProps & CustomContentProps;
|
export type InfoSnackbarProps = InfoProps & CustomContentProps;
|
||||||
|
|
||||||
export const InfoSnackbar = forwardRef<HTMLDivElement, InfoSnackbarProps>(
|
const InfoSnackbar = forwardRef<HTMLDivElement, InfoSnackbarProps>(({ onOk, okText, title, message, onClose }, ref) => {
|
||||||
({ onOk, okText, title, message, onClose }, ref) => {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -40,7 +39,6 @@ export const InfoSnackbar = forwardRef<HTMLDivElement, InfoSnackbarProps>(
|
|||||||
</Paper>
|
</Paper>
|
||||||
</SnackbarContent>
|
</SnackbarContent>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
export default InfoSnackbar;
|
export default InfoSnackbar;
|
||||||
|
@ -1,4 +1,7 @@
|
|||||||
import { InfoProps } from '@/components/_shared/notify/InfoSnackbar';
|
import { InfoProps } from '@/components/_shared/notify/InfoSnackbar';
|
||||||
|
import { lazy } from 'react';
|
||||||
|
|
||||||
|
export const InfoSnackbar = lazy(() => import('./InfoSnackbar'));
|
||||||
|
|
||||||
export const notify = {
|
export const notify = {
|
||||||
success: (message: string) => {
|
success: (message: string) => {
|
||||||
|
@ -10,10 +10,12 @@ const defaultProps: Partial<PopoverComponentProps> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Popover({ children, ...props }: PopoverComponentProps) {
|
function Popover({ children, ...props }: PopoverComponentProps) {
|
||||||
return (
|
return (
|
||||||
<PopoverComponent {...defaultProps} {...props}>
|
<PopoverComponent {...defaultProps} {...props}>
|
||||||
{children}
|
{children}
|
||||||
</PopoverComponent>
|
</PopoverComponent>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default Popover;
|
||||||
|
@ -1,2 +1,4 @@
|
|||||||
export * from './Popover';
|
import { lazy } from 'react';
|
||||||
export * from './RichTooltip';
|
|
||||||
|
export const RichTooltip = lazy(() => import('./RichTooltip'));
|
||||||
|
export const Popover = lazy(() => import('./Popover'));
|
||||||
|
@ -29,8 +29,6 @@ export const AFScroller = React.forwardRef(
|
|||||||
ref.current = scrollEl;
|
ref.current = scrollEl;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
renderTrackHorizontal={(props) => <div {...props} className='appflowy-scrollbar-track-horizontal' />}
|
|
||||||
renderTrackVertical={(props) => <div {...props} className='appflowy-scrollbar-track-vertical' />}
|
|
||||||
renderThumbHorizontal={(props) => <div {...props} className='appflowy-scrollbar-thumb-horizontal' />}
|
renderThumbHorizontal={(props) => <div {...props} className='appflowy-scrollbar-thumb-horizontal' />}
|
||||||
renderThumbVertical={(props) => <div {...props} className='appflowy-scrollbar-thumb-vertical' />}
|
renderThumbVertical={(props) => <div {...props} className='appflowy-scrollbar-thumb-vertical' />}
|
||||||
{...(overflowXHidden && {
|
{...(overflowXHidden && {
|
||||||
|
@ -140,6 +140,7 @@ function AppTheme({ children }: { children: React.ReactNode }) {
|
|||||||
color: 'var(--text-caption)',
|
color: 'var(--text-caption)',
|
||||||
WebkitTextFillColor: 'var(--text-caption) !important',
|
WebkitTextFillColor: 'var(--text-caption) !important',
|
||||||
},
|
},
|
||||||
|
borderRadius: '8px',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
styleOverrides: {
|
styleOverrides: {
|
||||||
|
@ -5,7 +5,7 @@ import AppConfig from '@/components/app/AppConfig';
|
|||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
import { SnackbarProvider } from 'notistack';
|
import { SnackbarProvider } from 'notistack';
|
||||||
import { styled } from '@mui/material';
|
import { styled } from '@mui/material';
|
||||||
import InfoSnackbar from '../_shared/notify/InfoSnackbar';
|
import { InfoSnackbar } from '../_shared/notify';
|
||||||
|
|
||||||
const StyledSnackbarProvider = styled(SnackbarProvider)`
|
const StyledSnackbarProvider = styled(SnackbarProvider)`
|
||||||
&.notistack-MuiContent-default {
|
&.notistack-MuiContent-default {
|
||||||
|
@ -8,7 +8,7 @@ export function Calendar() {
|
|||||||
const { dayPropGetter, localizer, formats, events, emptyEvents } = useCalendarSetup();
|
const { dayPropGetter, localizer, formats, events, emptyEvents } = useCalendarSetup();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'database-calendar h-full max-h-[960px] pb-4 pt-4 text-sm'}>
|
<div className={'database-calendar h-fit max-h-[960px] pb-4 pt-4 text-sm'}>
|
||||||
<BigCalendar
|
<BigCalendar
|
||||||
components={{
|
components={{
|
||||||
toolbar: (props) => <Toolbar {...props} emptyEvents={emptyEvents} />,
|
toolbar: (props) => <Toolbar {...props} emptyEvents={emptyEvents} />,
|
||||||
|
@ -29,7 +29,6 @@ $today-highlight-bg: transparent;
|
|||||||
|
|
||||||
.rbc-month-view {
|
.rbc-month-view {
|
||||||
border: none;
|
border: none;
|
||||||
@apply h-full overflow-auto;
|
|
||||||
|
|
||||||
.rbc-month-row {
|
.rbc-month-row {
|
||||||
border: 1px solid var(--line-divider);
|
border: 1px solid var(--line-divider);
|
||||||
@ -79,10 +78,6 @@ $today-highlight-bg: transparent;
|
|||||||
height: fit-content;
|
height: fit-content;
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
margin-bottom: 150px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-properties {
|
.event-properties {
|
||||||
|
@ -79,7 +79,6 @@ export const Column = memo(
|
|||||||
return (
|
return (
|
||||||
<VariableSizeList
|
<VariableSizeList
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={'pb-[150px]'}
|
|
||||||
height={height}
|
height={height}
|
||||||
itemCount={rowCount}
|
itemCount={rowCount}
|
||||||
itemSize={getItemSize}
|
itemSize={getItemSize}
|
||||||
|
@ -126,7 +126,7 @@ export const GridTable = ({ scrollLeft, columnWidth, columns, onScrollLeft }: Gr
|
|||||||
columnCount={columns.length}
|
columnCount={columns.length}
|
||||||
columnWidth={(index) => columnWidth(index, width)}
|
columnWidth={(index) => columnWidth(index, width)}
|
||||||
rowHeight={rowHeight}
|
rowHeight={rowHeight}
|
||||||
className={'grid-table pb-[150px]'}
|
className={'grid-table'}
|
||||||
overscanRowCount={5}
|
overscanRowCount={5}
|
||||||
overscanColumnCount={5}
|
overscanColumnCount={5}
|
||||||
style={{
|
style={{
|
||||||
|
@ -18,7 +18,7 @@ function DatabaseHeader({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
'my-10 flex w-full items-center gap-4 overflow-hidden whitespace-pre-wrap break-words break-all text-[2.25rem] font-bold leading-[1.5em] max-sm:text-[7vw]'
|
'my-10 flex w-full items-center gap-4 whitespace-pre-wrap break-words break-all text-[2.25rem] font-bold leading-[1.5em] max-sm:text-[7vw]'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className={'relative'}>
|
<div className={'relative'}>
|
||||||
|
@ -5,7 +5,7 @@ import { copyTextToClipboard } from '@/utils/copy';
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { CircularProgress } from '@mui/material';
|
import { CircularProgress } from '@mui/material';
|
||||||
import { ErrorOutline } from '@mui/icons-material';
|
import { ReactComponent as ErrorOutline } from '@/assets/error.svg';
|
||||||
|
|
||||||
const MIN_WIDTH = 100;
|
const MIN_WIDTH = 100;
|
||||||
|
|
||||||
@ -35,7 +35,11 @@ function ImageRender({
|
|||||||
}, [hasError, initialWidth, loading]);
|
}, [hasError, initialWidth, loading]);
|
||||||
const imageProps: React.ImgHTMLAttributes<HTMLImageElement> = useMemo(() => {
|
const imageProps: React.ImgHTMLAttributes<HTMLImageElement> = useMemo(() => {
|
||||||
return {
|
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',
|
className: 'object-cover',
|
||||||
ref: imgRef,
|
ref: imgRef,
|
||||||
src: url,
|
src: url,
|
||||||
@ -54,7 +58,9 @@ function ImageRender({
|
|||||||
const renderErrorNode = useCallback(() => {
|
const renderErrorNode = useCallback(() => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={'flex h-full w-full items-center justify-center gap-2 rounded border border-function-error bg-red-50'}
|
className={
|
||||||
|
'flex h-[48px] w-full items-center justify-center gap-2 rounded border border-function-error bg-red-50'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<ErrorOutline className={'text-function-error'} />
|
<ErrorOutline className={'text-function-error'} />
|
||||||
<div className={'text-function-error'}>{t('editor.imageLoadFailed')}</div>
|
<div className={'text-function-error'}>{t('editor.imageLoadFailed')}</div>
|
||||||
@ -68,7 +74,7 @@ function ImageRender({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
minWidth: MIN_WIDTH,
|
minWidth: MIN_WIDTH,
|
||||||
width: 'fit-content',
|
width: loading || hasError ? '100%' : 'fit-content',
|
||||||
}}
|
}}
|
||||||
className={`image-render relative min-h-[48px] ${hasError ? 'w-full' : ''}`}
|
className={`image-render relative min-h-[48px] ${hasError ? 'w-full' : ''}`}
|
||||||
>
|
>
|
||||||
|
@ -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 { notify } from '@/components/_shared/notify';
|
||||||
import RightTopActionsToolbar from '@/components/editor/components/block-actions/RightTopActionsToolbar';
|
import RightTopActionsToolbar from '@/components/editor/components/block-actions/RightTopActionsToolbar';
|
||||||
import { EditorElementProps, MathEquationNode } from '@/components/editor/editor.type';
|
import { EditorElementProps, MathEquationNode } from '@/components/editor/editor.type';
|
||||||
|
@ -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 { EditorElementProps, FormulaNode } from '@/components/editor/editor.type';
|
||||||
import React, { memo, forwardRef } from 'react';
|
import React, { memo, forwardRef } from 'react';
|
||||||
import { useSelected } from 'slate-react';
|
import { useSelected } from 'slate-react';
|
||||||
|
@ -0,0 +1,181 @@
|
|||||||
|
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 { LoginModal } from '@/components/login';
|
||||||
|
import { Button, TextareaAutosize } from '@mui/material';
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
import React, { memo, useCallback, useContext, useEffect, useRef } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import smoothScrollIntoViewIfNeeded from 'smooth-scroll-into-view-if-needed';
|
||||||
|
import { ReactComponent as CloseIcon } from '@/assets/close.svg';
|
||||||
|
|
||||||
|
function AddComment() {
|
||||||
|
const { reload, replyCommentId, replyComment: setReplyCommentId } = useGlobalCommentContext();
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const isAuthenticated = useContext(AFConfigContext)?.isAuthenticated;
|
||||||
|
const createCommentOnPublishView = useContext(AFConfigContext)?.service?.createCommentOnPublishView;
|
||||||
|
const viewId = useContext(PublishContext)?.viewMeta?.view_id;
|
||||||
|
const [content, setContent] = React.useState('');
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const [loginOpen, setLoginOpen] = React.useState(false);
|
||||||
|
const [focus, setFocus] = React.useState(false);
|
||||||
|
const url = window.location.href + '#addComment';
|
||||||
|
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
const handleOnFocus = (e: React.FocusEvent<HTMLTextAreaElement>) => {
|
||||||
|
setFocus(true);
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
setLoginOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = ref.current;
|
||||||
|
|
||||||
|
if (!element) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashHasComment = window.location.hash.includes('#addComment');
|
||||||
|
const duration = hashHasComment ? 1000 : 200;
|
||||||
|
|
||||||
|
if (hashHasComment || replyCommentId) {
|
||||||
|
setTimeout(() => {
|
||||||
|
void (async () => {
|
||||||
|
window.location.hash = '';
|
||||||
|
|
||||||
|
await smoothScrollIntoViewIfNeeded(element, {
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'nearest',
|
||||||
|
});
|
||||||
|
inputRef.current?.focus();
|
||||||
|
})();
|
||||||
|
}, duration);
|
||||||
|
}
|
||||||
|
}, [replyCommentId]);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
if (!createCommentOnPublishView || !viewId || loading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await createCommentOnPublishView(viewId, content, replyCommentId || undefined);
|
||||||
|
await reload();
|
||||||
|
setContent('');
|
||||||
|
|
||||||
|
setReplyCommentId(null);
|
||||||
|
} catch (_e) {
|
||||||
|
notify.error('Failed to create comment');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [loading, content, createCommentOnPublishView, viewId, replyCommentId, reload, setReplyCommentId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={'my-2 flex flex-col gap-2'}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: replyCommentId ? 'var(--fill-list-hover)' : undefined,
|
||||||
|
}}
|
||||||
|
className={'flex flex-col rounded-[8px]'}
|
||||||
|
>
|
||||||
|
{replyCommentId && (
|
||||||
|
<div className={'relative flex items-center gap-2 py-2 pl-2 pr-6'}>
|
||||||
|
<span className={'text-sm text-text-caption'}>{t('globalComment.replyingTo')}</span>
|
||||||
|
<div className={'flex-1 overflow-hidden'}> {<ReplyComment commentId={replyCommentId} />}</div>
|
||||||
|
|
||||||
|
<div className={'absolute right-2 top-2 cursor-pointer rounded-full p-1 hover:bg-fill-list-hover'}>
|
||||||
|
<CloseIcon className={'h-3 w-3 '} onClick={() => setReplyCommentId(null)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
id={'addComment'}
|
||||||
|
style={{
|
||||||
|
borderColor: focus ? 'var(--content-blue-400)' : undefined,
|
||||||
|
borderWidth: '1.5px',
|
||||||
|
scrollMarginTop: '100px',
|
||||||
|
borderTopLeftRadius: replyCommentId ? 0 : undefined,
|
||||||
|
borderTopRightRadius: replyCommentId ? 0 : undefined,
|
||||||
|
}}
|
||||||
|
className={
|
||||||
|
'flex w-full w-full transform flex-col gap-4 rounded-[8px] border border-line-divider bg-bg-body px-3 py-1.5 '
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TextareaAutosize
|
||||||
|
minRows={1}
|
||||||
|
ref={inputRef}
|
||||||
|
autoComplete={'off'}
|
||||||
|
spellCheck={false}
|
||||||
|
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) {
|
||||||
|
if (!content) return;
|
||||||
|
e.preventDefault();
|
||||||
|
void handleSubmit();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setContent('');
|
||||||
|
setReplyCommentId(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!!content && (
|
||||||
|
<div className={'flex items-center justify-end gap-2'}>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
inputRef.current?.blur();
|
||||||
|
setContent('');
|
||||||
|
setReplyCommentId(null);
|
||||||
|
}}
|
||||||
|
className={'h-7'}
|
||||||
|
size={'small'}
|
||||||
|
color={'inherit'}
|
||||||
|
variant={'outlined'}
|
||||||
|
>
|
||||||
|
{t('button.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className={'h-7'}
|
||||||
|
size={'small'}
|
||||||
|
disabled={!content || loading}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
variant={'contained'}
|
||||||
|
>
|
||||||
|
{loading ? <CircularProgress color={'inherit'} size={20} /> : t('button.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<LoginModal
|
||||||
|
redirectTo={url}
|
||||||
|
open={loginOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setLoginOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(AddComment);
|
@ -0,0 +1,38 @@
|
|||||||
|
import { CommentWrap } from '@/components/global-comment/comment';
|
||||||
|
import { useGlobalCommentContext } from '@/components/global-comment/GlobalComment.hooks';
|
||||||
|
|
||||||
|
import React, { memo } from 'react';
|
||||||
|
|
||||||
|
function CommentList() {
|
||||||
|
const { comments } = useGlobalCommentContext();
|
||||||
|
|
||||||
|
const isEmpty = !comments || comments.length === 0;
|
||||||
|
|
||||||
|
const [hoverId, setHoverId] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
if (isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onMouseLeave={() => {
|
||||||
|
setHoverId(null);
|
||||||
|
}}
|
||||||
|
className={'flex w-full flex-col gap-2'}
|
||||||
|
>
|
||||||
|
{comments?.map((comment) => (
|
||||||
|
<CommentWrap
|
||||||
|
isHovered={comment.commentId === hoverId}
|
||||||
|
onHovered={() => {
|
||||||
|
setHoverId(comment.commentId);
|
||||||
|
}}
|
||||||
|
key={comment.commentId}
|
||||||
|
commentId={comment.commentId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(CommentList);
|
@ -0,0 +1,43 @@
|
|||||||
|
import GlobalComment from '@/components/global-comment/GlobalComment';
|
||||||
|
import {
|
||||||
|
GlobalCommentContext,
|
||||||
|
useLoadComments,
|
||||||
|
useLoadReactions,
|
||||||
|
} from '@/components/global-comment/GlobalComment.hooks';
|
||||||
|
import React, { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
export function GlobalCommentProvider() {
|
||||||
|
const { comments, loading, reload } = useLoadComments();
|
||||||
|
const { reactions, toggleReaction } = useLoadReactions();
|
||||||
|
const [replyCommentId, setReplyCommentId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const getComment = useCallback(
|
||||||
|
(commentId: string) => {
|
||||||
|
return comments?.find((comment) => comment.commentId === commentId);
|
||||||
|
},
|
||||||
|
[comments]
|
||||||
|
);
|
||||||
|
|
||||||
|
const replyComment = useCallback((commentId: string | null) => {
|
||||||
|
setReplyCommentId(commentId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlobalCommentContext.Provider
|
||||||
|
value={{
|
||||||
|
reactions,
|
||||||
|
replyCommentId,
|
||||||
|
reload,
|
||||||
|
getComment,
|
||||||
|
loading,
|
||||||
|
comments,
|
||||||
|
replyComment,
|
||||||
|
toggleReaction,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GlobalComment />
|
||||||
|
</GlobalCommentContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GlobalCommentProvider;
|
@ -0,0 +1,242 @@
|
|||||||
|
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<void>;
|
||||||
|
getComment: (commentId: string) => GlobalComment | undefined;
|
||||||
|
loading: boolean;
|
||||||
|
comments: GlobalComment[] | null;
|
||||||
|
replyComment: (commentId: string | null) => void;
|
||||||
|
replyCommentId: string | null;
|
||||||
|
reactions: Record<string, Reaction[]> | null;
|
||||||
|
toggleReaction: (commentId: string, reactionType: string) => void;
|
||||||
|
}>({
|
||||||
|
reload: () => Promise.resolve(),
|
||||||
|
getComment: () => undefined,
|
||||||
|
loading: false,
|
||||||
|
comments: null,
|
||||||
|
replyComment: () => undefined,
|
||||||
|
replyCommentId: null,
|
||||||
|
reactions: null,
|
||||||
|
toggleReaction: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
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<Record<string, Reaction[]> | 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<GlobalComment[] | null>(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: <span>{comment.user.avatarUrl}</span>,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
@ -0,0 +1,35 @@
|
|||||||
|
import AddComment from '@/components/global-comment/AddComment';
|
||||||
|
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 (
|
||||||
|
<div className={'mb-[100px] mt-16 flex h-fit w-full justify-center'}>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'flex w-[964px] min-w-0 max-w-full transform flex-col gap-2 px-16 transition-all duration-300 ease-in-out max-sm:px-4'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={'text-[24px]'}>{t('globalComment.comments')}</div>
|
||||||
|
<Divider />
|
||||||
|
<AddComment />
|
||||||
|
{loading && !comments?.length ? (
|
||||||
|
<div className={'flex h-[200px] w-full items-center justify-center'}>
|
||||||
|
<CircularProgress />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<CommentList />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GlobalComment;
|
@ -0,0 +1,30 @@
|
|||||||
|
import { getAvatar, useGlobalCommentContext } from '@/components/global-comment/GlobalComment.hooks';
|
||||||
|
import { Avatar } from '@mui/material';
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
function ReplyComment({ commentId }: { commentId?: string | null }) {
|
||||||
|
const { getComment } = useGlobalCommentContext();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const replyComment = useMemo(() => {
|
||||||
|
if (!commentId) return;
|
||||||
|
return getComment(commentId);
|
||||||
|
}, [commentId, getComment]);
|
||||||
|
|
||||||
|
if (!replyComment) return null;
|
||||||
|
return (
|
||||||
|
<div className={'flex items-center gap-1 text-sm text-text-caption'}>
|
||||||
|
<Avatar {...getAvatar(replyComment)} className={'h-4 w-4 text-xs'} />
|
||||||
|
<div className={'text-xs font-medium'}>@{replyComment.user?.name}</div>
|
||||||
|
<div className={'truncate px-1'}>
|
||||||
|
{replyComment.isDeleted ? (
|
||||||
|
<span className={'text-xs'}>{`[${t('globalComment.hasBeenDeleted')}]`}</span>
|
||||||
|
) : (
|
||||||
|
replyComment.content
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReplyComment;
|
@ -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 (
|
||||||
|
<div className={'flex gap-2'}>
|
||||||
|
<ReactAction comment={comment} />
|
||||||
|
<ReplyAction comment={comment} />
|
||||||
|
<MoreActions comment={comment} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(CommentActions);
|
@ -0,0 +1,152 @@
|
|||||||
|
import { GlobalComment } from '@/application/comment.type';
|
||||||
|
import { PublishContext } from '@/application/publish';
|
||||||
|
import { NormalModal } from '@/components/_shared/modal';
|
||||||
|
import { notify } from '@/components/_shared/notify';
|
||||||
|
import { RichTooltip } 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 { debounce } from 'lodash-es';
|
||||||
|
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<React.SVGProps<SVGSVGElement>>;
|
||||||
|
label: string;
|
||||||
|
disabled: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
danger?: boolean;
|
||||||
|
tooltip?: TooltipProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MoreActions({ comment }: { comment: GlobalComment }) {
|
||||||
|
const { reload } = useGlobalCommentContext();
|
||||||
|
const canDeleted = comment.canDeleted;
|
||||||
|
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const [deleteModalOpen, setDeleteModalOpen] = React.useState(false);
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const debounceClose = useMemo(() => {
|
||||||
|
return debounce(handleClose, 200);
|
||||||
|
}, [handleClose]);
|
||||||
|
|
||||||
|
const handleOpen = () => {
|
||||||
|
debounceClose.cancel();
|
||||||
|
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: <div className={'text-center'}>{t('globalComment.noAccessDeleteComment')}</div>,
|
||||||
|
placement: 'top',
|
||||||
|
},
|
||||||
|
onClick: () => {
|
||||||
|
setDeleteModalOpen(true);
|
||||||
|
},
|
||||||
|
danger: true,
|
||||||
|
},
|
||||||
|
] as Item[];
|
||||||
|
}, [t, canDeleted]);
|
||||||
|
|
||||||
|
const renderItem = useCallback((action: Item) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size={'small'}
|
||||||
|
color={'inherit'}
|
||||||
|
disabled={action.disabled}
|
||||||
|
onClick={action.onClick}
|
||||||
|
className={`w-full items-center justify-start gap-2 p-1 ${action.danger ? 'hover:text-function-error' : ''}`}
|
||||||
|
>
|
||||||
|
<action.Icon className={'h-4 w-4'} />
|
||||||
|
<div>{action.label}</div>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<RichTooltip
|
||||||
|
content={
|
||||||
|
<div
|
||||||
|
className={'flex min-w-[150px] flex-col items-start p-2'}
|
||||||
|
onMouseEnter={handleOpen}
|
||||||
|
onMouseLeave={debounceClose}
|
||||||
|
>
|
||||||
|
{actions.map((action, index) => {
|
||||||
|
if (action.tooltip) {
|
||||||
|
return (
|
||||||
|
<Tooltip key={index} {...action.tooltip}>
|
||||||
|
<div className={'w-full'}>{renderItem(action)}</div>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={index} className={'w-full'}>
|
||||||
|
{renderItem(action)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
open={open}
|
||||||
|
onClose={handleClose}
|
||||||
|
>
|
||||||
|
<IconButton size={'small'} onMouseEnter={handleOpen} onMouseLeave={debounceClose}>
|
||||||
|
<MoreIcon className={'h-4 w-4'} />
|
||||||
|
</IconButton>
|
||||||
|
</RichTooltip>
|
||||||
|
<NormalModal
|
||||||
|
PaperProps={{
|
||||||
|
sx: {
|
||||||
|
maxWidth: 420,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
okText={t('button.delete')}
|
||||||
|
danger={true}
|
||||||
|
onOk={handleDeleteAction}
|
||||||
|
onCancel={() => {
|
||||||
|
setDeleteModalOpen(false);
|
||||||
|
}}
|
||||||
|
onClose={() => setDeleteModalOpen(false)}
|
||||||
|
open={deleteModalOpen}
|
||||||
|
title={<div className={'text-left'}>{t('globalComment.deleteComment')}</div>}
|
||||||
|
>
|
||||||
|
<div className={'w-full whitespace-pre-wrap break-words pb-1 text-text-caption'}>
|
||||||
|
{t('globalComment.confirmDeleteDescription')}
|
||||||
|
</div>
|
||||||
|
</NormalModal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(MoreActions);
|
@ -0,0 +1,60 @@
|
|||||||
|
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<HTMLButtonElement>(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 (
|
||||||
|
<>
|
||||||
|
<Tooltip title={t('globalComment.addReaction')}>
|
||||||
|
<IconButton ref={ref} onClick={handleOpen} size='small'>
|
||||||
|
<AddReactionRounded className={'h-4 w-4'} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
{open && (
|
||||||
|
<Popover
|
||||||
|
anchorEl={ref.current}
|
||||||
|
open={open}
|
||||||
|
onClose={handleClose}
|
||||||
|
sx={{
|
||||||
|
'& .MuiPopover-paper': {
|
||||||
|
width: PER_ROW_EMOJI_COUNT * EMOJI_SIZE,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Suspense fallback={<ComponentLoading />}>
|
||||||
|
<EmojiPicker hideRemove onEscape={handleClose} onEmojiSelect={handlePickEmoji} />
|
||||||
|
</Suspense>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(ReactAction);
|
@ -0,0 +1,27 @@
|
|||||||
|
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 (
|
||||||
|
<Tooltip title={t('globalComment.reply')}>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => {
|
||||||
|
replyComment(comment.commentId);
|
||||||
|
}}
|
||||||
|
size='small'
|
||||||
|
>
|
||||||
|
<ReplyOutlined className={'h-4 w-4'} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(ReplyAction);
|
@ -0,0 +1,39 @@
|
|||||||
|
import { GlobalComment } from '@/application/comment.type';
|
||||||
|
import { useCommentRender } from '@/components/global-comment/GlobalComment.hooks';
|
||||||
|
import { Reactions } from '@/components/global-comment/reactions';
|
||||||
|
import { Avatar, Tooltip } from '@mui/material';
|
||||||
|
import React, { memo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface CommentProps {
|
||||||
|
comment: GlobalComment;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Comment({ comment }: CommentProps) {
|
||||||
|
const { avatar, time, timeFormat } = useCommentRender(comment);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={' flex gap-4 '}>
|
||||||
|
<Avatar {...avatar} />
|
||||||
|
<div className={'flex flex-col gap-1'}>
|
||||||
|
<div className={'flex items-center gap-2'}>
|
||||||
|
<div className={'font-semibold'}>{comment.user?.name}</div>
|
||||||
|
<Tooltip title={timeFormat} enterNextDelay={500} enterDelay={1000} placement={'top-start'}>
|
||||||
|
<div className={'text-sm text-text-caption'}>{time}</div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div className={'whitespace-pre-wrap break-words'}>
|
||||||
|
{comment.isDeleted ? (
|
||||||
|
<span className={'text-text-caption'}>{`[${t('globalComment.hasBeenDeleted')}]`}</span>
|
||||||
|
) : (
|
||||||
|
comment.content
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!comment.isDeleted && <Reactions comment={comment} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(Comment);
|
@ -0,0 +1,83 @@
|
|||||||
|
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, useEffect, useMemo } from 'react';
|
||||||
|
import smoothScrollIntoViewIfNeeded from 'smooth-scroll-into-view-if-needed';
|
||||||
|
|
||||||
|
export interface CommentWrapProps {
|
||||||
|
commentId: string;
|
||||||
|
isHovered: boolean;
|
||||||
|
onHovered: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommentWrap({ commentId, isHovered, onHovered }: CommentWrapProps) {
|
||||||
|
const { getComment } = useGlobalCommentContext();
|
||||||
|
const comment = useMemo(() => getComment(commentId), [commentId, getComment]);
|
||||||
|
const ref = React.useRef<HTMLDivElement>(null);
|
||||||
|
const [highLight, setHighLight] = React.useState(false);
|
||||||
|
|
||||||
|
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 = '';
|
||||||
|
|
||||||
|
await smoothScrollIntoViewIfNeeded(element, {
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'center',
|
||||||
|
});
|
||||||
|
setHighLight(true);
|
||||||
|
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
setHighLight(false);
|
||||||
|
}, 10000);
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
timeout && clearTimeout(timeout);
|
||||||
|
};
|
||||||
|
}, [commentId]);
|
||||||
|
|
||||||
|
const renderReplyComment = useCallback((replyCommentId: string) => {
|
||||||
|
return (
|
||||||
|
<div className={'relative flex w-full items-center gap-2'}>
|
||||||
|
<div className={'reply-line relative top-2 ml-[1.75em] min-w-[28px]'} />
|
||||||
|
<div className={'flex-1 overflow-hidden '}> {<ReplyComment commentId={replyCommentId} />}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!comment) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={'flex flex-col gap-2'} data-comment-id={comment.commentId}>
|
||||||
|
{comment.replyCommentId && renderReplyComment(comment.replyCommentId)}
|
||||||
|
<div
|
||||||
|
className={`relative rounded-[8px] p-2 hover:bg-fill-list-hover ${highLight ? 'blink' : ''}`}
|
||||||
|
{...(comment.isDeleted ? { style: { opacity: 0.5 } } : {})}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
onHovered();
|
||||||
|
setHighLight(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Comment comment={comment} />
|
||||||
|
{isHovered && !comment.isDeleted && (
|
||||||
|
<div className={'absolute right-2 top-2'}>
|
||||||
|
<CommentActions comment={comment} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CommentWrap;
|
@ -0,0 +1 @@
|
|||||||
|
export * from './CommentWrap';
|
@ -0,0 +1,3 @@
|
|||||||
|
import { lazy } from 'react';
|
||||||
|
|
||||||
|
export const GlobalCommentProvider = lazy(() => import('./GlobaclCommentProvider'));
|
@ -0,0 +1,81 @@
|
|||||||
|
import { Reaction as ReactionType } from '@/application/comment.type';
|
||||||
|
import { AFConfigContext } from '@/components/app/AppConfig';
|
||||||
|
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 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]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
<div className={'break-word overflow-hidden whitespace-pre-wrap text-xs'}>
|
||||||
|
{t('globalComment.reactedBy')}
|
||||||
|
{` `}
|
||||||
|
{userNames}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={style}
|
||||||
|
onClick={() => onClick(reaction)}
|
||||||
|
onMouseEnter={() => 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'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className={'icon'}>{reaction.reactionType}</span>
|
||||||
|
{<div className={'text-xs font-medium'}>{reactCount}</div>}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(Reaction);
|
@ -0,0 +1,28 @@
|
|||||||
|
import { GlobalComment, Reaction as ReactionType } from '@/application/comment.type';
|
||||||
|
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]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={'flex w-full flex-wrap items-center gap-2 overflow-hidden'}>
|
||||||
|
{commentReactions.map((reaction) => {
|
||||||
|
return <Reaction reaction={reaction} onClick={handleReactionClick} key={reaction.reactionType} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(Reactions);
|
@ -0,0 +1 @@
|
|||||||
|
export * from './Reactions';
|
@ -3,7 +3,7 @@ import { Dialog, IconButton } from '@mui/material';
|
|||||||
import { Login } from './Login';
|
import { Login } from './Login';
|
||||||
import { ReactComponent as CloseIcon } from '@/assets/close.svg';
|
import { ReactComponent as CloseIcon } from '@/assets/close.svg';
|
||||||
|
|
||||||
export function LoginModal({ redirectTo, open, onClose }: { redirectTo: string; open: boolean; onClose: () => void }) {
|
function LoginModal({ redirectTo, open, onClose }: { redirectTo: string; open: boolean; onClose: () => void }) {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={onClose}>
|
<Dialog open={open} onClose={onClose}>
|
||||||
<div className={'relative px-6'}>
|
<div className={'relative px-6'}>
|
||||||
@ -17,3 +17,5 @@ export function LoginModal({ redirectTo, open, onClose }: { redirectTo: string;
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default LoginModal;
|
||||||
|
@ -3,7 +3,7 @@ import { AFConfigContext } from '@/components/app/AppConfig';
|
|||||||
import { Button, CircularProgress, OutlinedInput } from '@mui/material';
|
import { Button, CircularProgress, OutlinedInput } from '@mui/material';
|
||||||
import React, { useContext } from 'react';
|
import React, { useContext } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import validator from 'validator';
|
import isEmail from 'validator/lib/isEmail';
|
||||||
|
|
||||||
function MagicLink({ redirectTo }: { redirectTo: string }) {
|
function MagicLink({ redirectTo }: { redirectTo: string }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -11,7 +11,7 @@ function MagicLink({ redirectTo }: { redirectTo: string }) {
|
|||||||
const [loading, setLoading] = React.useState<boolean>(false);
|
const [loading, setLoading] = React.useState<boolean>(false);
|
||||||
const service = useContext(AFConfigContext)?.service;
|
const service = useContext(AFConfigContext)?.service;
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
const isValidEmail = validator.isEmail(email);
|
const isValidEmail = isEmail(email);
|
||||||
|
|
||||||
if (!isValidEmail) {
|
if (!isValidEmail) {
|
||||||
notify.error(t('signIn.invalidEmail'));
|
notify.error(t('signIn.invalidEmail'));
|
||||||
|
@ -1,2 +1,5 @@
|
|||||||
export * from './Login';
|
import { lazy } from 'react';
|
||||||
export * from './LoginModal';
|
|
||||||
|
export const Login = lazy(() => import('./Login'));
|
||||||
|
|
||||||
|
export const LoginModal = lazy(() => import('./LoginModal'));
|
||||||
|
@ -1,12 +1,14 @@
|
|||||||
import { YDoc } from '@/application/collab.type';
|
import { YDoc } from '@/application/collab.type';
|
||||||
import { PublishProvider } from '@/application/publish';
|
import { PublishProvider } from '@/application/publish';
|
||||||
|
import ComponentLoading from '@/components/_shared/progress/ComponentLoading';
|
||||||
import { AFScroller } from '@/components/_shared/scroller';
|
import { AFScroller } from '@/components/_shared/scroller';
|
||||||
import { AFConfigContext } from '@/components/app/AppConfig';
|
import { AFConfigContext } from '@/components/app/AppConfig';
|
||||||
|
import { GlobalCommentProvider } from '@/components/global-comment';
|
||||||
import CollabView from '@/components/publish/CollabView';
|
import CollabView from '@/components/publish/CollabView';
|
||||||
import OutlineDrawer from '@/components/publish/outline/OutlineDrawer';
|
import { OutlineDrawer } from '@/components/publish/outline';
|
||||||
import { createHotkey, HOT_KEY_NAME } from '@/utils/hotkeys';
|
import { createHotkey, HOT_KEY_NAME } from '@/utils/hotkeys';
|
||||||
import React, { useCallback, useContext, useEffect, useState } from 'react';
|
import React, { Suspense, useCallback, useContext, useEffect, useState } from 'react';
|
||||||
import { PublishViewHeader } from 'src/components/publish/header';
|
import { PublishViewHeader } from '@/components/publish/header';
|
||||||
import NotFound from '@/components/error/NotFound';
|
import NotFound from '@/components/error/NotFound';
|
||||||
|
|
||||||
export interface PublishViewProps {
|
export interface PublishViewProps {
|
||||||
@ -84,8 +86,13 @@ export function PublishView({ namespace, publishName }: PublishViewProps) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<CollabView doc={doc} />
|
<CollabView doc={doc} />
|
||||||
|
<Suspense fallback={<ComponentLoading />}>
|
||||||
|
<GlobalCommentProvider />
|
||||||
|
</Suspense>
|
||||||
</AFScroller>
|
</AFScroller>
|
||||||
|
<Suspense fallback={null}>
|
||||||
{open && <OutlineDrawer width={drawerWidth} open={open} onClose={() => setOpen(false)} />}
|
{open && <OutlineDrawer width={drawerWidth} open={open} onClose={() => setOpen(false)} />}
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</PublishProvider>
|
</PublishProvider>
|
||||||
);
|
);
|
||||||
|
@ -2,14 +2,14 @@ import { usePublishContext } from '@/application/publish';
|
|||||||
import { openOrDownload } from '@/components/publish/header/utils';
|
import { openOrDownload } from '@/components/publish/header/utils';
|
||||||
import { Divider, IconButton, Tooltip } from '@mui/material';
|
import { Divider, IconButton, Tooltip } from '@mui/material';
|
||||||
import { debounce } from 'lodash-es';
|
import { debounce } from 'lodash-es';
|
||||||
import React, { useCallback, useMemo } from 'react';
|
import React, { Suspense, useCallback, useMemo } from 'react';
|
||||||
import OutlinePopover from '@/components/publish/outline/OutlinePopover';
|
import { OutlinePopover } from '@/components/publish/outline';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import Breadcrumb from './Breadcrumb';
|
import Breadcrumb from './Breadcrumb';
|
||||||
import { ReactComponent as Logo } from '@/assets/logo.svg';
|
import { ReactComponent as Logo } from '@/assets/logo.svg';
|
||||||
import MoreActions from './MoreActions';
|
import MoreActions from './MoreActions';
|
||||||
import { ReactComponent as SideOutlined } from '@/assets/side_outlined.svg';
|
import { ReactComponent as SideOutlined } from '@/assets/side_outlined.svg';
|
||||||
import Duplicate from './duplicate/Duplicate';
|
import { Duplicate } from './duplicate';
|
||||||
|
|
||||||
export const HEADER_HEIGHT = 48;
|
export const HEADER_HEIGHT = 48;
|
||||||
|
|
||||||
@ -66,6 +66,7 @@ export function PublishViewHeader({ onOpenDrawer, openDrawer }: { onOpenDrawer:
|
|||||||
className={'appflowy-top-bar sticky top-0 z-10 flex px-5'}
|
className={'appflowy-top-bar sticky top-0 z-10 flex px-5'}
|
||||||
>
|
>
|
||||||
<div className={'flex w-full items-center justify-between gap-2 overflow-hidden'}>
|
<div className={'flex w-full items-center justify-between gap-2 overflow-hidden'}>
|
||||||
|
<Suspense fallback={null}>
|
||||||
{!openDrawer && (
|
{!openDrawer && (
|
||||||
<OutlinePopover
|
<OutlinePopover
|
||||||
onMouseEnter={handleOpenPopover}
|
onMouseEnter={handleOpenPopover}
|
||||||
@ -86,6 +87,7 @@ export function PublishViewHeader({ onOpenDrawer, openDrawer }: { onOpenDrawer:
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</OutlinePopover>
|
</OutlinePopover>
|
||||||
)}
|
)}
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
<div className={'h-full flex-1 overflow-hidden'}>
|
<div className={'h-full flex-1 overflow-hidden'}>
|
||||||
<Breadcrumb crumbs={crumbs} />
|
<Breadcrumb crumbs={crumbs} />
|
||||||
@ -93,7 +95,9 @@ export function PublishViewHeader({ onOpenDrawer, openDrawer }: { onOpenDrawer:
|
|||||||
|
|
||||||
<div className={'flex items-center gap-2'}>
|
<div className={'flex items-center gap-2'}>
|
||||||
<MoreActions />
|
<MoreActions />
|
||||||
|
<Suspense fallback={null}>
|
||||||
<Duplicate />
|
<Duplicate />
|
||||||
|
</Suspense>
|
||||||
<Divider orientation={'vertical'} className={'mx-2'} flexItem />
|
<Divider orientation={'vertical'} className={'mx-2'} flexItem />
|
||||||
<Tooltip title={t('publish.downloadApp')}>
|
<Tooltip title={t('publish.downloadApp')}>
|
||||||
<button onClick={openOrDownload}>
|
<button onClick={openOrDownload}>
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { Button } from '@mui/material';
|
import { Button } from '@mui/material';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { LoginModal } from '@/components/login/LoginModal';
|
import { LoginModal } from '@/components/login';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { useDuplicate } from '@/components/publish/header/duplicate/useDuplicate';
|
import { useDuplicate } from '@/components/publish/header/duplicate/useDuplicate';
|
||||||
import DuplicateModal from '@/components/publish/header/duplicate/DuplicateModal';
|
import DuplicateModal from '@/components/publish/header/duplicate/DuplicateModal';
|
||||||
@ -23,7 +23,7 @@ function Duplicate() {
|
|||||||
{t('publish.saveThisPage')}
|
{t('publish.saveThisPage')}
|
||||||
</Button>
|
</Button>
|
||||||
<LoginModal redirectTo={url} open={loginOpen} onClose={handleLoginClose} />
|
<LoginModal redirectTo={url} open={loginOpen} onClose={handleLoginClose} />
|
||||||
<DuplicateModal open={duplicateOpen} onClose={handleDuplicateClose} />
|
{duplicateOpen && <DuplicateModal open={duplicateOpen} onClose={handleDuplicateClose} />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,3 @@
|
|||||||
|
import { lazy } from 'react';
|
||||||
|
|
||||||
|
export const Duplicate = lazy(() => import('./Duplicate'));
|
@ -2,7 +2,7 @@ import { usePublishContext } from '@/application/publish';
|
|||||||
import Outline from '@/components/publish/outline/Outline';
|
import Outline from '@/components/publish/outline/Outline';
|
||||||
import { Divider, PopperPlacementType } from '@mui/material';
|
import { Divider, PopperPlacementType } from '@mui/material';
|
||||||
import React, { ReactElement, useMemo } from 'react';
|
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 Logo } from '@/assets/logo.svg';
|
||||||
import { ReactComponent as AppflowyLogo } from '@/assets/appflowy.svg';
|
import { ReactComponent as AppflowyLogo } from '@/assets/appflowy.svg';
|
||||||
|
|
||||||
|
@ -1 +1,4 @@
|
|||||||
export * from './OutlinePopover';
|
import { lazy } from 'react';
|
||||||
|
|
||||||
|
export const OutlineDrawer = lazy(() => import('./OutlineDrawer'));
|
||||||
|
export const OutlinePopover = lazy(() => import('./OutlinePopover'));
|
||||||
|
@ -123,3 +123,25 @@ body {
|
|||||||
@apply flex-wrap py-2;
|
@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;
|
||||||
|
}
|
@ -73,6 +73,7 @@ export function renderColor(color: string) {
|
|||||||
return argbToRgba(color);
|
return argbToRgba(color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function stringToColor(string: string) {
|
export function stringToColor(string: string) {
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
let i;
|
let i;
|
||||||
@ -93,3 +94,12 @@ export function stringToColor(string: string) {
|
|||||||
|
|
||||||
return color;
|
return color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function stringAvatar(name: string) {
|
||||||
|
return {
|
||||||
|
sx: {
|
||||||
|
bgcolor: stringToColor(name),
|
||||||
|
},
|
||||||
|
children: `${name.split('')[0]}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
14
frontend/appflowy_web_app/src/utils/emoji.ts
Normal file
@ -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/all.json');
|
||||||
|
}
|
20
frontend/appflowy_web_app/src/utils/position.ts
Normal file
@ -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;
|
||||||
|
}
|
@ -112,6 +112,7 @@ export default defineConfig({
|
|||||||
id.includes('/react-is@') ||
|
id.includes('/react-is@') ||
|
||||||
id.includes('/yjs@') ||
|
id.includes('/yjs@') ||
|
||||||
id.includes('/y-indexeddb@') ||
|
id.includes('/y-indexeddb@') ||
|
||||||
|
id.includes('/dexie') ||
|
||||||
id.includes('/redux') ||
|
id.includes('/redux') ||
|
||||||
id.includes('/react-custom-scrollbars')
|
id.includes('/react-custom-scrollbars')
|
||||||
) {
|
) {
|
||||||
@ -140,9 +141,7 @@ export default defineConfig({
|
|||||||
include: [
|
include: [
|
||||||
'react',
|
'react',
|
||||||
'react-dom',
|
'react-dom',
|
||||||
'@mui/icons-material/ErrorOutline',
|
'@mui/icons-material/Circle',
|
||||||
'@mui/icons-material/CheckCircleOutline',
|
|
||||||
'@mui/icons-material/FunctionsOutlined',
|
|
||||||
'react-katex',
|
'react-katex',
|
||||||
// 'react-custom-scrollbars-2',
|
// 'react-custom-scrollbars-2',
|
||||||
// 'react-window',
|
// 'react-window',
|
||||||
|
@ -2163,5 +2163,50 @@
|
|||||||
"privacyPolicy": "Privacy Policy",
|
"privacyPolicy": "Privacy Policy",
|
||||||
"signInError": "Sign in error",
|
"signInError": "Sign in error",
|
||||||
"login": "Sign up or log in"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|