feat: support global comment on publish (#5834)

* feat: support duplicate UI on web

* fix: replace google svg

* fix: modified some copy

* fix: adjust modal position

* fix: upgrade wasm package

* fix: text overflow

* fix: global comments

* fix: replace appflowy icon

* fix: demond load outline

* fix: lazy load

* fix: close duplicate entry

* fix: ci error

* fix: modified comment styles

* fix: adjust space

* fix: easy find reply comment

* fix: calendar scroll bugs

* fix: image render

* fix: replace loading

* fix: issues of test session

* fix: fixed adding comment

* fix: database view name
This commit is contained in:
Kilu.He 2024-08-01 12:59:04 +08:00 committed by GitHub
parent ed81a0aff2
commit 2402b4c6f1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
116 changed files with 3789 additions and 236 deletions

View File

@ -31,7 +31,6 @@
<body id="body"> <body id="body">
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
<script src="https://ajax.googleapis.com/ajax/libs/webfont/1.6.26/webfont.js"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
@ -64,8 +63,7 @@
} }
}); });
</script> </script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.26.0/prism.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.26.0/plugins/autoloader/prism-autoloader.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.5.1/highlight.min.js"></script>
</body> </body>
</html> </html>

View File

@ -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.2", "@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",
@ -49,6 +49,7 @@
"emoji-regex": "^10.2.1", "emoji-regex": "^10.2.1",
"events": "^3.3.0", "events": "^3.3.0",
"google-protobuf": "^3.15.12", "google-protobuf": "^3.15.12",
"highlight.js": "^11.10.0",
"i18next": "^22.4.10", "i18next": "^22.4.10",
"i18next-browser-languagedetector": "^7.0.1", "i18next-browser-languagedetector": "^7.0.1",
"i18next-resources-to-backend": "^1.1.4", "i18next-resources-to-backend": "^1.1.4",

View File

@ -6,8 +6,8 @@ settings:
dependencies: dependencies:
'@appflowyinc/client-api-wasm': '@appflowyinc/client-api-wasm':
specifier: 0.1.2 specifier: 0.1.4
version: 0.1.2 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)
@ -80,6 +80,9 @@ dependencies:
google-protobuf: google-protobuf:
specifier: ^3.15.12 specifier: ^3.15.12
version: 3.21.2 version: 3.21.2
highlight.js:
specifier: ^11.10.0
version: 11.10.0
i18next: i18next:
specifier: ^22.4.10 specifier: ^22.4.10
version: 22.5.1 version: 22.5.1
@ -451,8 +454,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.2: /@appflowyinc/client-api-wasm@0.1.4:
resolution: {integrity: sha512-+v0hs7/7BVKtgev/Bcbr0u2HLDhUuw4ZvZTaMddI+06HK8vt5S52dMaZKUcMvh1eUjVX8hjC6Mfe0X/yHqvFgA==} 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):
@ -7068,6 +7071,11 @@ packages:
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
dev: true dev: true
/highlight.js@11.10.0:
resolution: {integrity: sha512-SYVnVFswQER+zu1laSya563s+F8VDGt7o35d4utbamowvUNLLMovFqwCLSocpZTz3MgaSRA1IbqRWZv97dtErQ==}
engines: {node: '>=12.0.0'}
dev: false
/hoist-non-react-statics@3.3.2: /hoist-non-react-statics@3.3.2:
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
dependencies: dependencies:

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

View 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;
}

View File

@ -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,12 +15,23 @@ import {
signInGithub, signInGithub,
signInDiscord, signInDiscord,
signInWithUrl, signInWithUrl,
createGlobalCommentOnPublishView,
deleteGlobalCommentOnPublishView,
getPublishViewComments,
getWorkspaces,
getWorkspaceFolder,
getCurrentUser,
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';
import { afterAuth, AUTH_CALLBACK_URL, withSignIn } from '@/application/session/sign_in'; import { afterAuth, AUTH_CALLBACK_URL, withSignIn } from '@/application/session/sign_in';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import * as Y from 'yjs'; import * as Y from 'yjs';
import { DuplicatePublishView } from '@/application/types';
export class AFClientService implements AFService { export class AFClientService implements AFService {
private deviceId: string = nanoid(8); private deviceId: string = nanoid(8);
@ -199,4 +211,61 @@ export class AFClientService implements AFService {
async signInDiscord(_: { redirectTo: string }) { async signInDiscord(_: { redirectTo: string }) {
return await signInDiscord(AUTH_CALLBACK_URL); return await signInDiscord(AUTH_CALLBACK_URL);
} }
async getWorkspaces() {
const data = getWorkspaces();
return data;
}
async getWorkspaceFolder(workspaceId: string) {
const data = await getWorkspaceFolder(workspaceId);
return data;
}
async getCurrentUser() {
const data = await getCurrentUser();
return {
uid: data.uid,
email: data.email,
name: data.name,
avatar: data.icon_url,
uuid: data.uuid,
};
}
async duplicatePublishView(params: DuplicatePublishView) {
return duplicatePublishView({
workspace_id: params.workspaceId,
dest_view_id: params.spaceViewId,
published_view_id: params.viewId,
published_collab_type: params.collabType,
});
}
createCommentOnPublishView(viewId: string, content: string, replyCommentId: string | undefined): Promise<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);
}
} }

View File

@ -1,7 +1,9 @@
import { getToken, invalidToken, isTokenValid, refreshToken } from '@/application/session/token'; import { getToken, invalidToken, isTokenValid, refreshToken } from '@/application/session/token';
import { ClientAPI } from '@appflowyinc/client-api-wasm'; import { ClientAPI, WorkspaceFolder, DuplicatePublishViewPayload } from '@appflowyinc/client-api-wasm';
import { AFCloudConfig } from '@/application/services/services.type'; import { 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 } from '@/application/types';
import { GlobalComment, Reaction } from '@/application/comment.type';
let client: ClientAPI; let client: ClientAPI;
@ -115,3 +117,120 @@ export async function signInGithub(redirectTo: string) {
export async function signInDiscord(redirectTo: string) { export async function signInDiscord(redirectTo: string) {
return signInProvider('discord', redirectTo); return signInProvider('discord', redirectTo);
} }
export async function getWorkspaces() {
try {
const { data } = await client.get_workspaces();
return data.map((workspace) => ({
id: workspace.workspace_id,
name: workspace.workspace_name,
icon: workspace.icon,
memberCount: workspace.member_count || 0,
}));
} catch (e) {
return Promise.reject(e);
}
}
export async function getWorkspaceFolder(workspaceId: string): Promise<FolderView> {
try {
const data = await client.get_folder(workspaceId);
// eslint-disable-next-line no-inner-declarations
function iterateFolder(folder: WorkspaceFolder): FolderView {
return {
id: folder.view_id,
name: folder.name,
icon: folder.icon,
isSpace: folder.is_space,
extra: folder.extra,
isPrivate: folder.is_private,
children: folder.children.map((child: WorkspaceFolder) => {
return iterateFolder(child);
}),
};
}
return iterateFolder(data);
} catch (e) {
return Promise.reject(e);
}
}
export function getCurrentUser() {
return client.get_user();
}
export function duplicatePublishView(payload: DuplicatePublishViewPayload) {
return client.duplicate_publish_view(payload);
}
export async function getPublishViewComments(viewId: string): Promise<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);
}

View File

@ -1,6 +1,8 @@
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';
export type AFService = PublishService; export type AFService = PublishService;
@ -27,10 +29,21 @@ 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>;
signInGoogle: (params: { redirectTo: string }) => Promise<void>; signInGoogle: (params: { redirectTo: string }) => Promise<void>;
signInGithub: (params: { redirectTo: string }) => Promise<void>; signInGithub: (params: { redirectTo: string }) => Promise<void>;
signInDiscord: (params: { redirectTo: string }) => Promise<void>; signInDiscord: (params: { redirectTo: string }) => Promise<void>;
getWorkspaces: () => Promise<Workspace[]>;
getWorkspaceFolder: (workspaceId: string) => Promise<FolderView>;
getCurrentUser: () => Promise<User>;
duplicatePublishView: (params: DuplicatePublishView) => Promise<void>;
} }

View File

@ -1,7 +1,9 @@
import { YDoc } from '@/application/collab.type'; import { YDoc } from '@/application/collab.type';
import { GlobalComment, Reaction } from '@/application/comment.type';
import { AFService } from '@/application/services/services.type'; import { AFService } from '@/application/services/services.type';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { YMap } from 'yjs/dist/src/types/YMap'; import { YMap } from 'yjs/dist/src/types/YMap';
import { DuplicatePublishView, FolderView, User, Workspace } from '@/application/types';
export class AFClientService implements AFService { export class AFClientService implements AFService {
private deviceId: string = nanoid(8); private deviceId: string = nanoid(8);
@ -25,23 +27,23 @@ export class AFClientService implements AFService {
} }
loginAuth(_: string): Promise<void> { loginAuth(_: string): Promise<void> {
return Promise.resolve(undefined); return Promise.reject('Method not implemented');
} }
signInDiscord(_params: { redirectTo: string }): Promise<void> { signInDiscord(_params: { redirectTo: string }): Promise<void> {
return Promise.resolve(undefined); return Promise.reject('Method not implemented');
} }
signInGithub(_params: { redirectTo: string }): Promise<void> { signInGithub(_params: { redirectTo: string }): Promise<void> {
return Promise.resolve(undefined); return Promise.reject('Method not implemented');
} }
signInGoogle(_params: { redirectTo: string }): Promise<void> { signInGoogle(_params: { redirectTo: string }): Promise<void> {
return Promise.resolve(undefined); return Promise.reject('Method not implemented');
} }
signInMagicLink(_params: { email: string; redirectTo: string }): Promise<void> { signInMagicLink(_params: { email: string; redirectTo: string }): Promise<void> {
return Promise.resolve(undefined); return Promise.reject('Method not implemented');
} }
getPublishDatabaseViewRows( getPublishDatabaseViewRows(
@ -53,4 +55,44 @@ export class AFClientService implements AFService {
}> { }> {
return Promise.reject('Method not implemented'); return Promise.reject('Method not implemented');
} }
duplicatePublishView(_params: DuplicatePublishView): Promise<void> {
return Promise.reject('Method not implemented');
}
getCurrentUser(): Promise<User> {
return Promise.reject('Method not implemented');
}
getWorkspaceFolder(_workspaceId: string): Promise<FolderView> {
return Promise.reject('Method not implemented');
}
getWorkspaces(): Promise<Workspace[]> {
return Promise.reject('Method not implemented');
}
addPublishViewReaction(_viewId: string, _commentId: string, _reactionType: string): Promise<void> {
return Promise.reject('Method not implemented');
}
createCommentOnPublishView(_viewId: string, _content: string, _replyCommentId: string | undefined): Promise<void> {
return Promise.reject('Method not implemented');
}
deleteCommentOnPublishView(_viewId: string, _commentId: string): Promise<void> {
return Promise.reject('Method not implemented');
}
getPublishViewGlobalComments(_viewId: string): Promise<GlobalComment[]> {
return Promise.resolve([]);
}
getPublishViewReactions(_viewId: string, _commentId: string | undefined): Promise<Record<string, Reaction[]>> {
return Promise.reject('Method not implemented');
}
removePublishViewReaction(_viewId: string, _commentId: string, _reactionType: string): Promise<void> {
return Promise.reject('Method not implemented');
}
} }

View File

