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
@ -31,7 +31,6 @@
|
||||
<body id="body">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script src="https://ajax.googleapis.com/ajax/libs/webfont/1.6.26/webfont.js"></script>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
@ -64,8 +63,7 @@
|
||||
}
|
||||
});
|
||||
</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>
|
||||
</html>
|
||||
|
@ -24,7 +24,7 @@
|
||||
"coverage": "pnpm run test:unit && pnpm run test:components"
|
||||
},
|
||||
"dependencies": {
|
||||
"@appflowyinc/client-api-wasm": "0.1.2",
|
||||
"@appflowyinc/client-api-wasm": "0.1.4",
|
||||
"@atlaskit/primitives": "^5.5.3",
|
||||
"@emoji-mart/data": "^1.1.2",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
@ -49,6 +49,7 @@
|
||||
"emoji-regex": "^10.2.1",
|
||||
"events": "^3.3.0",
|
||||
"google-protobuf": "^3.15.12",
|
||||
"highlight.js": "^11.10.0",
|
||||
"i18next": "^22.4.10",
|
||||
"i18next-browser-languagedetector": "^7.0.1",
|
||||
"i18next-resources-to-backend": "^1.1.4",
|
||||
|
@ -6,8 +6,8 @@ settings:
|
||||
|
||||
dependencies:
|
||||
'@appflowyinc/client-api-wasm':
|
||||
specifier: 0.1.2
|
||||
version: 0.1.2
|
||||
specifier: 0.1.4
|
||||
version: 0.1.4
|
||||
'@atlaskit/primitives':
|
||||
specifier: ^5.5.3
|
||||
version: 5.7.0(@types/react@18.2.66)(react@18.2.0)
|
||||
@ -80,6 +80,9 @@ dependencies:
|
||||
google-protobuf:
|
||||
specifier: ^3.15.12
|
||||
version: 3.21.2
|
||||
highlight.js:
|
||||
specifier: ^11.10.0
|
||||
version: 11.10.0
|
||||
i18next:
|
||||
specifier: ^22.4.10
|
||||
version: 22.5.1
|
||||
@ -451,8 +454,8 @@ packages:
|
||||
'@jridgewell/gen-mapping': 0.3.5
|
||||
'@jridgewell/trace-mapping': 0.3.25
|
||||
|
||||
/@appflowyinc/client-api-wasm@0.1.2:
|
||||
resolution: {integrity: sha512-+v0hs7/7BVKtgev/Bcbr0u2HLDhUuw4ZvZTaMddI+06HK8vt5S52dMaZKUcMvh1eUjVX8hjC6Mfe0X/yHqvFgA==}
|
||||
/@appflowyinc/client-api-wasm@0.1.4:
|
||||
resolution: {integrity: sha512-3uBpy3n+aIG0fapPAroMfL8JLdAPtqPAkpV+LOxlRnMW4Au2JQcW8TW0P3K1YAe16tDZ62ZIZPoG6Bi40RDRoQ==}
|
||||
dev: false
|
||||
|
||||
/@atlaskit/analytics-next-stable-react-context@1.0.1(react@18.2.0):
|
||||
@ -7068,6 +7071,11 @@ packages:
|
||||
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
|
||||
dev: true
|
||||
|
||||
/highlight.js@11.10.0:
|
||||
resolution: {integrity: sha512-SYVnVFswQER+zu1laSya563s+F8VDGt7o35d4utbamowvUNLLMovFqwCLSocpZTz3MgaSRA1IbqRWZv97dtErQ==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
dev: false
|
||||
|
||||
/hoist-non-react-statics@3.3.2:
|
||||
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
|
||||
dependencies:
|
||||
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 5.3 KiB |
22
frontend/appflowy_web_app/src/application/comment.type.ts
Normal file
@ -0,0 +1,22 @@
|
||||
export interface CommentUser {
|
||||
uuid: string;
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
}
|
||||
|
||||
export interface GlobalComment {
|
||||
commentId: string;
|
||||
user: CommentUser | null;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
lastUpdatedAt: string;
|
||||
replyCommentId: string | null;
|
||||
isDeleted: boolean;
|
||||
canDeleted: boolean;
|
||||
}
|
||||
|
||||
export interface Reaction {
|
||||
reactionType: string;
|
||||
reactUsers: CommentUser[];
|
||||
commentId: string;
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
import { YDoc } from '@/application/collab.type';
|
||||
import { GlobalComment, Reaction } from '@/application/comment.type';
|
||||
import {
|
||||
deleteView,
|
||||
getPublishView,
|
||||
@ -14,12 +15,23 @@ import {
|
||||
signInGithub,
|
||||
signInDiscord,
|
||||
signInWithUrl,
|
||||
createGlobalCommentOnPublishView,
|
||||
deleteGlobalCommentOnPublishView,
|
||||
getPublishViewComments,
|
||||
getWorkspaces,
|
||||
getWorkspaceFolder,
|
||||
getCurrentUser,
|
||||
duplicatePublishView,
|
||||
getReactions,
|
||||
addReaction,
|
||||
removeReaction,
|
||||
} from '@/application/services/js-services/wasm/client_api';
|
||||
import { AFService, AFServiceConfig } from '@/application/services/services.type';
|
||||
import { emit, EventType } from '@/application/session';
|
||||
import { afterAuth, AUTH_CALLBACK_URL, withSignIn } from '@/application/session/sign_in';
|
||||
import { nanoid } from 'nanoid';
|
||||
import * as Y from 'yjs';
|
||||
import { DuplicatePublishView } from '@/application/types';
|
||||
|
||||
export class AFClientService implements AFService {
|
||||
private deviceId: string = nanoid(8);
|
||||
@ -199,4 +211,61 @@ export class AFClientService implements AFService {
|
||||
async signInDiscord(_: { redirectTo: string }) {
|
||||
return await signInDiscord(AUTH_CALLBACK_URL);
|
||||
}
|
||||
|
||||
async getWorkspaces() {
|
||||
const data = getWorkspaces();
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async getWorkspaceFolder(workspaceId: string) {
|
||||
const data = await getWorkspaceFolder(workspaceId);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async getCurrentUser() {
|
||||
const data = await getCurrentUser();
|
||||
|
||||
return {
|
||||
uid: data.uid,
|
||||
email: data.email,
|
||||
name: data.name,
|
||||
avatar: data.icon_url,
|
||||
uuid: data.uuid,
|
||||
};
|
||||
}
|
||||
|
||||
async duplicatePublishView(params: DuplicatePublishView) {
|
||||
return duplicatePublishView({
|
||||
workspace_id: params.workspaceId,
|
||||
dest_view_id: params.spaceViewId,
|
||||
published_view_id: params.viewId,
|
||||
published_collab_type: params.collabType,
|
||||
});
|
||||
}
|
||||
|
||||
createCommentOnPublishView(viewId: string, content: string, replyCommentId: string | undefined): Promise<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);
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,9 @@
|
||||
import { getToken, invalidToken, isTokenValid, refreshToken } from '@/application/session/token';
|
||||
import { ClientAPI } from '@appflowyinc/client-api-wasm';
|
||||
import { ClientAPI, WorkspaceFolder, DuplicatePublishViewPayload } from '@appflowyinc/client-api-wasm';
|
||||
import { AFCloudConfig } from '@/application/services/services.type';
|
||||
import { DatabaseId, PublishViewMetaData, RowId, ViewId, ViewLayout } from '@/application/collab.type';
|
||||
import { FolderView } from '@/application/types';
|
||||
import { GlobalComment, Reaction } from '@/application/comment.type';
|
||||
|
||||
let client: ClientAPI;
|
||||
|
||||
@ -115,3 +117,120 @@ export async function signInGithub(redirectTo: string) {
|
||||
export async function signInDiscord(redirectTo: string) {
|
||||
return signInProvider('discord', redirectTo);
|
||||
}
|
||||
|
||||
export async function getWorkspaces() {
|
||||
try {
|
||||
const { data } = await client.get_workspaces();
|
||||
|
||||
return data.map((workspace) => ({
|
||||
id: workspace.workspace_id,
|
||||
name: workspace.workspace_name,
|
||||
icon: workspace.icon,
|
||||
memberCount: workspace.member_count || 0,
|
||||
}));
|
||||
} catch (e) {
|
||||
return Promise.reject(e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWorkspaceFolder(workspaceId: string): Promise<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);
|
||||
}
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { YDoc } from '@/application/collab.type';
|
||||
import { GlobalComment, Reaction } from '@/application/comment.type';
|
||||
import { ViewMeta } from '@/application/db/tables/view_metas';
|
||||
import * as Y from 'yjs';
|
||||
import { DuplicatePublishView, FolderView, User, Workspace } from '@/application/types';
|
||||
|
||||
export type AFService = PublishService;
|
||||
|
||||
@ -27,10 +29,21 @@ export interface PublishService {
|
||||
rows: Y.Map<YDoc>;
|
||||
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>;
|
||||
signInMagicLink: (params: { email: string; redirectTo: string }) => Promise<void>;
|
||||
signInGoogle: (params: { redirectTo: string }) => Promise<void>;
|
||||
signInGithub: (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>;
|
||||
}
|
||||
|
@ -1,7 +1,9 @@
|
||||
import { YDoc } from '@/application/collab.type';
|
||||
import { GlobalComment, Reaction } from '@/application/comment.type';
|
||||
import { AFService } from '@/application/services/services.type';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { YMap } from 'yjs/dist/src/types/YMap';
|
||||
import { DuplicatePublishView, FolderView, User, Workspace } from '@/application/types';
|
||||
|
||||
export class AFClientService implements AFService {
|
||||
private deviceId: string = nanoid(8);
|
||||
@ -25,23 +27,23 @@ export class AFClientService implements AFService {
|
||||
}
|
||||
|
||||
loginAuth(_: string): Promise<void> {
|
||||
return Promise.resolve(undefined);
|
||||
return Promise.reject('Method not implemented');
|
||||
}
|
||||
|
||||
signInDiscord(_params: { redirectTo: string }): Promise<void> {
|
||||
return Promise.resolve(undefined);
|
||||
return Promise.reject('Method not implemented');
|
||||
}
|
||||
|
||||
signInGithub(_params: { redirectTo: string }): Promise<void> {
|
||||
return Promise.resolve(undefined);
|
||||
return Promise.reject('Method not implemented');
|
||||
}
|
||||
|
||||
signInGoogle(_params: { redirectTo: string }): Promise<void> {
|
||||
return Promise.resolve(undefined);
|
||||
return Promise.reject('Method not implemented');
|
||||
}
|
||||
|
||||
signInMagicLink(_params: { email: string; redirectTo: string }): Promise<void> {
|
||||
return Promise.resolve(undefined);
|
||||
return Promise.reject('Method not implemented');
|
||||
}
|
||||
|
||||
getPublishDatabaseViewRows(
|
||||
@ -53,4 +55,44 @@ export class AFClientService implements AFService {
|
||||
}> {
|
||||
return Promise.reject('Method not implemented');
|
||||
}
|
||||
|
||||
duplicatePublishView(_params: DuplicatePublishView): Promise<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');
|
||||
}
|
||||
}
|
||||
|
@ -28,7 +28,6 @@ export function withSignIn() {
|
||||
|
||||
saveRedirectTo(redirectTo);
|
||||
|
||||
console.log('=====saveRedirectTo', redirectTo);
|
||||
try {
|
||||
await originalMethod.apply(this, [args]);
|
||||
} catch (e) {
|
||||
|
40
frontend/appflowy_web_app/src/application/types.ts
Normal 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;
|
||||
}
|
4
frontend/appflowy_web_app/src/assets/add_reaction.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 10C6 9.17 6.67 8.5 7.5 8.5C8.33 8.5 9 9.17 9 10C9 10.83 8.33 11.5 7.5 11.5C6.67 11.5 6 10.83 6 10ZM11 18C13.33 18 15.31 16.54 16.11 14.5H5.89C6.69 16.54 8.67 18 11 18ZM14.5 11.5C15.33 11.5 16 10.83 16 10C16 9.17 15.33 8.5 14.5 8.5C13.67 8.5 13 9.17 13 10C13 10.83 13.67 11.5 14.5 11.5ZM21 1.5H19V3.5H17V5.5H19V7.5H21V5.5H23V3.5H21V1.5ZM19 12.5C19 16.92 15.42 20.5 11 20.5C6.58 20.5 3 16.92 3 12.5C3 8.08 6.58 4.5 11 4.5C12.46 4.5 13.82 4.9 15 5.58V3.34C13.77 2.8 12.42 2.5 10.99 2.5C5.47 2.5 1 6.98 1 12.5C1 18.02 5.47 22.5 10.99 22.5C16.52 22.5 21 18.02 21 12.5C21 11.45 20.83 10.45 20.53 9.5H18.4C18.78 10.43 19 11.44 19 12.5Z"
|
||||
fill="currentColor"/>
|
||||
</svg>
|
After Width: | Height: | Size: 781 B |
7
frontend/appflowy_web_app/src/assets/check_circle.svg
Normal 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 |
7
frontend/appflowy_web_app/src/assets/clock_alarm.svg
Normal 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 |
6
frontend/appflowy_web_app/src/assets/close.svg
Normal 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 |
4
frontend/appflowy_web_app/src/assets/collapse.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="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 |
3
frontend/appflowy_web_app/src/assets/corner_left_top.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="32" height="16" viewBox="0 0 32 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.8774 3.10363H15.9L28 3.0908C28.2761 3.0908 28.5 3.31796 28.5 3.5941C28.5 3.87024 28.2761 4.0908 28 4.0908L15.9 4.10363C14.7716 4.10363 13.9554 4.10402 13.3135 4.15647C12.6774 4.20844 12.2566 4.30902 11.911 4.48511C11.2525 4.82066 10.717 5.3561 10.3815 6.01466C10.2054 6.36025 10.1048 6.78107 10.0528 7.41715C10.0004 8.05908 10 8.87527 10 10.0036V11.6036C10 11.8797 9.77614 12.1036 9.5 12.1036C9.22386 12.1036 9 11.8797 9 11.6036V10.0036V9.981C9 8.88004 9 8.02311 9.05616 7.33572C9.11318 6.63779 9.23058 6.07073 9.49047 5.56067C9.9219 4.71394 10.6103 4.02553 11.457 3.5941C11.9671 3.33421 12.5342 3.21681 13.2321 3.15979C13.9195 3.10363 14.7764 3.10363 15.8774 3.10363Z" fill="black"/>
|
||||
</svg>
|
After Width: | Height: | Size: 841 B |
8
frontend/appflowy_web_app/src/assets/double_arrow.svg
Normal 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 |
4
frontend/appflowy_web_app/src/assets/error.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11 15H13V17H11V15ZM11 7H13V13H11V7ZM11.99 2C6.47 2 2 6.48 2 12C2 17.52 6.47 22 11.99 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 11.99 2ZM12 20C7.58 20 4 16.42 4 12C4 7.58 7.58 4 12 4C16.42 4 20 7.58 20 12C20 16.42 16.42 20 12 20Z"
|
||||
fill="currentColor"/>
|
||||
</svg>
|
After Width: | Height: | Size: 380 B |
7
frontend/appflowy_web_app/src/assets/error_outline.svg
Normal 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 |
4
frontend/appflowy_web_app/src/assets/expand.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="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 |
BIN
frontend/appflowy_web_app/src/assets/images/empty.png
Normal file
After Width: | Height: | Size: 50 KiB |
5
frontend/appflowy_web_app/src/assets/lock.svg
Normal 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 |
@ -1,17 +1,2 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="21" height="20" viewBox="0 0 21 20" fill="none">
|
||||
<g clip-path="url(#clip0_346_13747)">
|
||||
<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>
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<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>
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.1 KiB |
3
frontend/appflowy_web_app/src/assets/reply.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 8.5V4.5L3 11.5L10 18.5V14.4C15 14.4 18.5 16 21 19.5C20 14.5 17 9.5 10 8.5Z" fill="currentColor"/>
|
||||
</svg>
|
After Width: | Height: | Size: 218 B |
3
frontend/appflowy_web_app/src/assets/selected.svg
Normal 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 |
12
frontend/appflowy_web_app/src/assets/shuffle.svg
Normal 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 |
14
frontend/appflowy_web_app/src/assets/trash.svg
Normal file
@ -0,0 +1,14 @@
|
||||
<svg width="19" height="18" viewBox="0 0 19 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g opacity="0.6">
|
||||
<path d="M16.5 4.48535C14.0025 4.23785 11.49 4.11035 8.985 4.11035C7.5 4.11035 6.015 4.18535 4.53 4.33535L3 4.48535"
|
||||
stroke="currentColor" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M7.125 3.7275L7.29 2.745C7.41 2.0325 7.5 1.5 8.7675 1.5H10.7325C12 1.5 12.0975 2.0625 12.21 2.7525L12.375 3.7275"
|
||||
stroke="currentColor" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14.8844 6.85449L14.3969 14.407C14.3144 15.5845 14.2469 16.4995 12.1544 16.4995H7.33937C5.24687 16.4995 5.17938 15.5845 5.09688 14.407L4.60938 6.85449"
|
||||
stroke="currentColor" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8.49609 12.375H10.9936" stroke="currentColor" stroke-width="1.125" stroke-linecap="round"
|
||||
stroke-linejoin="round"/>
|
||||
<path d="M7.875 9.375H11.625" stroke="currentColor" stroke-width="1.125" stroke-linecap="round"
|
||||
stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
7
frontend/appflowy_web_app/src/assets/warning_amber.svg
Normal 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 |
@ -0,0 +1,175 @@
|
||||
import { MAX_FREQUENTLY_ROW_COUNT, PER_ROW_EMOJI_COUNT } from '@/components/_shared/emoji-picker/const';
|
||||
import { loadEmojiData } from '@/utils/emoji';
|
||||
import { EmojiMartData } from '@emoji-mart/data';
|
||||
import { PopoverProps } from '@mui/material/Popover';
|
||||
import { PopoverOrigin } from '@mui/material/Popover/Popover';
|
||||
import { FrequentlyUsed, getEmojiDataFromNative, init, Store } from 'emoji-mart';
|
||||
import chunk from 'lodash-es/chunk';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
export interface EmojiCategory {
|
||||
id: string;
|
||||
emojis: Emoji[];
|
||||
}
|
||||
|
||||
interface Emoji {
|
||||
id: string;
|
||||
name: string;
|
||||
native: string;
|
||||
}
|
||||
|
||||
export function useLoadEmojiData({ onEmojiSelect }: { onEmojiSelect: (emoji: string) => void }) {
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [emojiCategories, setEmojiCategories] = useState<EmojiCategory[]>([]);
|
||||
const [skin, setSkin] = useState<number>(() => {
|
||||
return Number(Store.get('skin')) || 0;
|
||||
});
|
||||
|
||||
const onSkinChange = useCallback((val: number) => {
|
||||
setSkin(val);
|
||||
Store.set('skin', String(val));
|
||||
}, []);
|
||||
|
||||
const searchEmojiData = useCallback(
|
||||
async (searchVal?: string) => {
|
||||
const emojiData = await loadEmojiData();
|
||||
|
||||
const { emojis, categories } = emojiData as EmojiMartData;
|
||||
|
||||
const filteredCategories = categories
|
||||
.map((category) => {
|
||||
const { id, emojis: categoryEmojis } = category;
|
||||
|
||||
return {
|
||||
id,
|
||||
emojis: categoryEmojis
|
||||
.filter((emojiId) => {
|
||||
const emoji = emojis[emojiId];
|
||||
|
||||
if (!searchVal) return true;
|
||||
return filterSearchValue(emoji, searchVal);
|
||||
})
|
||||
.map((emojiId) => {
|
||||
const emoji = emojis[emojiId];
|
||||
const { name, skins } = emoji;
|
||||
|
||||
return {
|
||||
id: emojiId,
|
||||
name,
|
||||
native: skins[skin] ? skins[skin].native : skins[0].native,
|
||||
};
|
||||
}),
|
||||
};
|
||||
})
|
||||
.filter((category) => category.emojis.length > 0);
|
||||
|
||||
setEmojiCategories(filteredCategories);
|
||||
},
|
||||
[skin]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
await init({
|
||||
maxFrequentRows: MAX_FREQUENTLY_ROW_COUNT,
|
||||
perLine: PER_ROW_EMOJI_COUNT,
|
||||
});
|
||||
await searchEmojiData();
|
||||
})();
|
||||
}, [searchEmojiData]);
|
||||
|
||||
useEffect(() => {
|
||||
void searchEmojiData(searchValue);
|
||||
}, [searchEmojiData, searchValue]);
|
||||
|
||||
const onSelect = useCallback(
|
||||
async (native: string) => {
|
||||
onEmojiSelect(native);
|
||||
if (!native) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await getEmojiDataFromNative(native);
|
||||
|
||||
FrequentlyUsed.add(data);
|
||||
} catch (e) {
|
||||
// do nothing
|
||||
}
|
||||
},
|
||||
[onEmojiSelect]
|
||||
);
|
||||
|
||||
return {
|
||||
emojiCategories,
|
||||
setSearchValue,
|
||||
searchValue,
|
||||
onSelect,
|
||||
onSkinChange,
|
||||
skin,
|
||||
};
|
||||
}
|
||||
|
||||
export function useSelectSkinPopoverProps(): PopoverProps & {
|
||||
onOpen: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onClose: () => void;
|
||||
} {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | undefined>(undefined);
|
||||
const onOpen = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
}, []);
|
||||
const onClose = useCallback(() => {
|
||||
setAnchorEl(undefined);
|
||||
}, []);
|
||||
const open = Boolean(anchorEl);
|
||||
const anchorOrigin = { vertical: 'bottom', horizontal: 'center' } as PopoverOrigin;
|
||||
const transformOrigin = { vertical: 'top', horizontal: 'center' } as PopoverOrigin;
|
||||
|
||||
return {
|
||||
anchorEl,
|
||||
onOpen,
|
||||
onClose,
|
||||
open,
|
||||
anchorOrigin,
|
||||
transformOrigin,
|
||||
};
|
||||
}
|
||||
|
||||
function filterSearchValue(
|
||||
emoji: {
|
||||
name: string;
|
||||
keywords?: string[];
|
||||
},
|
||||
searchValue: string
|
||||
) {
|
||||
const { name, keywords } = emoji;
|
||||
const searchValueLowerCase = searchValue.toLowerCase();
|
||||
|
||||
return (
|
||||
name.toLowerCase().includes(searchValueLowerCase) ||
|
||||
(keywords && keywords.some((keyword) => keyword.toLowerCase().includes(searchValueLowerCase)))
|
||||
);
|
||||
}
|
||||
|
||||
export function getRowsWithCategories(emojiCategories: EmojiCategory[], rowSize: number) {
|
||||
const rows: {
|
||||
id: string;
|
||||
type: 'category' | 'emojis';
|
||||
emojis?: Emoji[];
|
||||
}[] = [];
|
||||
|
||||
emojiCategories.forEach((category) => {
|
||||
rows.push({
|
||||
id: category.id,
|
||||
type: 'category',
|
||||
});
|
||||
chunk(category.emojis, rowSize).forEach((chunk, index) => {
|
||||
rows.push({
|
||||
type: 'emojis',
|
||||
emojis: chunk,
|
||||
id: `${category.id}-${index}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
return rows;
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useLoadEmojiData } from './EmojiPicker.hooks';
|
||||
import EmojiPickerHeader from './EmojiPickerHeader';
|
||||
import EmojiPickerCategories from './EmojiPickerCategories';
|
||||
|
||||
interface Props {
|
||||
onEmojiSelect: (emoji: string) => void;
|
||||
onEscape?: () => void;
|
||||
defaultEmoji?: string;
|
||||
hideRemove?: boolean;
|
||||
}
|
||||
|
||||
export function EmojiPicker({ defaultEmoji, onEscape, ...props }: Props) {
|
||||
const { skin, onSkinChange, emojiCategories, setSearchValue, searchValue, onSelect } = useLoadEmojiData(props);
|
||||
|
||||
return (
|
||||
<div tabIndex={0} className={'emoji-picker flex h-[360px] max-h-[70vh] flex-col p-4 pt-2'}>
|
||||
<EmojiPickerHeader
|
||||
onEmojiSelect={onSelect}
|
||||
skin={skin}
|
||||
hideRemove={props.hideRemove}
|
||||
onSkinSelect={onSkinChange}
|
||||
searchValue={searchValue}
|
||||
onSearchChange={setSearchValue}
|
||||
/>
|
||||
<EmojiPickerCategories
|
||||
defaultEmoji={defaultEmoji}
|
||||
onEscape={onEscape}
|
||||
onEmojiSelect={onSelect}
|
||||
emojiCategories={emojiCategories}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EmojiPicker;
|
@ -0,0 +1,354 @@
|
||||
import { EMOJI_SIZE, PER_ROW_EMOJI_COUNT } from '@/components/_shared/emoji-picker/const';
|
||||
import { AFScroller } from '@/components/_shared/scroller';
|
||||
import { getDistanceEdge, inView } from '@/utils/position';
|
||||
import { Tooltip } from '@mui/material';
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { FixedSizeList } from 'react-window';
|
||||
import { EmojiCategory, getRowsWithCategories } from './EmojiPicker.hooks';
|
||||
|
||||
function EmojiPickerCategories({
|
||||
emojiCategories,
|
||||
onEmojiSelect,
|
||||
onEscape,
|
||||
defaultEmoji,
|
||||
}: {
|
||||
emojiCategories: EmojiCategory[];
|
||||
onEmojiSelect: (emoji: string) => void;
|
||||
onEscape?: () => void;
|
||||
defaultEmoji?: string;
|
||||
}) {
|
||||
const scrollRef = React.useRef<HTMLDivElement>(null);
|
||||
const { t } = useTranslation();
|
||||
const [selectCell, setSelectCell] = React.useState({
|
||||
row: 1,
|
||||
column: 0,
|
||||
});
|
||||
const rows = useMemo(() => {
|
||||
return getRowsWithCategories(emojiCategories, PER_ROW_EMOJI_COUNT);
|
||||
}, [emojiCategories]);
|
||||
const mouseY = useRef<number | null>(null);
|
||||
const mouseX = useRef<number | null>(null);
|
||||
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const getCategoryName = useCallback(
|
||||
(id: string) => {
|
||||
const i18nName: Record<string, string> = {
|
||||
frequent: t('emoji.categories.frequentlyUsed'),
|
||||
people: t('emoji.categories.people'),
|
||||
nature: t('emoji.categories.nature'),
|
||||
foods: t('emoji.categories.food'),
|
||||
activity: t('emoji.categories.activities'),
|
||||
places: t('emoji.categories.places'),
|
||||
objects: t('emoji.categories.objects'),
|
||||
symbols: t('emoji.categories.symbols'),
|
||||
flags: t('emoji.categories.flags'),
|
||||
};
|
||||
|
||||
return i18nName[id];
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({
|
||||
top: 0,
|
||||
});
|
||||
|
||||
setSelectCell({
|
||||
row: 1,
|
||||
column: 0,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rows]);
|
||||
|
||||
const renderRow = useCallback(
|
||||
({ index, style }: { index: number; style: React.CSSProperties }) => {
|
||||
const item = rows[index];
|
||||
|
||||
return (
|
||||
<div style={style} data-index={index}>
|
||||
{item.type === 'category' ? (
|
||||
<div className={'pt-2 text-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;
|
@ -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;
|
@ -0,0 +1,3 @@
|
||||
export const EMOJI_SIZE = 38;
|
||||
export const PER_ROW_EMOJI_COUNT = 9;
|
||||
export const MAX_FREQUENTLY_ROW_COUNT = 2;
|
@ -0,0 +1,3 @@
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const EmojiPicker = lazy(() => import('./EmojiPicker'));
|
@ -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;
|
@ -0,0 +1,3 @@
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const KatexMath = lazy(() => import('./KatexMath'));
|
@ -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;
|
@ -0,0 +1 @@
|
||||
export * from './NormalModal';
|
@ -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)]';
|
||||
}
|
||||
}
|
@ -1,3 +1,8 @@
|
||||
import { InfoProps } from '@/components/_shared/notify/InfoSnackbar';
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const InfoSnackbar = lazy(() => import('./InfoSnackbar'));
|
||||
|
||||
export const notify = {
|
||||
success: (message: string) => {
|
||||
window.toast.success(message);
|
||||
@ -11,10 +16,19 @@ export const notify = {
|
||||
warning: (message: string) => {
|
||||
window.toast.warning(message);
|
||||
},
|
||||
info: (message: string) => {
|
||||
window.toast.info(message);
|
||||
info: (props: InfoProps) => {
|
||||
window.toast.info({
|
||||
...props,
|
||||
variant: 'info',
|
||||
anchorOrigin: {
|
||||
vertical: 'bottom',
|
||||
horizontal: 'center',
|
||||
},
|
||||
});
|
||||
},
|
||||
clear: () => {
|
||||
window.toast.clear();
|
||||
},
|
||||
};
|
||||
|
||||
export * from './InfoSnackbar';
|
||||
|
@ -17,3 +17,5 @@ export function Popover({ children, ...props }: PopoverComponentProps) {
|
||||
</PopoverComponent>
|
||||
);
|
||||
}
|
||||
|
||||
export default Popover;
|
||||
|
@ -2,10 +2,13 @@ import { clearData } from '@/application/db';
|
||||
import { EventType, on } from '@/application/session';
|
||||
import { isTokenValid } from '@/application/session/token';
|
||||
import { useAppLanguage } from '@/components/app/useAppLanguage';
|
||||
import { LoginModal } from '@/components/login';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import React, { createContext, useEffect, useState } from 'react';
|
||||
import React, { createContext, useCallback, useEffect, useState } from 'react';
|
||||
import { AFService, AFServiceConfig } from '@/application/services/services.type';
|
||||
import { getService } from '@/application/services';
|
||||
import { InfoSnackbarProps } from '@/components/_shared/notify';
|
||||
import { User } from '@/application/types';
|
||||
|
||||
const baseURL = import.meta.env.AF_BASE_URL || 'https://test.appflowy.cloud';
|
||||
const gotrueURL = import.meta.env.AF_GOTRUE_URL || 'https://test.appflowy.cloud/gotrue';
|
||||
@ -23,6 +26,8 @@ export const AFConfigContext = createContext<
|
||||
| {
|
||||
service: AFService | undefined;
|
||||
isAuthenticated: boolean;
|
||||
currentUser?: User;
|
||||
openLoginModal: (redirectTo?: string) => void;
|
||||
}
|
||||
| undefined
|
||||
>(undefined);
|
||||
@ -31,6 +36,14 @@ function AppConfig({ children }: { children: React.ReactNode }) {
|
||||
const [appConfig] = useState<AFServiceConfig>(defaultConfig);
|
||||
const [service, setService] = useState<AFService>();
|
||||
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(() => {
|
||||
return on(EventType.SESSION_VALID, () => {
|
||||
@ -38,6 +51,24 @@ function AppConfig({ children }: { children: React.ReactNode }) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
setCurrentUser(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
if (!service) return;
|
||||
try {
|
||||
const user = await service.getCurrentUser();
|
||||
|
||||
setCurrentUser(user);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
})();
|
||||
}, [isAuthenticated, service]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleStorageChange = (event: StorageEvent) => {
|
||||
if (event.key === 'token') setIsAuthenticated(isTokenValid());
|
||||
@ -79,8 +110,9 @@ function AppConfig({ children }: { children: React.ReactNode }) {
|
||||
default: (message: string) => {
|
||||
enqueueSnackbar(message, { variant: 'default' });
|
||||
},
|
||||
info: (message: string) => {
|
||||
enqueueSnackbar(message, { variant: 'info' });
|
||||
|
||||
info: (props: InfoSnackbarProps) => {
|
||||
enqueueSnackbar(props.message, props);
|
||||
},
|
||||
|
||||
clear: () => {
|
||||
@ -111,9 +143,20 @@ function AppConfig({ children }: { children: React.ReactNode }) {
|
||||
value={{
|
||||
service,
|
||||
isAuthenticated,
|
||||
currentUser,
|
||||
openLoginModal,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{loginOpen && (
|
||||
<LoginModal
|
||||
redirectTo={loginCompletedRedirectTo}
|
||||
open={loginOpen}
|
||||
onClose={() => {
|
||||
setLoginOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AFConfigContext.Provider>
|
||||
);
|
||||
}
|
||||
|
@ -41,20 +41,42 @@ function AppTheme({ children }: { children: React.ReactNode }) {
|
||||
},
|
||||
borderRadius: '4px',
|
||||
padding: '2px',
|
||||
'&.MuiIconButton-colorInherit': {
|
||||
color: 'var(--icon-primary)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
text: {
|
||||
borderRadius: '8px',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--fill-list-hover)',
|
||||
},
|
||||
},
|
||||
contained: {
|
||||
color: 'var(--content-on-fill)',
|
||||
boxShadow: 'none',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--content-blue-600)',
|
||||
},
|
||||
borderRadius: '8px',
|
||||
'&.Mui-disabled': {
|
||||
backgroundColor: 'var(--content-blue-400)',
|
||||
opacity: 0.3,
|
||||
color: 'var(--content-on-fill)',
|
||||
},
|
||||
},
|
||||
outlined: {
|
||||
'&.MuiButton-outlinedInherit': {
|
||||
borderColor: 'var(--line-divider)',
|
||||
},
|
||||
borderRadius: '8px',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
MuiButtonBase: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
@ -78,10 +100,16 @@ function AppTheme({ children }: { children: React.ReactNode }) {
|
||||
root: {
|
||||
backgroundImage: 'none',
|
||||
boxShadow: 'var(--shadow)',
|
||||
borderRadius: '10px',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiDialog: {
|
||||
styleOverrides: {
|
||||
paper: {
|
||||
borderRadius: '12px',
|
||||
},
|
||||
},
|
||||
defaultProps: {
|
||||
sx: {
|
||||
'& .MuiBackdrop-root': {
|
||||
@ -112,6 +140,7 @@ function AppTheme({ children }: { children: React.ReactNode }) {
|
||||
color: 'var(--text-caption)',
|
||||
WebkitTextFillColor: 'var(--text-caption) !important',
|
||||
},
|
||||
borderRadius: '8px',
|
||||
},
|
||||
},
|
||||
styleOverrides: {
|
||||
|
@ -5,6 +5,7 @@ import AppConfig from '@/components/app/AppConfig';
|
||||
import { Suspense } from 'react';
|
||||
import { SnackbarProvider } from 'notistack';
|
||||
import { styled } from '@mui/material';
|
||||
import { InfoSnackbar } from '../_shared/notify';
|
||||
|
||||
const StyledSnackbarProvider = styled(SnackbarProvider)`
|
||||
&.notistack-MuiContent-default {
|
||||
@ -39,6 +40,9 @@ export default function withAppWrapper(Component: React.FC): React.FC {
|
||||
horizontal: 'center',
|
||||
}}
|
||||
preventDuplicate
|
||||
Components={{
|
||||
info: InfoSnackbar,
|
||||
}}
|
||||
>
|
||||
<AppConfig>
|
||||
<Suspense>
|
||||
|
@ -8,15 +8,12 @@ export function Calendar() {
|
||||
const { dayPropGetter, localizer, formats, events, emptyEvents } = useCalendarSetup();
|
||||
|
||||
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
|
||||
components={{
|
||||
toolbar: (props) => <Toolbar {...props} emptyEvents={emptyEvents} />,
|
||||
eventWrapper: Event,
|
||||
}}
|
||||
style={{
|
||||
marginBottom: '24px',
|
||||
}}
|
||||
events={events}
|
||||
views={['month']}
|
||||
localizer={localizer}
|
||||
|
@ -27,17 +27,24 @@ $today-highlight-bg: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.rbc-calendar {
|
||||
height: fit-content;
|
||||
@apply w-full overflow-x-scroll;
|
||||
@include mixin.scrollbar-style;
|
||||
|
||||
}
|
||||
|
||||
.rbc-month-view {
|
||||
border: none;
|
||||
@apply h-full overflow-auto;
|
||||
height: fit-content;
|
||||
|
||||
.rbc-month-row {
|
||||
border: 1px solid var(--line-divider);
|
||||
border-top: none;
|
||||
min-width: 700px;
|
||||
min-width: 1200px;
|
||||
@apply max-sm:w-[650vw];
|
||||
}
|
||||
|
||||
@include mixin.scrollbar-style;
|
||||
|
||||
}
|
||||
|
||||
@ -51,7 +58,9 @@ $today-highlight-bg: transparent;
|
||||
top: 0;
|
||||
background: var(--bg-body);
|
||||
z-index: 50;
|
||||
min-width: 700px;
|
||||
min-width: 1200px;
|
||||
|
||||
@apply max-sm:w-[650vw];
|
||||
|
||||
.rbc-header {
|
||||
border: none;
|
||||
@ -79,10 +88,6 @@ $today-highlight-bg: transparent;
|
||||
height: fit-content;
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
.event-properties {
|
||||
|
@ -79,7 +79,6 @@ export const Column = memo(
|
||||
return (
|
||||
<VariableSizeList
|
||||
ref={ref}
|
||||
className={'pb-[150px]'}
|
||||
height={height}
|
||||
itemCount={rowCount}
|
||||
itemSize={getItemSize}
|
||||
|
@ -22,7 +22,7 @@ export function Toolbar({
|
||||
const { t } = useTranslation();
|
||||
|
||||
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={'flex items-center justify-end gap-2'}>
|
||||
<IconButton size={'small'} onClick={() => onNavigate('PREV')}>
|
||||
|
@ -2,7 +2,7 @@ import { FieldType } from '@/application/database-yjs';
|
||||
import { useDateTypeCellDispatcher } from '@/components/database/components/cell/Cell.hooks';
|
||||
import { CellProps, DateTimeCell as DateTimeCellType } from '@/application/database-yjs/cell.type';
|
||||
import React, { useMemo } from 'react';
|
||||
import { ReactComponent as ReminderSvg } from '$icons/16x/clock_alarm.svg';
|
||||
import { ReactComponent as ReminderSvg } from '@/assets/clock_alarm.svg';
|
||||
|
||||
export function DateTimeCell({ cell, fieldId, style, placeholder }: CellProps<DateTimeCellType>) {
|
||||
const { getDateTimeStr } = useDateTypeCellDispatcher(fieldId);
|
||||
|
@ -4,7 +4,7 @@ import Cell from '@/components/database/components/cell/Cell';
|
||||
import React, { CSSProperties, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function CardField({ rowId, fieldId }: { rowId: string; fieldId: string; index: number }) {
|
||||
export function CardField({ rowId, fieldId }: { rowId: string; fieldId: string; index: number }) {
|
||||
const { t } = useTranslation();
|
||||
const { field } = useFieldSelector(fieldId);
|
||||
const cell = useCellSelector({
|
||||
@ -21,7 +21,7 @@ function CardField({ rowId, fieldId }: { rowId: string; fieldId: string; index:
|
||||
textAlign: 'left',
|
||||
};
|
||||
|
||||
if ([FieldType.Relation, FieldType.SingleSelect, FieldType.MultiSelect].includes(Number(type))) {
|
||||
if (isPrimary || [FieldType.Relation, FieldType.SingleSelect, FieldType.MultiSelect].includes(Number(type))) {
|
||||
Object.assign(styleProperties, {
|
||||
breakWord: 'break-word',
|
||||
whiteSpace: 'normal',
|
||||
|
@ -126,7 +126,7 @@ export const GridTable = ({ scrollLeft, columnWidth, columns, onScrollLeft }: Gr
|
||||
columnCount={columns.length}
|
||||
columnWidth={(index) => columnWidth(index, width)}
|
||||
rowHeight={rowHeight}
|
||||
className={'grid-table pb-[150px]'}
|
||||
className={'grid-table'}
|
||||
overscanRowCount={5}
|
||||
overscanColumnCount={5}
|
||||
style={{
|
||||
|
@ -18,7 +18,7 @@ function DatabaseHeader({
|
||||
return (
|
||||
<div
|
||||
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'}>
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { DatabaseViewLayout, YDatabaseView, YjsDatabaseKey } from '@/application/collab.type';
|
||||
import { useDatabase, useDatabaseView } from '@/application/database-yjs';
|
||||
import { DatabaseContext, useDatabase, useDatabaseView } from '@/application/database-yjs';
|
||||
import { ViewMeta } from '@/application/db/tables/view_metas';
|
||||
import { DatabaseActions } from '@/components/database/components/conditions';
|
||||
import { Tooltip } from '@mui/material';
|
||||
import { forwardRef, FunctionComponent, SVGProps, useMemo } from 'react';
|
||||
import { forwardRef, FunctionComponent, SVGProps, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { ViewTabs, ViewTab } from './ViewTabs';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@ -32,12 +33,28 @@ export const DatabaseTabs = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
|
||||
const { t } = useTranslation();
|
||||
const view = useDatabaseView();
|
||||
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 handleChange = (_: React.SyntheticEvent, newValue: string) => {
|
||||
setSelectedViewId?.(newValue);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
if (loadViewMeta) {
|
||||
try {
|
||||
const meta = await loadViewMeta(iidIndex, setMeta);
|
||||
|
||||
setMeta(meta);
|
||||
} catch (e) {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, [loadViewMeta, iidIndex]);
|
||||
|
||||
const className = useMemo(() => {
|
||||
const classList = ['-mb-[0.5px] flex items-center overflow-hidden border-line-divider text-text-title'];
|
||||
|
||||
@ -48,12 +65,14 @@ export const DatabaseTabs = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
|
||||
return classList.join(' ');
|
||||
}, [layout]);
|
||||
|
||||
const showActions = !hideConditions && layout !== DatabaseViewLayout.Calendar;
|
||||
|
||||
if (viewIds.length === 0) return null;
|
||||
return (
|
||||
<div ref={ref} className={className}>
|
||||
<div
|
||||
style={{
|
||||
width: 'calc(100% - 120px)',
|
||||
width: showActions ? 'calc(100% - 120px)' : '100%',
|
||||
}}
|
||||
className='flex items-center '
|
||||
>
|
||||
@ -70,7 +89,7 @@ export const DatabaseTabs = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
|
||||
if (!view) return null;
|
||||
const layout = Number(view.get(YjsDatabaseKey.layout)) as DatabaseViewLayout;
|
||||
const Icon = DatabaseIcons[layout];
|
||||
const name = viewId === iidIndex ? viewName : view.get(YjsDatabaseKey.name);
|
||||
const name = viewId === iidIndex ? viewName : meta?.child_views?.find((v) => v.view_id === viewId)?.name;
|
||||
|
||||
return (
|
||||
<ViewTab
|
||||
@ -90,7 +109,7 @@ export const DatabaseTabs = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
|
||||
})}
|
||||
</ViewTabs>
|
||||
</div>
|
||||
{!hideConditions && layout !== DatabaseViewLayout.Calendar ? <DatabaseActions /> : null}
|
||||
{showActions ? <DatabaseActions /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { GetViewRowsMap, LoadView, LoadViewMeta, YDoc } from '@/application/collab.type';
|
||||
import ComponentLoading from '@/components/_shared/progress/ComponentLoading';
|
||||
import DocumentSkeleton from '@/components/document/DocumentSkeleton';
|
||||
import { Editor } from '@/components/editor';
|
||||
import React, { Suspense } from 'react';
|
||||
import ViewMetaPreview, { ViewMetaProps } from '@/components/view-meta/ViewMetaPreview';
|
||||
@ -17,7 +17,7 @@ export const Document = ({ doc, loadView, navigateToView, loadViewMeta, getViewR
|
||||
return (
|
||||
<div className={'mb-16 flex h-full w-full flex-col items-center justify-center'}>
|
||||
<ViewMetaPreview {...viewMeta} />
|
||||
<Suspense fallback={<ComponentLoading />}>
|
||||
<Suspense fallback={<DocumentSkeleton />}>
|
||||
<div className={'mx-16 w-[964px] min-w-0 max-w-full'}>
|
||||
<Editor
|
||||
loadView={loadView}
|
||||
|
@ -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;
|
@ -3,39 +3,49 @@ import { useEditorContext } from '@/components/editor/EditorContext';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { ReactEditor, useSlateStatic } from 'slate-react';
|
||||
import { Element as SlateElement, Transforms } from 'slate';
|
||||
|
||||
const Prism = window.Prism;
|
||||
const hljs = window.hljs;
|
||||
import Prism from 'prismjs';
|
||||
|
||||
export function useCodeBlock(node: CodeNode) {
|
||||
const language = node.data.language;
|
||||
const editor = useSlateStatic() as ReactEditor;
|
||||
|
||||
const addCodeGrammars = useEditorContext().addCodeGrammars;
|
||||
|
||||
useEffect(() => {
|
||||
const path = ReactEditor.findPath(editor, node);
|
||||
let detectedLanguage = language;
|
||||
void (async () => {
|
||||
const path = ReactEditor.findPath(editor, node);
|
||||
let detectedLanguage = language;
|
||||
|
||||
if (!language) {
|
||||
const codeSnippet = editor.string(path);
|
||||
if (!language) {
|
||||
const codeSnippet = editor.string(path);
|
||||
const script = document.createElement('script');
|
||||
|
||||
detectedLanguage = hljs.highlightAuto(codeSnippet).language;
|
||||
}
|
||||
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.5.1/highlight.min.js';
|
||||
document.body.appendChild(script);
|
||||
const promise = new Promise((resolve) => {
|
||||
script.onload = () => {
|
||||
resolve(true);
|
||||
};
|
||||
});
|
||||
|
||||
const prismLanguage = Prism.languages[detectedLanguage.toLowerCase()];
|
||||
await promise;
|
||||
|
||||
if (!prismLanguage) {
|
||||
const script = document.createElement('script');
|
||||
detectedLanguage = window.hljs.highlightAuto(codeSnippet).language || 'plaintext';
|
||||
}
|
||||
|
||||
script.src = `https://cdnjs.cloudflare.com/ajax/libs/prism/1.26.0/components/prism-${detectedLanguage.toLowerCase()}.min.js`;
|
||||
document.head.appendChild(script);
|
||||
script.onload = () => {
|
||||
const prismLanguage = Prism.languages[detectedLanguage.toLowerCase()];
|
||||
|
||||
if (!prismLanguage) {
|
||||
const script = document.createElement('script');
|
||||
|
||||
script.src = `https://cdnjs.cloudflare.com/ajax/libs/prism/1.26.0/components/prism-${detectedLanguage.toLowerCase()}.min.js`;
|
||||
document.body.appendChild(script);
|
||||
script.onload = () => {
|
||||
addCodeGrammars?.(node.blockId, detectedLanguage);
|
||||
};
|
||||
} else {
|
||||
addCodeGrammars?.(node.blockId, detectedLanguage);
|
||||
};
|
||||
} else {
|
||||
addCodeGrammars?.(node.blockId, detectedLanguage);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, [addCodeGrammars, editor, language, node]);
|
||||
|
||||
const handleChangeLanguage = useCallback(
|
||||
|
@ -1,8 +1,5 @@
|
||||
import { BaseRange, NodeEntry, Text, Path } from 'slate';
|
||||
|
||||
const Prism = window.Prism;
|
||||
|
||||
Prism.plugins.autoloader.languages_path = 'https://cdnjs.cloudflare.com/ajax/libs/prism/1.26.0/components/';
|
||||
import Prism, { Grammar } from 'prismjs';
|
||||
|
||||
const push_string = (
|
||||
token: string | Prism.Token,
|
||||
@ -86,7 +83,7 @@ export const decorateCode = ([node, path]: NodeEntry, language: string) => {
|
||||
return ranges;
|
||||
}
|
||||
|
||||
const highlightCode = (code: string, language: string) => {
|
||||
const highlightCode = (code: string, language: Grammar) => {
|
||||
try {
|
||||
const tokens = Prism.tokenize(code, language);
|
||||
|
||||
|
@ -4,8 +4,8 @@ import { ImageBlockNode } from '@/components/editor/editor.type';
|
||||
import { copyTextToClipboard } from '@/utils/copy';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CircularProgress } from '@mui/material';
|
||||
import { ErrorOutline } from '@mui/icons-material';
|
||||
import { Skeleton } from '@mui/material';
|
||||
import { ReactComponent as ErrorOutline } from '@/assets/error.svg';
|
||||
|
||||
const MIN_WIDTH = 100;
|
||||
|
||||
@ -35,7 +35,11 @@ function ImageRender({
|
||||
}, [hasError, initialWidth, loading]);
|
||||
const imageProps: React.ImgHTMLAttributes<HTMLImageElement> = useMemo(() => {
|
||||
return {
|
||||
style: { width: loading || hasError ? '0' : newWidth ?? '100%', opacity: selected ? 0.8 : 1 },
|
||||
style: {
|
||||
width: loading || hasError ? '0' : newWidth ?? '100%',
|
||||
opacity: selected ? 0.8 : 1,
|
||||
height: hasError ? 0 : 'auto',
|
||||
},
|
||||
className: 'object-cover',
|
||||
ref: imgRef,
|
||||
src: url,
|
||||
@ -54,7 +58,9 @@ function ImageRender({
|
||||
const renderErrorNode = useCallback(() => {
|
||||
return (
|
||||
<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'} />
|
||||
<div className={'text-function-error'}>{t('editor.imageLoadFailed')}</div>
|
||||
@ -68,7 +74,7 @@ function ImageRender({
|
||||
<div
|
||||
style={{
|
||||
minWidth: MIN_WIDTH,
|
||||
width: 'fit-content',
|
||||
width: loading || hasError ? '100%' : 'fit-content',
|
||||
}}
|
||||
className={`image-render relative min-h-[48px] ${hasError ? 'w-full' : ''}`}
|
||||
>
|
||||
@ -86,14 +92,7 @@ function ImageRender({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{hasError ? (
|
||||
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}
|
||||
{hasError ? renderErrorNode() : loading ? <Skeleton variant='rounded' width={'100%'} height={200} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import KatexMath from '@/components/_shared/katex-math/KatexMath';
|
||||
import { KatexMath } from '@/components/_shared/katex-math';
|
||||
import { notify } from '@/components/_shared/notify';
|
||||
import RightTopActionsToolbar from '@/components/editor/components/block-actions/RightTopActionsToolbar';
|
||||
import { EditorElementProps, MathEquationNode } from '@/components/editor/editor.type';
|
||||
|
@ -1,3 +1 @@
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const MathEquation = lazy(() => import('./MathEquation'));
|
||||
export * from './MathEquation';
|
||||
|
@ -1,4 +1,4 @@
|
||||
import KatexMath from '@/components/_shared/katex-math/KatexMath';
|
||||
import { KatexMath } from '@/components/_shared/katex-math';
|
||||
import { EditorElementProps, FormulaNode } from '@/components/editor/editor.type';
|
||||
import React, { memo, forwardRef } from 'react';
|
||||
import { useSelected } from 'slate-react';
|
||||
|
@ -1,5 +1 @@
|
||||
import { lazy } from 'react';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
export const Formula = lazy(() => import('./Formula?chunkName=formula'));
|
||||
export * from './Formula';
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { renderDate } from '@/utils/time';
|
||||
import React, { useMemo } from 'react';
|
||||
import { ReactComponent as DateSvg } from '@/assets/date.svg';
|
||||
import { ReactComponent as ReminderSvg } from '$icons/16x/clock_alarm.svg';
|
||||
import { ReactComponent as ReminderSvg } from '@/assets/clock_alarm.svg';
|
||||
|
||||
function MentionDate({ date, reminder }: { date: string; reminder?: { id: string; option: string } }) {
|
||||
const dateFormat = useMemo(() => {
|
||||
|
@ -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);
|
@ -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 };
|
||||
}
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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);
|
@ -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);
|
@ -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);
|
@ -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);
|
@ -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);
|
@ -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;
|
@ -0,0 +1 @@
|
||||
export * from './AddCommentWrapper';
|
@ -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);
|
@ -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;
|
@ -0,0 +1 @@
|
||||
export * from './CommentWrap';
|
@ -0,0 +1 @@
|
||||
export * from './GlobalCommentProvider';
|
@ -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);
|
@ -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);
|
@ -0,0 +1 @@
|
||||
export * from './Reactions';
|
@ -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);
|
||||
}
|
||||
}
|
@ -1,28 +1,18 @@
|
||||
import { AFConfigContext } from '@/components/app/AppConfig';
|
||||
import LoginProvider from '@/components/login/LoginProvider';
|
||||
import MagicLink from '@/components/login/MagicLink';
|
||||
import { Divider } from '@mui/material';
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import { ReactComponent as Logo } from '@/assets/logo.svg';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
export function Login() {
|
||||
export function Login({ redirectTo }: { redirectTo: string }) {
|
||||
const { t } = useTranslation();
|
||||
const [search] = useSearchParams();
|
||||
const redirectTo = search.get('redirectTo') || '';
|
||||
const isAuthenticated = useContext(AFConfigContext)?.isAuthenticated || false;
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && redirectTo && encodeURIComponent(redirectTo) !== window.location.href) {
|
||||
window.location.href = redirectTo;
|
||||
}
|
||||
}, [isAuthenticated, redirectTo]);
|
||||
return (
|
||||
<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'} />
|
||||
<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>
|
||||
<MagicLink redirectTo={redirectTo} />
|
||||
<div className={'flex w-full items-center justify-center gap-2 text-text-caption'}>
|
||||
|
@ -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;
|
@ -50,7 +50,7 @@ function LoginProvider({ redirectTo }: { redirectTo: string }) {
|
||||
};
|
||||
|
||||
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) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
@ -58,7 +58,7 @@ function LoginProvider({ redirectTo }: { redirectTo: string }) {
|
||||
variant={'outlined'}
|
||||
onClick={() => handleClick(option.value)}
|
||||
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]'} />
|
||||
|
@ -3,7 +3,7 @@ import { AFConfigContext } from '@/components/app/AppConfig';
|
||||
import { Button, CircularProgress, OutlinedInput } from '@mui/material';
|
||||
import React, { useContext } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import validator from 'validator';
|
||||
import isEmail from 'validator/lib/isEmail';
|
||||
|
||||
function MagicLink({ redirectTo }: { redirectTo: string }) {
|
||||
const { t } = useTranslation();
|
||||
@ -11,7 +11,7 @@ function MagicLink({ redirectTo }: { redirectTo: string }) {
|
||||
const [loading, setLoading] = React.useState<boolean>(false);
|
||||
const service = useContext(AFConfigContext)?.service;
|
||||
const handleSubmit = async () => {
|
||||
const isValidEmail = validator.isEmail(email);
|
||||
const isValidEmail = isEmail(email);
|
||||
|
||||
if (!isValidEmail) {
|
||||
notify.error(t('signIn.invalidEmail'));
|
||||
@ -34,11 +34,11 @@ function MagicLink({ redirectTo }: { redirectTo: string }) {
|
||||
};
|
||||
|
||||
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
|
||||
value={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')}
|
||||
inputProps={{
|
||||
className: 'px-0 py-0',
|
||||
@ -49,7 +49,7 @@ function MagicLink({ redirectTo }: { redirectTo: string }) {
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
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 ? (
|
||||
<>
|
||||
|
@ -1 +1,3 @@
|
||||
export * from './LoginModal';
|
||||
|
||||
export * from './Login';
|
||||
|
@ -45,7 +45,7 @@ function DatabaseView({ viewMeta, ...props }: DatabaseProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: 'calc(100vh - 48px)',
|
||||
minHeight: 'calc(100vh - 48px)',
|
||||
}}
|
||||
className={'relative flex h-full w-full flex-col px-16 max-md:px-4'}
|
||||
>
|
||||
|
@ -1,12 +1,14 @@
|
||||
import { YDoc } from '@/application/collab.type';
|
||||
import { PublishProvider } from '@/application/publish';
|
||||
import ComponentLoading from '@/components/_shared/progress/ComponentLoading';
|
||||
import { AFScroller } from '@/components/_shared/scroller';
|
||||
import { AFConfigContext } from '@/components/app/AppConfig';
|
||||
import { GlobalCommentProvider } from '@/components/global-comment';
|
||||
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 React, { useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { PublishViewHeader } from 'src/components/publish/header';
|
||||
import React, { Suspense, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { PublishViewHeader } from '@/components/publish/header';
|
||||
import NotFound from '@/components/error/NotFound';
|
||||
|
||||
export interface PublishViewProps {
|
||||
@ -84,6 +86,11 @@ export function PublishView({ namespace, publishName }: PublishViewProps) {
|
||||
/>
|
||||
|
||||
<CollabView doc={doc} />
|
||||
{doc && (
|
||||
<Suspense fallback={<ComponentLoading />}>
|
||||
<GlobalCommentProvider />
|
||||
</Suspense>
|
||||
)}
|
||||
</AFScroller>
|
||||
{open && <OutlineDrawer width={drawerWidth} open={open} onClose={() => setOpen(false)} />}
|
||||
</div>
|
||||
|
@ -1,20 +1,20 @@
|
||||
// import { invalidToken } from '@/application/session/token';
|
||||
import { invalidToken } from '@/application/session/token';
|
||||
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 { openUrl } from '@/utils/url';
|
||||
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 MoonIcon } from '@/assets/moon.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 { useTranslation } from 'react-i18next';
|
||||
import { ReactComponent as Logo } from '@/assets/logo.svg';
|
||||
import { ReactComponent as AppflowyLogo } from '@/assets/appflowy.svg';
|
||||
|
||||
// import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
function MoreActions() {
|
||||
const { isDark, setDark } = useContext(ThemeModeContext) || {};
|
||||
@ -31,21 +31,21 @@ function MoreActions() {
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
// const navigate = useNavigate();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// const isAuthenticated = useContext(AFConfigContext)?.isAuthenticated || false;
|
||||
//
|
||||
// const handleLogin = useCallback(() => {
|
||||
// invalidToken();
|
||||
// navigate('/login?redirectTo=' + encodeURIComponent(window.location.href));
|
||||
// }, [navigate]);
|
||||
const isAuthenticated = useContext(AFConfigContext)?.isAuthenticated || false;
|
||||
|
||||
const handleLogin = useCallback(() => {
|
||||
invalidToken();
|
||||
navigate('/login?redirectTo=' + encodeURIComponent(window.location.href));
|
||||
}, [navigate]);
|
||||
const actions = useMemo(() => {
|
||||
return [
|
||||
// {
|
||||
// Icon: LoginIcon,
|
||||
// label: isAuthenticated ? t('button.logout') : t('web.login'),
|
||||
// onClick: handleLogin,
|
||||
// },
|
||||
{
|
||||
Icon: LoginIcon,
|
||||
label: isAuthenticated ? t('button.logout') : t('web.login'),
|
||||
onClick: handleLogin,
|
||||
},
|
||||
isDark
|
||||
? {
|
||||
Icon: SunIcon,
|
||||
@ -69,54 +69,58 @@ function MoreActions() {
|
||||
},
|
||||
},
|
||||
];
|
||||
}, [t, isDark, setDark]);
|
||||
}, [isAuthenticated, t, handleLogin, isDark, setDark]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton onClick={handleClick}>
|
||||
<MoreIcon className={'text-text-caption'} />
|
||||
</IconButton>
|
||||
<Popover
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
open={open}
|
||||
anchorEl={anchorEl}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<div className={'flex w-[240px] flex-col gap-2 px-2 py-2'}>
|
||||
{actions.map((action, index) => (
|
||||
<button
|
||||
{open && (
|
||||
<Popover
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
open={open}
|
||||
anchorEl={anchorEl}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<div className={'flex w-[240px] flex-col gap-2 px-2 py-2'}>
|
||||
{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={() => {
|
||||
action.onClick();
|
||||
handleClose();
|
||||
window.open('https://appflowy.io', '_blank');
|
||||
}}
|
||||
key={index}
|
||||
className={
|
||||
'flex items-center gap-2 rounded-[8px] p-1.5 text-sm hover:bg-content-blue-50 focus:bg-content-blue-50 focus:outline-none'
|
||||
'flex w-full cursor-pointer items-center justify-center py-2 text-sm text-text-title opacity-50'
|
||||
}
|
||||
>
|
||||
<action.Icon />
|
||||
<span>{action.label}</span>
|
||||
</button>
|
||||
))}
|
||||
<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'} />
|
||||
Powered by
|
||||
<Logo className={'ml-3 h-4 w-4'} />
|
||||
<AppflowyLogo className={'w-20'} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
</Popover>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -3,12 +3,13 @@ import { openOrDownload } from '@/components/publish/header/utils';
|
||||
import { Divider, IconButton, Tooltip } from '@mui/material';
|
||||
import { debounce } from 'lodash-es';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import OutlinePopover from '@/components/publish/outline/OutlinePopover';
|
||||
import { OutlinePopover } from '@/components/publish/outline';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Breadcrumb from './Breadcrumb';
|
||||
import { ReactComponent as Logo } from '@/assets/logo.svg';
|
||||
import MoreActions from './MoreActions';
|
||||
import { ReactComponent as SideOutlined } from '@/assets/side_outlined.svg';
|
||||
// import { Duplicate } from './duplicate';
|
||||
|
||||
export const HEADER_HEIGHT = 48;
|
||||
|
||||
@ -65,7 +66,7 @@ export function PublishViewHeader({ onOpenDrawer, openDrawer }: { onOpenDrawer:
|
||||
className={'appflowy-top-bar sticky top-0 z-10 flex px-5'}
|
||||
>
|
||||
<div className={'flex w-full items-center justify-between gap-2 overflow-hidden'}>
|
||||
{!openDrawer && (
|
||||
{!openDrawer && openPopover && (
|
||||
<OutlinePopover
|
||||
onMouseEnter={handleOpenPopover}
|
||||
onMouseLeave={debounceClosePopover}
|
||||
@ -92,6 +93,9 @@ export function PublishViewHeader({ onOpenDrawer, openDrawer }: { onOpenDrawer:
|
||||
|
||||
<div className={'flex items-center gap-2'}>
|
||||
<MoreActions />
|
||||
{/*<Suspense fallback={null}>*/}
|
||||
{/* <Duplicate />*/}
|
||||
{/*</Suspense>*/}
|
||||
<Divider orientation={'vertical'} className={'mx-2'} flexItem />
|
||||
<Tooltip title={t('publish.downloadApp')}>
|
||||
<button onClick={openOrDownload}>
|
||||
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -0,0 +1 @@
|
||||
export * from './Duplicate';
|
@ -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,
|
||||
};
|
||||
}
|
@ -7,7 +7,7 @@ import { createHotKeyLabel, HOT_KEY_NAME } from '@/utils/hotkeys';
|
||||
import { Drawer, IconButton, Tooltip } from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function OutlineDrawer({ open, width, onClose }: { open: boolean; width: number; onClose: () => void }) {
|
||||
export function OutlineDrawer({ open, width, onClose }: { open: boolean; width: number; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const viewMeta = usePublishContext()?.viewMeta;
|
||||
|
||||
|