From 89218f19a36ad09293946aa6f72c094f594b680d Mon Sep 17 00:00:00 2001 From: qinluhe Date: Tue, 24 Dec 2024 10:19:35 +0800 Subject: [PATCH] fix: support billing --- frontend/appflowy_web_app/cypress.config.ts | 4 +- frontend/appflowy_web_app/package.json | 2 +- frontend/appflowy_web_app/pnpm-lock.yaml | 76 +----- .../services/js-services/http/gotrue.ts | 18 +- .../services/js-services/http/http_api.ts | 113 +++++++-- .../application/services/js-services/index.ts | 8 + .../src/application/services/services.type.ts | 4 +- .../src/application/session/sign_in.ts | 12 +- .../src/application/session/token.ts | 11 +- .../application/slate-yjs/command/index.ts | 15 +- .../appflowy_web_app/src/application/types.ts | 2 +- .../src/assets/icon_upgrade.svg | 7 + .../_shared/breadcrumb/BreadcrumbItem.tsx | 19 +- .../_shared/icon-picker/IconPicker.tsx | 20 +- .../_shared/image-upload/UploadImage.tsx | 7 - .../_shared/modal/ChangeAccount.tsx | 11 +- .../_shared/outline/OutlineItemContent.tsx | 18 +- .../_shared/view-icon/ChangeIconPopover.tsx | 32 ++- .../components/_shared/view-icon/PageIcon.tsx | 75 ++++++ .../{breadcrumb => view-icon}/SpaceIcon.tsx | 38 ++- .../src/components/app/SideBarBottom.tsx | 22 +- .../src/components/app/ViewModal.tsx | 53 +++-- .../src/components/app/app.hooks.tsx | 20 +- .../app/landing-pages/ApproveRequestPage.tsx | 16 +- .../src/components/app/outline/Outline.tsx | 65 +++-- .../src/components/app/outline/SpaceItem.tsx | 4 +- .../src/components/app/outline/ViewItem.tsx | 37 ++- .../app/view-actions/ManageSpace.tsx | 14 +- .../app/view-actions/MoreSpaceActions.tsx | 29 +-- .../components/app/view-actions/NewPage.tsx | 3 + .../app/view-actions/SpaceIconButton.tsx | 6 +- .../app/view-actions/ViewActions.tsx | 84 +------ .../app/view-actions/ViewActionsPopover.tsx | 82 +++++++ .../app/workspaces/InviteMember.tsx | 66 +++++- .../components/app/workspaces/Workspaces.tsx | 41 +++- .../src/components/billing/Billing.tsx | 9 + .../components/billing/CancelSubscribe.tsx | 224 ++++++++++++++++++ .../src/components/billing/UpgradePlan.tsx | 215 +++++++++++++++++ .../src/components/billing/index.ts | 1 + .../components/header/DatabaseHeader.tsx | 42 ---- .../components/editor/CollaborativeEditor.tsx | 1 + .../src/components/editor/Editable.tsx | 6 +- .../editor/__tests__/blocks/Code.cy.tsx | 100 ++++++++ .../src/components/editor/__tests__/mount.tsx | 4 +- .../__tests__/shortcuts/Markdown.cy.tsx | 31 +-- .../block-popover/FileBlockPopoverContent.tsx | 9 - .../ImageBlockPopoverContent.tsx | 22 +- .../editor/components/blocks/code/Code.tsx | 5 +- .../components/blocks/file/FileBlock.tsx | 9 +- .../components/blocks/file/FileToolbar.tsx | 3 +- .../components/blocks/image/ImageBlock.tsx | 6 - .../components/blocks/image/ImageRender.tsx | 9 +- .../components/blocks/image/ImageToolbar.tsx | 3 +- .../blocks/link-preview/LinkPreview.tsx | 20 +- .../blocks/simple-table/SimpleTableCell.tsx | 3 +- .../blocks/simple-table/simple-table.scss | 6 +- .../editor/components/blocks/text/Text.tsx | 2 +- .../blocks/toggle-list/ToggleIcon.tsx | 2 +- .../editor/components/leaf/Leaf.tsx | 4 + .../components/leaf/formula/FormulaLeaf.tsx | 11 +- .../components/leaf/mention/MentionLeaf.tsx | 21 +- .../components/leaf/mention/MentionPage.tsx | 67 ++++-- .../panels/mention-panel/MentionPanel.tsx | 29 ++- .../table-container/TableContainer.tsx | 40 +++- .../block-controls/BlockControls.cy.tsx | 3 +- .../selection-toolbar/actions/Formula.tsx | 13 +- .../src/components/editor/editor.scss | 31 ++- .../editor/plugins/withInsertData.ts | 39 ++- .../components/editor/plugins/withPasted.ts | 61 +++-- .../editor/utils/__tests__/fragment.test.ts | 2 +- .../src/components/editor/utils/fragment.ts | 45 +++- .../src/components/main/AppConfig.tsx | 4 +- .../src/components/main/AppTheme.tsx | 25 +- .../src/components/main/withAppWrapper.tsx | 2 +- .../publish/header/duplicate/SpaceList.tsx | 10 +- .../src/components/quick-note/AddNote.tsx | 38 +-- .../src/components/quick-note/Note.tsx | 67 +++++- .../src/components/quick-note/NoteHeader.tsx | 5 +- .../src/components/quick-note/NoteList.tsx | 134 ++++++----- .../components/quick-note/NoteListHeader.tsx | 5 +- .../components/quick-note/QuickNote.hooks.ts | 46 +++- .../src/components/quick-note/QuickNote.tsx | 110 ++++++--- .../src/components/quick-note/utils.ts | 2 +- .../src/components/view-meta/AddIconCover.tsx | 67 ++++-- .../components/view-meta/TitleEditable.tsx | 5 +- .../src/components/view-meta/ViewCover.tsx | 3 +- .../components/view-meta/ViewCoverActions.tsx | 24 +- .../components/view-meta/ViewMetaPreview.tsx | 28 ++- .../src/pages/AcceptInvitationPage.tsx | 19 +- .../appflowy_web_app/src/pages/AppPage.tsx | 32 +-- .../src/styles/variables/dark.variables.css | 2 + .../src/styles/variables/light.variables.css | 2 + frontend/appflowy_web_app/src/utils/emoji.ts | 26 +- .../appflowy_web_app/tailwind/box-shadow.cjs | 2 +- frontend/appflowy_web_app/tailwind/colors.cjs | 9 +- frontend/resources/translations/en.json | 87 ++++++- 96 files changed, 2053 insertions(+), 833 deletions(-) create mode 100644 frontend/appflowy_web_app/src/assets/icon_upgrade.svg create mode 100644 frontend/appflowy_web_app/src/components/_shared/view-icon/PageIcon.tsx rename frontend/appflowy_web_app/src/components/_shared/{breadcrumb => view-icon}/SpaceIcon.tsx (79%) create mode 100644 frontend/appflowy_web_app/src/components/app/view-actions/ViewActionsPopover.tsx create mode 100644 frontend/appflowy_web_app/src/components/billing/Billing.tsx create mode 100644 frontend/appflowy_web_app/src/components/billing/CancelSubscribe.tsx create mode 100644 frontend/appflowy_web_app/src/components/billing/UpgradePlan.tsx create mode 100644 frontend/appflowy_web_app/src/components/billing/index.ts delete mode 100644 frontend/appflowy_web_app/src/components/database/components/header/DatabaseHeader.tsx create mode 100644 frontend/appflowy_web_app/src/components/editor/__tests__/blocks/Code.cy.tsx diff --git a/frontend/appflowy_web_app/cypress.config.ts b/frontend/appflowy_web_app/cypress.config.ts index 3612fcbca5794..f06cce3289f08 100644 --- a/frontend/appflowy_web_app/cypress.config.ts +++ b/frontend/appflowy_web_app/cypress.config.ts @@ -15,7 +15,7 @@ export default defineConfig({ framework: 'react', bundler: 'vite', }, - setupNodeEvents (on, config) { + setupNodeEvents(on, config) { registerCodeCoverageTasks(on, config); addMatchImageSnapshotPlugin(on, config); return config; @@ -26,7 +26,7 @@ export default defineConfig({ retries: { // Configure retry attempts for `cypress run` // Default is 0 - runMode: 16, + runMode: 10, // Configure retry attempts for `cypress open` // Default is 0 openMode: 0, diff --git a/frontend/appflowy_web_app/package.json b/frontend/appflowy_web_app/package.json index 1144199cb132e..0849f37454ef2 100644 --- a/frontend/appflowy_web_app/package.json +++ b/frontend/appflowy_web_app/package.json @@ -24,7 +24,7 @@ "coverage": "pnpm run test:unit && pnpm run test:components" }, "dependencies": { - "@appflowyinc/editor": "^0.0.30", + "@appflowyinc/editor": "^0.0.39", "@atlaskit/primitives": "^5.5.3", "@emoji-mart/data": "^1.1.2", "@emoji-mart/react": "^1.1.1", diff --git a/frontend/appflowy_web_app/pnpm-lock.yaml b/frontend/appflowy_web_app/pnpm-lock.yaml index 969e21657213c..9211639bfad6f 100644 --- a/frontend/appflowy_web_app/pnpm-lock.yaml +++ b/frontend/appflowy_web_app/pnpm-lock.yaml @@ -2,8 +2,8 @@ lockfileVersion: '6.0' dependencies: '@appflowyinc/editor': - specifier: ^0.0.30 - version: 0.0.30(@types/react-dom@18.2.22)(@types/react@18.2.66)(i18next-resources-to-backend@1.2.1)(i18next@22.5.1)(react-dom@18.2.0)(react-i18next@14.1.2)(react@18.2.0)(slate-dom@0.111.0) + specifier: ^0.0.39 + version: 0.0.39(@types/react-dom@18.2.22)(@types/react@18.2.66)(i18next-resources-to-backend@1.2.1)(i18next@22.5.1)(react-dom@18.2.0)(react-i18next@14.1.2)(react@18.2.0)(slate-history@0.100.0)(slate-react@0.101.6)(slate@0.101.5) '@atlaskit/primitives': specifier: ^5.5.3 version: 5.7.0(@types/react@18.2.66)(react@18.2.0) @@ -535,14 +535,17 @@ packages: resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==} dev: false - /@appflowyinc/editor@0.0.30(@types/react-dom@18.2.22)(@types/react@18.2.66)(i18next-resources-to-backend@1.2.1)(i18next@22.5.1)(react-dom@18.2.0)(react-i18next@14.1.2)(react@18.2.0)(slate-dom@0.111.0): - resolution: {integrity: sha512-twUwwrQb8/iuN9MD9AW7+TRsquJVFrR5Kq2G4zvKbpz54K7U/Djh/daYyeeIReAsttN3JRSVeUJhz/+/DSt7Pg==} + /@appflowyinc/editor@0.0.39(@types/react-dom@18.2.22)(@types/react@18.2.66)(i18next-resources-to-backend@1.2.1)(i18next@22.5.1)(react-dom@18.2.0)(react-i18next@14.1.2)(react@18.2.0)(slate-history@0.100.0)(slate-react@0.101.6)(slate@0.101.5): + resolution: {integrity: sha512-Cbitz/yNcioB+QS3K06Xzt/7lN+AnlAbzZLQ4BPw6dPLvTFbE8f+b6a7QkxN/EU2WKgc1qMfHI+m4UAU7v5tvg==} peerDependencies: i18next: ^22.4.10 i18next-resources-to-backend: ^1.2.1 react: ^18.3.1 react-dom: ^18.3.1 react-i18next: ^14.1.0 + slate: ^0.112.0 + slate-history: ^0.110.3 + slate-react: ^0.112.0 dependencies: '@radix-ui/react-label': 2.1.1(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-popover': 1.1.4(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0) @@ -551,7 +554,6 @@ packages: '@radix-ui/react-slot': 1.1.1(@types/react@18.2.66)(react@18.2.0) '@radix-ui/react-switch': 1.1.2(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-tooltip': 1.1.6(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0) - '@types/prismjs': 1.26.5 class-variance-authority: 0.7.1 clsx: 2.1.1 i18next: 22.5.1 @@ -565,16 +567,15 @@ packages: react-dom: 18.2.0(react@18.2.0) react-i18next: 14.1.2(i18next@22.5.1)(react-dom@18.2.0)(react@18.2.0) sass: 1.83.0 - slate: 0.112.0 - slate-history: 0.110.3(slate@0.112.0) - slate-react: 0.112.0(react-dom@18.2.0)(react@18.2.0)(slate-dom@0.111.0)(slate@0.112.0) + slate: 0.101.5 + slate-history: 0.100.0(slate@0.101.5) + slate-react: 0.101.6(react-dom@18.2.0)(react@18.2.0)(slate@0.101.5) tailwind-merge: 2.5.5 tailwindcss: 3.4.17 tailwindcss-animate: 1.0.7(tailwindcss@3.4.17) transitivePeerDependencies: - '@types/react' - '@types/react-dom' - - slate-dom - supports-color - ts-node dev: false @@ -5398,10 +5399,6 @@ packages: resolution: {integrity: sha512-ZTaqn/qSqUuAq1YwvOFQfVW1AR/oQJlLSZVustdjwI+GZ8kr0MSHBj0tsXPW1EqHubx50gtBEjbPGsdZwQwCjQ==} dev: true - /@types/prismjs@1.26.5: - resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==} - dev: false - /@types/prop-types@15.7.12: resolution: {integrity: sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==} @@ -13128,21 +13125,6 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} - /slate-dom@0.111.0(slate@0.101.5): - resolution: {integrity: sha512-VjeBh2xIRvP6ToEhrO1TPahc5fPezxbeSUhsRTppBPtHfidEdyp/MTI9TjUrZnlznJiVZ7QKrORXilFq8hsbtQ==} - peerDependencies: - slate: '>=0.99.0' - dependencies: - '@juggle/resize-observer': 3.4.0 - direction: 1.0.4 - is-hotkey: 0.2.0 - is-plain-object: 5.0.0 - lodash: 4.17.21 - scroll-into-view-if-needed: 3.1.0 - slate: 0.101.5 - tiny-invariant: 1.3.1 - dev: false - /slate-history@0.100.0(slate@0.101.5): resolution: {integrity: sha512-x5rUuWLNtH97hs9PrFovGgt3Qc5zkTm/5mcUB+0NR/TK923eLax4HsL6xACLHMs245nI6aJElyM1y6hN0y5W/Q==} peerDependencies: @@ -13152,15 +13134,6 @@ packages: slate: 0.101.5 dev: false - /slate-history@0.110.3(slate@0.112.0): - resolution: {integrity: sha512-sgdff4Usdflmw5ZUbhDkxFwCBQ2qlDKMMkF93w66KdV48vHOgN2BmLrf+2H8SdX8PYIpP/cTB0w8qWC2GwhDVA==} - peerDependencies: - slate: '>=0.65.3' - dependencies: - is-plain-object: 5.0.0 - slate: 0.112.0 - dev: false - /slate-react@0.101.6(react-dom@18.2.0)(react@18.2.0)(slate@0.101.5): resolution: {integrity: sha512-aMtp9FY127hKWTkCcTBonfKIwKJC2ESPqFdw2o/RuOk3RMQRwsWay8XTOHx8OBGOHanI2fsKaTAPF5zxOLA1Qg==} peerDependencies: @@ -13182,27 +13155,6 @@ packages: tiny-invariant: 1.3.1 dev: false - /slate-react@0.112.0(react-dom@18.2.0)(react@18.2.0)(slate-dom@0.111.0)(slate@0.112.0): - resolution: {integrity: sha512-LoHb/XXnI5uf+n2hnjDKjWb3D+H3lGIg16N7Zzm1nHhhXm3NzwoKOTbzdKOMLdt2+tnhTaHpSxYfT7zZ+wdzUw==} - peerDependencies: - react: '>=18.2.0' - react-dom: '>=18.2.0' - slate: '>=0.99.0' - slate-dom: '>=0.110.2' - dependencies: - '@juggle/resize-observer': 3.4.0 - direction: 1.0.4 - is-hotkey: 0.2.0 - is-plain-object: 5.0.0 - lodash: 4.17.21 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - scroll-into-view-if-needed: 3.1.0 - slate: 0.112.0 - slate-dom: 0.111.0(slate@0.101.5) - tiny-invariant: 1.3.1 - dev: false - /slate@0.101.5: resolution: {integrity: sha512-ZZt1ia8ayRqxtpILRMi2a4MfdvwdTu64CorxTVq9vNSd0GQ/t3YDkze6wKjdeUtENmBlq5wNIDInZbx38Hfu5Q==} dependencies: @@ -13211,14 +13163,6 @@ packages: tiny-warning: 1.0.3 dev: false - /slate@0.112.0: - resolution: {integrity: sha512-PRnfFgDA3tSop4OH47zu4M1R4Uuhm/AmASu29Qp7sGghVFb713kPBKEnSf1op7Lx/nCHkRlCa3ThfHtCBy+5Yw==} - dependencies: - immer: 10.1.1 - is-plain-object: 5.0.0 - tiny-warning: 1.0.3 - dev: false - /slice-ansi@3.0.0: resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==} engines: {node: '>=8'} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/http/gotrue.ts b/frontend/appflowy_web_app/src/application/services/js-services/http/gotrue.ts index 4668af7d906d8..32ff40bbfaf8a 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/http/gotrue.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/http/gotrue.ts @@ -3,7 +3,7 @@ import axios, { AxiosInstance } from 'axios'; let axiosInstance: AxiosInstance | null = null; -export function initGrantService (baseURL: string) { +export function initGrantService(baseURL: string) { if (axiosInstance) { return; } @@ -21,7 +21,7 @@ export function initGrantService (baseURL: string) { }); } -export async function refreshToken (refresh_token: string) { +export async function refreshToken(refresh_token: string) { const response = await axiosInstance?.post<{ access_token: string; expires_at: number; @@ -34,12 +34,14 @@ export async function refreshToken (refresh_token: string) { if (newToken) { refreshSessionToken(JSON.stringify(newToken)); + } else { + return Promise.reject('Failed to refresh token'); } return newToken; } -export async function signInWithMagicLink (email: string, authUrl: string) { +export async function signInWithMagicLink(email: string, authUrl: string) { const res = await axiosInstance?.post( '/magiclink', { @@ -58,13 +60,13 @@ export async function signInWithMagicLink (email: string, authUrl: string) { return res?.data; } -export async function settings () { +export async function settings() { const res = await axiosInstance?.get('/settings'); return res?.data; } -export function signInGoogle (authUrl: string) { +export function signInGoogle(authUrl: string) { const provider = 'google'; const redirectTo = encodeURIComponent(authUrl); const accessType = 'offline'; @@ -75,7 +77,7 @@ export function signInGoogle (authUrl: string) { window.open(url, '_current'); } -export function signInApple (authUrl: string) { +export function signInApple(authUrl: string) { const provider = 'apple'; const redirectTo = encodeURIComponent(authUrl); const baseURL = axiosInstance?.defaults.baseURL; @@ -84,7 +86,7 @@ export function signInApple (authUrl: string) { window.open(url, '_current'); } -export function signInGithub (authUrl: string) { +export function signInGithub(authUrl: string) { const provider = 'github'; const redirectTo = encodeURIComponent(authUrl); const baseURL = axiosInstance?.defaults.baseURL; @@ -93,7 +95,7 @@ export function signInGithub (authUrl: string) { window.open(url, '_current'); } -export function signInDiscord (authUrl: string) { +export function signInDiscord(authUrl: string) { const provider = 'discord'; const redirectTo = encodeURIComponent(authUrl); const baseURL = axiosInstance?.defaults.baseURL; diff --git a/frontend/appflowy_web_app/src/application/services/js-services/http/http_api.ts b/frontend/appflowy_web_app/src/application/services/js-services/http/http_api.ts index 57cf36a7fc6de..ebf6506fc09b0 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/http/http_api.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/http/http_api.ts @@ -39,6 +39,7 @@ import axios, { AxiosInstance } from 'axios'; import dayjs from 'dayjs'; import { omit } from 'lodash-es'; import { nanoid } from 'nanoid'; +import { notify } from '@/components/_shared/notify'; export * from './gotrue'; @@ -72,9 +73,14 @@ export function initAPIService(config: AFCloudConfig) { const refresh_token = token.refresh_token; if (isExpired) { - const newToken = await refreshToken(refresh_token); - - access_token = newToken?.access_token || ''; + try { + const newToken = await refreshToken(refresh_token); + + access_token = newToken?.access_token || ''; + } catch (e) { + invalidToken(); + return config; + } } if (access_token) { @@ -1192,6 +1198,18 @@ export async function getSubscriptions() { } +export async function getWorkspaceSubscriptions(workspaceId: string) { + try { + const plans = await getActiveSubscription(workspaceId); + const subscriptions = await getSubscriptions(); + + return subscriptions?.filter((subscription) => plans?.includes(subscription.plan)); + + } catch (e) { + return Promise.reject(e); + } +} + export async function getActiveSubscription(workspaceId: string) { const url = `/billing/api/v1/active-subscription/${workspaceId}`; @@ -1405,32 +1423,62 @@ export async function updateSpace(workspaceId: string, payload: UpdateSpacePaylo export async function uploadFile(workspaceId: string, viewId: string, file: File, onProgress?: (progress: number) => void) { const url = `/api/file_storage/${workspaceId}/v1/blob/${viewId}`; - const response = await axiosInstance?.put<{ - code: number; - message: string; - data: { - file_id: string; + // Check file size, if over 7MB, check subscription plan + if (file.size > 7 * 1024 * 1024) { + const plan = await getActiveSubscription(workspaceId); + + if (plan?.length === 0 || plan?.[0] === SubscriptionPlan.Free) { + notify.error('Your file is over 7 MB limit of the Free plan. Upgrade for unlimited uploads.'); + + return Promise.reject({ + code: 413, + message: 'File size is too large. Please upgrade your plan for unlimited uploads.', + }); } - }>(url, file, { - onUploadProgress: (progressEvent) => { - const { progress = 0 } = progressEvent; + } - onProgress?.(progress); - }, - headers: { - 'Content-Type': file.type || 'application/octet-stream', - }, - }); + try { + const response = await axiosInstance?.put<{ + code: number; + message: string; + data: { + file_id: string; + } + }>(url, file, { + onUploadProgress: (progressEvent) => { + const { progress = 0 } = progressEvent; - if (response?.data.code === 0) { - const baseURL = axiosInstance?.defaults.baseURL; - const url = `${baseURL}/api/file_storage/${workspaceId}/v1/blob/${viewId}/${response?.data.data.file_id}`; + onProgress?.(progress); + }, + headers: { + 'Content-Type': file.type || 'application/octet-stream', + }, + }); + + if (response?.data.code === 0) { + const baseURL = axiosInstance?.defaults.baseURL; + const url = `${baseURL}/api/file_storage/${workspaceId}/v1/blob/${viewId}/${response?.data.data.file_id}`; + + return url; + } - console.log('Upload file success:', url); - return url; + return Promise.reject(response?.data); + // eslint-disable-next-line + } catch (e: any) { + + if (e.response.status === 413) { + return Promise.reject({ + code: 413, + message: 'File size is too large. Please upgrade your plan for unlimited uploads.', + }); + } } - return Promise.reject(response?.data); + return Promise.reject({ + code: -1, + message: 'Upload file failed.', + }); + } export async function inviteMembers(workspaceId: string, emails: string[]) { @@ -1571,5 +1619,24 @@ export async function deleteQuickNote(workspaceId: string, noteId: string) { return; } + return Promise.reject(res?.data); +} + +export async function cancelSubscription(workspaceId: string, plan: SubscriptionPlan, reason?: string) { + const url = `/billing/api/v1/cancel-subscription`; + const res = await axiosInstance?.post<{ + code: number; + message: string; + }>(url, { + workspace_id: workspaceId, + plan, + sync: true, + reason, + }); + + if (res?.data.code === 0) { + return; + } + return Promise.reject(res?.data); } \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/application/services/js-services/index.ts b/frontend/appflowy_web_app/src/application/services/js-services/index.ts index 2526c6b63ff35..14f635ab80897 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/index.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/index.ts @@ -452,6 +452,10 @@ export class AFClientService implements AFService { return APIService.getSubscriptionLink(workspaceId, plan, interval); } + cancelSubscription(workspaceId: string, plan: SubscriptionPlan, reason?: string) { + return APIService.cancelSubscription(workspaceId, plan, reason); + } + getSubscriptions() { return APIService.getSubscriptions(); } @@ -460,6 +464,10 @@ export class AFClientService implements AFService { return APIService.getActiveSubscription(workspaceId); } + getWorkspaceSubscriptions(workspaceId: string) { + return APIService.getWorkspaceSubscriptions(workspaceId); + } + registerDocUpdate(doc: Y.Doc, context: { workspaceId: string, objectId: string, collabType: Types }) { diff --git a/frontend/appflowy_web_app/src/application/services/services.type.ts b/frontend/appflowy_web_app/src/application/services/services.type.ts index 57c863eb2f964..8b329be754e76 100644 --- a/frontend/appflowy_web_app/src/application/services/services.type.ts +++ b/frontend/appflowy_web_app/src/application/services/services.type.ts @@ -19,7 +19,7 @@ import { UpdateSpacePayload, WorkspaceMember, QuickNoteEditorData, - QuickNote, + QuickNote, Subscription, } from '@/application/types'; import { GlobalComment, Reaction } from '@/application/comment.type'; import { ViewMeta } from '@/application/db/tables/view_metas'; @@ -83,7 +83,9 @@ export interface AppService { sendRequestAccess: (workspaceId: string, viewId: string) => Promise; getSubscriptionLink: (workspaceId: string, plan: SubscriptionPlan, interval: SubscriptionInterval) => Promise; getSubscriptions: () => Promise; + cancelSubscription: (workspaceId: string, plan: SubscriptionPlan, reason?: string) => Promise; getActiveSubscription: (workspaceId: string) => Promise; + getWorkspaceSubscriptions: (workspaceId: string) => Promise; registerDocUpdate: (doc: YDoc, context: { workspaceId: string, objectId: string, collabType: Types }) => void; diff --git a/frontend/appflowy_web_app/src/application/session/sign_in.ts b/frontend/appflowy_web_app/src/application/session/sign_in.ts index 9efd523bf3b0a..75892078c5d9b 100644 --- a/frontend/appflowy_web_app/src/application/session/sign_in.ts +++ b/frontend/appflowy_web_app/src/application/session/sign_in.ts @@ -1,19 +1,19 @@ -export function saveRedirectTo (redirectTo: string) { +export function saveRedirectTo(redirectTo: string) { localStorage.setItem('redirectTo', redirectTo); } -export function getRedirectTo () { +export function getRedirectTo() { return localStorage.getItem('redirectTo'); } -export function clearRedirectTo () { +export function clearRedirectTo() { localStorage.removeItem('redirectTo'); } export const AUTH_CALLBACK_PATH = '/auth/callback'; export const AUTH_CALLBACK_URL = `${window.location.origin}${AUTH_CALLBACK_PATH}`; -export function withSignIn () { +export function withSignIn() { return function ( // eslint-disable-next-line _target: any, @@ -40,11 +40,11 @@ export function withSignIn () { }; } -export function afterAuth () { +export function afterAuth() { const redirectTo = getRedirectTo(); if (redirectTo) { clearRedirectTo(); - window.location.href = redirectTo; + window.location.href = decodeURIComponent(redirectTo); } } diff --git a/frontend/appflowy_web_app/src/application/session/token.ts b/frontend/appflowy_web_app/src/application/session/token.ts index 2d43a9aba7164..73c8482759faa 100644 --- a/frontend/appflowy_web_app/src/application/session/token.ts +++ b/frontend/appflowy_web_app/src/application/session/token.ts @@ -1,25 +1,24 @@ import { emit, EventType } from '@/application/session/event'; -export function refreshToken (token: string) { - localStorage.removeItem('token'); +export function refreshToken(token: string) { localStorage.setItem('token', token); emit(EventType.SESSION_REFRESH, token); } -export function invalidToken () { +export function invalidToken() { localStorage.removeItem('token'); emit(EventType.SESSION_INVALID); } -export function isTokenValid () { +export function isTokenValid() { return !!localStorage.getItem('token'); } -export function getToken () { +export function getToken() { return localStorage.getItem('token'); } -export function getTokenParsed (): { +export function getTokenParsed(): { access_token: string; expires_at: number; refresh_token: string; diff --git a/frontend/appflowy_web_app/src/application/slate-yjs/command/index.ts b/frontend/appflowy_web_app/src/application/slate-yjs/command/index.ts index 56b976b43a5b7..9bcf5caa62d20 100644 --- a/frontend/appflowy_web_app/src/application/slate-yjs/command/index.ts +++ b/frontend/appflowy_web_app/src/application/slate-yjs/command/index.ts @@ -45,19 +45,6 @@ import { } from '@/application/slate-yjs/utils/yjs'; export const CustomEditor = { - // find entry from blockId - getBlockEntry(editor: YjsEditor, blockId: string): NodeEntry | undefined { - const [entry] = editor.nodes({ - at: [], - match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId === blockId, - }); - - if (!entry) { - return; - } - - return entry as NodeEntry; - }, // Get the text content of a block node, including the text content of its children and formula nodes getBlockTextContent(node: Node, depth: number = Infinity): string { if (Text.isText(node)) { @@ -106,7 +93,7 @@ export const CustomEditor = { const newProperties = { data: newData, } as Partial; - const entry = CustomEditor.getBlockEntry(editor, blockId); + const entry = findSlateEntryByBlockId(editor, blockId); if (!entry) { console.error('Block not found'); diff --git a/frontend/appflowy_web_app/src/application/types.ts b/frontend/appflowy_web_app/src/application/types.ts index ac79b9a8efc13..1ceb16b9d199e 100644 --- a/frontend/appflowy_web_app/src/application/types.ts +++ b/frontend/appflowy_web_app/src/application/types.ts @@ -796,7 +796,7 @@ export interface DuplicatePublishView { export enum ViewIconType { Emoji = 0, - Icon = 1, + Icon = 2, } export interface ViewIcon { diff --git a/frontend/appflowy_web_app/src/assets/icon_upgrade.svg b/frontend/appflowy_web_app/src/assets/icon_upgrade.svg new file mode 100644 index 0000000000000..40f85de570680 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/icon_upgrade.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/frontend/appflowy_web_app/src/components/_shared/breadcrumb/BreadcrumbItem.tsx b/frontend/appflowy_web_app/src/components/_shared/breadcrumb/BreadcrumbItem.tsx index 8597c031e423d..0ec55031d05a5 100644 --- a/frontend/appflowy_web_app/src/components/_shared/breadcrumb/BreadcrumbItem.tsx +++ b/frontend/appflowy_web_app/src/components/_shared/breadcrumb/BreadcrumbItem.tsx @@ -1,25 +1,21 @@ import { UIVariant, View } from '@/application/types'; import PublishIcon from '@/components/_shared/view-icon/PublishIcon'; import { notify } from '@/components/_shared/notify'; -import { ViewIcon } from '@/components/_shared/view-icon'; -import SpaceIcon from '@/components/_shared/breadcrumb/SpaceIcon'; -import { isFlagEmoji } from '@/utils/emoji'; +import SpaceIcon from '@/components/_shared/view-icon/SpaceIcon'; import { Tooltip } from '@mui/material'; import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import PageIcon from '@/components/_shared/view-icon/PageIcon'; -function BreadcrumbItem ({ crumb, disableClick = false, toView, variant }: { +function BreadcrumbItem({ crumb, disableClick = false, toView, variant }: { crumb: View; disableClick?: boolean; toView?: (viewId: string) => Promise; variant?: UIVariant }) { - const { view_id, icon, name, layout, extra, is_published } = crumb; + const { view_id, name, extra, is_published } = crumb; const { t } = useTranslation(); - const isFlag = useMemo(() => { - return icon ? isFlagEmoji(icon.value) : false; - }, [icon]); const className = useMemo(() => { const classList = ['flex', 'items-center', 'gap-1.5', 'text-sm', 'overflow-hidden', 'max-sm:text-base']; @@ -57,12 +53,7 @@ function BreadcrumbItem ({ crumb, disableClick = false, toView, variant }: { char={extra.space_icon ? undefined : name.slice(0, 1)} /> ) : ( - - {icon?.value || } - + )} void; + onSelect: (icon: { value: string, color: string, content: string }) => void; onEscape?: () => void; }) { const { t } = useTranslation(); @@ -156,7 +156,7 @@ function IconPicker ({
} + startAdornment={} value={searchValue} onChange={(e) => { setSearchValue(e.target.value); @@ -189,10 +189,10 @@ function IconPicker ({ const icon = await randomIcon(); const color = randomColor(IconColors); - onSelect({ value: icon.id, color }); + onSelect({ value: icon.id, color, content: icon.content }); }} > - + @@ -256,7 +256,15 @@ function IconPicker ({ className={'h-9 w-9 min-w-[36px] px-0 py-0'} onClick={() => { if (!selectIcon) return; - onSelect({ value: selectIcon, color }); + const [groupName, iconName] = selectIcon.split('/'); + + const category = icons?.[groupName as ICON_CATEGORY]; + + if (!category) return; + + const content = category.find((icon) => icon.name === iconName)?.content; + + onSelect({ value: selectIcon, color, content: content || '' }); setAnchorEl(null); }} > diff --git a/frontend/appflowy_web_app/src/components/_shared/image-upload/UploadImage.tsx b/frontend/appflowy_web_app/src/components/_shared/image-upload/UploadImage.tsx index ad994dfc34057..225e6dc609311 100644 --- a/frontend/appflowy_web_app/src/components/_shared/image-upload/UploadImage.tsx +++ b/frontend/appflowy_web_app/src/components/_shared/image-upload/UploadImage.tsx @@ -3,7 +3,6 @@ import { notify } from '@/components/_shared/notify'; import React, { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -export const MAX_IMAGE_SIZE = 7 * 1024 * 1024; // 7MB export const ALLOWED_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp']; export function UploadImage({ onDone, uploadAction }: { @@ -16,12 +15,6 @@ export function UploadImage({ onDone, uploadAction }: { if (!file) return; - if (file.size > MAX_IMAGE_SIZE) { - notify.error(`File size is too large, please upload a file less than ${MAX_IMAGE_SIZE / 1024 / 1024}MB`); - - return; - } - try { const url = await uploadAction?.(file); diff --git a/frontend/appflowy_web_app/src/components/_shared/modal/ChangeAccount.tsx b/frontend/appflowy_web_app/src/components/_shared/modal/ChangeAccount.tsx index 0584b116f1a1a..b4ef7a6537619 100644 --- a/frontend/appflowy_web_app/src/components/_shared/modal/ChangeAccount.tsx +++ b/frontend/appflowy_web_app/src/components/_shared/modal/ChangeAccount.tsx @@ -5,13 +5,14 @@ import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { ReactComponent as ErrorIcon } from '@/assets/error.svg'; -function ChangeAccount ({ +function ChangeAccount({ setModalOpened, modalOpened, + redirectTo, }: { setModalOpened: (opened: boolean) => void; modalOpened: boolean; - + redirectTo: string; }) { const currentUser = useCurrentUser(); const navigate = useNavigate(); @@ -26,10 +27,12 @@ function ChangeAccount ({ }} closable={false} cancelText={t('invitation.errorModal.close')} - onOk={openLoginModal} + onOk={() => { + openLoginModal?.(redirectTo); + }} okText={t('invitation.errorModal.changeAccount')} title={
- + {t('invitation.errorModal.title')}
} open={modalOpened} diff --git a/frontend/appflowy_web_app/src/components/_shared/outline/OutlineItemContent.tsx b/frontend/appflowy_web_app/src/components/_shared/outline/OutlineItemContent.tsx index 84c48bbcb5bf3..7d0dfb5ce1389 100644 --- a/frontend/appflowy_web_app/src/components/_shared/outline/OutlineItemContent.tsx +++ b/frontend/appflowy_web_app/src/components/_shared/outline/OutlineItemContent.tsx @@ -1,13 +1,12 @@ import { UIVariant, View, ViewLayout } from '@/application/types'; -import SpaceIcon from '@/components/_shared/breadcrumb/SpaceIcon'; -import { ViewIcon } from '@/components/_shared/view-icon'; +import SpaceIcon from '@/components/_shared/view-icon/SpaceIcon'; import PublishIcon from '@/components/_shared/view-icon/PublishIcon'; -import { isFlagEmoji } from '@/utils/emoji'; import { Tooltip } from '@mui/material'; import React, { memo } from 'react'; import { useTranslation } from 'react-i18next'; +import PageIcon from '@/components/_shared/view-icon/PageIcon'; -function OutlineItemContent ({ +function OutlineItemContent({ item, setIsExpanded, navigateToView, @@ -21,7 +20,7 @@ function OutlineItemContent ({ variant?: UIVariant; }) { - const { icon, layout, name, view_id, extra } = item; + const { name, view_id, extra } = item; const [hovered, setHovered] = React.useState(false); const isSpace = extra?.is_space; const { t } = useTranslation(); @@ -54,14 +53,7 @@ function OutlineItemContent ({ value={extra.space_icon || ''} char={extra.space_icon ? undefined : name.slice(0, 1)} /> : -
- {icon?.value || } -
+ } , - onSelectIcon?: (icon: { ty: ViewIconType, value: string, color?: string }) => void, + onSelectIcon?: (icon: { ty: ViewIconType, value: string, color?: string, content?: string }) => void, removeIcon?: () => void, hideRemove?: boolean, }) { const [value, setValue] = useState(defaultType); const { t } = useTranslation(); + const handleClose = () => { + onClose(); + setValue(defaultType); + }; + return ( + { - iconEnabled && ( + emojiEnabled && ( ) } { - emojiEnabled && ( + iconEnabled && ( ) } @@ -90,13 +96,13 @@ function ChangeIconPopover ({ value={value} > { onSelectIcon?.({ ty: ViewIconType.Icon, ...icon, }); - onClose(); + handleClose(); }} /> } @@ -111,7 +117,7 @@ function ChangeIconPopover ({ value: emoji, }); }} - onEscape={onClose} + onEscape={handleClose} hideRemove /> } diff --git a/frontend/appflowy_web_app/src/components/_shared/view-icon/PageIcon.tsx b/frontend/appflowy_web_app/src/components/_shared/view-icon/PageIcon.tsx new file mode 100644 index 0000000000000..38af645c93d3f --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/view-icon/PageIcon.tsx @@ -0,0 +1,75 @@ +import React, { useMemo } from 'react'; +import { ViewIcon, ViewIconType, ViewLayout } from '@/application/types'; +import { ReactComponent as BoardSvg } from '@/assets/board.svg'; +import { ReactComponent as CalendarSvg } from '@/assets/calendar.svg'; +import { ReactComponent as DocumentSvg } from '@/assets/document.svg'; +import { ReactComponent as GridSvg } from '@/assets/grid.svg'; +import { ReactComponent as ChatSvg } from '@/assets/chat_ai.svg'; +import { isFlagEmoji } from '@/utils/emoji'; +import DOMPurify from 'dompurify'; +import { renderColor } from '@/utils/color'; + +function PageIcon({ + view, + className, +}: { + view: { + icon?: ViewIcon | null; + layout: ViewLayout; + }; + className?: string; +}) { + + const emoji = useMemo(() => { + if (view.icon && view.icon.ty === ViewIconType.Emoji) { + return view.icon.value; + } + + return null; + }, [view]); + + const isFlag = useMemo(() => { + return emoji ? isFlagEmoji(emoji) : false; + }, [emoji]); + + const icon = useMemo(() => { + if (view.icon && view.icon.ty === ViewIconType.Icon) { + const json = JSON.parse(view.icon.value); + const cleanSvg = DOMPurify.sanitize(json.iconContent.replaceAll('black', renderColor(json.color)).replace('; + } + }, [view, className]); + + if (emoji) { + return <> + {emoji} + ; + } + + if (icon) { + return icon; + } + + switch (view.layout) { + case ViewLayout.AIChat: + return ; + case ViewLayout.Grid: + return ; + case ViewLayout.Board: + return ; + case ViewLayout.Calendar: + return ; + case ViewLayout.Document: + return ; + default: + return null; + } + +} + +export default PageIcon; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/_shared/breadcrumb/SpaceIcon.tsx b/frontend/appflowy_web_app/src/components/_shared/view-icon/SpaceIcon.tsx similarity index 79% rename from frontend/appflowy_web_app/src/components/_shared/breadcrumb/SpaceIcon.tsx rename to frontend/appflowy_web_app/src/components/_shared/view-icon/SpaceIcon.tsx index c2a42318e8a0d..3aff545a3a6d4 100644 --- a/frontend/appflowy_web_app/src/components/_shared/breadcrumb/SpaceIcon.tsx +++ b/frontend/appflowy_web_app/src/components/_shared/view-icon/SpaceIcon.tsx @@ -1,6 +1,6 @@ import { ThemeModeContext } from '@/components/main/useAppThemeMode'; import { renderColor } from '@/utils/color'; -import { getIconSvgEncodedContent } from '@/utils/emoji'; +import { getIcon } from '@/utils/emoji'; import React, { useContext, useEffect, useMemo, useState } from 'react'; import { ReactComponent as SpaceIcon1 } from '@/assets/space_icon/space_icon_1.svg'; import { ReactComponent as SpaceIcon2 } from '@/assets/space_icon/space_icon_2.svg'; @@ -17,6 +17,7 @@ import { ReactComponent as SpaceIcon12 } from '@/assets/space_icon/space_icon_12 import { ReactComponent as SpaceIcon13 } from '@/assets/space_icon/space_icon_13.svg'; import { ReactComponent as SpaceIcon14 } from '@/assets/space_icon/space_icon_14.svg'; import { ReactComponent as SpaceIcon15 } from '@/assets/space_icon/space_icon_15.svg'; +import DOMPurify from 'dompurify'; export const getIconComponent = (icon: string) => { switch (icon) { @@ -57,38 +58,35 @@ export const getIconComponent = (icon: string) => { } }; -function SpaceIcon ({ value, char, bgColor, className: classNameProp }: { +function SpaceIcon({ value, char, bgColor, className: classNameProp }: { value: string, char?: string, bgColor?: string, className?: string }) { const IconComponent = getIconComponent(value); - const [iconEncodeContent, setIconEncodeContent] = useState(null); const isDark = useContext(ThemeModeContext)?.isDark || false; + const [customIconContent, setCustomIconContent] = useState(''); useEffect(() => { - if (!char && value && !IconComponent) { - void getIconSvgEncodedContent(value, isDark ? 'black' : 'white').then((res) => { - setIconEncodeContent(res); + if (value) { + void getIcon(value).then(icon => { + setCustomIconContent(icon?.content || ''); }); } - }, [isDark, IconComponent, value, char]); + }, [value]); const customIcon = useMemo(() => { - if (!iconEncodeContent) { - return null; - } + if (customIconContent) { + const cleanSvg = DOMPurify.sanitize(customIconContent.replaceAll('black', isDark ? 'black' : 'white').replace('; - }, [iconEncodeContent, value]); + return ; + } + }, [customIconContent, isDark]); const content = useMemo(() => { if (char) { @@ -103,7 +101,7 @@ function SpaceIcon ({ value, char, bgColor, className: classNameProp }: { return customIcon; } - return ; + return ; }, [IconComponent, char, customIcon]); const className = useMemo(() => { diff --git a/frontend/appflowy_web_app/src/components/app/SideBarBottom.tsx b/frontend/appflowy_web_app/src/components/app/SideBarBottom.tsx index e876ad05b496f..4fd3b6473c61e 100644 --- a/frontend/appflowy_web_app/src/components/app/SideBarBottom.tsx +++ b/frontend/appflowy_web_app/src/components/app/SideBarBottom.tsx @@ -1,7 +1,7 @@ import { IconButton, Tooltip } from '@mui/material'; import React from 'react'; import { useTranslation } from 'react-i18next'; -import { ReactComponent as TemplateIcon } from '@/assets/template.svg'; +// import { ReactComponent as TemplateIcon } from '@/assets/template.svg'; import { useNavigate } from 'react-router-dom'; import { ReactComponent as TrashIcon } from '@/assets/trash.svg'; import { QuickNote } from '@/components/quick-note'; @@ -18,16 +18,16 @@ function SideBarBottom() { className={'flex py-4 border-t border-line-divider gap-1 justify-around items-center'} > - - { - window.open('https://appflowy.io/templates', '_blank'); - }} - > - - - + {/**/} + {/* {*/} + {/* window.open('https://appflowy.io/templates', '_blank');*/} + {/* }}*/} + {/* >*/} + {/* */} + {/* */} + {/**/} (undefined); + const [doc, setDoc] = React.useState<{ + id: string; + doc: YDoc; + } | undefined>(undefined); const [notFound, setNotFound] = React.useState(false); - const loadPageDoc = useCallback(async () => { - - if (!viewId) { - return; - } + const loadPageDoc = useCallback(async (id: string) => { setNotFound(false); setDoc(undefined); try { - const doc = await loadView(viewId); - - setDoc(doc); + const doc = await loadView(id); + + setDoc({ doc, id }); } catch (e) { setNotFound(true); console.error(e); } - }, [loadView, viewId]); + }, [loadView]); useEffect(() => { - void loadPageDoc(); - }, [loadPageDoc]); + if (open && viewId) { + + void loadPageDoc(viewId); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, viewId]); + + const handleClose = useCallback(() => { + setDoc(undefined); + onClose(); + }, [onClose]); const view = useMemo(() => { if (!outline || !viewId) return; @@ -124,7 +132,7 @@ function ViewModal({ size={'small'} onClick={async () => { await toView(viewId); - onClose(); + handleClose(); }} > @@ -155,9 +163,7 @@ function ViewModal({
{ - onClose(); - }} + onDeleted={handleClose} viewId={viewId} /> @@ -167,7 +173,7 @@ function ViewModal({ /> @@ -175,7 +181,7 @@ function ViewModal({
); - }, [onClose, outline, t, toView, viewId]); + }, [handleClose, outline, t, toView, viewId]); const layout = view?.layout || ViewLayout.Document; @@ -193,9 +199,9 @@ function ViewModal({ }, [layout]) as React.FC; const viewDom = useMemo(() => { - if (!doc || !viewMeta) return null; + if (!doc || !viewMeta || doc.id !== viewMeta.viewId) return null; return Promise; updateSpace?: (payload: UpdateSpacePayload) => Promise; uploadFile?: (viewId: string, file: File, onProgress?: (n: number) => void) => Promise; + getSubscriptions?: () => Promise; } const USER_NO_ACCESS_CODE = [1024, 1012]; @@ -217,7 +218,6 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => { }, [currentWorkspaceId, loadViewMeta, navigate]); const loadView = useCallback(async (id: string) => { - const errorCallback = (e: { code: number; }) => { @@ -626,6 +626,20 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => { } }, [currentWorkspaceId, service]); + const getSubscriptions = useCallback(async () => { + if (!service || !currentWorkspaceId) { + throw new Error('No service found'); + } + + try { + const res = await service.getWorkspaceSubscriptions(currentWorkspaceId); + + return res; + } catch (e) { + return Promise.reject(e); + } + }, [currentWorkspaceId, service]); + return { createSpace, updateSpace, uploadFile, + getSubscriptions, }} > {requestAccessOpened ? : children} @@ -802,6 +817,7 @@ export function useAppHandlers() { createSpace: context.createSpace, updateSpace: context.updateSpace, uploadFile: context.uploadFile, + getSubscriptions: context.getSubscriptions, }; } diff --git a/frontend/appflowy_web_app/src/components/app/landing-pages/ApproveRequestPage.tsx b/frontend/appflowy_web_app/src/components/app/landing-pages/ApproveRequestPage.tsx index bb76898245a07..eaaf94fca94d1 100644 --- a/frontend/appflowy_web_app/src/components/app/landing-pages/ApproveRequestPage.tsx +++ b/frontend/appflowy_web_app/src/components/app/landing-pages/ApproveRequestPage.tsx @@ -20,7 +20,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom'; const WorkspaceMemberLimitExceededCode = 1027; const REPEAT_REQUEST_CODE = 1043; -function ApproveRequestPage () { +function ApproveRequestPage() { const [searchParams] = useSearchParams(); const isAuthenticated = useContext(AFConfigContext)?.isAuthenticated; @@ -35,12 +35,16 @@ function ApproveRequestPage () { const [errorModalOpen, setErrorModalOpen] = React.useState(false); const [alreadyProModalOpen, setAlreadyProModalOpen] = React.useState(false); const [clicked, setClicked] = React.useState(false); + const url = useMemo(() => { + return window.location.href; + }, []); useEffect(() => { if (!isAuthenticated) { navigate('/login?redirectTo=' + encodeURIComponent(window.location.href)); } }, [isAuthenticated, navigate]); + const loadRequestInfo = useCallback(async () => { if (!service || !requestId) return; try { @@ -126,7 +130,7 @@ function ApproveRequestPage () { }} className={'flex w-full cursor-pointer max-md:justify-center max-md:h-32 h-20 items-center justify-between sticky'} > - +
- + />} + setAlreadyProModalOpen(false)} keepMounted={false} title={
- + {t('approveAccess.alreadyProTitle')}
} diff --git a/frontend/appflowy_web_app/src/components/app/outline/Outline.tsx b/frontend/appflowy_web_app/src/components/app/outline/Outline.tsx index 30f19f18731e0..3e377228da094 100644 --- a/frontend/appflowy_web_app/src/components/app/outline/Outline.tsx +++ b/frontend/appflowy_web_app/src/components/app/outline/Outline.tsx @@ -5,6 +5,7 @@ import { useAppHandlers, useAppOutline } from '@/components/app/app.hooks'; import SpaceItem from '@/components/app/outline/SpaceItem'; import React, { useCallback, Suspense } from 'react'; import { Favorite } from '@/components/app/favorite'; +import ViewActionsPopover from '@/components/app/view-actions/ViewActionsPopover'; const ViewActions = React.lazy(() => import('@/components/app/view-actions/ViewActions')); @@ -14,6 +15,19 @@ export function Outline({ width: number; }) { const outline = useAppOutline(); + const [popoverView, setPopoverView] = React.useState(undefined); + const [popoverType, setPopoverType] = React.useState<{ + category: 'space' | 'page'; + type: 'more' | 'add'; + } | undefined>(undefined); + const [anchorPosition, setAnchorPosition] = React.useState(undefined); + const handleClosePopover = () => { + setAnchorPosition(undefined); + }; + const [expandViewIds, setExpandViewIds] = React.useState(Object.keys(getOutlineExpands())); const toggleExpandView = useCallback((id: string, isExpanded: boolean) => { @@ -24,10 +38,13 @@ export function Outline({ }, []); const renderActions = useCallback(({ hovered, view }: { hovered: boolean; view: View }) => { return ; - }, []); + }, [popoverView, anchorPosition]); const { toView, @@ -38,24 +55,32 @@ export function Outline({ }, [toView]); return ( -
- - {!outline || outline.length === 0 ?
-
: - outline.map((view) => )} -
+ <> +
+ + {!outline || outline.length === 0 ?
+
: + outline.map((view) => )} +
+ + ); } diff --git a/frontend/appflowy_web_app/src/components/app/outline/SpaceItem.tsx b/frontend/appflowy_web_app/src/components/app/outline/SpaceItem.tsx index 3c5e295cd0cbf..88aa135bbaf9a 100644 --- a/frontend/appflowy_web_app/src/components/app/outline/SpaceItem.tsx +++ b/frontend/appflowy_web_app/src/components/app/outline/SpaceItem.tsx @@ -1,4 +1,4 @@ -import SpaceIcon from '@/components/_shared/breadcrumb/SpaceIcon'; +import SpaceIcon from '@/components/_shared/view-icon/SpaceIcon'; import ViewItem from '@/components/app/outline/ViewItem'; import { Tooltip } from '@mui/material'; import React, { useMemo } from 'react'; @@ -52,7 +52,7 @@ function SpaceItem({ } > import('@/components/_shared/view-icon/ChangeIconPopover')); @@ -17,8 +16,8 @@ const popoverProps: Origins = { horizontal: 'left', }, anchorOrigin: { - vertical: 'top', - horizontal: 'right', + vertical: 30, + horizontal: 'left', }, }; @@ -60,7 +59,7 @@ function ViewItem({ view, width, level = 0, renderExtra, expandIds, toggleExpand const renderItem = useMemo(() => { if (!view) return null; - const { layout, icon } = view; + const { layout } = view; return (
- {icon?.value || } + +
{ + if (icon.ty === ViewIconType.Icon) { + void handleChangeIcon({ + ty: ViewIconType.Icon, + value: JSON.stringify({ + color: icon.color, + groupName: icon.value.split('/')[0], + iconName: icon.value.split('/')[1], + iconContent: icon.content, + }), + }); + return; + } + + void handleChangeIcon(icon); + }} removeIcon={handleRemoveIcon} /> diff --git a/frontend/appflowy_web_app/src/components/app/view-actions/ManageSpace.tsx b/frontend/appflowy_web_app/src/components/app/view-actions/ManageSpace.tsx index fcdcb1932ca04..e111c2c3f5708 100644 --- a/frontend/appflowy_web_app/src/components/app/view-actions/ManageSpace.tsx +++ b/frontend/appflowy_web_app/src/components/app/view-actions/ManageSpace.tsx @@ -8,7 +8,7 @@ import { OutlinedInput } from '@mui/material'; import React from 'react'; import { useTranslation } from 'react-i18next'; -function ManageSpace ({ open, onClose, viewId }: { +function ManageSpace({ open, onClose, viewId }: { open: boolean; onClose: () => void; viewId: string; @@ -43,6 +43,8 @@ function ManageSpace ({ open, onClose, viewId }: { } }; + const inputRef = React.useRef(null); + if (!view) return null; return ( { + if (!input) return; + if (!inputRef.current) { + setTimeout(() => { + input.setSelectionRange(0, input.value.length); + }, 100); + inputRef.current = input; + } + }} fullWidth={true} onChange={(e) => setSpaceName(e.target.value)} size={'small'} diff --git a/frontend/appflowy_web_app/src/components/app/view-actions/MoreSpaceActions.tsx b/frontend/appflowy_web_app/src/components/app/view-actions/MoreSpaceActions.tsx index 0c2f41701189a..e70b44a9000b9 100644 --- a/frontend/appflowy_web_app/src/components/app/view-actions/MoreSpaceActions.tsx +++ b/frontend/appflowy_web_app/src/components/app/view-actions/MoreSpaceActions.tsx @@ -10,7 +10,7 @@ import { ReactComponent as DuplicateIcon } from '@/assets/duplicate.svg'; import { ReactComponent as SettingsIcon } from '@/assets/settings.svg'; import { ReactComponent as AddIcon } from '@/assets/add.svg'; -function MoreSpaceActions ({ +function MoreSpaceActions({ view, onClose, }: { @@ -24,18 +24,18 @@ function MoreSpaceActions ({ const actions = useMemo(() => { return [{ label: t('space.manage'), - icon: , + icon: , onClick: () => { setManageModalOpen(true); }, }, { label: t('space.duplicate'), - icon: , + icon: , hidden: true, onClick: () => { // }, - } + }, ]; }, [t]); @@ -53,7 +53,7 @@ function MoreSpaceActions ({ {action.label} ))} - + - + - { setManageModalOpen(false); onClose(); }} viewId={view.view_id} - /> - } + {createSpaceModalOpen && setCreateSpaceModalOpen(false)} - /> - } + {deleteModalOpen && { setDeleteModalOpen(false); onClose(); }} - /> + />} +
); } diff --git a/frontend/appflowy_web_app/src/components/app/view-actions/NewPage.tsx b/frontend/appflowy_web_app/src/components/app/view-actions/NewPage.tsx index 445a7d15abe6b..c52035ee0201e 100644 --- a/frontend/appflowy_web_app/src/components/app/view-actions/NewPage.tsx +++ b/frontend/appflowy_web_app/src/components/app/view-actions/NewPage.tsx @@ -79,6 +79,9 @@ function NewPage() { onOk={() => { void handleAddPage(selectedSpaceId); }} + okButtonProps={{ + disabled: !selectedSpaceId, + }} okLoading={loading} > = { }, }; -function SpaceIconButton ({ +function SpaceIconButton({ spaceIcon, spaceIconColor, spaceName, @@ -56,7 +56,7 @@ function SpaceIconButton ({ {spaceIconEditing &&
- +
} diff --git a/frontend/appflowy_web_app/src/components/app/view-actions/ViewActions.tsx b/frontend/appflowy_web_app/src/components/app/view-actions/ViewActions.tsx index c38a71b3357ac..1b93256c98fff 100644 --- a/frontend/appflowy_web_app/src/components/app/view-actions/ViewActions.tsx +++ b/frontend/appflowy_web_app/src/components/app/view-actions/ViewActions.tsx @@ -1,43 +1,21 @@ import { View, ViewLayout } from '@/application/types'; -import { Popover } from '@/components/_shared/popover'; -import AddPageActions from '@/components/app/view-actions/AddPageActions'; -import MorePageActions from '@/components/app/view-actions/MorePageActions'; -import MoreSpaceActions from '@/components/app/view-actions/MoreSpaceActions'; import PageActions from '@/components/app/view-actions/PageActions'; import SpaceActions from '@/components/app/view-actions/SpaceActions'; -import { PopoverProps } from '@mui/material/Popover'; import React, { useCallback, useMemo } from 'react'; import { useAppHandlers } from '@/components/app/app.hooks'; import { notify } from '@/components/_shared/notify'; -const popoverProps: Partial = { - transformOrigin: { - vertical: 'top', - horizontal: 'left', - }, - anchorOrigin: { - vertical: 'bottom', - horizontal: 'left', - }, -}; - -export function ViewActions({ view, hovered }: { +export function ViewActions({ view, hovered, setPopoverType, setAnchorPosition, setPopoverView }: { view: View; hovered?: boolean; -}) { - const isSpace = view?.extra?.is_space; - const [popoverType, setPopoverType] = React.useState<{ + setPopoverType: (popoverType: { category: 'space' | 'page'; type: 'more' | 'add'; - } | null>(null); - const [anchorPosition, setAnchorPosition] = React.useState(undefined); - const open = Boolean(anchorPosition); - const handleClosePopover = () => { - setAnchorPosition(undefined); - }; + }) => void; + setAnchorPosition: (position: { top: number; left: number }) => void; + setPopoverView: (view: View) => void; +}) { + const isSpace = view?.extra?.is_space; const { addPage, @@ -63,8 +41,9 @@ export function ViewActions({ view, hovered }: { setPopoverType(popoverType); const rect = (e.target as HTMLElement).getBoundingClientRect(); + setPopoverView(view); setAnchorPosition({ top: rect.bottom, left: rect.left }); - }, []); + }, [setAnchorPosition, setPopoverType, setPopoverView, view]); const renderButton = useMemo(() => { if (!hovered || !view) return null; @@ -90,52 +69,9 @@ export function ViewActions({ view, hovered }: { />; }, [handleClick, hovered, isSpace, view, handleAddPage]); - const popoverContent = useMemo(() => { - if (!popoverType) return null; - - if (popoverType.type === 'add') { - return { - handleClosePopover(); - }} - view={view} - />; - } - - if (popoverType.category === 'space') { - return { - handleClosePopover(); - }} - view={view} - />; - } else { - return { - handleClosePopover(); - }} - />; - } - }, [popoverType, view]); - return
e.stopPropagation()}> {renderButton} - - {popoverContent} - +
; } diff --git a/frontend/appflowy_web_app/src/components/app/view-actions/ViewActionsPopover.tsx b/frontend/appflowy_web_app/src/components/app/view-actions/ViewActionsPopover.tsx new file mode 100644 index 0000000000000..5bbdad49517cb --- /dev/null +++ b/frontend/appflowy_web_app/src/components/app/view-actions/ViewActionsPopover.tsx @@ -0,0 +1,82 @@ +import React, { useMemo } from 'react'; +import AddPageActions from '@/components/app/view-actions/AddPageActions'; +import MoreSpaceActions from '@/components/app/view-actions/MoreSpaceActions'; +import MorePageActions from '@/components/app/view-actions/MorePageActions'; +import { Popover } from '@/components/_shared/popover'; +import { PopoverProps } from '@mui/material/Popover'; +import { View } from '@/application/types'; + +const popoverProps: Partial = { + transformOrigin: { + vertical: 'top', + horizontal: 'left', + }, + anchorOrigin: { + vertical: 'bottom', + horizontal: 'left', + }, +}; + +function ViewActionsPopover({ + popoverType, + anchorPosition, + view, + onClose, +}: { + view?: View; + onClose: () => void; + popoverType?: { + category: 'space' | 'page'; + type: 'more' | 'add'; + }, + anchorPosition?: { + top: number; + left: number; + } +}) { + + const open = Boolean(anchorPosition); + + const popoverContent = useMemo(() => { + if (!popoverType || !view) return null; + + if (popoverType.type === 'add') { + return ; + } + + if (popoverType.category === 'space') { + return ; + } else { + return ; + } + }, [onClose, popoverType, view]); + + return ( + + {popoverContent} + + ); +} + +export default ViewActionsPopover; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/app/workspaces/InviteMember.tsx b/frontend/appflowy_web_app/src/components/app/workspaces/InviteMember.tsx index 4fbd629832c1b..8f00ed90392ea 100644 --- a/frontend/appflowy_web_app/src/components/app/workspaces/InviteMember.tsx +++ b/frontend/appflowy_web_app/src/components/app/workspaces/InviteMember.tsx @@ -1,22 +1,32 @@ -import React, { useCallback, useEffect, useRef } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { Button, OutlinedInput } from '@mui/material'; import { ReactComponent as AddUserIcon } from '@/assets/add_user.svg'; import { useTranslation } from 'react-i18next'; import { NormalModal } from '@/components/_shared/modal'; import { notify } from '@/components/_shared/notify'; import { useCurrentUser, useService } from '@/components/main/app.hooks'; -import { Workspace, WorkspaceMember } from '@/application/types'; +import { Subscription, SubscriptionPlan, Workspace, WorkspaceMember } from '@/application/types'; +import { useAppHandlers } from '@/components/app/app.hooks'; +import { ReactComponent as TipIcon } from '@/assets/warning.svg'; +import { useSearchParams } from 'react-router-dom'; -function InviteMember({ workspace }: { +function InviteMember({ workspace, onClick }: { workspace: Workspace; + onClick?: () => void; }) { + const { + getSubscriptions, + } = useAppHandlers(); const { t } = useTranslation(); const [open, setOpen] = React.useState(false); const [value, setValue] = React.useState(''); const [loading, setLoading] = React.useState(false); const service = useService(); const currentWorkspaceId = workspace.id; + const [, setSearch] = useSearchParams(); + const currentUser = useCurrentUser(); + const [memberCount, setMemberCount] = React.useState(0); const memberListRef = useRef([]); const isOwner = workspace.owner?.uid.toString() === currentUser?.uid.toString(); @@ -24,11 +34,40 @@ function InviteMember({ workspace }: { try { if (!service || !currentWorkspaceId) return; memberListRef.current = await service.getWorkspaceMembers(currentWorkspaceId); + setMemberCount(memberListRef.current.length); } catch (e) { console.error(e); } }, [currentWorkspaceId, service]); + const [activeSubscription, setActiveSubscription] = React.useState(null); + + const loadSubscription = useCallback(async () => { + try { + const subscriptions = await getSubscriptions?.(); + + if (!subscriptions || subscriptions.length === 0) return; + const subscription = subscriptions[0]; + + setActiveSubscription(subscription); + } catch (e) { + console.error(e); + } + }, [getSubscriptions]); + + const isExceed = useMemo(() => { + + if (!activeSubscription || activeSubscription.plan === SubscriptionPlan.Free) { + return memberCount >= 2; + } + + if (activeSubscription.plan === SubscriptionPlan.Pro) { + return memberCount >= 10; + } + + return false; + }, [activeSubscription, memberCount]); + const handleOk = async () => { if (!service || !currentWorkspaceId) return; try { @@ -59,20 +98,28 @@ function InviteMember({ workspace }: { setValue(''); } else { void loadMembers(); + void loadSubscription(); } - }, [open, loadMembers]); + }, [open, loadMembers, loadSubscription]); + + const handleUpgrade = useCallback(async () => { + setSearch(prev => { + prev.set('action', 'change_plan'); + return prev; + }); + }, [setSearch]); if (!isOwner) return null; return ( <> setOpen(false)} >
{currentUser?.email} @@ -100,11 +105,14 @@ export function Workspaces() {
- {selectedWorkspace && } + {selectedWorkspace && { + setOpen(false); + }} workspace={selectedWorkspace}/>} + + >{t('button.logout')} + + + {isOwner && <> + + + }
+ {isOwner && + { + setOpenUpgradePlan(true); + } + } open={openUpgradePlan} onClose={() => setOpenUpgradePlan(false)}/>} ; } diff --git a/frontend/appflowy_web_app/src/components/billing/Billing.tsx b/frontend/appflowy_web_app/src/components/billing/Billing.tsx new file mode 100644 index 0000000000000..d496efad3f0b9 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/billing/Billing.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +export function Billing() { + return ( +
+ ); +} + +export default Billing; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/billing/CancelSubscribe.tsx b/frontend/appflowy_web_app/src/components/billing/CancelSubscribe.tsx new file mode 100644 index 0000000000000..bcabe2cdef126 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/billing/CancelSubscribe.tsx @@ -0,0 +1,224 @@ +import React, { useCallback, useMemo } from 'react'; +import { NormalModal } from '@/components/_shared/modal'; +import { useTranslation } from 'react-i18next'; +import { Divider } from '@mui/material'; +import { SubscriptionPlan } from '@/application/types'; +import { notify } from '@/components/_shared/notify'; +import { useService } from '@/components/main/app.hooks'; +import { useCurrentWorkspaceId } from '@/components/app/app.hooks'; + +function CancelSubscribe({ open, onClose, onCanceled }: { + open: boolean; + onClose: () => void; + onCanceled: () => void; +}) { + const { t } = useTranslation(); + const [page, setPage] = React.useState(0); + const [loading, setLoading] = React.useState(false); + const service = useService(); + const currentWorkspaceId = useCurrentWorkspaceId(); + const [answers, setAnswers] = React.useState<{ + questionIndex: number; + answer: string; + }[]>([]); + + const questions = useMemo(() => { + return [{ + title: t('subscribe.cancelPlan.questionOne.question'), + choices: [ + { + value: 'A', + label: t('subscribe.cancelPlan.questionOne.answerOne'), + }, + { + value: 'B', + label: t('subscribe.cancelPlan.questionOne.answerTwo'), + }, + { + value: 'C', + label: t('subscribe.cancelPlan.questionOne.answerThree'), + }, + { + value: 'D', + label: t('subscribe.cancelPlan.questionOne.answerFour'), + }, + { + value: 'E', + label: t('subscribe.cancelPlan.questionOne.answerFive'), + }, + ], + }, { + title: t('subscribe.cancelPlan.questionTwo.question'), + choices: [ + { + value: 'A', + label: t('subscribe.cancelPlan.questionTwo.answerOne'), + }, + { + value: 'B', + label: t('subscribe.cancelPlan.questionTwo.answerTwo'), + }, + { + value: 'C', + label: t('subscribe.cancelPlan.questionTwo.answerThree'), + }, + { + value: 'D', + label: t('subscribe.cancelPlan.questionTwo.answerFour'), + }, + { + value: 'E', + label: t('subscribe.cancelPlan.questionTwo.answerFive'), + }, + ], + }, { + title: t('subscribe.cancelPlan.questionThree.question'), + choices: [ + { + value: 'A', + label: t('subscribe.cancelPlan.questionThree.answerOne'), + }, + { + value: 'B', + label: t('subscribe.cancelPlan.questionThree.answerTwo'), + }, + { + value: 'C', + label: t('subscribe.cancelPlan.questionThree.answerThree'), + }, + { + value: 'D', + label: t('subscribe.cancelPlan.questionThree.answerFour'), + }, + ], + }, { + title: t('subscribe.cancelPlan.questionFour.question'), + choices: [ + { + value: 'A', + label: t('subscribe.cancelPlan.questionFour.answerOne'), + }, + { + value: 'B', + label: t('subscribe.cancelPlan.questionFour.answerTwo'), + }, + { + value: 'C', + label: t('subscribe.cancelPlan.questionFour.answerThree'), + }, + { + value: 'D', + label: t('subscribe.cancelPlan.questionFour.answerFour'), + }, + { + value: 'E', + label: t('subscribe.cancelPlan.questionFour.answerFive'), + }, + ], + }]; + }, [t]); + + const question = questions[page]; + + const handleCancel = useCallback(async () => { + if (!service || !currentWorkspaceId) return; + setLoading(true); + const plan = SubscriptionPlan.Pro; + + try { + await service.cancelSubscription(currentWorkspaceId, plan, JSON.stringify(answers.map(item => { + const question = questions[item.questionIndex]; + + return { + question: question.title, + answer: question.choices.find(choice => choice.value === item.answer)?.label ?? item.answer, + }; + }))); + notify.success(t('subscribe.cancelPlan.success')); + onCanceled(); + onClose(); + // eslint-disable-next-line + } catch (e: any) { + notify.error(e.message); + } + + setLoading(false); + + }, [answers, currentWorkspaceId, onClose, onCanceled, questions, service, t]); + + const handlePrevious = () => { + if (page === 0) { + onClose(); + return; + } + + setPage(prev => prev - 1); + }; + + const handleNext = useCallback((currentPage: number) => { + if (currentPage === 3) { + void handleCancel(); + return; + } + + setPage(currentPage + 1); + + }, [handleCancel]); + + return ( + {t('subscribe.cancelPlan.title')}} + open={open} + onClose={() => onClose()} + onCancel={handlePrevious} + cancelText={ + page === 0 ? t('button.cancel') : t('button.previous') + } + okLoading={loading} + okButtonProps={{ + disabled: loading, + }} + okText={ + page === 3 ? t('button.done') : t('button.next') + } + onOk={() => handleNext(page)} + > +
{t('subscribe.cancelPlan.description')}
+ + {question &&
+
{question.title}
+
+ {question.choices.map((choice) => { + const selected = answers[page]?.answer === choice.value; + + return
{ + setAnswers(prev => { + const newAnswers = [...prev]; + + newAnswers[page] = { + questionIndex: page, + answer: choice.value, + }; + + return newAnswers; + }); + }} key={choice.value} + className={'flex border border-fill-list-hover p-1 hover:bg-fill-list-hover cursor-pointer rounded-[8px] font-medium items-center gap-2'}> + + {choice.value} + + {choice.label} +
; + })} +
+
+ } + +
+ ); +} + +export default CancelSubscribe; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/billing/UpgradePlan.tsx b/frontend/appflowy_web_app/src/components/billing/UpgradePlan.tsx new file mode 100644 index 0000000000000..39c9f7bda8b5c --- /dev/null +++ b/frontend/appflowy_web_app/src/components/billing/UpgradePlan.tsx @@ -0,0 +1,215 @@ +import React, { useCallback, useEffect, useMemo } from 'react'; +import { NormalModal } from '@/components/_shared/modal'; +import { useTranslation } from 'react-i18next'; +import { Subscription, SubscriptionInterval, SubscriptionPlan } from '@/application/types'; +import { useAppHandlers, useCurrentWorkspaceId } from '@/components/app/app.hooks'; +import { ViewTabs, ViewTab } from '@/components/_shared/tabs/ViewTabs'; +import { Button } from '@mui/material'; +import { notify } from '@/components/_shared/notify'; +import { useService } from '@/components/main/app.hooks'; +import CancelSubscribe from '@/components/billing/CancelSubscribe'; +import { useSearchParams } from 'react-router-dom'; + +function UpgradePlan({ open, onClose, onOpen }: { + open: boolean; + onClose: () => void; + onOpen: () => void; +}) { + const { t } = useTranslation(); + const [activeSubscription, setActiveSubscription] = React.useState(null); + const service = useService(); + const currentWorkspaceId = useCurrentWorkspaceId(); + const [cancelOpen, setCancelOpen] = React.useState(false); + const { getSubscriptions } = useAppHandlers(); + + const [search, setSearch] = useSearchParams(); + const action = search.get('action'); + + useEffect(() => { + if (!open && action === 'change_plan') { + onOpen(); + } + + if (open) { + setSearch(prev => { + prev.set('action', 'change_plan'); + return prev; + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [action, open, setSearch]); + + const loadSubscription = useCallback(async () => { + try { + const subscriptions = await getSubscriptions?.(); + + if (!subscriptions || subscriptions.length === 0) { + setActiveSubscription({ + plan: SubscriptionPlan.Free, + currency: '', + recurring_interval: SubscriptionInterval.Month, + price_cents: 0, + }); + return; + } + + const subscription = subscriptions[0]; + + setActiveSubscription(subscription); + } catch (e) { + console.error(e); + } + }, [getSubscriptions]); + + const handleClose = useCallback(() => { + onClose(); + setSearch(prev => { + prev.delete('action'); + return prev; + }); + }, [onClose, setSearch]); + const [interval, setInterval] = React.useState(SubscriptionInterval.Year); + + const handleUpgrade = useCallback(async () => { + if (!service || !currentWorkspaceId) return; + const plan = SubscriptionPlan.Pro; + + try { + const link = await service.getSubscriptionLink(currentWorkspaceId, plan, interval); + + window.open(link, '_current'); + // eslint-disable-next-line + } catch (e: any) { + notify.error(e.message); + } + }, [currentWorkspaceId, service, interval]); + + useEffect(() => { + if (open) { + void loadSubscription(); + } + }, [open, loadSubscription]); + + const plans = useMemo(() => { + return [{ + key: SubscriptionPlan.Free, + name: t('subscribe.free'), + price: 'Free', + description: t('subscribe.freeDescription'), + duration: t('subscribe.freeDuration'), + points: [ + t('subscribe.freePoints.first'), + t('subscribe.freePoints.second'), + t('subscribe.freePoints.three'), + t('subscribe.freePoints.four'), + t('subscribe.freePoints.five'), + t('subscribe.freePoints.six'), + t('subscribe.freePoints.seven'), + ], + }, { + key: SubscriptionPlan.Pro, + name: t('subscribe.pro'), + price: interval === SubscriptionInterval.Month ? '$12.5' : '$10', + description: t('subscribe.proDescription'), + duration: interval === SubscriptionInterval.Month ? t('subscribe.proDuration.monthly') : t('subscribe.proDuration.yearly'), + points: [ + t('subscribe.proPoints.first'), + t('subscribe.proPoints.second'), + t('subscribe.proPoints.three'), + t('subscribe.proPoints.four'), + t('subscribe.proPoints.five'), + ], + }]; + }, [t, interval]); + + return ( + +
+
+ { + setInterval(v); + }}> + + + +
+ {t('subscribe.priceIn')} + {`$USD`} +
+
+ +
+ {plans.map((plan) => { + return
+ {activeSubscription?.plan === plan.key && +
+ {t('subscribe.currentPlan')} +
} +
{plan.name}
+
{plan.description}
+
{plan.price} +
+
{plan.duration}
+ + {plan.key === SubscriptionPlan.Pro ? +
+ {activeSubscription?.plan !== plan.key && + } + {t('subscribe.everythingInFree')} +
: + activeSubscription?.plan !== plan.key && + + } +
+ {plan.points.map((point, index) => { + return
+
+
+
+
{point}
+
; + })} +
+
; + })} +
+
+ { + setCancelOpen(false); + }}/> + + ); +} + +export default UpgradePlan; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/billing/index.ts b/frontend/appflowy_web_app/src/components/billing/index.ts new file mode 100644 index 0000000000000..6da3db55522fb --- /dev/null +++ b/frontend/appflowy_web_app/src/components/billing/index.ts @@ -0,0 +1 @@ +export * from './Billing'; diff --git a/frontend/appflowy_web_app/src/components/database/components/header/DatabaseHeader.tsx b/frontend/appflowy_web_app/src/components/database/components/header/DatabaseHeader.tsx deleted file mode 100644 index 9406cceac3725..0000000000000 --- a/frontend/appflowy_web_app/src/components/database/components/header/DatabaseHeader.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { ViewLayout, ViewMetaIcon } from '@/application/types'; -import { ViewIcon } from '@/components/_shared/view-icon'; -import { isFlagEmoji } from '@/utils/emoji'; -import React, { useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; - -function DatabaseHeader({ - icon, - name, - layout, -}: { - icon?: ViewMetaIcon; - name?: string; - viewId?: string; - layout?: ViewLayout; -}) { - const { t } = useTranslation(); - const isFlag = useMemo(() => { - return icon ? isFlagEmoji(icon.value) : false; - }, [icon]); - - return ( -
-
- {icon?.value ? ( -
{icon?.value}
- ) : ( - - )} -
-
- {name || {t('menuAppHeader.defaultNewPageName')}} -
-
- ); -} - -export default DatabaseHeader; diff --git a/frontend/appflowy_web_app/src/components/editor/CollaborativeEditor.tsx b/frontend/appflowy_web_app/src/components/editor/CollaborativeEditor.tsx index 2e6015dfc5d17..c843e57bd3727 100644 --- a/frontend/appflowy_web_app/src/components/editor/CollaborativeEditor.tsx +++ b/frontend/appflowy_web_app/src/components/editor/CollaborativeEditor.tsx @@ -60,6 +60,7 @@ function CollaborativeEditor({ doc }: { doc: Y.Doc }) { setIsConnected(true); return () => { + console.log('disconnect'); editor.disconnect(); }; }, [editor]); diff --git a/frontend/appflowy_web_app/src/components/editor/Editable.tsx b/frontend/appflowy_web_app/src/components/editor/Editable.tsx index 8fb515eddb3fd..4237e68d4d6be 100644 --- a/frontend/appflowy_web_app/src/components/editor/Editable.tsx +++ b/frontend/appflowy_web_app/src/components/editor/Editable.tsx @@ -102,9 +102,9 @@ const EditorEditable = () => { const handleClick = useCallback((e: React.MouseEvent) => { const currentTarget = e.currentTarget as HTMLElement; - const bottomArea = currentTarget.getBoundingClientRect().bottom - 36 * 4; + const bottomArea = currentTarget.getBoundingClientRect().bottom - 56 * 4; - if (e.clientY > bottomArea && e.clientY < (bottomArea + 36)) { + if (e.clientY > bottomArea && e.clientY < (bottomArea + 56)) { const lastBlock = editor.children[editor.children.length - 1] as SlateElement; const isEmptyLine = CustomEditor.getBlockTextContent(lastBlock) === ''; const type = lastBlock.type; @@ -155,7 +155,7 @@ const EditorEditable = () => { return [...codeDecoration, ...decoration]; }} id={`editor-${viewId}`} - className={'outline-none custom-caret scroll-mb-[100px] scroll-mt-[300px] pb-36 min-w-0 max-w-full w-[988px] max-sm:px-6 px-24 focus:outline-none'} + className={'outline-none custom-caret scroll-mb-[100px] scroll-mt-[300px] pb-56 min-w-0 max-w-full w-[988px] max-sm:px-6 px-24 focus:outline-none'} renderLeaf={Leaf} renderElement={renderElement} readOnly={readOnly} diff --git a/frontend/appflowy_web_app/src/components/editor/__tests__/blocks/Code.cy.tsx b/frontend/appflowy_web_app/src/components/editor/__tests__/blocks/Code.cy.tsx new file mode 100644 index 0000000000000..6855d2362d86b --- /dev/null +++ b/frontend/appflowy_web_app/src/components/editor/__tests__/blocks/Code.cy.tsx @@ -0,0 +1,100 @@ +import { initialEditorTest, moveCursor } from '@/components/editor/__tests__/mount'; +import { FromBlockJSON } from 'cypress/support/document'; + +const initialData: FromBlockJSON[] = [{ + type: 'paragraph', + data: {}, + text: [{ insert: '' }], + children: [], +}]; + +const { assertJSON, initializeEditor } = initialEditorTest(); + +describe('CodeBlock', () => { + beforeEach(() => { + cy.viewport(1280, 720); + Object.defineProperty(window.navigator, 'language', { value: 'en-US' }); + initializeEditor(initialData); + const selector = '[role="textbox"]'; + + cy.get(selector).as('editor'); + + cy.wait(1000); + + cy.get(selector).focus(); + }); + + it('should turn to code block when typing ```', () => { + moveCursor(0, 0); + cy.get('@editor').type('```'); + cy.get('@editor').type(`function main() {\n console.log('Hello, World!');\n}`); + assertJSON([ + { + type: 'code', + data: {}, + text: [{ insert: 'function main() {\n console.log(\'Hello, World!\');\n}' }], + children: [], + }, + ]); + }); + + it('should add a paragraph below the code block when pressing Shift+Enter', () => { + moveCursor(0, 0); + cy.get('@editor').type('```'); + cy.get('@editor').type(`function main() {\n console.log('Hello, World!');\n}`); + cy.get('@editor').get('[data-block-type="code"]').as('code'); + cy.get('@code').should('exist'); + cy.get('@editor').realPress(['Shift', 'Enter']); + assertJSON([ + { + type: 'code', + data: {}, + text: [{ insert: 'function main() {\n console.log(\'Hello, World!\');\n}' }], + children: [], + }, + { + type: 'paragraph', + data: {}, + text: [], + children: [], + }, + ]); + }); + + it('should insert soft break when pressing Enter', () => { + moveCursor(0, 0); + cy.get('@editor').type('```'); + cy.get('@editor').type(`function main() {\n console.log('Hello, World!');\n}`); + + cy.get('@editor').realPress('Enter'); + assertJSON([ + { + type: 'code', + data: {}, + text: [{ insert: 'function main() {\n console.log(\'Hello, World!\');\n}\n' }], + children: [], + }, + ]); + }); + + it('should remove the code block when pressing Backspace at the beginning', () => { + moveCursor(0, 0); + cy.get('@editor').type('```'); + cy.get('@editor').type(`function main() {\n console.log('Hello, World!');\n}`); + + cy.get('@editor').get('[data-block-type="code"]').as('code'); + cy.get('@code').should('exist'); + moveCursor(0, 0); + cy.get('@editor').realPress(['Backspace']); + cy.get('@editor').get('[data-block-type="code"]').should('not.exist'); + assertJSON([ + { + type: 'paragraph', + data: {}, + text: [{ insert: 'function main() {\n console.log(\'Hello, World!\');\n}' }], + children: [], + }, + ]); + }); + +}); \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/editor/__tests__/mount.tsx b/frontend/appflowy_web_app/src/components/editor/__tests__/mount.tsx index f9bc377f12c30..243b57223745c 100644 --- a/frontend/appflowy_web_app/src/components/editor/__tests__/mount.tsx +++ b/frontend/appflowy_web_app/src/components/editor/__tests__/mount.tsx @@ -4,7 +4,7 @@ import React from 'react'; import Editor, { EditorProps } from '@/components/editor/Editor'; import withAppWrapper from '@/components/main/withAppWrapper'; -export function mountEditor (props: EditorProps) { +export function mountEditor(props: EditorProps) { const AppWrapper = withAppWrapper(() => { return (
@@ -13,7 +13,7 @@ export function mountEditor (props: EditorProps) { ); }); - cy.mount(); + cy.mount(); } export const moveToEnd = () => { diff --git a/frontend/appflowy_web_app/src/components/editor/__tests__/shortcuts/Markdown.cy.tsx b/frontend/appflowy_web_app/src/components/editor/__tests__/shortcuts/Markdown.cy.tsx index 1436fd7d8a428..f5dc98a85682a 100644 --- a/frontend/appflowy_web_app/src/components/editor/__tests__/shortcuts/Markdown.cy.tsx +++ b/frontend/appflowy_web_app/src/components/editor/__tests__/shortcuts/Markdown.cy.tsx @@ -592,41 +592,14 @@ describe('Markdown editing', () => { assertJSON(expectedJson); }); - it('should handle code block', () => { - - moveToEnd(); - // Test 6: Code block - cy.get('@editor').realPress('Enter'); - cy.get('@editor').type('```'); - cy.get('@editor').type(`function main() {\n console.log('Hello, World!');\n}`); - cy.get('@editor').realPress(['Shift', 'Enter']); - expectedJson = [ - ...expectedJson, - { - type: 'code', - data: {}, - text: [{ - insert: 'function main() {\n console.log(\'Hello, World!\');\n}', - }], - children: [], - }, - { - type: 'paragraph', - data: {}, - text: [], - children: [], - }, - ]; - assertJSON(expectedJson); - }); - it('should handle divider', () => { moveToEnd(); + cy.get('@editor').realPress('Enter'); // Last test: Divider cy.get('@editor').type('--'); cy.get('@editor').realPress('-'); expectedJson = [ - ...expectedJson.slice(0, -1), + ...expectedJson, { type: 'divider', data: {}, diff --git a/frontend/appflowy_web_app/src/components/editor/components/block-popover/FileBlockPopoverContent.tsx b/frontend/appflowy_web_app/src/components/editor/components/block-popover/FileBlockPopoverContent.tsx index dc7636f123549..a7133d86334dc 100644 --- a/frontend/appflowy_web_app/src/components/editor/components/block-popover/FileBlockPopoverContent.tsx +++ b/frontend/appflowy_web_app/src/components/editor/components/block-popover/FileBlockPopoverContent.tsx @@ -2,7 +2,6 @@ import { YjsEditor } from '@/application/slate-yjs'; import { CustomEditor } from '@/application/slate-yjs/command'; import { BlockType, FieldURLType, FileBlockData } from '@/application/types'; import FileDropzone from '@/components/_shared/file-dropzone/FileDropzone'; -import { notify } from '@/components/_shared/notify'; import { TabPanel, ViewTab, ViewTabs } from '@/components/_shared/tabs/ViewTabs'; import { useEditorContext } from '@/components/editor/EditorContext'; import React, { useCallback, useMemo } from 'react'; @@ -12,8 +11,6 @@ import EmbedLink from 'src/components/_shared/image-upload/EmbedLink'; import { FileHandler } from '@/utils/file'; import { findSlateEntryByBlockId } from '@/application/slate-yjs/utils/editor'; -export const MAX_FILE_SIZE = 7 * 1024 * 1024; // 7MB - export function getFileName(url: string) { const urlObj = new URL(url); const name = urlObj.pathname.split('/').pop(); @@ -58,12 +55,6 @@ function FileBlockPopoverContent({ }, [blockId, editor, onClose]); const uploadFileRemote = useCallback(async (file: File) => { - if (file.size > MAX_FILE_SIZE) { - notify.error(`File size is too large, please upload a file less than ${MAX_FILE_SIZE / 1024 / 1024}MB`); - - throw new Error('File size is too large'); - } - try { if (uploadFile) { return await uploadFile(file); diff --git a/frontend/appflowy_web_app/src/components/editor/components/block-popover/ImageBlockPopoverContent.tsx b/frontend/appflowy_web_app/src/components/editor/components/block-popover/ImageBlockPopoverContent.tsx index 5a7025fa85dab..5ca0ed51c1986 100644 --- a/frontend/appflowy_web_app/src/components/editor/components/block-popover/ImageBlockPopoverContent.tsx +++ b/frontend/appflowy_web_app/src/components/editor/components/block-popover/ImageBlockPopoverContent.tsx @@ -1,14 +1,13 @@ import { YjsEditor } from '@/application/slate-yjs'; import { CustomEditor } from '@/application/slate-yjs/command'; import { BlockType, ImageBlockData, ImageType } from '@/application/types'; -import { ALLOWED_IMAGE_EXTENSIONS, MAX_IMAGE_SIZE, Unsplash } from '@/components/_shared/image-upload'; +import { ALLOWED_IMAGE_EXTENSIONS, Unsplash } from '@/components/_shared/image-upload'; import EmbedLink from '@/components/_shared/image-upload/EmbedLink'; import { TabPanel, ViewTab, ViewTabs } from '@/components/_shared/tabs/ViewTabs'; import { useEditorContext } from '@/components/editor/EditorContext'; import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { useSlateStatic } from 'slate-react'; -import { notify } from '@/components/_shared/notify'; +import { ReactEditor, useSlateStatic } from 'slate-react'; import { FileHandler } from '@/utils/file'; import FileDropzone from '@/components/_shared/file-dropzone/FileDropzone'; import { findSlateEntryByBlockId } from '@/application/slate-yjs/utils/editor'; @@ -49,12 +48,6 @@ function ImageBlockPopoverContent({ }, [blockId, editor, onClose]); const uploadFileRemote = useCallback(async (file: File) => { - if (file.size > MAX_IMAGE_SIZE) { - notify.error(`Image size is too large, please upload a file less than ${MAX_IMAGE_SIZE / 1024 / 1024}MB`); - - throw new Error('Image size is too large'); - } - try { if (uploadFile) { return await uploadFile(file); @@ -110,12 +103,21 @@ function ImageBlockPopoverContent({ belowBlockId = CustomEditor.addBelowBlock(editor, belowBlockId, BlockType.Paragraph, {}); - const [, path] = belowBlockId ? findSlateEntryByBlockId(editor, belowBlockId) : [null, null]; + const [node, path] = belowBlockId ? findSlateEntryByBlockId(editor, belowBlockId) : [null, null]; onClose(); + if (path) { editor.select(editor.start(path)); } + + setTimeout(() => { + if (!node) return; + const el = ReactEditor.toDOMNode(editor, node); + + el?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + + }, 250); }, [blockId, editor, getData, insertImageBlock, onClose, uploadFileRemote]); const tabOptions = useMemo(() => { diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/code/Code.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/code/Code.tsx index 5992c0c364758..46b6d74a06f84 100644 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/code/Code.tsx +++ b/frontend/appflowy_web_app/src/components/editor/components/blocks/code/Code.tsx @@ -24,8 +24,11 @@ export const CodeBlock = memo( }} onMouseLeave={() => setShowToolbar(false)} > - {showToolbar &&
>(({ node, children, ...attributes }, ref) => { @@ -27,7 +27,7 @@ export const FileBlock = memo( const [localUrl, setLocalUrl] = useState(undefined); const [loading, setLoading] = useState(false); const { url, name, retry_local_url } = useMemo(() => data || {}, [data]); - const readOnly = useReadOnly(); + const readOnly = useReadOnly() || editor.isElementReadOnly(node as unknown as Element); const emptyRef = useRef(null); const [showToolbar, setShowToolbar] = useState(false); @@ -92,11 +92,6 @@ export const FileBlock = memo( }, [readOnly, retry_local_url, fileHandler]); const uploadFileRemote = useCallback(async (file: File) => { - if (file.size > MAX_FILE_SIZE) { - notify.error(`File size is too large, please upload a file less than ${MAX_FILE_SIZE / 1024 / 1024}MB`); - - return; - } try { if (uploadFile) { diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/file/FileToolbar.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/file/FileToolbar.tsx index 92896d66ac888..e948223d3684c 100644 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/file/FileToolbar.tsx +++ b/frontend/appflowy_web_app/src/components/editor/components/blocks/file/FileToolbar.tsx @@ -15,12 +15,13 @@ import { ReactComponent as DeleteIcon } from '@/assets/trash.svg'; import { ReactComponent as EditIcon } from '@/assets/edit.svg'; import { useReadOnly, useSlateStatic } from 'slate-react'; +import { Element } from 'slate'; function FileToolbar({ node }: { node: FileNode }) { const editor = useSlateStatic() as YjsEditor; - const readOnly = useReadOnly(); + const readOnly = useReadOnly() || editor.isElementReadOnly(node as unknown as Element); const { t } = useTranslation(); const url = node.data.url || ''; const name = node.data.name || ''; diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/image/ImageBlock.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/image/ImageBlock.tsx index 3a600182fc6fd..c43273da191bd 100644 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/image/ImageBlock.tsx +++ b/frontend/appflowy_web_app/src/components/editor/components/blocks/image/ImageBlock.tsx @@ -10,7 +10,6 @@ import { useEditorContext } from '@/components/editor/EditorContext'; import { YjsEditor } from '@/application/slate-yjs'; import { FileHandler } from '@/utils/file'; import { CustomEditor } from '@/application/slate-yjs/command'; -import { MAX_IMAGE_SIZE } from '@/components/_shared/image-upload'; import { useTranslation } from 'react-i18next'; import { ReactComponent as ErrorIcon } from '@/assets/error.svg'; import { CircularProgress } from '@mui/material'; @@ -99,11 +98,6 @@ export const ImageBlock = memo( }, [readOnly, retry_local_url, fileHandler]); const uploadFileRemote = useCallback(async (file: File) => { - if (file.size > MAX_IMAGE_SIZE) { - notify.error(`Image size is too large, please upload a file less than ${MAX_IMAGE_SIZE / 1024 / 1024}MB`); - - return; - } try { if (uploadFile) { diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/image/ImageRender.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/image/ImageRender.tsx index 67eb63fdfbe62..fed4d596d063a 100644 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/image/ImageRender.tsx +++ b/frontend/appflowy_web_app/src/components/editor/components/blocks/image/ImageRender.tsx @@ -8,7 +8,8 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next'; import { Skeleton } from '@mui/material'; import { ReactComponent as ErrorOutline } from '@/assets/error.svg'; -import { useSlateStatic } from 'slate-react'; +import { useReadOnly, useSlateStatic } from 'slate-react'; +import { Element } from 'slate'; const MIN_WIDTH = 100; @@ -23,9 +24,11 @@ function ImageRender({ node: ImageBlockNode; showToolbar?: boolean; }) { + const editor = useSlateStatic() as YjsEditor; + const readOnly = useReadOnly() || editor.isElementReadOnly(node as unknown as Element); + const [loading, setLoading] = useState(true); const [hasError, setHasError] = useState(false); - const editor = useSlateStatic() as YjsEditor; const imgRef = useRef(null); const { width: imageWidth } = useMemo(() => node.data || {}, [node.data]); @@ -105,7 +108,7 @@ function ImageRender({ loading={'lazy'} {...imageProps} alt={`image-${blockId}`} /> - {initialWidth && ( + {!readOnly && initialWidth && ( <> >(({ node, children, ...attributes }, ref) => { @@ -49,14 +50,25 @@ export const LinkPreview = memo( >
{notFound ? ( -
-
Could not load preview
-
{url}
+
+
+ {'Empty +
+
+
+ The link cannot be previewed. Click to open in a new tab. +
+
+ {url} +
+
+
) : ( <> diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/simple-table/SimpleTableCell.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/simple-table/SimpleTableCell.tsx index 4a6de0ee5714c..1fc1513152d0f 100644 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/simple-table/SimpleTableCell.tsx +++ b/frontend/appflowy_web_app/src/components/editor/components/blocks/simple-table/SimpleTableCell.tsx @@ -84,7 +84,8 @@ const SimpleTableCell = style={{ ...attributes.style, backgroundColor: bgColor ? renderColor(bgColor) : undefined, - maxWidth: width ? `${width}px` : undefined, + minWidth: width ? `${width}px` : undefined, + width: width ? `${width}px` : undefined, }} >
div { + margin-top: 4px !important; + margin-bottom: 4px !important; + } } td { diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/text/Text.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/text/Text.tsx index 98e9f22238bec..fb83490aef1bc 100644 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/text/Text.tsx +++ b/frontend/appflowy_web_app/src/components/editor/components/blocks/text/Text.tsx @@ -25,7 +25,7 @@ export const Text = forwardRef>( const content = useMemo(() => { return <> - + {placeholder}{children} ; }, [placeholder, isEmpty, children]); diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/toggle-list/ToggleIcon.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/toggle-list/ToggleIcon.tsx index 39d96c3e14d81..60c821bde3fb3 100644 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/toggle-list/ToggleIcon.tsx +++ b/frontend/appflowy_web_app/src/components/editor/components/blocks/toggle-list/ToggleIcon.tsx @@ -30,7 +30,7 @@ function ToggleIcon({ block, className }: { block: ToggleListNode; className: st onMouseDown={e => { e.preventDefault(); }} - className={`${className} ${readOnly ? '' : 'cursor-pointer hover:text-fill-default'} pr-1 text-xl h-full`} + className={`${className} ${readOnly ? '' : 'cursor-pointer hover:text-fill-default'} pr-1 toggle-icon`} > {collapsed ? : } diff --git a/frontend/appflowy_web_app/src/components/editor/components/leaf/Leaf.tsx b/frontend/appflowy_web_app/src/components/editor/components/leaf/Leaf.tsx index c2a0c60bac399..40d2e355df1d7 100644 --- a/frontend/appflowy_web_app/src/components/editor/components/leaf/Leaf.tsx +++ b/frontend/appflowy_web_app/src/components/editor/components/leaf/Leaf.tsx @@ -53,6 +53,10 @@ export function Leaf({ attributes, children, leaf, text }: RenderLeafProps) { if (text.text && (leaf.mention || leaf.formula)) { style['position'] = 'relative'; + if (leaf.mention) { + style['display'] = 'inline-block'; + } + const node = leaf.mention ? - {isCursorBefore && { - `\u200B` - }} {children} diff --git a/frontend/appflowy_web_app/src/components/editor/components/leaf/mention/MentionLeaf.tsx b/frontend/appflowy_web_app/src/components/editor/components/leaf/mention/MentionLeaf.tsx index 4d49b9394d8cd..3bd3548bed2e0 100644 --- a/frontend/appflowy_web_app/src/components/editor/components/leaf/mention/MentionLeaf.tsx +++ b/frontend/appflowy_web_app/src/components/editor/components/leaf/mention/MentionLeaf.tsx @@ -40,7 +40,7 @@ export function MentionLeaf({ mention, text, children }: { // check if the mention is selected const { isSelected, select, isCursorBefore } = useLeafSelected(text); const className = useMemo(() => { - const classList = ['w-fit mention', 'relative', 'rounded', 'py-0.5 px-1']; + const classList = ['w-fit mention', 'relative', 'rounded-[2px]', 'py-0.5 px-1']; if (readonly) classList.push('cursor-default'); else if (type !== MentionType.Date) classList.push('cursor-pointer'); @@ -49,24 +49,31 @@ export function MentionLeaf({ mention, text, children }: { return classList.join(' '); }, [type, readonly, isSelected]); + const ref = React.useRef(null); + return <> - {isCursorBefore && !isSelected && { - `\u200B` - }} + {children} - {content} + {content} - + ; } diff --git a/frontend/appflowy_web_app/src/components/editor/components/leaf/mention/MentionPage.tsx b/frontend/appflowy_web_app/src/components/editor/components/leaf/mention/MentionPage.tsx index 33eeff850d331..c9a0f9cb6bbc1 100644 --- a/frontend/appflowy_web_app/src/components/editor/components/leaf/mention/MentionPage.tsx +++ b/frontend/appflowy_web_app/src/components/editor/components/leaf/mention/MentionPage.tsx @@ -5,13 +5,14 @@ import { MentionType, UIVariant, View, ViewLayout, YjsEditorKey, YSharedRoot } f import { ReactComponent as NorthEast } from '@/assets/north_east.svg'; import { ReactComponent as MarkIcon } from '@/assets/paragraph_mark.svg'; -import { ViewIcon } from '@/components/_shared/view-icon'; import { useEditorContext } from '@/components/editor/EditorContext'; -import { isFlagEmoji } from '@/utils/emoji'; -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useFocused, useReadOnly, useSlateStatic } from 'slate-react'; +import { ReactEditor, useReadOnly, useSlate } from 'slate-react'; import { Element, Text } from 'slate'; +import PageIcon from '@/components/_shared/view-icon/PageIcon'; +import { findSlateEntryByBlockId } from '@/application/slate-yjs/utils/editor'; +import smoothScrollIntoViewIfNeeded from 'smooth-scroll-into-view-if-needed'; function MentionPage({ text, pageId, blockId, type }: { text: Text | Element; @@ -20,10 +21,11 @@ function MentionPage({ text, pageId, blockId, type }: { type?: MentionType }) { const context = useEditorContext(); - const editor = useSlateStatic(); + const editor = useSlate(); + const selection = editor.selection; const variant = context.variant; const currentViewId = context.viewId; - const focused = useFocused(); + const { navigateToView, loadViewMeta, loadView, openPageModal } = context; const [noAccess, setNoAccess] = useState(false); const [meta, setMeta] = useState(null); @@ -53,10 +55,6 @@ function MentionPage({ text, pageId, blockId, type }: { const { t } = useTranslation(); - const isFlag = useMemo(() => { - return icon ? isFlagEmoji(icon.value) : false; - }, [icon]); - useEffect(() => { void ( async () => { @@ -64,7 +62,7 @@ function MentionPage({ text, pageId, blockId, type }: { if (blockId) { if (currentViewId === pageId) { - const entry = CustomEditor.getBlockEntry(editor as YjsEditor, blockId); + const entry = findSlateEntryByBlockId(editor as YjsEditor, blockId); if (entry) { const [node] = entry; @@ -109,7 +107,7 @@ function MentionPage({ text, pageId, blockId, type }: { setContent(pageName); } )(); - }, [focused, blockId, currentViewId, editor, loadView, meta?.name, pageId, t]); + }, [selection, blockId, currentViewId, editor, loadView, meta?.name, pageId, t]); const mentionIcon = useMemo(() => { if (pageId === currentViewId && blockId) { @@ -117,21 +115,43 @@ function MentionPage({ text, pageId, blockId, type }: { } return <> - {icon?.value || } + + {type === MentionType.PageRef && - + } ; - }, [blockId, currentViewId, icon?.value, meta?.layout, pageId, type]); + }, [blockId, currentViewId, icon, meta?.layout, pageId, type]); const readOnly = useReadOnly() || editor.isElementReadOnly(text as unknown as Element); + const handleScrollToBlock = useCallback(async () => { + if (blockId) { + const entry = findSlateEntryByBlockId(editor as YjsEditor, blockId); + + if (entry) { + const [node] = entry; + const dom = ReactEditor.toDOMNode(editor, node); + + await smoothScrollIntoViewIfNeeded(dom, { + behavior: 'smooth', + scrollMode: 'if-needed', + }); + + dom.className += ' highlight-block'; + setTimeout(() => { + dom.className = dom.className.replace('highlight-block', ''); + }, 5000); + } + } + }, [blockId, editor]); + return ( { @@ -140,6 +160,11 @@ function MentionPage({ text, pageId, blockId, type }: { void navigateToView?.(pageId, blockId); } else { if (noAccess) return; + if (pageId === currentViewId) { + void handleScrollToBlock(); + return; + } + openPageModal?.(pageId); } }} @@ -156,11 +181,11 @@ function MentionPage({ text, pageId, blockId, type }: { } ) : ( <> - + {mentionIcon} - + {content} diff --git a/frontend/appflowy_web_app/src/components/editor/components/panels/mention-panel/MentionPanel.tsx b/frontend/appflowy_web_app/src/components/editor/components/panels/mention-panel/MentionPanel.tsx index f4c96fc2e9474..3ab8e83d10152 100644 --- a/frontend/appflowy_web_app/src/components/editor/components/panels/mention-panel/MentionPanel.tsx +++ b/frontend/appflowy_web_app/src/components/editor/components/panels/mention-panel/MentionPanel.tsx @@ -3,11 +3,9 @@ import { CustomEditor } from '@/application/slate-yjs/command'; import { EditorMarkFormat } from '@/application/slate-yjs/types'; import { Mention, MentionType, View, ViewLayout } from '@/application/types'; import { flattenViews } from '@/components/_shared/outline/utils'; -import { ViewIcon } from '@/components/_shared/view-icon'; import { usePanelContext } from '@/components/editor/components/panels/Panels.hooks'; import { PanelType } from '@/components/editor/components/panels/PanelsContext'; import { useEditorContext } from '@/components/editor/EditorContext'; -import { isFlagEmoji } from '@/utils/emoji'; import { Button, Divider } from '@mui/material'; import { sortBy, uniqBy } from 'lodash-es'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -19,6 +17,7 @@ import { ReactComponent as ArrowIcon } from '@/assets/north_east.svg'; import { ReactComponent as MoreIcon } from '@/assets/more.svg'; import { Popover } from '@/components/_shared/popover'; import dayjs from 'dayjs'; +import PageIcon from '@/components/_shared/view-icon/PageIcon'; enum MentionTag { Reminder = 'reminder', @@ -131,9 +130,6 @@ export function MentionPanel() { }, [filteredViews, moreCount]); const showMore = moreCount < filteredViews.length; - const handleClickMore = useCallback(() => { - setMoreCount(moreCount + 5); - }, [moreCount]); useEffect(() => { selectedOptionRef.current = selectedOption; @@ -142,6 +138,7 @@ export function MentionPanel() { category, index, } = selectedOption; + const el = ref.current?.querySelector(`[data-option-category="${category}"] [data-option-index="${index}"]`) as HTMLButtonElement | null; el?.scrollIntoView({ @@ -248,6 +245,18 @@ export function MentionPanel() { ].filter(option => searchText ? option.name.toLowerCase().includes(searchText.toLowerCase()) : true); }, [handleAddMention, t, showDate, searchText]); + const handleClickMore = useCallback(() => { + setMoreCount(moreCount + 5); + + setSelectedOption(prev => { + if (!prev) return null; + return { + category: MentionTag.Page, + index: moreCount, + }; + }); + }, [moreCount]); + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (!open) return; @@ -354,13 +363,7 @@ export function MentionPanel() { key={view.view_id} data-option-index={index} startIcon={ - - {view.icon?.value || } - + } className={`justify-start truncate scroll-m-2 min-h-[32px] hover:bg-fill-list-hover ${selectedOption?.index === index && selectedOption?.category === MentionTag.Page ? 'bg-fill-list-hover' : ''}`} onClick={() => handleSelectedPage(view.view_id)} @@ -388,7 +391,7 @@ export function MentionPanel() {
}
- {showDate &&
+ {showDate &&
{t('inlineActions.date')}
{ dateOptions.map((option, index) => ( diff --git a/frontend/appflowy_web_app/src/components/editor/components/table-container/TableContainer.tsx b/frontend/appflowy_web_app/src/components/editor/components/table-container/TableContainer.tsx index 8060bf8fcc461..a1d0ee25c05cf 100644 --- a/frontend/appflowy_web_app/src/components/editor/components/table-container/TableContainer.tsx +++ b/frontend/appflowy_web_app/src/components/editor/components/table-container/TableContainer.tsx @@ -53,6 +53,31 @@ function TableContainer({ blockId, readSummary, children, paddingLeft = 0 }: { } }, []); + const left = Math.max(offsetLeftRef.current - paddingLeft, 0); + + const timeoutRef = useRef(null); + const handleHorizontalScroll = useCallback((e: React.UIEvent) => { + const currentTarget = e.currentTarget as HTMLElement; + const isHorizontal = currentTarget.scrollLeft > 0; + const controlEl = controlRef.current; + + if (isHorizontal && controlEl) { + controlEl.style.opacity = '0'; + + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + timeoutRef.current = setTimeout(() => { + controlEl.style.opacity = '1'; + const scrollLeft = currentTarget.scrollLeft; + + controlEl.style.left = Math.max(-scrollLeft + offsetLeftRef.current - 64, -64) + 'px'; + }, 300); + } + + }, []); + return (
{ - setShowControl(false); + // setShowControl(false); }} className={`relative w-full `} style={{ @@ -74,7 +99,7 @@ function TableContainer({ blockId, readSummary, children, paddingLeft = 0 }: {
{ - const isHorizontal = e.currentTarget.scrollLeft > 0; - const controlEl = controlRef.current; - - if (isHorizontal && controlEl) { - controlEl.style.left = Math.max(-e.currentTarget.scrollLeft + offsetLeftRef.current - 64, -64) + 'px'; - } - }} + onScroll={handleHorizontalScroll} className={'h-full w-full overflow-x-auto overflow-y-hidden'} style={{ - paddingLeft: Math.max(offsetLeftRef.current - paddingLeft, 0) + 'px', + paddingLeft: left + 'px', }} > {children} diff --git a/frontend/appflowy_web_app/src/components/editor/components/toolbar/block-controls/BlockControls.cy.tsx b/frontend/appflowy_web_app/src/components/editor/components/toolbar/block-controls/BlockControls.cy.tsx index 9974f6b3a0b76..35819c9ff562f 100644 --- a/frontend/appflowy_web_app/src/components/editor/components/toolbar/block-controls/BlockControls.cy.tsx +++ b/frontend/appflowy_web_app/src/components/editor/components/toolbar/block-controls/BlockControls.cy.tsx @@ -112,9 +112,8 @@ describe('BlockControls', () => { cy.realPress(['Enter']); cy.wait(100); - const meta = getModKey(); - cy.realPress([meta, 'v']); + cy.get('@editor').realPress([getModKey(), 'v']); cy.wait(50); cy.wrap(null).then(() => { const finalJson = getFinalJSON(); diff --git a/frontend/appflowy_web_app/src/components/editor/components/toolbar/selection-toolbar/actions/Formula.tsx b/frontend/appflowy_web_app/src/components/editor/components/toolbar/selection-toolbar/actions/Formula.tsx index a96a8f8c54ce1..88f697acd8dd7 100644 --- a/frontend/appflowy_web_app/src/components/editor/components/toolbar/selection-toolbar/actions/Formula.tsx +++ b/frontend/appflowy_web_app/src/components/editor/components/toolbar/selection-toolbar/actions/Formula.tsx @@ -50,7 +50,6 @@ function Formula() { const isActivated = CustomEditor.isMarkActive(editor, EditorMarkFormat.Formula); if (!isActivated) { - const start = editor.start(selection); const text = editor.string(selection); editor.delete(); @@ -65,9 +64,13 @@ function Formula() { } Transforms.select(editor, { - anchor: start, + anchor: { + path: newSelection.anchor.path, + offset: newSelection.anchor.offset - 1, + }, focus: newSelection.focus, }); + CustomEditor.addMark(editor, { key: EditorMarkFormat.Formula, value: text, @@ -80,10 +83,14 @@ function Formula() { if (!entry) return; - const [, path] = entry; + const [node, path] = entry; + const formula = (node as Text).formula; + if (!formula) return; editor.select(path); CustomEditor.removeMark(editor, EditorMarkFormat.Formula); + editor.delete(); + editor.insertText(formula); } setState(getState()); diff --git a/frontend/appflowy_web_app/src/components/editor/editor.scss b/frontend/appflowy_web_app/src/components/editor/editor.scss index 95c21e86fa23d..872905d75b4b5 100644 --- a/frontend/appflowy_web_app/src/components/editor/editor.scss +++ b/frontend/appflowy_web_app/src/components/editor/editor.scss @@ -98,7 +98,7 @@ .block-element.block-align-left { > div > .text-element { - text-align: left; + text-align: left !important; justify-content: flex-start; } @@ -109,7 +109,7 @@ .block-element.block-align-right { > div > .text-element { - text-align: right; + text-align: right !important; justify-content: flex-end; } @@ -120,7 +120,7 @@ .block-element.block-align-center { > div > .text-element { - text-align: center; + text-align: center !important; justify-content: center; } @@ -151,6 +151,7 @@ [role=textbox] { .text-element { @apply my-1; + &::selection { @apply bg-transparent; } @@ -278,7 +279,7 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) { .formula-inline, .mention { &.selected { - @apply rounded bg-content-blue-100; + @apply rounded-[2px] bg-content-blue-100; .mention-inline { @apply bg-content-blue-100 select-all; } @@ -415,6 +416,10 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) { .toggle-heading, .heading, .hover-controls { &.level-1 { @apply py-[8px]; + > span > .toggle-icon { + @apply relative top-3; + } + > .hover-controls-placeholder, > .text-element { @apply text-[2rem] max-md:text-[24px] font-semibold; @@ -423,6 +428,10 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) { &.level-2 { @apply py-[6px]; + > span > .toggle-icon { + @apply relative top-2; + } + > .hover-controls-placeholder, > .text-element { @apply text-[1.75rem] max-md:text-[22px] font-semibold; @@ -431,6 +440,10 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) { &.level-3 { @apply py-[4px]; + > span > .toggle-icon { + @apply relative top-1.5; + } + > .hover-controls-placeholder, > .text-element { @apply text-[1.5rem] max-md:text-[20px] font-semibold; @@ -439,6 +452,10 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) { &.level-4 { @apply py-[4px]; + > span > .toggle-icon { + @apply relative top-1; + } + > .hover-controls-placeholder, > .text-element { @apply text-[1.25rem] max-md:text-[16px] font-semibold; @@ -447,6 +464,10 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) { &.level-5 { @apply py-[2px]; + > span > .toggle-icon { + @apply relative top-0.5; + } + > .hover-controls-placeholder, > .text-element { @apply text-[1.125rem] font-semibold; @@ -460,4 +481,4 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) { @apply text-[1rem] font-semibold; } } -} \ No newline at end of file +} diff --git a/frontend/appflowy_web_app/src/components/editor/plugins/withInsertData.ts b/frontend/appflowy_web_app/src/components/editor/plugins/withInsertData.ts index de2f62dab11bc..78dcee46596b0 100644 --- a/frontend/appflowy_web_app/src/components/editor/plugins/withInsertData.ts +++ b/frontend/appflowy_web_app/src/components/editor/plugins/withInsertData.ts @@ -3,9 +3,9 @@ import { YjsEditor } from '@/application/slate-yjs'; import { findSlateEntryByBlockId, getBlockEntry } from '@/application/slate-yjs/utils/editor'; import { ReactEditor } from 'slate-react'; import { BlockType, FieldURLType, FileBlockData, ImageBlockData, ImageType } from '@/application/types'; -import { MAX_IMAGE_SIZE } from '@/components/_shared/image-upload'; import { FileHandler } from '@/utils/file'; -import { notify } from '@/components/_shared/notify'; +import { convertSlateFragmentTo } from '@/components/editor/utils/fragment'; +import { Node } from 'slate'; export const withInsertData = (editor: ReactEditor) => { const { insertData } = editor; @@ -13,23 +13,31 @@ export const withInsertData = (editor: ReactEditor) => { const e = editor as YjsEditor; editor.insertData = (data: DataTransfer) => { + const fragment = data.getData('application/x-slate-fragment'); + + if (fragment) { + const decoded = decodeURIComponent(window.atob(fragment)); + const parsed = JSON.parse(decoded) as Node[]; + const newFragment = convertSlateFragmentTo(parsed); + + return e.insertFragment(newFragment); + } + // Do something with the data... const fileArray = Array.from(data.files); const { selection } = editor; - const blockId = getBlockEntry(e)[0].blockId; + const entry = getBlockEntry(e); + const [node] = entry; + const blockId = node.blockId; insertData(data); if (blockId && fileArray.length > 0 && selection) { void (async () => { - let newBlockId: string | undefined = blockId; + const text = CustomEditor.getBlockTextContent(node); + let newBlockId: string = blockId; for (const file of fileArray) { - if (file.size > MAX_IMAGE_SIZE) { - notify.error('File size is too large, max size is 7MB'); - return; - } - const url = await e.uploadFile?.(file); let fileId = ''; @@ -53,7 +61,7 @@ export const withInsertData = (editor: ReactEditor) => { } // Handle images... - newBlockId = CustomEditor.addBelowBlock(e, blockId, BlockType.ImageBlock, data); + newBlockId = CustomEditor.addBelowBlock(e, newBlockId, BlockType.ImageBlock, data) || newBlockId; } else { const data = { url: url, @@ -67,12 +75,18 @@ export const withInsertData = (editor: ReactEditor) => { } // Handle files... - newBlockId = CustomEditor.addBelowBlock(e, blockId, BlockType.FileBlock, data); + newBlockId = CustomEditor.addBelowBlock(e, newBlockId, BlockType.FileBlock, data) || newBlockId; } } - if (newBlockId) { + if (!text) { + CustomEditor.deleteBlock(e, blockId); + } + + const firstIsImage = fileArray[0].type.startsWith('image/'); + + if (newBlockId && firstIsImage) { const id = CustomEditor.addBelowBlock(e, newBlockId, BlockType.Paragraph, {}); if (!id) return; @@ -80,6 +94,7 @@ export const withInsertData = (editor: ReactEditor) => { const [, path] = findSlateEntryByBlockId(e, id); editor.select(editor.start(path)); + } })(); diff --git a/frontend/appflowy_web_app/src/components/editor/plugins/withPasted.ts b/frontend/appflowy_web_app/src/components/editor/plugins/withPasted.ts index 41fe7e81ac213..d961a146ec519 100644 --- a/frontend/appflowy_web_app/src/components/editor/plugins/withPasted.ts +++ b/frontend/appflowy_web_app/src/components/editor/plugins/withPasted.ts @@ -11,8 +11,9 @@ import { deserializeHTML } from '@/components/editor/utils/fragment'; import { BasePoint, Node, Transforms, Text, Element } from 'slate'; import { ReactEditor } from 'slate-react'; import isURL from 'validator/lib/isURL'; -import { assertDocExists, getBlock, getChildrenArray } from '@/application/slate-yjs/utils/yjs'; +import { assertDocExists, deleteBlock, getBlock, getChildrenArray } from '@/application/slate-yjs/utils/yjs'; import { CustomEditor } from '@/application/slate-yjs/command'; +import { processUrl } from '@/utils/url'; export const withPasted = (editor: ReactEditor) => { @@ -32,31 +33,34 @@ export const withPasted = (editor: ReactEditor) => { const [node] = getBlockEntry(editor as YjsEditor, point); if (lineLength === 1) { - const isBlockLinkUrl = isURL(text, { - host_whitelist: ['localhost', 'appflowy.com', 'test.appflowy.com', 'beta.appflowy.com'], - }); - const isUrl = isURL(text); - - if (isBlockLinkUrl) { - const url = new URL(text); - const blockId = url.searchParams.get('blockId'); - - if (blockId) { - const pageId = url.pathname.split('/').pop(); - const point = editor.selection?.anchor as BasePoint; - - Transforms.insertNodes(editor, { - text: '@', mention: { - type: MentionType.PageRef, - page_id: pageId, - block_id: blockId, - }, - }, { at: point, select: true, voids: false }); + const isUrl = !!processUrl(text); + if (isUrl) { + const isAppFlowyLinkUrl = isURL(text, { + host_whitelist: ['localhost', 'appflowy.com', 'test.appflowy.com', 'beta.appflowy.com'], + }); + + console.log('isAppFlowyLinkUrl', isAppFlowyLinkUrl); + if (isAppFlowyLinkUrl) { + const url = new URL(text); + const blockId = url.searchParams.get('blockId'); + + if (blockId) { + const pageId = url.pathname.split('/').pop(); + const point = editor.selection?.anchor as BasePoint; + + Transforms.insertNodes(editor, { + text: '@', mention: { + type: MentionType.PageRef, + page_id: pageId, + block_id: blockId, + }, + }, { at: point, select: true, voids: false }); + + return true; + } } - return true; - } else if (isUrl) { const currentBlockId = node.blockId as string; CustomEditor.addBelowBlock(editor as YjsEditor, currentBlockId, BlockType.LinkPreview, { url: text } as LinkPreviewBlockData); @@ -94,7 +98,7 @@ export const withPasted = (editor: ReactEditor) => { return editor; }; -function insertHtmlData(editor: ReactEditor, data: DataTransfer) { +export function insertHtmlData(editor: ReactEditor, data: DataTransfer) { const html = data.getData('text/html'); if (html) { @@ -118,6 +122,7 @@ function insertFragment(editor: ReactEditor, fragment: Node[], options = {}) { const [node] = getBlockEntry(editor as YjsEditor, point); const blockId = node.blockId as string; const sharedRoot = getSharedRoot(editor as YjsEditor); + const isEmptyNode = CustomEditor.getBlockTextContent(node) === ''; const block = getBlock(blockId, sharedRoot); const parent = getBlock(block.get(YjsEditorKey.block_parent), sharedRoot); const parentChildren = getChildrenArray(parent.get(YjsEditorKey.block_children), sharedRoot); @@ -157,12 +162,18 @@ function insertFragment(editor: ReactEditor, fragment: Node[], options = {}) { const newBlockIds = slateContentInsertToYData(block.get(YjsEditorKey.block_parent), index + 1, fragment, doc); lastBlockId = newBlockIds[newBlockIds.length - 1]; + if (isEmptyNode) { + deleteBlock(sharedRoot, blockId); + } }); setTimeout(() => { const [, path] = findSlateEntryByBlockId(editor as YjsEditor, lastBlockId); - editor.select(editor.end(path)); + const point = editor.end(path); + + editor.select(point); + }, 50); return; diff --git a/frontend/appflowy_web_app/src/components/editor/utils/__tests__/fragment.test.ts b/frontend/appflowy_web_app/src/components/editor/utils/__tests__/fragment.test.ts index 239cecac81ce2..02ce01b0ace19 100644 --- a/frontend/appflowy_web_app/src/components/editor/utils/__tests__/fragment.test.ts +++ b/frontend/appflowy_web_app/src/components/editor/utils/__tests__/fragment.test.ts @@ -20,7 +20,7 @@ describe('deserializeHTML', () => { expect(result).toBeDefined(); expect(Array.isArray(result)).toBe(true); - expect(result.length).toBe(6); + expect(result.length).toBe(7); // Check paragraph let blockId = (result[0] as Element).blockId as string; diff --git a/frontend/appflowy_web_app/src/components/editor/utils/fragment.ts b/frontend/appflowy_web_app/src/components/editor/utils/fragment.ts index 81a6c0729b65e..e71e268a59996 100644 --- a/frontend/appflowy_web_app/src/components/editor/utils/fragment.ts +++ b/frontend/appflowy_web_app/src/components/editor/utils/fragment.ts @@ -12,7 +12,7 @@ import { } from '@/application/types'; import { filter } from 'lodash-es'; import { - createBlock, createEmptyDocument, + createBlock, createEmptyDocument, generateBlockId, getBlock, getChildrenArray, getPageId, @@ -20,6 +20,7 @@ import { updateBlockParent, } from '@/application/slate-yjs/utils/yjs'; import { Op } from 'quill-delta'; +import { Text as SlateText, Element as SlateElement, Node as SlateNode } from 'slate'; export function deserialize(body: HTMLElement, sharedRoot: YSharedRoot) { const pageId = getPageId(sharedRoot); @@ -482,4 +483,44 @@ export function deserializeHTML(html: string) { const slateContent = yDocToSlateContent(doc); return slateContent?.children; -} \ No newline at end of file +} + +export function convertSlateFragmentTo(fragment: SlateNode[]) { + const traverse = (node: SlateNode) => { + + if (SlateText.isText(node)) { + return node; + } + + if (SlateElement.isElement(node)) { + const isTextChildren = node.children.every(SlateText.isText); + const children = node.children.map(traverse).filter(Boolean) as SlateText[]; + const blockId = generateBlockId(); + + let type = node.type as BlockType; + + if (!Object.values(BlockType).includes(type)) { + type = BlockType.Paragraph; + } + + const blockChildren = isTextChildren ? [{ + textId: blockId, + children: isTextChildren ? children : [], + }] : [{ + textId: blockId, + children: [{ text: '' }], + }, ...children]; + + return { + blockId, + data: node.data, + type, + children: blockChildren, + }; + } + + return null; + }; + + return fragment.map(traverse).filter(Boolean) as SlateElement[]; +} diff --git a/frontend/appflowy_web_app/src/components/main/AppConfig.tsx b/frontend/appflowy_web_app/src/components/main/AppConfig.tsx index 86d60acf15186..2dbf3778aa44d 100644 --- a/frontend/appflowy_web_app/src/components/main/AppConfig.tsx +++ b/frontend/appflowy_web_app/src/components/main/AppConfig.tsx @@ -12,7 +12,7 @@ import { useLiveQuery } from 'dexie-react-hooks'; import { useSnackbar } from 'notistack'; import React, { Suspense, useCallback, useEffect, useMemo, useState } from 'react'; -function AppConfig ({ children }: { children: React.ReactNode }) { +function AppConfig({ children }: { children: React.ReactNode }) { const [appConfig] = useState(defaultConfig); const service = useMemo(() => getService(appConfig), [appConfig]); const [isAuthenticated, setIsAuthenticated] = React.useState(isTokenValid()); @@ -38,6 +38,7 @@ function AppConfig ({ children }: { children: React.ReactNode }) { useEffect(() => { return on(EventType.SESSION_VALID, () => { + console.log('session valid'); setIsAuthenticated(true); }); }, []); @@ -70,6 +71,7 @@ function AppConfig ({ children }: { children: React.ReactNode }) { }, []); useEffect(() => { return on(EventType.SESSION_INVALID, () => { + console.log('session invalid'); setIsAuthenticated(false); }); }, []); diff --git a/frontend/appflowy_web_app/src/components/main/AppTheme.tsx b/frontend/appflowy_web_app/src/components/main/AppTheme.tsx index e7af463d2d755..691a4921d2be1 100644 --- a/frontend/appflowy_web_app/src/components/main/AppTheme.tsx +++ b/frontend/appflowy_web_app/src/components/main/AppTheme.tsx @@ -62,6 +62,12 @@ function AppTheme({ children }: { children: React.ReactNode; }) { color: 'var(--fill-default)', }, }, + colorSecondary: { + color: 'var(--billing-primary)', + '&:hover': { + color: 'var(--billing-primary-hover)', + }, + }, }, }, @@ -96,12 +102,26 @@ function AppTheme({ children }: { children: React.ReactNode; }) { boxShadow: 'var(--shadow)', }, }, + '&.MuiButton-containedSecondary': { + backgroundColor: 'var(--billing-primary)', + '&:hover': { + backgroundColor: 'var(--billing-primary-hover)', + }, + }, }, outlined: { '&.MuiButton-outlinedInherit': { borderColor: 'var(--line-divider)', }, borderRadius: '8px', + '&.MuiButton-outlinedSecondary': { + color: 'var(--billing-primary)', + borderColor: 'var(--billing-primary)', + '&:hover': { + color: 'var(--billing-primary-hover)', + borderColor: 'var(--billing-primary-hover)', + }, + }, }, }, @@ -127,7 +147,6 @@ function AppTheme({ children }: { children: React.ReactNode; }) { boxShadow: 'none !important', }, }, - }, MuiPaper: { styleOverrides: { @@ -211,6 +230,10 @@ function AppTheme({ children }: { children: React.ReactNode; }) { main: '#00BCF0', dark: '#00BCF0', }, + secondary: { + main: '#8427e0', + dark: '#601DAA', + }, error: { main: '#FB006D', dark: '#D32772', diff --git a/frontend/appflowy_web_app/src/components/main/withAppWrapper.tsx b/frontend/appflowy_web_app/src/components/main/withAppWrapper.tsx index 1a3ec7a2c5627..02c3ce27409d3 100644 --- a/frontend/appflowy_web_app/src/components/main/withAppWrapper.tsx +++ b/frontend/appflowy_web_app/src/components/main/withAppWrapper.tsx @@ -30,7 +30,7 @@ export default function withAppWrapper(Component: React.FC): React.FC { horizontal: 'center', }} preventDuplicate - autoHideDuration={2000} + autoHideDuration={3000} Components={{ info: InfoSnackbar, success: CustomSnackbar, diff --git a/frontend/appflowy_web_app/src/components/publish/header/duplicate/SpaceList.tsx b/frontend/appflowy_web_app/src/components/publish/header/duplicate/SpaceList.tsx index 8b71b1ff4ff28..1225de6abcc1a 100644 --- a/frontend/appflowy_web_app/src/components/publish/header/duplicate/SpaceList.tsx +++ b/frontend/appflowy_web_app/src/components/publish/header/duplicate/SpaceList.tsx @@ -2,7 +2,7 @@ import { SpaceView } from '@/application/types'; import React, { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { ReactComponent as CheckIcon } from '@/assets/selected.svg'; -import SpaceIcon from '@/components/_shared/breadcrumb/SpaceIcon'; +import SpaceIcon from '@/components/_shared/view-icon/SpaceIcon'; import { Button, CircularProgress, Tooltip } from '@mui/material'; import { ReactComponent as LockSvg } from '@/assets/lock.svg'; @@ -14,7 +14,7 @@ export interface SpaceListProps { title?: React.ReactNode; } -function SpaceList ({ loading, spaceList, value, onChange, title }: SpaceListProps) { +function SpaceList({ loading, spaceList, value, onChange, title }: SpaceListProps) { const { t } = useTranslation(); const getExtraObj = useCallback((extra: string) => { @@ -45,7 +45,7 @@ function SpaceList ({ loading, spaceList, value, onChange, title }: SpaceListPro />
{space.name} - {space.isPrivate && } + {space.isPrivate && }
); @@ -58,7 +58,7 @@ function SpaceList ({ loading, spaceList, value, onChange, title }: SpaceListPro {title ||
{t('publish.addTo')}
} {loading ? (
- +
) : (
@@ -83,7 +83,7 @@ function SpaceList ({ loading, spaceList, value, onChange, title }: SpaceListPro >
{renderSpace(space)}
- {isSelected && } + {isSelected && }
diff --git a/frontend/appflowy_web_app/src/components/quick-note/AddNote.tsx b/frontend/appflowy_web_app/src/components/quick-note/AddNote.tsx index 3063e77b33334..7f8966014f770 100644 --- a/frontend/appflowy_web_app/src/components/quick-note/AddNote.tsx +++ b/frontend/appflowy_web_app/src/components/quick-note/AddNote.tsx @@ -1,8 +1,6 @@ -import React, { useContext } from 'react'; +import React from 'react'; import { ReactComponent as AddIcon } from '@/assets/add.svg'; -import { useService } from '@/components/main/app.hooks'; -import { ToastContext } from '@/components/quick-note/QuickNote.hooks'; -import { useCurrentWorkspaceId } from '@/components/app/app.hooks'; +import { useAddNode } from '@/components/quick-note/QuickNote.hooks'; import { QuickNote } from '@/application/types'; import { Button, CircularProgress } from '@mui/material'; import { useTranslation } from 'react-i18next'; @@ -14,31 +12,13 @@ function AddNote({ onEnterNote: (node: QuickNote) => void; onAdd: (note: QuickNote) => void; }) { - const toast = useContext(ToastContext); - - const [loading, setLoading] = React.useState(false); - const currentWorkspaceId = useCurrentWorkspaceId(); - const service = useService(); - const handleAdd = async () => { - if (!service || !currentWorkspaceId || loading) return; - setLoading(true); - try { - const note = await service.createQuickNote(currentWorkspaceId, [{ - type: 'paragraph', - delta: [{ insert: '' }], - children: [], - }]); - - onEnterNote(note); - onAdd(note); - // eslint-disable-next-line - } catch (e: any) { - console.error(e); - toast.onOpen(e.message); - } finally { - setLoading(false); - } - }; + const { + handleAdd, + loading, + } = useAddNode({ + onEnterNote, + onAdd, + }); const { t } = useTranslation(); diff --git a/frontend/appflowy_web_app/src/components/quick-note/Note.tsx b/frontend/appflowy_web_app/src/components/quick-note/Note.tsx index c2b0af0b861bb..144eab48c47af 100644 --- a/frontend/appflowy_web_app/src/components/quick-note/Note.tsx +++ b/frontend/appflowy_web_app/src/components/quick-note/Note.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useContext, useEffect, useMemo } from 'react'; -import { Editor, EditorData, EditorProvider, useEditor } from '@appflowyinc/editor'; +import { Editor, EditorData, EditorProvider, useEditor, FixedToolbar } from '@appflowyinc/editor'; import '@appflowyinc/editor/style'; +import { ReactComponent as AddIcon } from '@/assets/add.svg'; import { useTranslation } from 'react-i18next'; import { ThemeModeContext } from '@/components/main/useAppThemeMode'; @@ -8,13 +9,21 @@ import { useCurrentWorkspaceId } from '@/components/app/app.hooks'; import { useService } from '@/components/main/app.hooks'; import { debounce } from 'lodash-es'; import { QuickNote, QuickNoteEditorData } from '@/application/types'; +import dayjs from 'dayjs'; +import { useAddNode } from '@/components/quick-note/QuickNote.hooks'; +import { CircularProgress, IconButton, Tooltip } from '@mui/material'; +import { getTitle } from '@/components/quick-note/utils'; function Note({ note, onUpdateData, + onEnterNote, + onAdd, }: { note: QuickNote, onUpdateData: (data: QuickNoteEditorData[]) => void; + onEnterNote: (node: QuickNote) => void; + onAdd: (note: QuickNote) => void; }) { const ref = React.useRef(null); @@ -44,7 +53,7 @@ function Note({ return (
- +
); @@ -53,15 +62,20 @@ function Note({ function NoteEditor({ note, onUpdateData, + onEnterNote, + onAdd, }: { note: QuickNote, onUpdateData: (data: QuickNoteEditorData[]) => void; + onEnterNote: (node: QuickNote) => void; + onAdd: (note: QuickNote) => void; }) { - const { i18n } = useTranslation(); + const { i18n, t } = useTranslation(); const locale = useMemo(() => ({ lang: i18n.language, }), [i18n.language]); + const [, setClock] = React.useState(0); const isDark = useContext(ThemeModeContext)?.isDark; const theme = isDark ? 'dark' : 'light'; @@ -69,6 +83,7 @@ function NoteEditor({ useEffect(() => { editor.applyData(note.data as EditorData); + setClock(prev => prev + 1); // eslint-disable-next-line }, [editor, note.id]); @@ -85,6 +100,12 @@ function NoteEditor({ } }, [service, currentWorkspaceId, note.id]); + const updatedAt = useMemo(() => { + const date = dayjs(note.last_updated_at); + + return date.format('MMMM D, YYYY') + ' at ' + date.format('h:mm A'); + }, [note.last_updated_at]); + const debounceUpdate = useMemo(() => debounce(handleUpdate, 300), [handleUpdate]); const handleChange = useCallback((data: EditorData) => { @@ -98,11 +119,41 @@ function NoteEditor({ }; }, [debounceUpdate]); - return ; + const { + handleAdd, + loading, + } = useAddNode({ + onEnterNote, + onAdd, + }); + + const CustomToolbar = useCallback(() => { + return
+
+
+ +
+
+ + + {loading ? : } + + +
+
+ +
{updatedAt}
+
; + }, [handleAdd, loading, note, t, updatedAt]); + + return <> + + ; } export default Note; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/quick-note/NoteHeader.tsx b/frontend/appflowy_web_app/src/components/quick-note/NoteHeader.tsx index 51a4e1d7e98ba..16c5674e6f6ba 100644 --- a/frontend/appflowy_web_app/src/components/quick-note/NoteHeader.tsx +++ b/frontend/appflowy_web_app/src/components/quick-note/NoteHeader.tsx @@ -31,7 +31,10 @@ function NoteHeader({ note, onBack, onClose, expand, onToggleExpand }: {
{title}
- + { + e.currentTarget.blur(); + onToggleExpand?.(); + }} size={'small'}> {expand ? : } diff --git a/frontend/appflowy_web_app/src/components/quick-note/NoteList.tsx b/frontend/appflowy_web_app/src/components/quick-note/NoteList.tsx index d959f7f4fa332..3d0fa3d926ff3 100644 --- a/frontend/appflowy_web_app/src/components/quick-note/NoteList.tsx +++ b/frontend/appflowy_web_app/src/components/quick-note/NoteList.tsx @@ -6,100 +6,108 @@ import { ReactComponent as EditIcon } from '@/assets/edit.svg'; import { ReactComponent as DeleteIcon } from '@/assets/trash.svg'; import DeleteNoteModal from '@/components/quick-note/DeleteNoteModal'; import { getSummary, getTitle, getUpdateTime } from '@/components/quick-note/utils'; +import AddNote from '@/components/quick-note/AddNote'; function NoteList({ list, onEnterNode, onDelete, onScroll, + onAdd, }: { list: QuickNoteType[]; onEnterNode: (node: QuickNoteType) => void; + onAdd: (note: QuickNote) => void; onDelete: (id: string) => void; onScroll: (e: React.UIEvent) => void; }) { const { t } = useTranslation(); const renderTitle = useCallback((note: QuickNote) => { - return getTitle(note) || t('menuAppHeader.defaultNewPageName'); + return getTitle(note).trim() || t('menuAppHeader.defaultNewPageName'); }, [t]); const [openDeleteModal, setOpenDeleteModal] = React.useState(false); const [selectedNote, setSelectedNote] = React.useState(null); const [hoverId, setHoverId] = React.useState(null); - if (!list || list.length === 0) { - return
{t('quickNote.quickNotesEmpty')}
; - } - return ( -
-
- { - list.map((note, index) => { - return ( - -
onEnterNode(note)} - onMouseEnter={() => setHoverId(note.id)} - onMouseLeave={() => setHoverId(null)} - key={note.id} - className={`px-5 relative hover:bg-content-blue-50 text-sm overflow-hidden cursor-pointer`} - > -
+ {list.length === 0 && (
{t('quickNote.quickNotesEmpty')}
)} + {list && +
+
+ { + list.map((note, index) => { + return ( + +
onEnterNode(note)} + onMouseEnter={() => setHoverId(note.id)} + onMouseLeave={() => setHoverId(null)} + key={note.id} + className={`px-5 relative hover:bg-content-blue-50 text-sm overflow-hidden cursor-pointer`} + > +
-
- {renderTitle(note)} -
-
+
+ {renderTitle(note)} +
+
{getUpdateTime(note)} - {getSummary(note)} + {getSummary(note) || t('quickNote.noAdditionalText')} +
+
+ {hoverId === note.id ?
+ + { + e.stopPropagation(); + onEnterNode(note); + }} size={'small'}> + + + + + + { + e.stopPropagation(); + setSelectedNote(note); + setOpenDeleteModal(true); + }} size={'small'}> + + + +
: null}
-
- {hoverId === note.id ?
- - { - e.stopPropagation(); - onEnterNode(note); - }} size={'small'}> - - - - - - { - e.stopPropagation(); - setSelectedNote(note); - setOpenDeleteModal(true); - }} size={'small'}> - - - -
: null} -
+ + ); + }) + } +
+ {selectedNote && { + setOpenDeleteModal(false); + setSelectedNote(null); + }} + note={selectedNote}/> + } + +
} - - ); - }) - } +
+
- {selectedNote && { - setOpenDeleteModal(false); - setSelectedNote(null); - }} - note={selectedNote}/> - } + -
); } diff --git a/frontend/appflowy_web_app/src/components/quick-note/NoteListHeader.tsx b/frontend/appflowy_web_app/src/components/quick-note/NoteListHeader.tsx index d062e250cbc0c..cc7423524c077 100644 --- a/frontend/appflowy_web_app/src/components/quick-note/NoteListHeader.tsx +++ b/frontend/appflowy_web_app/src/components/quick-note/NoteListHeader.tsx @@ -70,7 +70,10 @@ function NoteListHeader({ }
- + { + e.currentTarget.blur(); + onToggleExpand?.(); + }} size={'small'}> {expand ? : } diff --git a/frontend/appflowy_web_app/src/components/quick-note/QuickNote.hooks.ts b/frontend/appflowy_web_app/src/components/quick-note/QuickNote.hooks.ts index 08793367de4bf..05dcc6f09f4ff 100644 --- a/frontend/appflowy_web_app/src/components/quick-note/QuickNote.hooks.ts +++ b/frontend/appflowy_web_app/src/components/quick-note/QuickNote.hooks.ts @@ -1,4 +1,7 @@ -import React from 'react'; +import React, { useContext } from 'react'; +import { useCurrentWorkspaceId } from '@/components/app/app.hooks'; +import { useService } from '@/components/main/app.hooks'; +import { QuickNote } from '@/application/types'; export const ToastContext = React.createContext<{ onOpen: (message: string) => void; @@ -14,4 +17,43 @@ export const ToastContext = React.createContext<{ open: false, }); -export const LISI_LIMIT = 100; \ No newline at end of file +export const LISI_LIMIT = 100; + +export function useAddNode({ + onEnterNote, + onAdd, +}: { + onEnterNote: (node: QuickNote) => void; + onAdd: (note: QuickNote) => void; +}) { + const toast = useContext(ToastContext); + + const [loading, setLoading] = React.useState(false); + const currentWorkspaceId = useCurrentWorkspaceId(); + const service = useService(); + const handleAdd = async () => { + if (!service || !currentWorkspaceId || loading) return; + setLoading(true); + try { + const note = await service.createQuickNote(currentWorkspaceId, [{ + type: 'paragraph', + delta: [{ insert: '' }], + children: [], + }]); + + onEnterNote(note); + onAdd(note); + // eslint-disable-next-line + } catch (e: any) { + console.error(e); + toast.onOpen(e.message); + } finally { + setLoading(false); + } + }; + + return { + handleAdd, + loading, + }; +} \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/quick-note/QuickNote.tsx b/frontend/appflowy_web_app/src/components/quick-note/QuickNote.tsx index 9fb11dc34f0a7..bacb741525e4d 100644 --- a/frontend/appflowy_web_app/src/components/quick-note/QuickNote.tsx +++ b/frontend/appflowy_web_app/src/components/quick-note/QuickNote.tsx @@ -1,4 +1,4 @@ -import React, { Suspense, useCallback, useEffect, useMemo, useRef } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { IconButton, Tooltip, Zoom, Snackbar, Portal } from '@mui/material'; import { ReactComponent as EditIcon } from '@/assets/edit.svg'; import { useTranslation } from 'react-i18next'; @@ -7,15 +7,13 @@ import Popover from '@mui/material/Popover'; import NoteHeader from '@/components/quick-note/NoteHeader'; import NoteListHeader from '@/components/quick-note/NoteListHeader'; import { TransitionProps } from '@mui/material/transitions'; -import AddNote from '@/components/quick-note/AddNote'; import { LISI_LIMIT, ToastContext } from './QuickNote.hooks'; import { useService } from '@/components/main/app.hooks'; import { useCurrentWorkspaceId } from '@/components/app/app.hooks'; import { QuickNote as QuickNoteType, QuickNoteEditorData } from '@/application/types'; import NoteList from '@/components/quick-note/NoteList'; import { getPopoverPosition, setPopoverPosition } from '@/components/quick-note/utils'; - -const Note = React.lazy(() => import('@/components/quick-note/Note')); +import Note from '@/components/quick-note/Note'; const PAPER_SIZE = [480, 396]; const Transition = React.forwardRef(function Transition( @@ -201,11 +199,7 @@ export function QuickNote() { }, [resetPosition]); const buttonRef = useRef(null); - const handleOpen = useCallback(async () => { - if (open) { - handleClose(); - return; - } + const handleOpen = useCallback(async (forceCreate?: boolean) => { const el = buttonRef.current; @@ -223,21 +217,30 @@ export function QuickNote() { } : prev); } - const list = await initNoteList(); + await initNoteList(); - if (list?.data.length === 0) { + if (route === QuickNoteRoute.LIST || forceCreate) { await handleAdd(); } setOpen(true); - }, [open, expand, initNoteList, handleAdd]); + }, [expand, initNoteList, route, handleAdd]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (createHotkey(HOT_KEY_NAME.QUICK_NOTE)(e)) { e.stopPropagation(); e.preventDefault(); - void handleOpen(); + + void (async () => { + try { + await handleOpen(true); + // eslint-disable-next-line + } catch (e: any) { + console.error(e); + handleOpenToast(e.message); + } + })(); } else if (createHotkey(HOT_KEY_NAME.ESCAPE)(e)) { handleClose(); } @@ -248,7 +251,7 @@ export function QuickNote() { return () => { document.removeEventListener('keydown', handleKeyDown); }; - }, [handleOpen]); + }, [handleOpen, handleOpenToast]); const handleMouseDown = (event: React.MouseEvent) => { if (!position) return; @@ -307,10 +310,13 @@ export function QuickNote() { return { ...note, data, + last_updated_at: new Date().toISOString(), }; } return note; + }).sort((a, b) => { + return new Date(b.last_updated_at).getTime() - new Date(a.last_updated_at).getTime(); }); }); if (id === currentNote?.id) { @@ -319,6 +325,7 @@ export function QuickNote() { return { ...prev, data, + last_updated_at: new Date().toISOString(), }; } @@ -336,6 +343,48 @@ export function QuickNote() { }, []); + const handleDeleteNotes = useCallback((notes: QuickNoteType[]) => { + if (!service || !currentWorkspaceId) return; + notes.forEach(note => { + void (async () => { + try { + await service.deleteQuickNote?.(currentWorkspaceId, note.id); + setNoteList(prev => prev.filter(n => n.id !== note.id)); + // eslint-disable-next-line + } catch (e: any) { + console.error(e); + handleOpenToast(e.message); + } + })(); + }); + }, [currentWorkspaceId, handleOpenToast, service]); + + const clearEmptyNotes = useCallback(() => { + + if (!currentNote) return; + + if (currentNote.last_updated_at === currentNote.created_at) { + handleDeleteNotes([currentNote]); + } + + }, [handleDeleteNotes, currentNote]); + + const handleBackList = useCallback(() => { + const search = listParamsRef.current.searchTerm; + + if (search) { + listParamsRef.current = { + offset: 0, + limit: LISI_LIMIT, + searchTerm: '', + }; + void handleSearch(''); + } + + setRoute(QuickNoteRoute.LIST); + clearEmptyNotes(); + }, [handleSearch, clearEmptyNotes]); + const renderHeader = () => { if (route === QuickNoteRoute.NOTE && currentNote) { return ( @@ -344,9 +393,7 @@ export function QuickNote() { expand={expand} onToggleExpand={handleToggleExpand} onClose={handleClose} - onBack={() => { - setRoute(QuickNoteRoute.LIST); - }}/> + onBack={handleBackList}/> ); } @@ -370,6 +417,10 @@ export function QuickNote() { } }, [handleLoadMore]); + const handleAddedNote = useCallback((note: QuickNoteType) => { + setNoteList(prev => [note, ...prev]); + }, []); + return ( <> { + e.currentTarget.blur(); + if (open) { + handleClose(); + return; + } + + void handleOpen(); + }} > @@ -445,12 +504,14 @@ export function QuickNote() { { route === QuickNoteRoute.NOTE && currentNote ? <> - - { + { handleUpdateNodeData(currentNote.id, data); }}/> - : }
-
- { - setNoteList(prev => [note, ...prev]); - }} onEnterNote={handleEnterNote}/> -
- - void; @@ -25,27 +27,32 @@ function AddIconCover ({ }) { const { t } = useTranslation(); - return ( -
+ {!hasIcon && } - {!hasCover && } + startIcon={} + >{t('document.plugins.cover.addIcon')}} + {!hasCover && } + +
) : null; + + return ( + <> + {actions} { setIconAnchorEl(null); + if (icon.ty === ViewIconType.Icon) { + onUpdateIcon?.({ + ty: ViewIconType.Icon, + value: JSON.stringify({ + color: icon.color, + groupName: icon.value.split('/')[0], + iconName: icon.value.split('/')[1], + iconContent: icon.content, + }), + }); + return; + } + onUpdateIcon?.(icon); }} removeIcon={() => { @@ -63,7 +83,8 @@ function AddIconCover ({ onUpdateIcon?.({ ty: ViewIconType.Emoji, value: '' }); }} /> -
+ + ); } diff --git a/frontend/appflowy_web_app/src/components/view-meta/TitleEditable.tsx b/frontend/appflowy_web_app/src/components/view-meta/TitleEditable.tsx index 8f953ac98a4e2..e9fa6a86f9a2e 100644 --- a/frontend/appflowy_web_app/src/components/view-meta/TitleEditable.tsx +++ b/frontend/appflowy_web_app/src/components/view-meta/TitleEditable.tsx @@ -90,7 +90,10 @@ function TitleEditable({ ref={contentRef} suppressContentEditableWarning={true} id={`editor-title-${viewId}`} - className={'relative flex-1 custom-caret cursor-text focus:outline-none empty:before:content-[attr(data-placeholder)] empty:before:text-text-placeholder'} + style={{ + wordBreak: 'break-word', + }} + className={'relative flex-1 custom-caret break-words whitespace-pre-wrap cursor-text focus:outline-none empty:before:content-[attr(data-placeholder)] empty:before:text-text-placeholder'} data-placeholder={t('menuAppHeader.defaultNewPageName')} contentEditable={true} aria-readonly={false} diff --git a/frontend/appflowy_web_app/src/components/view-meta/ViewCover.tsx b/frontend/appflowy_web_app/src/components/view-meta/ViewCover.tsx index 04bad54f721cf..baa7ebb427302 100644 --- a/frontend/appflowy_web_app/src/components/view-meta/ViewCover.tsx +++ b/frontend/appflowy_web_app/src/components/view-meta/ViewCover.tsx @@ -5,7 +5,7 @@ import React, { lazy, useCallback, useRef, useState, Suspense } from 'react'; const ViewCoverActions = lazy(() => import('@/components/view-meta/ViewCoverActions')); -function ViewCover ({ coverValue, coverType, onUpdateCover, onRemoveCover, readOnly = true }: { +function ViewCover({ coverValue, coverType, onUpdateCover, onRemoveCover, readOnly = true }: { coverValue?: string; coverType?: string; onUpdateCover: (cover: ViewMetaCover) => void; @@ -64,6 +64,7 @@ function ViewCover ({ coverValue, coverType, onUpdateCover, onRemoveCover, readO setShowAction(false)} onUpdateCover={onUpdateCover} onRemove={onRemoveCover} /> diff --git a/frontend/appflowy_web_app/src/components/view-meta/ViewCoverActions.tsx b/frontend/appflowy_web_app/src/components/view-meta/ViewCoverActions.tsx index 0fd86451665c1..e64e627be1e68 100644 --- a/frontend/appflowy_web_app/src/components/view-meta/ViewCoverActions.tsx +++ b/frontend/appflowy_web_app/src/components/view-meta/ViewCoverActions.tsx @@ -6,11 +6,12 @@ import { useTranslation } from 'react-i18next'; import { ReactComponent as DeleteIcon } from '@/assets/trash.svg'; import CoverPopover from '@/components/view-meta/CoverPopover'; -function ViewCoverActions ( - { show, onRemove, onUpdateCover }: { +function ViewCoverActions( + { show, onRemove, onUpdateCover, onClose }: { show: boolean; onRemove: () => void; onUpdateCover: (cover: ViewMetaCover) => void; + onClose: () => void; }, ref: React.ForwardedRef, ) { @@ -27,10 +28,13 @@ function ViewCoverActions ( >
@@ -64,7 +68,11 @@ function ViewCoverActions ( showPopover } onClose={ - () => setAnchorPosition(undefined) + () => { + setAnchorPosition(undefined); + onClose(); + } + } onUpdateCover={onUpdateCover} />} diff --git a/frontend/appflowy_web_app/src/components/view-meta/ViewMetaPreview.tsx b/frontend/appflowy_web_app/src/components/view-meta/ViewMetaPreview.tsx index 3e305b7aef1d0..14bc935741b01 100644 --- a/frontend/appflowy_web_app/src/components/view-meta/ViewMetaPreview.tsx +++ b/frontend/appflowy_web_app/src/components/view-meta/ViewMetaPreview.tsx @@ -1,14 +1,14 @@ -import { CoverType, ViewIconType, ViewMetaCover, ViewMetaIcon, ViewMetaProps } from '@/application/types'; +import { CoverType, ViewIconType, ViewLayout, ViewMetaCover, ViewMetaIcon, ViewMetaProps } from '@/application/types'; import { notify } from '@/components/_shared/notify'; import TitleEditable from '@/components/view-meta/TitleEditable'; import ViewCover from '@/components/view-meta/ViewCover'; -import { isFlagEmoji } from '@/utils/emoji'; import React, { lazy, Suspense, useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import PageIcon from '@/components/_shared/view-icon/PageIcon'; const AddIconCover = lazy(() => import('@/components/view-meta/AddIconCover')); -export function ViewMetaPreview ({ +export function ViewMetaPreview({ icon: iconProp, cover: coverProp, name, @@ -61,9 +61,6 @@ export function ViewMetaPreview ({ }, [coverType, cover?.value]); const { t } = useTranslation(); - const isFlag = useMemo(() => { - return icon ? isFlagEmoji(icon.value) : false; - }, [icon]); const [isHover, setIsHover] = React.useState(false); const handleUpdateIcon = React.useCallback(async (icon: { ty: ViewIconType, value: string }) => { @@ -139,7 +136,8 @@ export function ViewMetaPreview ({ className={'flex mt-2 flex-col relative w-full overflow-hidden'} >
- {isHover && !readOnly && + {icon?.value ?
{ if (readOnly) return; setIconAnchorEl(e.currentTarget); }} - className={`view-icon flex h-[1.25em] px-1.5 items-center justify-center ${readOnly ? 'cursor-default' : 'cursor-pointer hover:bg-fill-list-hover '} ${isFlag ? 'icon' : ''}`} + className={`view-icon flex h-[1.25em] px-1.5 items-center justify-center ${readOnly ? 'cursor-default' : 'cursor-pointer hover:bg-fill-list-hover '}`} > - {icon?.value} +
: null } @@ -185,7 +190,10 @@ export function ViewMetaPreview ({ onEnter={onEnter} /> :
diff --git a/frontend/appflowy_web_app/src/pages/AcceptInvitationPage.tsx b/frontend/appflowy_web_app/src/pages/AcceptInvitationPage.tsx index 5300a3c54c43b..f33e488ac1ba5 100644 --- a/frontend/appflowy_web_app/src/pages/AcceptInvitationPage.tsx +++ b/frontend/appflowy_web_app/src/pages/AcceptInvitationPage.tsx @@ -4,15 +4,13 @@ import ChangeAccount from '@/components/_shared/modal/ChangeAccount'; import { notify } from '@/components/_shared/notify'; import { getAvatar } from '@/components/_shared/view-icon/utils'; import { AFConfigContext, useCurrentUser, useService } from '@/components/main/app.hooks'; -import { openOrDownload } from '@/utils/open_schema'; -import { openAppFlowySchema } from '@/utils/url'; import { EmailOutlined } from '@mui/icons-material'; import { Avatar, Button, Divider } from '@mui/material'; import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate, useSearchParams } from 'react-router-dom'; -function AcceptInvitationPage () { +function AcceptInvitationPage() { const isAuthenticated = useContext(AFConfigContext)?.isAuthenticated; const currentUser = useCurrentUser(); const navigate = useNavigate(); @@ -58,6 +56,9 @@ function AcceptInvitationPage () { name: invitation.workspace_name, }); }, [invitation]); + const url = useMemo(() => { + return window.location.href; + }, []); const inviterIconProps = useMemo(() => { if (!invitation) return {}; @@ -78,7 +79,7 @@ function AcceptInvitationPage () { }} className={'flex w-full cursor-pointer max-md:justify-center max-md:h-32 h-20 items-center justify-between sticky'} > - +
AppFlowy
- +
- + {currentUser?.email}
@@ -142,7 +143,9 @@ function AcceptInvitationPage () { okText: t('invitation.openWorkspace'), onOk: () => { - openOrDownload(openAppFlowySchema + '#workspace_id=' + invitation?.workspace_id); + const origin = window.location.origin; + + window.open(`${origin}/app/${invitation?.workspace_id}`, '_current'); }, }); @@ -154,7 +157,7 @@ function AcceptInvitationPage () { {t('invitation.joinWorkspace')}
- + {isAuthenticated && }
); } diff --git a/frontend/appflowy_web_app/src/pages/AppPage.tsx b/frontend/appflowy_web_app/src/pages/AppPage.tsx index ee7674411db26..b3916c320510b 100644 --- a/frontend/appflowy_web_app/src/pages/AppPage.tsx +++ b/frontend/appflowy_web_app/src/pages/AppPage.tsx @@ -2,7 +2,12 @@ import { UIVariant, ViewComponentProps, ViewLayout, ViewMetaProps, YDoc } from ' import Help from '@/components/_shared/help/Help'; import { findView } from '@/components/_shared/outline/utils'; -import { AppContext, useAppHandlers, useAppOutline, useAppViewId } from '@/components/app/app.hooks'; +import { + AppContext, + useAppHandlers, + useAppOutline, + useAppViewId, +} from '@/components/app/app.hooks'; import DatabaseView from '@/components/app/DatabaseView'; import { Document } from '@/components/document'; import RecordNotFound from '@/components/error/RecordNotFound'; @@ -11,9 +16,10 @@ import React, { lazy, memo, Suspense, useCallback, useContext, useEffect, useMem const ViewHelmet = lazy(() => import('@/components/_shared/helmet/ViewHelmet')); -function AppPage () { +function AppPage() { const viewId = useAppViewId(); const outline = useAppOutline(); + const ref = React.useRef(null); const { toView, loadViewMeta, @@ -44,16 +50,12 @@ function AppPage () { const [doc, setDoc] = React.useState(undefined); const [notFound, setNotFound] = React.useState(false); - const loadPageDoc = useCallback(async () => { - - if (!viewId) { - return; - } + const loadPageDoc = useCallback(async (id: string) => { setNotFound(false); setDoc(undefined); try { - const doc = await loadView(viewId); + const doc = await loadView(id); setDoc(doc); } catch (e) { @@ -61,11 +63,13 @@ function AppPage () { console.error(e); } - }, [loadView, viewId]); + }, [loadView]); useEffect(() => { - void loadPageDoc(); - }, [loadPageDoc]); + if (!viewId) return; + + void loadPageDoc(viewId); + }, [loadPageDoc, viewId]); const View = useMemo(() => { switch (view?.layout) { @@ -133,17 +137,17 @@ function AppPage () { if (!viewId) return null; return ( -
+
{helmet} {notFound ? ( - + ) : (
{viewDom}
)} - {view && doc && } + {view && doc && }
); diff --git a/frontend/appflowy_web_app/src/styles/variables/dark.variables.css b/frontend/appflowy_web_app/src/styles/variables/dark.variables.css index b0c6073068e54..907cf60a7f01a 100644 --- a/frontend/appflowy_web_app/src/styles/variables/dark.variables.css +++ b/frontend/appflowy_web_app/src/styles/variables/dark.variables.css @@ -78,4 +78,6 @@ --bg-header: #1a202ccc; --bg-footer: #00000000; --note-header: #232b38; + --billing-primary: #601DAA; + --billing-primary-hover: #7A2EBF; } \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/styles/variables/light.variables.css b/frontend/appflowy_web_app/src/styles/variables/light.variables.css index 06441a0cd651e..8ff71b01aa4b1 100644 --- a/frontend/appflowy_web_app/src/styles/variables/light.variables.css +++ b/frontend/appflowy_web_app/src/styles/variables/light.variables.css @@ -80,4 +80,6 @@ --bg-header: #FFFFFFCC; --bg-footer: #FFFFFFCC; --note-header: #EDEFF3; + --billing-primary: #8427e0; + --billing-primary-hover: #9f3ae6; } \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/utils/emoji.ts b/frontend/appflowy_web_app/src/utils/emoji.ts index cdfd312ef634d..78282093aa829 100644 --- a/frontend/appflowy_web_app/src/utils/emoji.ts +++ b/frontend/appflowy_web_app/src/utils/emoji.ts @@ -1,7 +1,7 @@ import { EmojiMartData } from '@emoji-mart/data'; import axios from 'axios'; -export async function randomEmoji (skin = 0) { +export async function randomEmoji(skin = 0) { const emojiData = await loadEmojiData(); const emojis = (emojiData as EmojiMartData).emojis; const keys = Object.keys(emojis); @@ -10,11 +10,11 @@ export async function randomEmoji (skin = 0) { return emojis[randomKey].skins[skin].native; } -export async function loadEmojiData () { +export async function loadEmojiData() { return import('@emoji-mart/data/sets/15/native.json'); } -export function isFlagEmoji (emoji: string) { +export function isFlagEmoji(emoji: string) { return /\uD83C[\uDDE6-\uDDFF]/.test(emoji); } @@ -45,7 +45,7 @@ let icons: Record | undefined; -export async function loadIcons (): Promise< +export async function loadIcons(): Promise< Record< ICON_CATEGORY, { @@ -66,7 +66,7 @@ export async function loadIcons (): Promise< }); } -export async function getIconSvgEncodedContent (id: string, color: string) { +export async function getIconSvgEncodedContent(id: string, color: string) { try { const { data } = await axios.get(`/af_icons/${id}.svg`); @@ -79,7 +79,7 @@ export async function getIconSvgEncodedContent (id: string, color: string) { } } -export async function randomIcon () { +export async function randomIcon() { const icons = await loadIcons(); const categories = Object.keys(icons); const randomCategory = categories[Math.floor(Math.random() * categories.length)] as ICON_CATEGORY; @@ -87,3 +87,17 @@ export async function randomIcon () { return randomIcon; } + +export async function getIcon(id: string) { + const icons = await loadIcons(); + + for (const category of Object.keys(icons)) { + for (const icon of icons[category as ICON_CATEGORY]) { + if (icon.id === id) { + return icon; + } + } + } + + return null; +} diff --git a/frontend/appflowy_web_app/tailwind/box-shadow.cjs b/frontend/appflowy_web_app/tailwind/box-shadow.cjs index e65cd45c701fa..510e8f398797f 100644 --- a/frontend/appflowy_web_app/tailwind/box-shadow.cjs +++ b/frontend/appflowy_web_app/tailwind/box-shadow.cjs @@ -1,7 +1,7 @@ /** * Do not edit directly -* Generated on Thu, 19 Dec 2024 03:21:55 GMT +* Generated on Tue, 24 Dec 2024 08:57:39 GMT * Generated from $pnpm css:variables */ diff --git a/frontend/appflowy_web_app/tailwind/colors.cjs b/frontend/appflowy_web_app/tailwind/colors.cjs index 2c63e82c740f8..71c17594aa35f 100644 --- a/frontend/appflowy_web_app/tailwind/colors.cjs +++ b/frontend/appflowy_web_app/tailwind/colors.cjs @@ -1,7 +1,7 @@ /** * Do not edit directly -* Generated on Thu, 19 Dec 2024 03:21:55 GMT +* Generated on Tue, 24 Dec 2024 08:57:39 GMT * Generated from $pnpm css:variables */ @@ -96,7 +96,10 @@ module.exports = { "track": "var(--scrollbar-track)" }, "note": { - "header": "var(--note-header)", - "btn": "var(--note-btn)" + "header": "var(--note-header)" + }, + "billing": { + "primary": "var(--billing-primary)", + "primary-hover": "var(--billing-primary-hover)" } }; diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 592c08e386885..17ab725b00cc2 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -2941,13 +2941,15 @@ "unknownError": "File open failed" }, "inviteMember": { - "requestInviteMembers": "Request to invite members", - "addEmail": "Enter emails, separated by `,`", - "requestInvites": "Request invites", + "requestInviteMembers": "Invite to your workspace", + "inviteFailedMemberLimit": "Member limit has been reached, please ", + "upgrade": "upgrade", + "addEmail": "email@example.com, email2@example.com...", + "requestInvites": "Send invites", "inviteAlready": "You've already invited this email: {email}", "inviteSuccess": "Invitation sent successfully", "description": "Input emails below with commas between them. Charges are based on member count.", - "emails": "Emails" + "emails": "Email" }, "quickNote": { "label": "Quick note", @@ -2959,6 +2961,81 @@ "quickNotesEmpty": "No quick notes", "emptyNote": "Empty note", "deleteNotePrompt": "The selected note will be deleted permanently. Are you sure you want to delete it?", - "addNote": "New note" + "addNote": "New note", + "noAdditionalText": "No additional text" + }, + "subscribe": { + "upgradePlanTitle": "Compare & select plan", + "yearly": "Yearly", + "save": "Save {discount}%", + "monthly": "Monthly", + "priceIn": "Price in ", + "free": "Free", + "pro": "Pro", + "freeDescription": "For individuals up to 2 members to organize everything", + "proDescription": "For small teams to manage projects and team knowledge", + "proDuration": { + "monthly": "per member per month\nbilled monthly", + "yearly": "per member per month\nbilled annually" + }, + "cancel": "Downgrade", + "changePlan": "Upgrade to Pro Plan", + "everythingInFree": "Everything in Free +", + "currentPlan": "Current", + "freeDuration": "forever", + "freePoints": { + "first": "1 collaborative workspace up to 2 members", + "second": "Unlimited pages & blocks", + "three": "5 GB storage", + "four": "Intelligent search", + "five": "20 AI responses", + "six": "Mobile app", + "seven": "Real-time collaboration" + }, + "proPoints": { + "first": "Unlimited storage", + "second": "Up to 10 workspace members", + "three": "Unlimited AI responses", + "four": "Unlimited file uploads", + "five": "Custom namespace" + }, + "cancelPlan": { + "title": "Sorry to see you go", + "success": "Your subscription has been canceled successfully", + "description": "We're sorry to see you go. We'd love to hear your feedback to help us improve AppFlowy. Please take a moment to answer a few questions.", + "commonOther": "Other", + "otherHint": "Write your answer here", + "questionOne": { + "question": "What prompted you to cancel your AppFlowy Pro subscription?", + "answerOne": "Cost too high", + "answerTwo": "Features did not meet expectations", + "answerThree": "Found a better alternative", + "answerFour": "Did not use it enough to justify the expense", + "answerFive": "Service issue or technical difficulties" + }, + "questionTwo": { + "question": "How likely are you to consider re-subscribing to AppFlowy Pro in the future?", + "answerOne": "Very likely", + "answerTwo": "Somewhat likely", + "answerThree": "Not sure", + "answerFour": "Unlikely", + "answerFive": "Very unlikely" + }, + "questionThree": { + "question": "Which Pro feature did you value the most during your subscription?", + "answerOne": "Multi-user collaboration", + "answerTwo": "Longer time version history", + "answerThree": "Unlimited AI responses", + "answerFour": "Access to local AI models" + }, + "questionFour": { + "question": "How would you describe your overall experience with AppFlowy?", + "answerOne": "Great", + "answerTwo": "Good", + "answerThree": "Average", + "answerFour": "Below average", + "answerFive": "Unsatisfied" + } + } } }