@ -28,7 +28,6 @@ export function withSignIn() {
saveRedirectTo(redirectTo); saveRedirectTo(redirectTo);
console.log('=====saveRedirectTo', redirectTo);
try { try {
await originalMethod.apply(this, [args]); await originalMethod.apply(this, [args]);
} catch (e) { } catch (e) {

View File

@ -0,0 +1,40 @@
import { CollabType } from '@/application/collab.type';
export interface Workspace {
icon: string;
id: string;
name: string;
memberCount: number;
}
export interface SpaceView {
id: string;
extra: string | null;
name: string;
isPrivate: boolean;
}
export interface FolderView {
id: string;
icon: string | null;
extra: string | null;
name: string;
isSpace: boolean;
isPrivate: boolean;
children: FolderView[];
}
export interface User {
email: string | null;
name: string | null;
uid: string;
avatar: string | null;
uuid: string;
}
export interface DuplicatePublishView {
workspaceId: string;
spaceViewId: string;
collabType: CollabType;
viewId: string;
}

View 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

View File

@ -0,0 +1,7 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="check_circle">
<path id="Vector"
d="M12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2ZM12 20C7.59 20 4 16.41 4 12C4 7.59 7.59 4 12 4C16.41 4 20 7.59 20 12C20 16.41 16.41 20 12 20ZM16.59 7.58L10 14.17L7.41 11.59L6 13L10 17L18 9L16.59 7.58Z"
fill="currentColor"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 463 B

View File

@ -0,0 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 13C10.7614 13 13 10.7614 13 8C13 5.23858 10.7614 3 8 3C5.23858 3 3 5.23858 3 8C3 10.7614 5.23858 13 8 13Z"
stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 5V8L10 9" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.5 2.5L13.5 4.5" stroke="currentColor" stroke-linecap="round"/>
<path d="M4.5 2.5L2.5 4.5" stroke="currentColor" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 562 B

View File

@ -0,0 +1,6 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.7">
<path d="M15.1924 15.1924C15.4853 14.8995 15.4875 14.4268 15.1946 14.1339L10.061 9.00027L15.1945 3.86672C15.4874 3.57382 15.4853 3.10107 15.1924 2.80818C14.8995 2.51529 14.4268 2.51316 14.1339 2.80606C13.841 3.09895 9.00031 7.93961 9.00031 7.93961L3.86671 2.80601C3.57382 2.51312 3.10107 2.51524 2.80817 2.80814C2.51528 3.10103 2.51316 3.57378 2.80605 3.86667L7.93965 9.00027L2.80601 14.1339C2.51312 14.4268 2.51524 14.8996 2.80814 15.1924C3.10103 15.4853 3.57378 15.4875 3.86667 15.1946L9.00031 10.0609L14.1339 15.1945C14.4268 15.4874 14.8995 15.4853 15.1924 15.1924Z"
fill="currentColor"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 747 B

View 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="M7.41003 18.59L8.83003 20L12 16.83L15.17 20L16.58 18.59L12 14L7.41003 18.59ZM16.59 5.41L15.17 4L12 7.17L8.83003 4L7.41003 5.41L12 10L16.59 5.41Z"
fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 294 B

View 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

View File

@ -0,0 +1,8 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="double_arrow">
<g id="Vector">
<path d="M15.25 5H10.75L15.75 12L10.75 19H15.25L20.25 12L15.25 5Z" fill="currentColor"/>
<path d="M8.25 5H3.75L8.75 12L3.75 19H8.25L13.25 12L8.25 5Z" fill="currentColor"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 371 B

View 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

View File

@ -0,0 +1,7 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="error_outline">
<path id="Vector"
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"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 450 B

View 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="M12 5.83L15.17 9L16.58 7.59L12 3L7.41003 7.59L8.83003 9L12 5.83ZM12 18.17L8.83003 15L7.42003 16.41L12 21L16.59 16.41L15.17 15L12 18.17Z"
fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 285 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View File

@ -0,0 +1,5 @@
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path opacity="0.4" fill-rule="evenodd" clip-rule="evenodd"
d="M8.78714 4.75039V4.16515C8.78714 3.42984 8.6 1.25 5.99904 1.25C3.3 1.25 3.21094 3.42984 3.21094 4.16515V4.75039L3.38817 4.73529L3.2119 4.75123C2.30484 4.99873 2 5.7229 2 7.29041V8.20709C2 10.2238 2.50558 10.9996 4.14127 10.9996H7.85873C9.49442 10.9996 10 10.2238 10 8.20709V7.29041C10 5.7229 9.69516 4.99873 8.7881 4.75123L8.64331 4.73813L8.78714 4.75039ZM4.32618 4.16515V4.65048L7.6719 4.65039V4.16515C7.6719 3.0108 7.3 2.3002 5.99904 2.3002C4.65 2.3002 4.32618 3.0108 4.32618 4.16515ZM6.00078 8.95078C6.66352 8.95078 7.20078 8.41352 7.20078 7.75078C7.20078 7.08804 6.66352 6.55078 6.00078 6.55078C5.33804 6.55078 4.80078 7.08804 4.80078 7.75078C4.80078 8.41352 5.33804 8.95078 6.00078 8.95078Z"
fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 908 B

View File

@ -1,17 +1,2 @@
<svg xmlns="http://www.w3.org/2000/svg" width="21" height="20" viewBox="0 0 21 20" fill="none"> <?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<g clip-path="url(#clip0_346_13747)"> <svg width="800px" height="800px" viewBox="-3 0 262 262" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid"><path d="M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622 38.755 30.023 2.685.268c24.659-22.774 38.875-56.282 38.875-96.027" fill="#4285F4"/><path d="M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055-34.523 0-63.824-22.773-74.269-54.25l-1.531.13-40.298 31.187-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1" fill="#34A853"/><path d="M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82 0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602l42.356-32.782" fill="#FBBC05"/><path d="M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0 79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251" fill="#EB4335"/></svg>
<path d="M4.68181 9.99793C4.68181 9.36293 4.79014 8.75376 4.98181 8.18293L1.61681 5.66626C0.940723 7.00999 0.589632 8.4937 0.591807 9.99793C0.591807 11.5546 0.96014 13.0229 1.61514 14.3263L4.97847 11.8054C4.78236 11.223 4.68217 10.6125 4.68181 9.99793Z"
fill="#FBBC05"/>
<path d="M10.5917 4.22046C12 4.22046 13.2725 4.70879 14.2725 5.50879L17.1817 2.66463C15.4092 1.15379 13.1367 0.220459 10.5917 0.220459C6.64003 0.220459 3.24337 2.43296 1.6167 5.66629L4.98337 8.18296C5.75837 5.87796 7.96837 4.22046 10.5917 4.22046Z"
fill="#EA4335"/>
<path d="M10.5917 15.7755C7.96753 15.7755 5.75753 14.118 4.9817 11.813L1.6167 14.3297C3.24253 17.563 6.6392 19.7755 10.5917 19.7755C13.03 19.7755 15.3584 18.928 17.1067 17.3388L13.9117 14.9205C13.0109 15.4763 11.8759 15.7755 10.5909 15.7755"
fill="#34A853"/>
<path d="M20.1367 9.99796C20.1367 9.42046 20.045 8.79796 19.9092 8.22046H10.5908V11.998H15.9542C15.6867 13.2863 14.9567 14.2763 13.9125 14.9205L17.1067 17.3388C18.9425 15.6705 20.1367 13.1855 20.1367 9.99796Z"
fill="#4285F4"/>
</g>
<defs>
<clipPath id="clip0_346_13747">
<rect width="20" height="20" fill="white" transform="translate(0.5 -0.0020752)"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View 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

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 8.2L6.84615 10L13 4" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 210 B

View File

@ -0,0 +1,12 @@
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.75 16.4824L5.08749 16.4916C5.92166 16.4916 6.70083 16.0791 7.15916 15.3916L13.0167 6.60992C13.475 5.92242 14.2542 5.50075 15.0883 5.50991L19.2592 5.52826"
stroke="currentColor" stroke-width="1.375" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M17.418 18.3158L19.2513 16.4824" stroke="currentColor" stroke-width="1.375" stroke-linecap="round"
stroke-linejoin="round"/>
<path d="M8.14918 7.90182L7.15916 6.52682C6.69166 5.87599 5.93999 5.49099 5.14249 5.50016L2.75 5.50934"
stroke="currentColor" stroke-width="1.375" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.8867 14.0996L13.0051 15.5388C13.4726 16.1438 14.2059 16.5013 14.9759 16.5013L19.2567 16.4829"
stroke="currentColor" stroke-width="1.375" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M19.2513 5.51693L17.418 3.68359" stroke="currentColor" stroke-width="1.375" stroke-linecap="round"
stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View 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

View File

@ -0,0 +1,7 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="warning_amber">
<path id="Vector"
d="M12 6.49L19.53 19.5H4.47L12 6.49ZM12 2.5L1 21.5H23L12 2.5ZM13 16.5H11V18.5H13V16.5ZM13 10.5H11V14.5H13V10.5Z"
fill="currentColor"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 328 B

View File

@ -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;
}

View File

@ -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;

View File

@ -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-base 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} disableInteractive={true}>
<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={`flex cursor-pointer items-center justify-center rounded text-[20px] hover:bg-fill-list-hover ${
isSelected ? 'bg-fill-list-hover' : 'hover:bg-transparent'
} ${isDefaultEmoji ? 'bg-fill-list-active' : ''}`}
>
{emoji.native}
</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;

View File

@ -0,0 +1,156 @@
import { useSelectSkinPopoverProps } from './EmojiPicker.hooks';
import React, { useCallback } from 'react';
import { Button, OutlinedInput } from '@mui/material';
import Tooltip from '@mui/material/Tooltip';
import { randomEmoji } from '@/utils/emoji';
import { ReactComponent as ShuffleIcon } from '@/assets/shuffle.svg';
import Popover from '@mui/material/Popover';
import { useTranslation } from 'react-i18next';
import { ReactComponent as DeleteOutlineRounded } from '@/assets/trash.svg';
import { ReactComponent as SearchOutlined } from '@/assets/search.svg';
const skinTones = [
{
value: 0,
icon: '👋',
},
{
value: 1,
icon: '👋🏻',
},
{
value: 2,
icon: '👋🏼',
},
{
value: 3,
icon: '👋🏽',
},
{
value: 4,
icon: '👋🏾',
},
{
value: 5,
icon: '👋🏿',
},
];
interface Props {
onEmojiSelect: (emoji: string) => void;
skin: number;
onSkinSelect: (skin: number) => void;
searchValue: string;
onSearchChange: (value: string) => void;
hideRemove?: boolean;
}
function EmojiPickerHeader({ hideRemove, onEmojiSelect, onSkinSelect, searchValue, onSearchChange, skin }: Props) {
const { onOpen, ...popoverProps } = useSelectSkinPopoverProps();
const { t } = useTranslation();
const renderButton = useCallback(
({
onClick,
tooltip,
children,
}: {
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
tooltip: string;
children: React.ReactNode;
}) => {
return (
<Tooltip title={tooltip}>
<Button
size={'small'}
variant={'outlined'}
color={'inherit'}
className={'h-9 w-9 min-w-[36px] px-0 py-0'}
onClick={onClick}
>
{children}
</Button>
</Tooltip>
);
},
[]
);
return (
<div className={'px-0.5 py-2'}>
<div className={'search-input flex items-end justify-between gap-2'}>
<OutlinedInput
startAdornment={<SearchOutlined className={'h-6 h-6'} />}
value={searchValue}
onChange={(e) => {
onSearchChange(e.target.value);
}}
autoFocus={true}
fullWidth={true}
size={'small'}
autoCorrect={'off'}
autoComplete={'off'}
spellCheck={false}
inputProps={{
className: 'px-2 py-1.5 text-base',
}}
className={'search-emoji-input'}
placeholder={t('search.label')}
/>
<div className={'flex items-center gap-1'}>
{renderButton({
onClick: async () => {
const emoji = await randomEmoji();
onEmojiSelect(emoji);
},
tooltip: t('emoji.random'),
children: <ShuffleIcon className={'h-5 w-5'} />,
})}
{renderButton({
onClick: onOpen,
tooltip: t('emoji.selectSkinTone'),
children: <span className={'text-xl'}>{skinTones[skin].icon}</span>,
})}
{hideRemove
? null
: renderButton({
onClick: () => {
onEmojiSelect('');
},
tooltip: t('emoji.remove'),
children: <DeleteOutlineRounded className={'h-5 w-5'} />,
})}
</div>
</div>
<Popover {...popoverProps}>
<div className={'flex items-center p-2'}>
{skinTones.map((skinTone) => (
<div className={'mx-0.5'} key={skinTone.value}>
<Button
style={{
backgroundColor: skinTone.value === skin ? 'var(--fill-list-hover)' : undefined,
}}
size={'small'}
variant={'outlined'}
color={'inherit'}
className={'h-9 w-9 min-w-[36px] text-xl'}
onClick={() => {
onSkinSelect(skinTone.value);
popoverProps.onClose?.();
}}
>
{skinTone.icon}
</Button>
</div>
))}
</div>
</Popover>
</div>
);
}
export default EmojiPickerHeader;

View File

@ -0,0 +1,3 @@
export const EMOJI_SIZE = 38;
export const PER_ROW_EMOJI_COUNT = 9;
export const MAX_FREQUENTLY_ROW_COUNT = 2;

View File

@ -0,0 +1,3 @@
import { lazy } from 'react';
export const EmojiPicker = lazy(() => import('./EmojiPicker'));

View File

@ -0,0 +1,50 @@
import { Skeleton } from '@mui/material';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ReactComponent as ErrorOutline } from '@/assets/error.svg';
interface ImageRenderProps extends React.HTMLAttributes<HTMLImageElement> {
src: string;
alt?: string;
}
export function ImageRender({ src, ...props }: ImageRenderProps) {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [hasError, setHasError] = useState(false);
return (
<>
{hasError ? (
<div className={'flex h-full w-full items-center justify-center gap-2 bg-red-50'}>
<ErrorOutline className={'text-function-error'} />
<div className={'text-function-error'}>{t('editor.imageLoadFailed')}</div>
</div>
) : loading ? (
<Skeleton variant='rectangular' width={'100%'} height={'100%'} />
) : null}
<img
style={{
display: hasError ? 'none' : 'block',
height: loading ? 0 : '100%',
width: loading ? 1 : '100%',
}}
draggable={false}
src={src}
{...props}
onLoad={(e) => {
props.onLoad?.(e);
setLoading(false);
setHasError(false);
}}
onError={(e) => {
props.onError?.(e);
setHasError(true);
setLoading(false);
}}
/>
</>
);
}
export default ImageRender;

View File

@ -0,0 +1,3 @@
import { lazy } from 'react';
export const KatexMath = lazy(() => import('./KatexMath'));

View File

@ -0,0 +1,82 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Button, ButtonProps, CircularProgress, Dialog, DialogProps, IconButton } from '@mui/material';
import { ReactComponent as CloseIcon } from '@/assets/close.svg';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
export interface NormalModalProps extends DialogProps {
okText?: string;
cancelText?: string;
onOk?: () => void;
onCancel?: () => void;
danger?: boolean;
onClose?: () => void;
title: string | React.ReactNode;
okButtonProps?: ButtonProps;
cancelButtonProps?: ButtonProps;
okLoading?: boolean;
}
export function NormalModal({
okText,
title,
cancelText,
onOk,
onCancel,
danger,
onClose,
children,
okButtonProps,
cancelButtonProps,
okLoading,
...dialogProps
}: NormalModalProps) {
const { t } = useTranslation();
const modalOkText = okText || t('button.ok');
const modalCancelText = cancelText || t('button.cancel');
const buttonColor = danger ? 'var(--function-error)' : undefined;
return (
<Dialog
onKeyDown={(e) => {
if (e.key === 'Escape') {
onClose?.();
}
}}
{...dialogProps}
>
<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-1 text-center '}>{title}</div>
<div className={'relative -right-1.5'}>
<IconButton size={'small'} color={'inherit'} className={'h-6 w-6'} onClick={onClose || onCancel}>
<CloseIcon className={'h-4 w-4'} />
</IconButton>
</div>
</div>
<div className={'flex-1'}>{children}</div>
<div className={'flex w-full justify-end gap-4'}>
<Button color={'inherit'} variant={'outlined'} onClick={onCancel} {...cancelButtonProps}>
{modalCancelText}
</Button>
<Button
color={'primary'}
variant={'contained'}
style={{ backgroundColor: buttonColor }}
onClick={() => {
if (okLoading) return;
onOk?.();
}}
{...okButtonProps}
>
{okLoading ? <CircularProgress size={24} /> : modalOkText}
</Button>
</div>
</div>
</Dialog>
);
}
export default NormalModal;

View File

@ -0,0 +1 @@
export * from './NormalModal';

View File

@ -0,0 +1,122 @@
import { notify } from '@/components/_shared/notify/index';
import React, { forwardRef } from 'react';
import { Button, IconButton, Paper } from '@mui/material';
import { useTranslation } from 'react-i18next';
import { ReactComponent as CloseIcon } from '@/assets/close.svg';
import { CustomContentProps, SnackbarContent } from 'notistack';
import { ReactComponent as CheckCircle } from '@/assets/check_circle.svg';
import { ReactComponent as ErrorOutline } from '@/assets/error_outline.svg';
import { ReactComponent as WarningAmber } from '@/assets/warning_amber.svg';
export interface InfoProps {
onOk?: () => void;
okText?: string;
title?: string;
message?: JSX.Element | string;
onClose?: () => void;
autoHideDuration?: number | null;
type?: 'success' | 'info' | 'warning' | 'error';
showActions?: boolean;
}
export type InfoSnackbarProps = InfoProps & CustomContentProps;
const InfoSnackbar = forwardRef<HTMLDivElement, InfoSnackbarProps>(
({ showActions = true, type = 'info', onOk, okText, title, message, onClose }, ref) => {
const { t } = useTranslation();
const handleClose = () => {
onClose?.();
notify.clear();
};
return (
<SnackbarContent ref={ref}>
<Paper className={`relative flex flex-col gap-4 border p-4 ${getBorderColor(type)}`}>
<div className={'flex w-full items-center justify-between text-base font-medium'}>
<div className={'flex flex-1 items-center gap-2 text-left font-semibold'}>
{getIcon(type)}
<div>{title}</div>
</div>
<div className={'relative -right-1.5'}>
<IconButton size={'small'} color={'inherit'} className={'h-6 w-6'} onClick={handleClose}>
<CloseIcon className={'h-4 w-4'} />
</IconButton>
</div>
</div>
<div className={'flex-1 pr-10'}>{message}</div>
{showActions && (
<div className={'flex w-full justify-end gap-4'}>
<Button
color={'primary'}
variant={'contained'}
onClick={() => {
onOk?.();
handleClose();
}}
className={`${getButtonBgColor(type)} ${getButtonHoverBgColor(type)}}`}
>
{okText || t('button.ok')}
</Button>
</div>
)}
</Paper>
</SnackbarContent>
);
}
);
export default InfoSnackbar;
function getIcon(type: 'success' | 'info' | 'warning' | 'error') {
switch (type) {
case 'success':
return <CheckCircle className={'h-6 w-6 text-[var(--function-success)]'} />;
case 'info':
return '';
case 'warning':
return <WarningAmber className={'h-6 w-6 text-[var(--function-warning)]'} />;
case 'error':
return <ErrorOutline className={'h-6 w-6 text-[var(--function-error)]'} />;
}
}
function getButtonBgColor(type: 'success' | 'info' | 'warning' | 'error') {
switch (type) {
case 'success':
return 'bg-[var(--function-success)]';
case 'info':
return '';
case 'warning':
return 'bg-[var(--function-warning)]';
case 'error':
return 'bg-[var(--function-error)]';
}
}
function getButtonHoverBgColor(type: 'success' | 'info' | 'warning' | 'error') {
switch (type) {
case 'success':
return 'hover:bg-[var(--function-success-hover)]';
case 'info':
return '';
case 'warning':
return 'hover:bg-[var(--function-warning-hover)]';
case 'error':
return 'hover:bg-[var(--function-error-hover)]';
}
}
function getBorderColor(type: 'success' | 'info' | 'warning' | 'error') {
switch (type) {
case 'success':
return 'border-[var(--function-success)]';
case 'info':
return 'border-transparent';
case 'warning':
return 'border-[var(--function-warning)]';
case 'error':
return 'border-[var(--function-error)]';
}
}

View File

@ -1,3 +1,8 @@
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) => {
window.toast.success(message); window.toast.success(message);
@ -11,10 +16,19 @@ export const notify = {
warning: (message: string) => { warning: (message: string) => {
window.toast.warning(message); window.toast.warning(message);
}, },
info: (message: string) => { info: (props: InfoProps) => {
window.toast.info(message); window.toast.info({
...props,
variant: 'info',
anchorOrigin: {
vertical: 'bottom',
horizontal: 'center',
},
});
}, },
clear: () => { clear: () => {
window.toast.clear(); window.toast.clear();
}, },
}; };
export * from './InfoSnackbar';

View File

@ -17,3 +17,5 @@ export function Popover({ children, ...props }: PopoverComponentProps) {
</PopoverComponent> </PopoverComponent>
); );
} }
export default Popover;

View File

@ -2,10 +2,13 @@ import { clearData } from '@/application/db';
import { EventType, on } from '@/application/session'; import { EventType, on } from '@/application/session';
import { isTokenValid } from '@/application/session/token'; import { isTokenValid } from '@/application/session/token';
import { useAppLanguage } from '@/components/app/useAppLanguage'; import { useAppLanguage } from '@/components/app/useAppLanguage';
import { LoginModal } from '@/components/login';
import { useSnackbar } from 'notistack'; import { useSnackbar } from 'notistack';
import React, { createContext, useEffect, useState } from 'react'; import React, { createContext, useCallback, useEffect, useState } from 'react';
import { AFService, AFServiceConfig } from '@/application/services/services.type'; import { AFService, AFServiceConfig } from '@/application/services/services.type';
import { getService } from '@/application/services'; import { getService } from '@/application/services';
import { InfoSnackbarProps } from '@/components/_shared/notify';
import { User } from '@/application/types';
const baseURL = import.meta.env.AF_BASE_URL || 'https://test.appflowy.cloud'; const baseURL = import.meta.env.AF_BASE_URL || 'https://test.appflowy.cloud';
const gotrueURL = import.meta.env.AF_GOTRUE_URL || 'https://test.appflowy.cloud/gotrue'; const gotrueURL = import.meta.env.AF_GOTRUE_URL || 'https://test.appflowy.cloud/gotrue';
@ -23,6 +26,8 @@ export const AFConfigContext = createContext<
| { | {
service: AFService | undefined; service: AFService | undefined;
isAuthenticated: boolean; isAuthenticated: boolean;
currentUser?: User;
openLoginModal: (redirectTo?: string) => void;
} }
| undefined | undefined
>(undefined); >(undefined);
@ -31,6 +36,14 @@ function AppConfig({ children }: { children: React.ReactNode }) {
const [appConfig] = useState<AFServiceConfig>(defaultConfig); const [appConfig] = useState<AFServiceConfig>(defaultConfig);
const [service, setService] = useState<AFService>(); const [service, setService] = useState<AFService>();
const [isAuthenticated, setIsAuthenticated] = React.useState<boolean>(isTokenValid()); const [isAuthenticated, setIsAuthenticated] = React.useState<boolean>(isTokenValid());
const [currentUser, setCurrentUser] = React.useState<User>();
const [loginOpen, setLoginOpen] = React.useState(false);
const [loginCompletedRedirectTo, setLoginCompletedRedirectTo] = React.useState<string>('');
const openLoginModal = useCallback((redirectTo?: string) => {
setLoginOpen(true);
setLoginCompletedRedirectTo(redirectTo || '');
}, []);
useEffect(() => { useEffect(() => {
return on(EventType.SESSION_VALID, () => { return on(EventType.SESSION_VALID, () => {
@ -38,6 +51,24 @@ function AppConfig({ children }: { children: React.ReactNode }) {
}); });
}, []); }, []);
useEffect(() => {
if (!isAuthenticated) {
setCurrentUser(undefined);
return;
}
void (async () => {
if (!service) return;
try {
const user = await service.getCurrentUser();
setCurrentUser(user);
} catch (e) {
console.error(e);
}
})();
}, [isAuthenticated, service]);
useEffect(() => { useEffect(() => {
const handleStorageChange = (event: StorageEvent) => { const handleStorageChange = (event: StorageEvent) => {
if (event.key === 'token') setIsAuthenticated(isTokenValid()); if (event.key === 'token') setIsAuthenticated(isTokenValid());
@ -79,8 +110,9 @@ function AppConfig({ children }: { children: React.ReactNode }) {
default: (message: string) => { default: (message: string) => {
enqueueSnackbar(message, { variant: 'default' }); enqueueSnackbar(message, { variant: 'default' });
}, },
info: (message: string) => {
enqueueSnackbar(message, { variant: 'info' }); info: (props: InfoSnackbarProps) => {
enqueueSnackbar(props.message, props);
}, },
clear: () => { clear: () => {
@ -111,9 +143,20 @@ function AppConfig({ children }: { children: React.ReactNode }) {
value={{ value={{
service, service,
isAuthenticated, isAuthenticated,
currentUser,
openLoginModal,
}} }}
> >
{children} {children}
{loginOpen && (
<LoginModal
redirectTo={loginCompletedRedirectTo}
open={loginOpen}
onClose={() => {
setLoginOpen(false);
}}
/>
)}
</AFConfigContext.Provider> </AFConfigContext.Provider>
); );
} }

View File

@ -41,20 +41,42 @@ function AppTheme({ children }: { children: React.ReactNode }) {
}, },
borderRadius: '4px', borderRadius: '4px',
padding: '2px', padding: '2px',
'&.MuiIconButton-colorInherit': {
color: 'var(--icon-primary)',
},
}, },
}, },
}, },
MuiButton: { MuiButton: {
styleOverrides: { styleOverrides: {
text: {
borderRadius: '8px',
'&:hover': {
backgroundColor: 'var(--fill-list-hover)',
},
},
contained: { contained: {
color: 'var(--content-on-fill)', color: 'var(--content-on-fill)',
boxShadow: 'none', boxShadow: 'none',
'&:hover': { '&:hover': {
backgroundColor: 'var(--content-blue-600)', backgroundColor: 'var(--content-blue-600)',
}, },
borderRadius: '8px',
'&.Mui-disabled': {
backgroundColor: 'var(--content-blue-400)',
opacity: 0.3,
color: 'var(--content-on-fill)',
},
},
outlined: {
'&.MuiButton-outlinedInherit': {
borderColor: 'var(--line-divider)',
},
borderRadius: '8px',
}, },
}, },
}, },
MuiButtonBase: { MuiButtonBase: {
styleOverrides: { styleOverrides: {
root: { root: {
@ -78,10 +100,16 @@ function AppTheme({ children }: { children: React.ReactNode }) {
root: { root: {
backgroundImage: 'none', backgroundImage: 'none',
boxShadow: 'var(--shadow)', boxShadow: 'var(--shadow)',
borderRadius: '10px',
}, },
}, },
}, },
MuiDialog: { MuiDialog: {
styleOverrides: {
paper: {
borderRadius: '12px',
},
},
defaultProps: { defaultProps: {
sx: { sx: {
'& .MuiBackdrop-root': { '& .MuiBackdrop-root': {
@ -112,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: {

View File

@ -5,6 +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';
const StyledSnackbarProvider = styled(SnackbarProvider)` const StyledSnackbarProvider = styled(SnackbarProvider)`
&.notistack-MuiContent-default { &.notistack-MuiContent-default {
@ -39,6 +40,9 @@ export default function withAppWrapper(Component: React.FC): React.FC {
horizontal: 'center', horizontal: 'center',
}} }}
preventDuplicate preventDuplicate
Components={{
info: InfoSnackbar,
}}
> >
<AppConfig> <AppConfig>
<Suspense> <Suspense>

View File

@ -8,15 +8,12 @@ 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 pb-4 pt-4 text-sm'}>
<BigCalendar <BigCalendar
components={{ components={{
toolbar: (props) => <Toolbar {...props} emptyEvents={emptyEvents} />, toolbar: (props) => <Toolbar {...props} emptyEvents={emptyEvents} />,
eventWrapper: Event, eventWrapper: Event,
}} }}
style={{
marginBottom: '24px',
}}
events={events} events={events}
views={['month']} views={['month']}
localizer={localizer} localizer={localizer}

View File

@ -27,17 +27,24 @@ $today-highlight-bg: transparent;
} }
} }
.rbc-calendar {
height: fit-content;
@apply w-full overflow-x-scroll;
@include mixin.scrollbar-style;
}
.rbc-month-view { .rbc-month-view {
border: none; border: none;
@apply h-full overflow-auto; height: fit-content;
.rbc-month-row { .rbc-month-row {
border: 1px solid var(--line-divider); border: 1px solid var(--line-divider);
border-top: none; border-top: none;
min-width: 700px; min-width: 1200px;
@apply max-sm:w-[650vw];
} }
@include mixin.scrollbar-style;
} }
@ -51,7 +58,9 @@ $today-highlight-bg: transparent;
top: 0; top: 0;
background: var(--bg-body); background: var(--bg-body);
z-index: 50; z-index: 50;
min-width: 700px; min-width: 1200px;
@apply max-sm:w-[650vw];
.rbc-header { .rbc-header {
border: none; border: none;
@ -79,10 +88,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 {

View File

@ -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}

View File

@ -22,7 +22,7 @@ export function Toolbar({
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className={'flex items-center justify-between overflow-x-auto overflow-y-hidden'}> <div className={'sticky left-0 flex items-center justify-between overflow-x-auto overflow-y-hidden'}>
<div className={'whitespace-nowrap text-sm font-medium'}>{dateStr}</div> <div className={'whitespace-nowrap text-sm font-medium'}>{dateStr}</div>
<div className={'flex items-center justify-end gap-2'}> <div className={'flex items-center justify-end gap-2'}>
<IconButton size={'small'} onClick={() => onNavigate('PREV')}> <IconButton size={'small'} onClick={() => onNavigate('PREV')}>

View File

@ -2,7 +2,7 @@ import { FieldType } from '@/application/database-yjs';
import { useDateTypeCellDispatcher } from '@/components/database/components/cell/Cell.hooks'; import { useDateTypeCellDispatcher } from '@/components/database/components/cell/Cell.hooks';
import { CellProps, DateTimeCell as DateTimeCellType } from '@/application/database-yjs/cell.type'; import { CellProps, DateTimeCell as DateTimeCellType } from '@/application/database-yjs/cell.type';
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { ReactComponent as ReminderSvg } from '$icons/16x/clock_alarm.svg'; import { ReactComponent as ReminderSvg } from '@/assets/clock_alarm.svg';
export function DateTimeCell({ cell, fieldId, style, placeholder }: CellProps<DateTimeCellType>) { export function DateTimeCell({ cell, fieldId, style, placeholder }: CellProps<DateTimeCellType>) {
const { getDateTimeStr } = useDateTypeCellDispatcher(fieldId); const { getDateTimeStr } = useDateTypeCellDispatcher(fieldId);

View File

@ -4,7 +4,7 @@ import Cell from '@/components/database/components/cell/Cell';
import React, { CSSProperties, useMemo } from 'react'; import React, { CSSProperties, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
function CardField({ rowId, fieldId }: { rowId: string; fieldId: string; index: number }) { export function CardField({ rowId, fieldId }: { rowId: string; fieldId: string; index: number }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { field } = useFieldSelector(fieldId); const { field } = useFieldSelector(fieldId);
const cell = useCellSelector({ const cell = useCellSelector({
@ -21,7 +21,7 @@ function CardField({ rowId, fieldId }: { rowId: string; fieldId: string; index:
textAlign: 'left', textAlign: 'left',
}; };
if ([FieldType.Relation, FieldType.SingleSelect, FieldType.MultiSelect].includes(Number(type))) { if (isPrimary || [FieldType.Relation, FieldType.SingleSelect, FieldType.MultiSelect].includes(Number(type))) {
Object.assign(styleProperties, { Object.assign(styleProperties, {
breakWord: 'break-word', breakWord: 'break-word',
whiteSpace: 'normal', whiteSpace: 'normal',

View File

@ -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={{

View File

@ -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'}>

View File

@ -1,8 +1,9 @@
import { DatabaseViewLayout, YDatabaseView, YjsDatabaseKey } from '@/application/collab.type'; import { DatabaseViewLayout, YDatabaseView, YjsDatabaseKey } from '@/application/collab.type';
import { useDatabase, useDatabaseView } from '@/application/database-yjs'; import { DatabaseContext, useDatabase, useDatabaseView } from '@/application/database-yjs';
import { ViewMeta } from '@/application/db/tables/view_metas';
import { DatabaseActions } from '@/components/database/components/conditions'; import { DatabaseActions } from '@/components/database/components/conditions';
import { Tooltip } from '@mui/material'; import { Tooltip } from '@mui/material';
import { forwardRef, FunctionComponent, SVGProps, useMemo } from 'react'; import { forwardRef, FunctionComponent, SVGProps, useContext, useEffect, useMemo, useState } from 'react';
import { ViewTabs, ViewTab } from './ViewTabs'; import { ViewTabs, ViewTab } from './ViewTabs';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -32,12 +33,28 @@ export const DatabaseTabs = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
const { t } = useTranslation(); const { t } = useTranslation();
const view = useDatabaseView(); const view = useDatabaseView();
const views = useDatabase().get(YjsDatabaseKey.views); const views = useDatabase().get(YjsDatabaseKey.views);
const loadViewMeta = useContext(DatabaseContext)?.loadViewMeta;
const [meta, setMeta] = useState<ViewMeta | null>(null);
const layout = Number(view?.get(YjsDatabaseKey.layout)) as DatabaseViewLayout; const layout = Number(view?.get(YjsDatabaseKey.layout)) as DatabaseViewLayout;
const handleChange = (_: React.SyntheticEvent, newValue: string) => { const handleChange = (_: React.SyntheticEvent, newValue: string) => {
setSelectedViewId?.(newValue); setSelectedViewId?.(newValue);
}; };
useEffect(() => {
void (async () => {
if (loadViewMeta) {
try {
const meta = await loadViewMeta(iidIndex, setMeta);
setMeta(meta);
} catch (e) {
// do nothing
}
}
})();
}, [loadViewMeta, iidIndex]);
const className = useMemo(() => { const className = useMemo(() => {
const classList = ['-mb-[0.5px] flex items-center overflow-hidden border-line-divider text-text-title']; const classList = ['-mb-[0.5px] flex items-center overflow-hidden border-line-divider text-text-title'];
@ -48,12 +65,14 @@ export const DatabaseTabs = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
return classList.join(' '); return classList.join(' ');
}, [layout]); }, [layout]);
const showActions = !hideConditions && layout !== DatabaseViewLayout.Calendar;
if (viewIds.length === 0) return null; if (viewIds.length === 0) return null;
return ( return (
<div ref={ref} className={className}> <div ref={ref} className={className}>
<div <div
style={{ style={{
width: 'calc(100% - 120px)', width: showActions ? 'calc(100% - 120px)' : '100%',
}} }}
className='flex items-center ' className='flex items-center '
> >
@ -70,7 +89,7 @@ export const DatabaseTabs = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
if (!view) return null; if (!view) return null;
const layout = Number(view.get(YjsDatabaseKey.layout)) as DatabaseViewLayout; const layout = Number(view.get(YjsDatabaseKey.layout)) as DatabaseViewLayout;
const Icon = DatabaseIcons[layout]; const Icon = DatabaseIcons[layout];
const name = viewId === iidIndex ? viewName : view.get(YjsDatabaseKey.name); const name = viewId === iidIndex ? viewName : meta?.child_views?.find((v) => v.view_id === viewId)?.name;
return ( return (
<ViewTab <ViewTab
@ -90,7 +109,7 @@ export const DatabaseTabs = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
})} })}
</ViewTabs> </ViewTabs>
</div> </div>
{!hideConditions && layout !== DatabaseViewLayout.Calendar ? <DatabaseActions /> : null} {showActions ? <DatabaseActions /> : null}
</div> </div>
); );
} }

View File

@ -1,5 +1,5 @@
import { GetViewRowsMap, LoadView, LoadViewMeta, YDoc } from '@/application/collab.type'; import { GetViewRowsMap, LoadView, LoadViewMeta, YDoc } from '@/application/collab.type';
import ComponentLoading from '@/components/_shared/progress/ComponentLoading'; import DocumentSkeleton from '@/components/document/DocumentSkeleton';
import { Editor } from '@/components/editor'; import { Editor } from '@/components/editor';
import React, { Suspense } from 'react'; import React, { Suspense } from 'react';
import ViewMetaPreview, { ViewMetaProps } from '@/components/view-meta/ViewMetaPreview'; import ViewMetaPreview, { ViewMetaProps } from '@/components/view-meta/ViewMetaPreview';
@ -17,7 +17,7 @@ export const Document = ({ doc, loadView, navigateToView, loadViewMeta, getViewR
return ( return (
<div className={'mb-16 flex h-full w-full flex-col items-center justify-center'}> <div className={'mb-16 flex h-full w-full flex-col items-center justify-center'}>
<ViewMetaPreview {...viewMeta} /> <ViewMetaPreview {...viewMeta} />
<Suspense fallback={<ComponentLoading />}> <Suspense fallback={<DocumentSkeleton />}>
<div className={'mx-16 w-[964px] min-w-0 max-w-full'}> <div className={'mx-16 w-[964px] min-w-0 max-w-full'}>
<Editor <Editor
loadView={loadView} loadView={loadView}

View File

@ -0,0 +1,17 @@
import Skeleton from '@mui/material/Skeleton';
import React from 'react';
function DocumentSkeleton() {
return (
<div
style={{
minHeight: '50vh',
}}
className={'mx-16 w-[964px] min-w-0 max-w-full px-16'}
>
<Skeleton variant='rectangular' width={'100%'} height={'100%'} />
</div>
);
}
export default DocumentSkeleton;

View File

@ -3,39 +3,49 @@ import { useEditorContext } from '@/components/editor/EditorContext';
import { useCallback, useEffect } from 'react'; import { useCallback, useEffect } from 'react';
import { ReactEditor, useSlateStatic } from 'slate-react'; import { ReactEditor, useSlateStatic } from 'slate-react';
import { Element as SlateElement, Transforms } from 'slate'; import { Element as SlateElement, Transforms } from 'slate';
import Prism from 'prismjs';
const Prism = window.Prism;
const hljs = window.hljs;
export function useCodeBlock(node: CodeNode) { export function useCodeBlock(node: CodeNode) {
const language = node.data.language; const language = node.data.language;
const editor = useSlateStatic() as ReactEditor; const editor = useSlateStatic() as ReactEditor;
const addCodeGrammars = useEditorContext().addCodeGrammars; const addCodeGrammars = useEditorContext().addCodeGrammars;
useEffect(() => { useEffect(() => {
const path = ReactEditor.findPath(editor, node); void (async () => {
let detectedLanguage = language; const path = ReactEditor.findPath(editor, node);
let detectedLanguage = language;
if (!language) { if (!language) {
const codeSnippet = editor.string(path); const codeSnippet = editor.string(path);
const script = document.createElement('script');
detectedLanguage = hljs.highlightAuto(codeSnippet).language; script.src = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.5.1/highlight.min.js';
} document.body.appendChild(script);
const promise = new Promise((resolve) => {
script.onload = () => {
resolve(true);
};
});
const prismLanguage = Prism.languages[detectedLanguage.toLowerCase()]; await promise;
if (!prismLanguage) { detectedLanguage = window.hljs.highlightAuto(codeSnippet).language || 'plaintext';
const script = document.createElement('script'); }
script.src = `https://cdnjs.cloudflare.com/ajax/libs/prism/1.26.0/components/prism-${detectedLanguage.toLowerCase()}.min.js`; const prismLanguage = Prism.languages[detectedLanguage.toLowerCase()];
document.head.appendChild(script);
script.onload = () => { if (!prismLanguage) {
const script = document.createElement('script');
script.src = `https://cdnjs.cloudflare.com/ajax/libs/prism/1.26.0/components/prism-${detectedLanguage.toLowerCase()}.min.js`;
document.body.appendChild(script);
script.onload = () => {
addCodeGrammars?.(node.blockId, detectedLanguage);
};
} else {
addCodeGrammars?.(node.blockId, detectedLanguage); addCodeGrammars?.(node.blockId, detectedLanguage);
}; }
} else { })();
addCodeGrammars?.(node.blockId, detectedLanguage);
}
}, [addCodeGrammars, editor, language, node]); }, [addCodeGrammars, editor, language, node]);
const handleChangeLanguage = useCallback( const handleChangeLanguage = useCallback(

View File

@ -1,8 +1,5 @@
import { BaseRange, NodeEntry, Text, Path } from 'slate'; import { BaseRange, NodeEntry, Text, Path } from 'slate';
import Prism, { Grammar } from 'prismjs';
const Prism = window.Prism;
Prism.plugins.autoloader.languages_path = 'https://cdnjs.cloudflare.com/ajax/libs/prism/1.26.0/components/';
const push_string = ( const push_string = (
token: string | Prism.Token, token: string | Prism.Token,
@ -86,7 +83,7 @@ export const decorateCode = ([node, path]: NodeEntry, language: string) => {
return ranges; return ranges;
} }
const highlightCode = (code: string, language: string) => { const highlightCode = (code: string, language: Grammar) => {
try { try {
const tokens = Prism.tokenize(code, language); const tokens = Prism.tokenize(code, language);

View File

@ -4,8 +4,8 @@ import { ImageBlockNode } from '@/components/editor/editor.type';
import { copyTextToClipboard } from '@/utils/copy'; 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 { Skeleton } 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' : ''}`}
> >
@ -86,14 +92,7 @@ function ImageRender({
}} }}
/> />
)} )}
{hasError ? ( {hasError ? renderErrorNode() : loading ? <Skeleton variant='rounded' width={'100%'} height={200} /> : null}
renderErrorNode()
) : loading ? (
<div className={'flex h-full w-full items-center justify-center gap-2 rounded bg-gray-100'}>
<CircularProgress size={24} />
<div className={'text-text-caption'}>{t('editor.loading')}</div>
</div>
) : null}
</div> </div>
); );
} }

View File

@ -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';

View File

@ -1,3 +1 @@
import { lazy } from 'react'; export * from './MathEquation';
export const MathEquation = lazy(() => import('./MathEquation'));

View File

@ -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';

View File

@ -1,5 +1 @@
import { lazy } from 'react'; export * from './Formula';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
export const Formula = lazy(() => import('./Formula?chunkName=formula'));

View File

@ -1,7 +1,7 @@
import { renderDate } from '@/utils/time'; import { renderDate } from '@/utils/time';
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { ReactComponent as DateSvg } from '@/assets/date.svg'; import { ReactComponent as DateSvg } from '@/assets/date.svg';
import { ReactComponent as ReminderSvg } from '$icons/16x/clock_alarm.svg'; import { ReactComponent as ReminderSvg } from '@/assets/clock_alarm.svg';
function MentionDate({ date, reminder }: { date: string; reminder?: { id: string; option: string } }) { function MentionDate({ date, reminder }: { date: string; reminder?: { id: string; option: string } }) {
const dateFormat = useMemo(() => { const dateFormat = useMemo(() => {

View File

@ -0,0 +1,39 @@
import { CommentWrap } from '@/components/global-comment/comment';
import { useGlobalCommentContext } from '@/components/global-comment/GlobalComment.hooks';
import React, { memo } from 'react';
function CommentList() {
const { comments, highLightCommentId } = useGlobalCommentContext();
const isEmpty = !comments || comments.length === 0;
const [hoverId, setHoverId] = React.useState<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);
}}
isHighLight={comment.commentId === highLightCommentId}
key={comment.commentId}
commentId={comment.commentId}
/>
))}
</div>
);
}
export default memo(CommentList);

View File

@ -0,0 +1,246 @@
import { CommentUser, GlobalComment, Reaction } from '@/application/comment.type';
import { PublishContext } from '@/application/publish';
import { AFConfigContext } from '@/components/app/AppConfig';
import { stringAvatar } from '@/utils/color';
import dayjs from 'dayjs';
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
export const GlobalCommentContext = React.createContext<{
reload: () => Promise<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;
setHighLightCommentId: (commentId: string | null) => void;
highLightCommentId: string | null;
}>({
reload: () => Promise.resolve(),
getComment: () => undefined,
loading: false,
comments: null,
replyComment: () => undefined,
replyCommentId: null,
reactions: null,
toggleReaction: () => undefined,
setHighLightCommentId: () => undefined,
highLightCommentId: null,
});
export function useGlobalCommentContext() {
return useContext(GlobalCommentContext);
}
export function useLoadReactions() {
const viewId = useContext(PublishContext)?.viewMeta?.view_id;
const service = useContext(AFConfigContext)?.service;
const currentUser = useContext(AFConfigContext)?.currentUser;
const [reactions, setReactions] = useState<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 };
}

View File

@ -0,0 +1,36 @@
import { AddCommentWrapper } from '@/components/global-comment/add-comment';
import CommentList from '@/components/global-comment/CommentList';
import { useGlobalCommentContext } from '@/components/global-comment/GlobalComment.hooks';
import { Divider } from '@mui/material';
import CircularProgress from '@mui/material/CircularProgress';
import React from 'react';
import { useTranslation } from 'react-i18next';
function GlobalComment() {
const { t } = useTranslation();
const { loading, comments } = useGlobalCommentContext();
return (
<div className={'mb-[480px] mt-16 flex h-fit w-full justify-center max-md:mb-[100px]'}>
<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 />
<AddCommentWrapper />
{loading && !comments?.length ? (
<div className={'flex h-[200px] w-full items-center justify-center'}>
<CircularProgress />
</div>
) : (
<CommentList />
)}
</div>
</div>
);
}
export default GlobalComment;

View File

@ -0,0 +1,60 @@
import GlobalComment from '@/components/global-comment/GlobalComment';
import {
GlobalCommentContext,
useLoadComments,
useLoadReactions,
} from '@/components/global-comment/GlobalComment.hooks';
import { debounce } from 'lodash-es';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
export function GlobalCommentProvider() {
const { comments, loading, reload } = useLoadComments();
const { reactions, toggleReaction } = useLoadReactions();
const [replyCommentId, setReplyCommentId] = useState<string | null>(null);
const [highLightCommentId, setHighLightCommentId] = 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);
}, []);
const debounceClearHighLightCommentId = useMemo(() => {
return debounce(() => {
setHighLightCommentId(null);
}, 5000);
}, []);
useEffect(() => {
if (highLightCommentId) {
debounceClearHighLightCommentId();
} else {
debounceClearHighLightCommentId.cancel();
}
}, [highLightCommentId, debounceClearHighLightCommentId]);
return (
<GlobalCommentContext.Provider
value={{
reactions,
replyCommentId,
reload,
getComment,
loading,
comments,
replyComment,
toggleReaction,
highLightCommentId,
setHighLightCommentId,
}}
>
<GlobalComment />
</GlobalCommentContext.Provider>
);
}
export default GlobalCommentProvider;

View File

@ -0,0 +1,47 @@
import { getAvatar, useGlobalCommentContext } from '@/components/global-comment/GlobalComment.hooks';
import { Avatar } from '@mui/material';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import smoothScrollIntoViewIfNeeded from 'smooth-scroll-into-view-if-needed';
function ReplyComment({ commentId }: { commentId?: string | null }) {
const { getComment, setHighLightCommentId } = useGlobalCommentContext();
const { t } = useTranslation();
const replyComment = useMemo(() => {
if (!commentId) return;
return getComment(commentId);
}, [commentId, getComment]);
const avatar = useMemo(() => (replyComment ? getAvatar(replyComment) : null), [replyComment]);
const handleClick = () => {
if (commentId) {
const element = document.querySelector('[data-comment-id="' + commentId + '"]');
if (element) {
void smoothScrollIntoViewIfNeeded(element, {
behavior: 'smooth',
block: 'center',
});
setHighLightCommentId(commentId);
}
}
};
if (!replyComment) return null;
return (
<div className={'flex items-center gap-1 text-sm text-text-caption'}>
<Avatar {...avatar} className={'h-4 w-4 text-xs'} />
<div className={'whitespace-nowrap text-xs font-medium text-content-blue-400'}>@{replyComment.user?.name}</div>
<div onClick={handleClick} className={'cursor-pointer truncate px-1 hover:text-text-title'}>
{replyComment.isDeleted ? (
<span className={'text-xs'}>{`[${t('globalComment.hasBeenDeleted')}]`}</span>
) : (
replyComment.content
)}
</div>
</div>
);
}
export default ReplyComment;

View File

@ -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 (
<>
<ReactAction comment={comment} />
<ReplyAction comment={comment} />
<MoreActions comment={comment} />
</>
);
}
export default memo(CommentActions);

View File

@ -0,0 +1,147 @@
import { GlobalComment } from '@/application/comment.type';
import { PublishContext } from '@/application/publish';
import { NormalModal } from '@/components/_shared/modal';
import { notify } from '@/components/_shared/notify';
import { Popover } from '@/components/_shared/popover';
import { AFConfigContext } from '@/components/app/AppConfig';
import { useGlobalCommentContext } from '@/components/global-comment/GlobalComment.hooks';
import { Button, IconButton, Tooltip, TooltipProps } from '@mui/material';
import React, { memo, useCallback, useContext, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { ReactComponent as MoreIcon } from '@/assets/more.svg';
import { ReactComponent as TrashIcon } from '@/assets/trash.svg';
interface Item {
Icon: React.FC<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 ref = React.useRef<HTMLButtonElement>(null);
const [open, setOpen] = React.useState(false);
const [deleteModalOpen, setDeleteModalOpen] = React.useState(false);
const handleClose = useCallback(() => {
setOpen(false);
}, []);
const handleOpen = () => {
setOpen(true);
};
const { t } = useTranslation();
const service = useContext(AFConfigContext)?.service;
const viewId = useContext(PublishContext)?.viewMeta?.view_id;
const handleDeleteAction = useCallback(async () => {
if (!viewId || !service) return;
try {
await service?.deleteCommentOnPublishView(viewId, comment.commentId);
await reload();
} catch (e) {
console.error(e);
notify.error('Failed to delete comment');
} finally {
setDeleteModalOpen(false);
}
}, [comment.commentId, reload, service, viewId]);
const actions = useMemo(() => {
return [
{
Icon: TrashIcon,
label: t('button.delete'),
disabled: !canDeleted,
tooltip: canDeleted
? undefined
: {
title: <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 (
<>
<IconButton ref={ref} size={'small'} onClick={handleOpen} className={'h-full'}>
<MoreIcon className={'h-5 w-5'} />
</IconButton>
<Popover
anchorEl={ref.current}
open={open}
onClose={handleClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
>
<div className={'flex min-w-[150px] flex-col items-start p-2'}>
{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>
</Popover>
{deleteModalOpen && (
<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);

View File

@ -0,0 +1,62 @@
import { GlobalComment } from '@/application/comment.type';
import { EmojiPicker } from '@/components/_shared/emoji-picker';
import { EMOJI_SIZE, PER_ROW_EMOJI_COUNT } from '@/components/_shared/emoji-picker/const';
import { Popover } from '@/components/_shared/popover';
import ComponentLoading from '@/components/_shared/progress/ComponentLoading';
import { useGlobalCommentContext } from '@/components/global-comment/GlobalComment.hooks';
import { ReactComponent as AddReactionRounded } from '@/assets/add_reaction.svg';
import { IconButton, Tooltip } from '@mui/material';
import React, { memo, Suspense, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
function ReactAction({ comment }: { comment: GlobalComment }) {
const { toggleReaction } = useGlobalCommentContext();
const { t } = useTranslation();
const ref = React.useRef<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' className={'h-full'}>
<AddReactionRounded className={'h-5 w-5'} />
</IconButton>
</Tooltip>
{open && (
<Popover
anchorEl={ref.current}
open={open}
onClose={handleClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
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);

View File

@ -0,0 +1,28 @@
import { GlobalComment } from '@/application/comment.type';
import { useGlobalCommentContext } from '@/components/global-comment/GlobalComment.hooks';
import { ReactComponent as ReplyOutlined } from '@/assets/reply.svg';
import { Tooltip } from '@mui/material';
import React, { memo } from 'react';
import { useTranslation } from 'react-i18next';
import IconButton from '@mui/material/IconButton';
function ReplyAction({ comment }: { comment: GlobalComment }) {
const { t } = useTranslation();
const replyComment = useGlobalCommentContext().replyComment;
return (
<Tooltip title={t('globalComment.reply')}>
<IconButton
onClick={() => {
replyComment(comment.commentId);
}}
size='small'
className={'h-full'}
>
<ReplyOutlined className={'h-5 w-5'} />
</IconButton>
</Tooltip>
);
}
export default memo(ReplyAction);

View File

@ -0,0 +1,175 @@
import { PublishContext } from '@/application/publish';
import { notify } from '@/components/_shared/notify';
import { AFConfigContext } from '@/components/app/AppConfig';
import { useGlobalCommentContext } from '@/components/global-comment/GlobalComment.hooks';
import ReplyComment from '@/components/global-comment/ReplyComment';
import { Button, TextareaAutosize } from '@mui/material';
import CircularProgress from '@mui/material/CircularProgress';
import React, { memo, useCallback, useContext, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { ReactComponent as CloseIcon } from '@/assets/close.svg';
import smoothScrollIntoViewIfNeeded from 'smooth-scroll-into-view-if-needed';
interface AddCommentProps {
content: string;
setContent: (content: string) => void;
focus: boolean;
setFocus: (focus: boolean) => void;
}
function AddComment({ content, setContent, focus, setFocus }: AddCommentProps) {
const { reload, replyCommentId, replyComment: setReplyCommentId } = useGlobalCommentContext();
const { t } = useTranslation();
const isAuthenticated = useContext(AFConfigContext)?.isAuthenticated;
const openLoginModal = useContext(AFConfigContext)?.openLoginModal;
const createCommentOnPublishView = useContext(AFConfigContext)?.service?.createCommentOnPublishView;
const viewId = useContext(PublishContext)?.viewMeta?.view_id;
const [loading, setLoading] = React.useState(false);
const url = window.location.href + '#addComment';
const ref = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const handleOnFocus = () => {
setFocus(true);
};
const handleSubmit = useCallback(async () => {
if (!createCommentOnPublishView || !viewId || loading) {
return;
}
if (!content || content.trim().length === 0) return;
setLoading(true);
try {
await createCommentOnPublishView(viewId, content, replyCommentId || undefined);
await reload();
setContent('');
setReplyCommentId(null);
notify.info({
type: 'success',
title: t('globalComment.commentAddedSuccessfully'),
message: t('globalComment.askForViewComment'),
okText: t('button.yes'),
onOk: () => {
const element = document.getElementById('addComment') as HTMLElement;
if (!element) return;
void smoothScrollIntoViewIfNeeded(element, { behavior: 'smooth', scrollMode: 'if-needed', block: 'start' });
},
});
} catch (_e) {
notify.error(t('globalComment.failedToAddComment'));
} finally {
setLoading(false);
}
}, [createCommentOnPublishView, viewId, loading, content, replyCommentId, reload, setReplyCommentId, t, setContent]);
return (
<div className={'flex flex-col gap-2'}>
<div className={'bg-bg-body'}>
<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 className={'flex w-full flex-col gap-2'}>
<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,
transition: 'width 0.3s ease-in-out',
}}
className={
'flex flex-1 transform flex-col gap-4 rounded-[8px] border border-line-border bg-bg-body px-3 py-1.5'
}
>
<TextareaAutosize
minRows={1}
autoFocus={focus}
ref={inputRef}
autoComplete={'off'}
spellCheck={false}
onMouseDown={() => {
if (!isAuthenticated && openLoginModal) {
openLoginModal(url);
}
}}
readOnly={!isAuthenticated}
onFocus={handleOnFocus}
onBlur={() => setFocus(false)}
value={content}
className={'w-full resize-none'}
onChange={(e) => {
setContent(e.target.value);
}}
placeholder={t('globalComment.addComment')}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey) {
e.preventDefault();
void handleSubmit();
}
if (e.key === 'Escape') {
setContent('');
setReplyCommentId(null);
}
}}
/>
</div>
</div>
</div>
</div>
{!!content && (
<div className={'flex justify-end gap-2'}>
<Button
onClick={() => {
inputRef.current?.blur();
setContent('');
setReplyCommentId(null);
}}
className={'h-7 bg-bg-body'}
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.add')}
</Button>
</div>
)}
</div>
);
}
export default memo(AddComment);

View File

@ -0,0 +1,68 @@
import { useGlobalCommentContext } from '@/components/global-comment/GlobalComment.hooks';
import { getScrollParent } from '@/components/global-comment/utils';
import { HEADER_HEIGHT } from '@/components/publish/header';
import React, { useEffect, useRef, useState } from 'react';
import AddComment from './AddComment';
import { Portal } from '@mui/material';
export function AddCommentWrapper() {
const { replyCommentId } = useGlobalCommentContext();
const addCommentRef = useRef<HTMLDivElement>(null);
const [showFixedAddComment, setShowFixedAddComment] = useState(false);
const [focus, setFocus] = useState(false);
const [content, setContent] = useState('');
useEffect(() => {
if (replyCommentId) {
setFocus(true);
}
}, [replyCommentId]);
useEffect(() => {
const element = addCommentRef.current;
if (!element) return;
const scrollContainer = getScrollParent(element);
if (!scrollContainer) return;
const handleScroll = () => {
const isIntersecting = element.getBoundingClientRect().top < HEADER_HEIGHT;
if (isIntersecting) {
setShowFixedAddComment(true);
} else {
setShowFixedAddComment(false);
}
};
scrollContainer.addEventListener('scroll', handleScroll);
return () => {
scrollContainer.removeEventListener('scroll', handleScroll);
};
}, []);
return (
<>
<div className={'my-2'} id='addComment' ref={addCommentRef}>
<AddComment
content={content}
setContent={setContent}
focus={focus && !showFixedAddComment}
setFocus={setFocus}
/>
</div>
{showFixedAddComment && (
<Portal container={document.body}>
<div className={'fixed top-[48px] flex w-full justify-center'}>
<div className={'w-[964px] min-w-0 max-w-full px-16 max-sm:px-4'}>
<AddComment content={content} setContent={setContent} focus={focus} setFocus={setFocus} />
</div>
</div>
</Portal>
)}
</>
);
}
export default AddCommentWrapper;

View File

@ -0,0 +1 @@
export * from './AddCommentWrapper';

View File

@ -0,0 +1,106 @@
import { GlobalComment } from '@/application/comment.type';
import { useCommentRender } from '@/components/global-comment/GlobalComment.hooks';
import { Reactions } from '@/components/global-comment/reactions';
import { Avatar, Divider, Tooltip } from '@mui/material';
import React, { memo, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { ReactComponent as BulletedListIcon } from '@/assets/bulleted_list_icon_1.svg';
import { ReactComponent as DoubleArrow } from '@/assets/double_arrow.svg';
import smoothScrollIntoViewIfNeeded from 'smooth-scroll-into-view-if-needed';
interface CommentProps {
comment: GlobalComment;
}
const MAX_HEIGHT = 320;
function Comment({ comment }: CommentProps) {
const { avatar, time, timeFormat } = useCommentRender(comment);
const { t } = useTranslation();
const ref = React.useRef<HTMLDivElement>(null);
const contentRef = React.useRef<HTMLSpanElement>(null);
const [showExpand, setShowExpand] = React.useState(false);
const [isExpand, setIsExpand] = React.useState(false);
useEffect(() => {
const contentEl = contentRef.current;
if (!contentEl) return;
const contentHeight = contentEl.offsetHeight;
setShowExpand(contentHeight > MAX_HEIGHT);
}, []);
const toggleExpand = useCallback(() => {
setIsExpand((prev) => {
return !prev;
});
}, []);
return (
<div className={'comment flex flex-col gap-2'} ref={ref}>
<div className={'flex items-center gap-2'}>
<div className={'flex items-center gap-4'}>
<Avatar {...avatar} className={'h-8 w-8'} />
<div className={'font-semibold'}>{comment.user?.name}</div>
</div>
<Tooltip title={timeFormat} enterNextDelay={500} enterDelay={1000} placement={'top-start'}>
<div className={'flex items-center gap-2 text-text-caption'}>
<BulletedListIcon className={'h-3 w-3'} />
<div className={'text-sm'}>{time}</div>
</div>
</Tooltip>
</div>
<div className={'ml-12 flex flex-col gap-2'}>
{comment.isDeleted ? (
<span className={'text-text-caption'}>{`[${t('globalComment.hasBeenDeleted')}]`}</span>
) : (
<div
style={{
height: showExpand && !isExpand ? MAX_HEIGHT : 'auto',
overflow: isExpand ? 'unset' : 'hidden',
transition: 'height 0.8s ease-in-out',
}}
>
<span ref={contentRef} className={'transform whitespace-pre-wrap break-words'}>
{comment.content}
</span>
</div>
)}
{showExpand && (
<>
<Tooltip
title={isExpand ? t('globalComment.collapse') : t('globalComment.readMore')}
disableInteractive={true}
>
<div
onClick={() => {
const originalExpand = isExpand;
toggleExpand();
if (originalExpand && ref.current) {
void smoothScrollIntoViewIfNeeded(ref.current, {
behavior: 'smooth',
block: 'start',
});
}
}}
className={
'relative flex cursor-pointer items-center justify-center gap-2 bg-transparent text-text-caption hover:text-content-blue-400'
}
>
<Divider className={'flex-1'} />
<DoubleArrow className={`h-5 w-5 transform ${isExpand ? '-rotate-90' : 'rotate-90'} `} />
<Divider className={'flex-1'} />
</div>
</Tooltip>
</>
)}
{!comment.isDeleted && <Reactions comment={comment} />}
</div>
</div>
);
}
export default memo(Comment);

View File

@ -0,0 +1,82 @@
import { AFConfigContext } from '@/components/app/AppConfig';
import CommentActions from '@/components/global-comment/actions/CommentActions';
import Comment from './Comment';
import { useGlobalCommentContext } from '@/components/global-comment/GlobalComment.hooks';
import ReplyComment from '@/components/global-comment/ReplyComment';
import React, { useCallback, useContext, useEffect, useMemo } from 'react';
import smoothScrollIntoViewIfNeeded from 'smooth-scroll-into-view-if-needed';
export interface CommentWrapProps {
commentId: string;
isHovered: boolean;
onHovered: () => void;
isHighLight: boolean;
}
export function CommentWrap({ commentId, isHighLight, isHovered, onHovered }: CommentWrapProps) {
const { getComment, setHighLightCommentId } = useGlobalCommentContext();
const comment = useMemo(() => getComment(commentId), [commentId, getComment]);
const ref = React.useRef<HTMLDivElement>(null);
const isAuthenticated = useContext(AFConfigContext)?.isAuthenticated;
useEffect(() => {
const hashHasComment = window.location.hash.includes(`#comment-${commentId}`);
if (!hashHasComment) return;
const element = ref.current;
if (!element) return;
let timeout: NodeJS.Timeout | null = null;
void (async () => {
window.location.hash = '';
timeout = setTimeout(() => {
void smoothScrollIntoViewIfNeeded(element, {
behavior: 'smooth',
block: 'center',
});
setHighLightCommentId(commentId);
}, 500);
})();
return () => {
timeout && clearTimeout(timeout);
};
}, [commentId, setHighLightCommentId]);
const renderReplyComment = useCallback((replyCommentId: string) => {
return (
<div className={'relative flex w-full items-center gap-2'}>
<div className={'reply-line relative top-2 ml-[1.5em] w-[25px]'} />
<div className={'flex-1 overflow-hidden '}> {<ReplyComment commentId={replyCommentId} />}</div>
</div>
);
}, []);
if (!comment) {
return null;
}
return (
<div ref={ref} className={'flex flex-col gap-1'} data-comment-id={comment.commentId}>
{comment.replyCommentId && renderReplyComment(comment.replyCommentId)}
<div
className={`relative rounded-[8px] p-2 py-2.5 hover:bg-fill-list-hover ${isHighLight ? 'blink' : ''}`}
{...(comment.isDeleted ? { style: { opacity: 0.5, backgroundColor: 'var(--bg-body)' } } : {})}
onMouseEnter={() => {
onHovered();
}}
>
<Comment comment={comment} />
{isHovered && isAuthenticated && !comment.isDeleted && (
<div className={'absolute right-2 top-2.5 flex h-8 items-center gap-2'}>
<CommentActions comment={comment} />
</div>
)}
</div>
</div>
);
}
export default CommentWrap;

View File

@ -0,0 +1 @@
export * from './CommentWrap';

View File

@ -0,0 +1 @@
export * from './GlobalCommentProvider';

View File

@ -0,0 +1,101 @@
import { Reaction as ReactionType } from '@/application/comment.type';
import { AFConfigContext } from '@/components/app/AppConfig';
import { getPlatform } from '@/utils/platform';
import { Tooltip } from '@mui/material';
import React, { memo, useContext, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
function Reaction({ reaction, onClick }: { reaction: ReactionType; onClick: (reaction: ReactionType) => void }) {
const { t } = useTranslation();
const isAuthenticated = useContext(AFConfigContext)?.isAuthenticated;
const openLoginModal = useContext(AFConfigContext)?.openLoginModal;
const url = window.location.href + '#comment-' + reaction.commentId;
const reactCount = useMemo(() => {
return reaction.reactUsers.length;
}, [reaction.reactUsers]);
const userNames = useMemo(() => {
let sliceOffset = reactCount;
let suffix = '';
if (reactCount > 20) {
sliceOffset = 20;
suffix = ` ${t('globalComment.reactedByMore', { count: reactCount - 20 })}`;
}
return (
reaction.reactUsers
.slice(0, sliceOffset)
.map((user) => user.name)
.join(', ') + suffix
);
}, [reaction.reactUsers, t, reactCount]);
const currentUser = useContext(AFConfigContext)?.currentUser;
const currentUid = currentUser?.uuid;
const isCurrentUserReacted = useMemo(() => {
return reaction.reactUsers.some((user) => user.uuid === currentUid);
}, [currentUid, reaction.reactUsers]);
const [hover, setHover] = React.useState(false);
const style = useMemo(() => {
const styleProperties: React.CSSProperties = {};
if (hover) {
Object.assign(styleProperties, {
borderColor: 'var(--line-border)',
backgroundColor: 'var(--bg-body)',
});
} else if (isCurrentUserReacted) {
Object.assign(styleProperties, {
borderColor: 'var(--content-blue-400)',
backgroundColor: 'var(--content-blue-100)',
});
}
return styleProperties;
}, [hover, isCurrentUserReacted]);
const handleSelected = () => {
if (!isAuthenticated && openLoginModal) {
openLoginModal(url);
return;
}
onClick(reaction);
};
const isMobile = useMemo(() => {
return getPlatform().isMobile;
}, []);
return (
<Tooltip
title={
<div className={'break-word overflow-hidden whitespace-pre-wrap text-xs'}>
{t('globalComment.reactedBy')}
{` `}
{userNames}
</div>
}
>
<div
style={style}
onClick={!isMobile ? handleSelected : undefined}
onTouchEnd={isMobile ? handleSelected : undefined}
onMouseEnter={() => {
if (isMobile) return;
setHover(true);
}}
onMouseLeave={() => setHover(false)}
className={
'flex cursor-pointer items-center gap-1 rounded-full border border-transparent bg-fill-list-hover px-1 py-0.5 text-sm'
}
>
<span className={''}>{reaction.reactionType}</span>
{<div className={'text-xs font-medium'}>{reactCount}</div>}
</div>
</Tooltip>
);
}
export default memo(Reaction);

View File

@ -0,0 +1,31 @@
import { GlobalComment, Reaction as ReactionType } from '@/application/comment.type';
import ReactAction from '@/components/global-comment/actions/ReactAction';
import { useGlobalCommentContext } from '@/components/global-comment/GlobalComment.hooks';
import Reaction from '@/components/global-comment/reactions/Reaction';
import React, { memo, useCallback, useMemo } from 'react';
export function Reactions({ comment }: { comment: GlobalComment }) {
const { reactions, toggleReaction } = useGlobalCommentContext();
const commentReactions = useMemo(() => {
return reactions?.[comment.commentId]?.filter((reaction) => reaction.reactUsers.length > 0) || [];
}, [reactions, comment.commentId]);
const handleReactionClick = useCallback(
(reaction: ReactionType) => {
toggleReaction(comment.commentId, reaction.reactionType);
},
[comment.commentId, toggleReaction]
);
if (!commentReactions.length) return null;
return (
<div className={'flex w-full flex-wrap items-center gap-2 overflow-hidden pt-1'}>
{commentReactions.map((reaction) => {
return <Reaction reaction={reaction} onClick={handleReactionClick} key={reaction.reactionType} />;
})}
<ReactAction comment={comment} />
</div>
);
}
export default memo(Reactions);

View File

@ -0,0 +1 @@
export * from './Reactions';

View File

@ -0,0 +1,11 @@
export function getScrollParent(node: Element | null): Element | null {
if (node === null) {
return null;
}
if (node.scrollHeight > node.clientHeight) {
return node;
} else {
return getScrollParent(node.parentElement);
}
}

View File

@ -1,28 +1,18 @@
import { AFConfigContext } from '@/components/app/AppConfig';
import LoginProvider from '@/components/login/LoginProvider'; import LoginProvider from '@/components/login/LoginProvider';
import MagicLink from '@/components/login/MagicLink'; import MagicLink from '@/components/login/MagicLink';
import { Divider } from '@mui/material'; import { Divider } from '@mui/material';
import React, { useContext, useEffect } from 'react'; import React from 'react';
import { ReactComponent as Logo } from '@/assets/logo.svg'; import { ReactComponent as Logo } from '@/assets/logo.svg';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSearchParams } from 'react-router-dom';
export function Login() { export function Login({ redirectTo }: { redirectTo: string }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [search] = useSearchParams();
const redirectTo = search.get('redirectTo') || '';
const isAuthenticated = useContext(AFConfigContext)?.isAuthenticated || false;
useEffect(() => {
if (isAuthenticated && redirectTo && encodeURIComponent(redirectTo) !== window.location.href) {
window.location.href = redirectTo;
}
}, [isAuthenticated, redirectTo]);
return ( return (
<div className={'my-10 flex flex-col items-center justify-center gap-[24px] px-4'}> <div className={'my-10 flex flex-col items-center justify-center gap-[24px] px-4'}>
<div className={'flex flex-col items-center justify-center gap-[14px]'}> <div className={'flex w-full flex-col items-center justify-center gap-[14px]'}>
<Logo className={'h-10 w-10'} /> <Logo className={'h-10 w-10'} />
<div className={'text-[24px] font-semibold'}>{t('welcomeTo')} AppFlowy</div> <div className={'text-[24px] font-semibold max-sm:text-[20px]'}>{t('welcomeTo')} AppFlowy</div>
</div> </div>
<MagicLink redirectTo={redirectTo} /> <MagicLink redirectTo={redirectTo} />
<div className={'flex w-full items-center justify-center gap-2 text-text-caption'}> <div className={'flex w-full items-center justify-center gap-2 text-text-caption'}>

View File

@ -0,0 +1,21 @@
import React from 'react';
import { Dialog, IconButton } from '@mui/material';
import { Login } from './Login';
import { ReactComponent as CloseIcon } from '@/assets/close.svg';
export function LoginModal({ redirectTo, open, onClose }: { redirectTo: string; open: boolean; onClose: () => void }) {
return (
<Dialog open={open} onClose={onClose}>
<div className={'relative px-6'}>
<Login redirectTo={redirectTo} />
<div className={'absolute top-2 right-2'}>
<IconButton size={'small'} color={'inherit'} className={'h-6 w-6'} onClick={onClose}>
<CloseIcon className={'h-4 w-4'} />
</IconButton>
</div>
</div>
</Dialog>
);
}
export default LoginModal;

View File

@ -50,7 +50,7 @@ function LoginProvider({ redirectTo }: { redirectTo: string }) {
}; };
return ( return (
<div className={'flex flex-col items-center justify-center gap-[10px]'}> <div className={'flex w-full flex-col items-center justify-center gap-[10px]'}>
{options.map((option) => ( {options.map((option) => (
<Button <Button
key={option.value} key={option.value}
@ -58,7 +58,7 @@ function LoginProvider({ redirectTo }: { redirectTo: string }) {
variant={'outlined'} variant={'outlined'}
onClick={() => handleClick(option.value)} onClick={() => handleClick(option.value)}
className={ className={
'flex h-[46px] w-[380px] items-center justify-center gap-[10px] rounded-[12px] border border-line-divider text-sm font-medium' 'flex h-[46px] w-[380px] items-center justify-center gap-[10px] rounded-[12px] border border-line-divider text-sm font-medium text-text-title max-sm:w-full'
} }
> >
<option.Icon className={'h-[20px] w-[20px]'} /> <option.Icon className={'h-[20px] w-[20px]'} />

View File

@ -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'));
@ -34,11 +34,11 @@ function MagicLink({ redirectTo }: { redirectTo: string }) {
}; };
return ( return (
<div className={'flex flex-col items-center justify-center gap-[12px]'}> <div className={'flex w-full flex-col items-center justify-center gap-[12px]'}>
<OutlinedInput <OutlinedInput
value={email} value={email}
type={'email'} type={'email'}
className={'h-[46px] w-[380px] rounded-[12px] py-[15px] px-[20px] text-base'} className={'h-[46px] w-[380px] rounded-[12px] py-[15px] px-[20px] text-base max-sm:w-full'}
placeholder={t('signIn.pleaseInputYourEmail')} placeholder={t('signIn.pleaseInputYourEmail')}
inputProps={{ inputProps={{
className: 'px-0 py-0', className: 'px-0 py-0',
@ -49,7 +49,7 @@ function MagicLink({ redirectTo }: { redirectTo: string }) {
onClick={handleSubmit} onClick={handleSubmit}
disabled={loading} disabled={loading}
variant={'contained'} variant={'contained'}
className={'flex h-[46px] w-[380px] items-center justify-center gap-2 rounded-[12px] text-base'} className={'flex h-[46px] w-[380px] items-center justify-center gap-2 rounded-[12px] text-base max-sm:w-full'}
> >
{loading ? ( {loading ? (
<> <>

View File

@ -1 +1,3 @@
export * from './LoginModal';
export * from './Login'; export * from './Login';

View File

@ -45,7 +45,7 @@ function DatabaseView({ viewMeta, ...props }: DatabaseProps) {
return ( return (
<div <div
style={{ style={{
height: 'calc(100vh - 48px)', minHeight: 'calc(100vh - 48px)',
}} }}
className={'relative flex h-full w-full flex-col px-16 max-md:px-4'} className={'relative flex h-full w-full flex-col px-16 max-md:px-4'}
> >

View File

@ -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,6 +86,11 @@ export function PublishView({ namespace, publishName }: PublishViewProps) {
/> />
<CollabView doc={doc} /> <CollabView doc={doc} />
{doc && (
<Suspense fallback={<ComponentLoading />}>
<GlobalCommentProvider />
</Suspense>
)}
</AFScroller> </AFScroller>
{open && <OutlineDrawer width={drawerWidth} open={open} onClose={() => setOpen(false)} />} {open && <OutlineDrawer width={drawerWidth} open={open} onClose={() => setOpen(false)} />}
</div> </div>

View File

@ -1,20 +1,20 @@
// import { invalidToken } from '@/application/session/token'; import { invalidToken } from '@/application/session/token';
import { Popover } from '@/components/_shared/popover'; import { Popover } from '@/components/_shared/popover';
// import { AFConfigContext } from '@/components/app/AppConfig'; import { AFConfigContext } from '@/components/app/AppConfig';
import { ThemeModeContext } from '@/components/app/useAppThemeMode'; import { ThemeModeContext } from '@/components/app/useAppThemeMode';
import { openUrl } from '@/utils/url'; import { openUrl } from '@/utils/url';
import { IconButton } from '@mui/material'; import { IconButton } from '@mui/material';
import React, { useContext, useMemo } from 'react'; import React, { useCallback, useContext, useMemo } from 'react';
import { ReactComponent as MoreIcon } from '@/assets/more.svg'; import { ReactComponent as MoreIcon } from '@/assets/more.svg';
import { ReactComponent as MoonIcon } from '@/assets/moon.svg'; import { ReactComponent as MoonIcon } from '@/assets/moon.svg';
import { ReactComponent as SunIcon } from '@/assets/sun.svg'; import { ReactComponent as SunIcon } from '@/assets/sun.svg';
// import { ReactComponent as LoginIcon } from '@/assets/login.svg'; import { ReactComponent as LoginIcon } from '@/assets/login.svg';
import { ReactComponent as ReportIcon } from '@/assets/report.svg'; import { ReactComponent as ReportIcon } from '@/assets/report.svg';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
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';
// import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
function MoreActions() { function MoreActions() {
const { isDark, setDark } = useContext(ThemeModeContext) || {}; const { isDark, setDark } = useContext(ThemeModeContext) || {};
@ -31,21 +31,21 @@ function MoreActions() {
const { t } = useTranslation(); const { t } = useTranslation();
// const navigate = useNavigate(); const navigate = useNavigate();
// const isAuthenticated = useContext(AFConfigContext)?.isAuthenticated || false; const isAuthenticated = useContext(AFConfigContext)?.isAuthenticated || false;
//
// const handleLogin = useCallback(() => { const handleLogin = useCallback(() => {
// invalidToken(); invalidToken();
// navigate('/login?redirectTo=' + encodeURIComponent(window.location.href)); navigate('/login?redirectTo=' + encodeURIComponent(window.location.href));
// }, [navigate]); }, [navigate]);
const actions = useMemo(() => { const actions = useMemo(() => {
return [ return [
// { {
// Icon: LoginIcon, Icon: LoginIcon,
// label: isAuthenticated ? t('button.logout') : t('web.login'), label: isAuthenticated ? t('button.logout') : t('web.login'),
// onClick: handleLogin, onClick: handleLogin,
// }, },
isDark isDark
? { ? {
Icon: SunIcon, Icon: SunIcon,
@ -69,54 +69,58 @@ function MoreActions() {
}, },
}, },
]; ];
}, [t, isDark, setDark]); }, [isAuthenticated, t, handleLogin, isDark, setDark]);
return ( return (
<> <>
<IconButton onClick={handleClick}> <IconButton onClick={handleClick}>
<MoreIcon className={'text-text-caption'} /> <MoreIcon className={'text-text-caption'} />
</IconButton> </IconButton>
<Popover {open && (
anchorOrigin={{ <Popover
vertical: 'bottom', anchorOrigin={{
horizontal: 'right', vertical: 'bottom',
}} horizontal: 'right',
transformOrigin={{ }}
vertical: 'top', transformOrigin={{
horizontal: 'right', vertical: 'top',
}} horizontal: 'right',
open={open} }}
anchorEl={anchorEl} open={open}
onClose={handleClose} anchorEl={anchorEl}
> onClose={handleClose}
<div className={'flex w-[240px] flex-col gap-2 px-2 py-2'}> >
{actions.map((action, index) => ( <div className={'flex w-[240px] flex-col gap-2 px-2 py-2'}>
<button {actions.map((action, index) => (
<button
onClick={() => {
action.onClick();
handleClose();
}}
key={index}
className={
'flex items-center gap-2 rounded-[8px] p-1.5 text-sm hover:bg-content-blue-50 focus:bg-content-blue-50 focus:outline-none'
}
>
<action.Icon />
<span>{action.label}</span>
</button>
))}
<div
onClick={() => { onClick={() => {
action.onClick(); window.open('https://appflowy.io', '_blank');
handleClose();
}} }}
key={index}
className={ className={
'flex items-center gap-2 rounded-[8px] p-1.5 text-sm hover:bg-content-blue-50 focus:bg-content-blue-50 focus:outline-none' 'flex w-full cursor-pointer items-center justify-center py-2 text-sm text-text-title opacity-50'
} }
> >
<action.Icon /> Powered by
<span>{action.label}</span> <Logo className={'ml-3 h-4 w-4'} />
</button> <AppflowyLogo className={'w-20'} />
))} </div>
<div
onClick={() => {
window.open('https://appflowy.io', '_blank');
}}
className={'flex w-full cursor-pointer items-center justify-center py-2 text-sm text-text-title opacity-50'}
>
Powered by
<Logo className={'ml-3 h-4 w-4'} />
<AppflowyLogo className={'w-20'} />
</div> </div>
</div> </Popover>
</Popover> )}
</> </>
); );
} }

View File

@ -3,12 +3,13 @@ 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, { 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';
export const HEADER_HEIGHT = 48; export const HEADER_HEIGHT = 48;
@ -65,7 +66,7 @@ export function PublishViewHeader({ onOpenDrawer, openDrawer }: { onOpenDrawer:
className={'appflowy-top-bar sticky top-0 z-10 flex px-5'} 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'}>
{!openDrawer && ( {!openDrawer && openPopover && (
<OutlinePopover <OutlinePopover
onMouseEnter={handleOpenPopover} onMouseEnter={handleOpenPopover}
onMouseLeave={debounceClosePopover} onMouseLeave={debounceClosePopover}
@ -92,6 +93,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 />*/}
{/*</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}>

View File

@ -0,0 +1,31 @@
import React, { useCallback } from 'react';
import { Button } from '@mui/material';
import { useTranslation } from 'react-i18next';
import { LoginModal } from '@/components/login';
import { useSearchParams } from 'react-router-dom';
import { useDuplicate } from '@/components/publish/header/duplicate/useDuplicate';
import DuplicateModal from '@/components/publish/header/duplicate/DuplicateModal';
export function Duplicate() {
const { t } = useTranslation();
const { loginOpen, duplicateOpen, handleDuplicateClose, handleLoginClose, url } = useDuplicate();
const [search, setSearch] = useSearchParams();
const handleClick = useCallback(() => {
setSearch({
...search,
action: 'duplicate',
});
}, [search, setSearch]);
return (
<>
<Button onClick={handleClick} size={'small'} variant={'outlined'} color={'inherit'}>
{t('publish.saveThisPage')}
</Button>
<LoginModal redirectTo={url} open={loginOpen} onClose={handleLoginClose} />
{duplicateOpen && <DuplicateModal open={duplicateOpen} onClose={handleDuplicateClose} />}
</>
);
}
export default Duplicate;

View File

@ -0,0 +1,129 @@
import React, { useCallback, useContext, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { NormalModal } from '@/components/_shared/modal';
import SelectWorkspace from '@/components/publish/header/duplicate/SelectWorkspace';
import { useLoadWorkspaces } from '@/components/publish/header/duplicate/useDuplicate';
import SpaceList from '@/components/publish/header/duplicate/SpaceList';
import { downloadPage, openAppFlowySchema } from '@/utils/url';
import { AFConfigContext } from '@/components/app/AppConfig';
import { PublishContext } from '@/application/publish';
import { CollabType, ViewLayout } from '@/application/collab.type';
import { notify } from '@/components/_shared/notify';
function getCollabTypeFromViewLayout(layout: ViewLayout) {
switch (layout) {
case ViewLayout.Document:
return CollabType.Document;
case ViewLayout.Grid:
case ViewLayout.Board:
case ViewLayout.Calendar:
return CollabType.Database;
default:
return null;
}
}
function DuplicateModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const { t } = useTranslation();
const service = useContext(AFConfigContext)?.service;
const viewMeta = useContext(PublishContext)?.viewMeta;
const viewId = viewMeta?.view_id;
const layout = viewMeta?.layout as ViewLayout;
const [loading, setLoading] = React.useState<boolean>(false);
const [successModalOpen, setSuccessModalOpen] = React.useState<boolean>(false);
const {
workspaceList,
spaceList,
setSelectedSpaceId,
setSelectedWorkspaceId,
selectedWorkspaceId,
selectedSpaceId,
workspaceLoading,
spaceLoading,
} = useLoadWorkspaces();
useEffect(() => {
if (!open) {
setSelectedWorkspaceId(workspaceList[0]?.id || '');
setSelectedSpaceId('');
}
}, [open, setSelectedSpaceId, setSelectedWorkspaceId, workspaceList]);
const handleDuplicate = useCallback(async () => {
if (!viewId) return;
const collabType = getCollabTypeFromViewLayout(layout);
if (collabType === null) return;
setLoading(true);
try {
await service?.duplicatePublishView({
workspaceId: selectedWorkspaceId,
spaceViewId: selectedSpaceId,
viewId,
collabType,
});
onClose();
setSuccessModalOpen(true);
} catch (e) {
notify.error(t('publish.duplicateFailed'));
} finally {
setLoading(false);
}
}, [viewId, layout, service, selectedWorkspaceId, selectedSpaceId, onClose, t]);
return (
<>
<NormalModal
okButtonProps={{
disabled: !selectedWorkspaceId || !selectedSpaceId,
}}
onCancel={onClose}
okText={t('button.add')}
title={t('publish.duplicateTitle')}
open={open}
onClose={onClose}
classes={{ container: 'items-start max-md:mt-auto max-md:items-center mt-[10%] ' }}
onOk={handleDuplicate}
okLoading={loading}
>
<div className={'flex flex-col gap-4'}>
<SelectWorkspace
loading={workspaceLoading}
workspaceList={workspaceList}
value={selectedWorkspaceId}
onChange={setSelectedWorkspaceId}
/>
<SpaceList
loading={spaceLoading}
spaceList={spaceList}
value={selectedSpaceId}
onChange={setSelectedSpaceId}
/>
</div>
</NormalModal>
<NormalModal
PaperProps={{
sx: {
maxWidth: 420,
},
}}
okText={t('publish.openApp')}
cancelText={t('publish.downloadIt')}
onOk={() => window.open(openAppFlowySchema, '_self')}
onCancel={() => {
window.open(downloadPage, '_blank');
}}
onClose={() => setSuccessModalOpen(false)}
open={successModalOpen}
title={<div className={'text-left'}>{t('publish.duplicateSuccessfully')}</div>}
>
<div className={'w-full whitespace-pre-wrap break-words pb-1 text-text-caption'}>
{t('publish.duplicateSuccessfullyDescription')}
</div>
</NormalModal>
</>
);
}
export default DuplicateModal;

View File

@ -0,0 +1,143 @@
import React, { useCallback, useContext, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Avatar, Button, CircularProgress, Divider, Tooltip } from '@mui/material';
import { Workspace } from '@/application/types';
import { ReactComponent as RightIcon } from '@/assets/arrow_right.svg';
import { ReactComponent as CheckIcon } from '@/assets/selected.svg';
import { Popover } from '@/components/_shared/popover';
import { stringToColor } from '@/utils/color';
import { AFConfigContext } from '@/components/app/AppConfig';
export interface SelectWorkspaceProps {
value: string;
onChange?: (value: string) => void;
workspaceList: Workspace[];
loading?: boolean;
}
function stringAvatar(name: string) {
return {
sx: {
bgcolor: stringToColor(name),
},
children: `${name.split('')[0]}`,
};
}
function SelectWorkspace({ loading, value, onChange, workspaceList }: SelectWorkspaceProps) {
const { t } = useTranslation();
const email = useContext(AFConfigContext)?.currentUser?.email || '';
const selectedWorkspace = useMemo(() => {
return workspaceList.find((workspace) => workspace.id === value);
}, [value, workspaceList]);
const ref = useRef<HTMLButtonElement | null>(null);
const [selectOpen, setSelectOpen] = useState<boolean>(false);
const renderWorkspace = useCallback(
(workspace: Workspace) => {
return (
<div className={'flex items-center gap-[10px] overflow-hidden'}>
{workspace.icon ? (
<div className={'h-10 w-10 text-2xl'}>{workspace.icon}</div>
) : (
<Avatar
className={'border border-line-border'}
sizes={'24px'}
variant={'rounded'}
{...stringAvatar(workspace.name)}
/>
)}
<div className={'flex flex-1 flex-col items-start gap-0.5 overflow-hidden'}>
<div className={'w-full truncate text-left text-sm font-medium'}>{workspace.name}</div>
<div className={'text-xs text-text-caption'}>
{t('publish.membersCount', {
count: workspace.memberCount || 0,
})}
</div>
</div>
</div>
);
},
[t]
);
return (
<div className={'flex w-[360px] flex-col gap-2 max-sm:w-full'}>
<div className={'text-sm text-text-caption'}>{t('publish.selectWorkspace')}</div>
<Button
ref={ref}
onClick={() => {
setSelectOpen(true);
}}
className={'px-3 py-2'}
variant={'outlined'}
color={'inherit'}
>
<div className={'flex w-full items-center gap-[10px]'}>
{loading ? (
<div className={'flex w-full items-center justify-center'}>
<CircularProgress size={24} />
</div>
) : (
<>
<div className={'flex-1 overflow-hidden'}>
{selectedWorkspace ? renderWorkspace(selectedWorkspace) : null}
</div>
<span className={`h-4 w-4 ${selectOpen ? '-rotate-90' : 'rotate-90'} transform`}>
<RightIcon className={'h-full w-full'} />
</span>
</>
)}
</div>
</Button>
<Popover
anchorEl={ref.current}
open={selectOpen}
transformOrigin={{
vertical: -8,
horizontal: 'left',
}}
onClose={() => {
setSelectOpen(false);
}}
>
<div className={'flex max-h-[340px] w-[360px] flex-col gap-1 p-2 max-sm:w-full'}>
<div className={'w-full px-3 py-2 text-sm font-medium text-text-caption'}>{email}</div>
<Divider />
<div className={'appflowy-scroller flex flex-1 flex-col overflow-y-auto overflow-x-hidden'}>
{workspaceList.map((workspace) => {
const isSelected = workspace.id === selectedWorkspace?.id;
return (
<Tooltip
title={workspace.name}
key={workspace.id}
placement={'bottom'}
enterDelay={1000}
enterNextDelay={1000}
>
<Button
onClick={() => {
onChange?.(workspace.id);
setSelectOpen(false);
}}
className={'w-full px-3 py-2'}
variant={'text'}
color={'inherit'}
>
<div className={'flex-1 overflow-hidden'}>{renderWorkspace(workspace)}</div>
<div className={'h-6 w-6'}>
{isSelected && <CheckIcon className={'h-6 w-6 text-content-blue-400'} />}
</div>
</Button>
</Tooltip>
);
})}
</div>
</div>
</Popover>
</div>
);
}
export default SelectWorkspace;

View File

@ -0,0 +1,95 @@
import React, { useCallback } from 'react';
import { SpaceView } from '@/application/types';
import { useTranslation } from 'react-i18next';
import { ReactComponent as CheckIcon } from '@/assets/selected.svg';
import { renderColor } from '@/utils/color';
import SpaceIcon from '@/components/publish/header/SpaceIcon';
import { Button, CircularProgress, Tooltip } from '@mui/material';
import { ReactComponent as LockSvg } from '@/assets/lock.svg';
export interface SpaceListProps {
value: string;
onChange?: (value: string) => void;
spaceList: SpaceView[];
loading?: boolean;
}
function SpaceList({ loading, spaceList, value, onChange }: SpaceListProps) {
const { t } = useTranslation();
const getExtraObj = useCallback((extra: string) => {
try {
return extra
? (JSON.parse(extra) as {
is_space?: boolean;
space_icon?: string;
space_icon_color?: string;
})
: {};
} catch (e) {
return {};
}
}, []);
const renderSpace = useCallback(
(space: SpaceView) => {
const extraObj = getExtraObj(space.extra || '');
return (
<div className={'flex items-center gap-[10px] overflow-hidden text-sm'}>
<span
className={'icon h-5 w-5'}
style={{
backgroundColor: extraObj.space_icon_color ? renderColor(extraObj.space_icon_color) : undefined,
borderRadius: '8px',
}}
>
<SpaceIcon value={extraObj.space_icon || ''} />
</span>
<div className={'flex flex-1 items-center gap-2 truncate'}>
{space.name}
{space.isPrivate && <LockSvg className={'h-3.5 w-3.5 text-icon-primary'} />}
</div>
</div>
);
},
[getExtraObj]
);
return (
<div className={'flex max-h-[280px] w-[360px] flex-col gap-2 overflow-hidden max-sm:w-full'}>
<div className={'text-sm text-text-caption'}>{t('publish.addTo')}</div>
{loading ? (
<div className={'flex w-full items-center justify-center'}>
<CircularProgress size={24} />
</div>
) : (
<div className={'appflowy-scroller flex w-full flex-1 flex-col gap-1 overflow-y-auto overflow-x-hidden'}>
{spaceList.map((space) => {
const isSelected = value === space.id;
return (
<Tooltip title={space.name} key={space.id} placement={'bottom'} enterDelay={1000} enterNextDelay={1000}>
<Button
variant={'text'}
color={'inherit'}
className={'flex items-center p-1 font-normal'}
onClick={() => {
onChange?.(space.id);
}}
>
<div className={'flex-1 overflow-hidden text-left'}>{renderSpace(space)}</div>
<div className={'h-6 w-6'}>
{isSelected && <CheckIcon className={'h-6 w-6 text-content-blue-400'} />}
</div>
</Button>
</Tooltip>
);
})}
</div>
)}
</div>
);
}
export default SpaceList;

View File

@ -0,0 +1 @@
export * from './Duplicate';

View File

@ -0,0 +1,131 @@
import React, { useCallback, useContext, useEffect, useState } from 'react';
import { AFConfigContext } from '@/components/app/AppConfig';
import { useSearchParams } from 'react-router-dom';
import { SpaceView, Workspace } from '@/application/types';
import { notify } from '@/components/_shared/notify';
export function useDuplicate() {
const isAuthenticated = useContext(AFConfigContext)?.isAuthenticated || false;
const [search, setSearch] = useSearchParams();
const [loginOpen, setLoginOpen] = React.useState(false);
const [duplicateOpen, setDuplicateOpen] = React.useState(false);
useEffect(() => {
const isDuplicate = search.get('action') === 'duplicate';
if (!isDuplicate) return;
setLoginOpen(!isAuthenticated);
setDuplicateOpen(isAuthenticated);
}, [isAuthenticated, search, setSearch]);
const url = window.location.href;
const handleLoginClose = useCallback(() => {
setLoginOpen(false);
setSearch((prev) => {
prev.delete('action');
return prev;
});
}, [setSearch]);
const handleDuplicateClose = useCallback(() => {
setDuplicateOpen(false);
setSearch((prev) => {
prev.delete('action');
return prev;
});
}, [setSearch]);
return {
loginOpen,
handleLoginClose,
url,
duplicateOpen,
handleDuplicateClose,
};
}
export function useLoadWorkspaces() {
const [spaceLoading, setSpaceLoading] = useState<boolean>(false);
const [workspaceLoading, setWorkspaceLoading] = useState<boolean>(false);
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string>('1');
const [selectedSpaceId, setSelectedSpaceId] = useState<string>('1');
const [workspaceList, setWorkspaceList] = useState<Workspace[]>([]);
const [spaceList, setSpaceList] = useState<SpaceView[]>([]);
const service = useContext(AFConfigContext)?.service;
useEffect(() => {
void (async () => {
setWorkspaceLoading(true);
try {
const workspaces = await service?.getWorkspaces();
if (workspaces) {
setWorkspaceList(workspaces);
setSelectedWorkspaceId(workspaces[0].id);
} else {
setWorkspaceList([]);
setSelectedWorkspaceId('');
}
} catch (e) {
notify.error('Failed to load workspaces');
} finally {
setWorkspaceLoading(false);
}
})();
}, [service]);
useEffect(() => {
if (workspaceList.length === 0 || !selectedWorkspaceId || workspaceLoading) {
setSpaceList([]);
setSelectedSpaceId('');
return;
}
setSpaceLoading(true);
void (async () => {
try {
const folder = await service?.getWorkspaceFolder(selectedWorkspaceId);
if (folder) {
const spaces = [];
for (const child of folder.children) {
if (child.isSpace) {
spaces.push({
id: child.id,
name: child.name,
isPrivate: child.isPrivate,
extra: child.extra,
});
}
}
setSpaceList(spaces);
} else {
setSpaceList([]);
}
} catch (e) {
notify.error('Failed to load spaces');
} finally {
setSelectedSpaceId('');
setSpaceLoading(false);
}
})();
}, [selectedWorkspaceId, service, workspaceList.length, workspaceLoading]);
return {
workspaceList,
spaceList,
selectedWorkspaceId,
setSelectedWorkspaceId,
selectedSpaceId,
setSelectedSpaceId,
workspaceLoading,
spaceLoading,
};
}

View File

@ -7,7 +7,7 @@ import { createHotKeyLabel, HOT_KEY_NAME } from '@/utils/hotkeys';
import { Drawer, IconButton, Tooltip } from '@mui/material'; import { Drawer, IconButton, Tooltip } from '@mui/material';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
function OutlineDrawer({ open, width, onClose }: { open: boolean; width: number; onClose: () => void }) { export function OutlineDrawer({ open, width, onClose }: { open: boolean; width: number; onClose: () => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
const viewMeta = usePublishContext()?.viewMeta; const viewMeta = usePublishContext()?.viewMeta;

Some files were not shown because too many files have changed in this diff Show More