From 6d0598b1010003640e099a51a0e29b9fbf98525d Mon Sep 17 00:00:00 2001 From: "Kilu.He" <108015703+qinluhe@users.noreply.github.com> Date: Tue, 30 Apr 2024 11:32:26 +0800 Subject: [PATCH] feat: parse folder collab and display document title/icon/cover (#5222) * feat: support web document and cypress test * fix: support blocks * fix: support table and outline * fix: update nginx * fix: support document title, icon, cover fix: mock test folder --- .github/workflows/ios_ci.yaml | 2 + .../cypress/support/component.ts | 1 - .../cypress/support/document.ts | 15 +- frontend/appflowy_web_app/package.json | 2 + frontend/appflowy_web_app/pnpm-lock.yaml | 15 + .../src/application/collab.type.ts | 308 +++++++++++++++++- .../src/application/folder-yjs/context.ts | 8 + .../src/application/folder-yjs/index.ts | 2 + .../src/application/folder-yjs/selector.ts | 62 ++++ .../services/js-services/db/index.ts | 2 +- .../services/js-services/document.service.ts | 3 +- .../services/js-services/folder.service.ts | 45 +++ .../application/services/js-services/index.ts | 5 + .../services/js-services/storage/document.ts | 2 +- .../services/js-services/storage/folder.ts | 21 ++ .../services/js-services/storage/token.ts | 73 +++-- .../services/js-services/user.service.ts | 6 +- .../services/js-services/wasm/client_api.ts | 20 +- .../src/application/services/services.type.ts | 7 +- .../services/tauri-services/folder.service.ts | 12 + .../services/tauri-services/index.ts | 12 +- .../application/slate-yjs/plugins/withYjs.ts | 3 +- .../application/slate-yjs/utils/convert.ts | 4 +- .../utils/translateYjsEvent/arrayEvent.ts | 2 +- .../utils/translateYjsEvent/index.ts | 64 ++-- .../utils/translateYjsEvent/mapEvent.ts | 2 +- .../ydoc/apply/__tests__/document.test.ts | 2 +- .../src/application/ydoc/apply/document.ts | 2 +- .../src/assets/clock_alarm.svg | 6 + .../src/assets/database/checkbox-check.svg | 4 +- .../src/assets/database/checkbox-uncheck.svg | 2 +- .../context-provider/FolderProvider.tsx | 9 + .../_shared/context-provider/IdProvider.tsx | 4 +- .../_shared/not-found/RecordNotFound.tsx | 29 ++ .../src/components/_shared/not-found/index.ts | 1 + .../src/components/_shared/page/Page.tsx | 30 ++ .../src/components/_shared/page/index.ts | 1 + .../components/_shared/page/usePageInfo.tsx | 45 +++ .../src/components/app/App.tsx | 5 +- .../src/components/app/AppTheme.tsx | 40 +-- .../src/components/auth/LoginButtonGroup.tsx | 3 + .../src/components/auth/ProtectedRoutes.tsx | 4 +- .../src/components/document/Document.tsx | 50 ++- .../document_header/DocumentCover.tsx | 37 +++ .../document_header/DocumentHeader.tsx | 42 +++ .../document_header/default_cover.jpg | Bin 0 -> 281498 bytes .../document/document_header/index.ts | 1 + .../document/document_header/useBlockCover.ts | 36 ++ .../components/editor/CollaborativeEditor.tsx | 15 +- .../src/components/editor/Editor.cy.tsx | 24 +- .../src/components/editor/Editor.tsx | 39 +-- .../src/components/editor/command/index.ts | 37 +++ .../components/blocks/callout/CalloutIcon.tsx | 5 +- .../components/blocks/code/useDecorate.ts | 2 +- .../components/blocks/image/ImageBlock.tsx | 2 +- .../components/blocks/outline/Outline.tsx | 2 +- .../editor/components/blocks/outline/utils.ts | 11 +- .../editor/components/blocks/table/Table.tsx | 2 +- .../components/blocks/text/Placeholder.tsx | 2 +- .../blocks/text/StartIcon.hooks.tsx | 2 +- .../editor/components/blocks/text/Text.tsx | 2 +- .../editor/components/element/Element.tsx | 2 +- .../components/leaf/formula/Formula.tsx | 2 +- .../editor/components/leaf/href/Href.tsx | 19 +- .../components/leaf/mention/MentionDate.tsx | 10 +- .../components/leaf/mention/MentionLeaf.tsx | 11 +- .../components/leaf/mention/MentionPage.tsx | 57 +--- .../src/components/editor/editor.scss | 32 +- .../src/components/editor/editor.type.ts | 2 +- .../editor/plugins/withInlineElement.ts | 2 +- .../src/components/editor/utils/list.ts | 2 +- .../src/components/folder/Folder.tsx | 17 + .../src/components/folder/ViewItem.tsx | 24 ++ .../src/components/folder/index.ts | 1 + .../src/components/layout/Header.tsx | 90 +++-- .../src/components/layout/Layout.tsx | 32 +- .../src/components/layout/layout.scss | 86 +++++ .../appflowy_web_app/src/pages/FolderPage.tsx | 8 + .../appflowy_web_app/src/pages/LoginPage.tsx | 47 +-- .../src/pages/ProductPage.tsx | 1 - frontend/appflowy_web_app/src/utils/log.ts | 20 ++ frontend/appflowy_web_app/src/utils/time.ts | 10 + frontend/appflowy_web_app/src/utils/url.ts | 47 +++ 83 files changed, 1355 insertions(+), 363 deletions(-) create mode 100644 frontend/appflowy_web_app/src/application/folder-yjs/context.ts create mode 100644 frontend/appflowy_web_app/src/application/folder-yjs/index.ts create mode 100644 frontend/appflowy_web_app/src/application/folder-yjs/selector.ts create mode 100644 frontend/appflowy_web_app/src/application/services/js-services/folder.service.ts create mode 100644 frontend/appflowy_web_app/src/application/services/js-services/storage/folder.ts create mode 100644 frontend/appflowy_web_app/src/application/services/tauri-services/folder.service.ts create mode 100644 frontend/appflowy_web_app/src/assets/clock_alarm.svg create mode 100644 frontend/appflowy_web_app/src/components/_shared/context-provider/FolderProvider.tsx create mode 100644 frontend/appflowy_web_app/src/components/_shared/not-found/RecordNotFound.tsx create mode 100644 frontend/appflowy_web_app/src/components/_shared/not-found/index.ts create mode 100644 frontend/appflowy_web_app/src/components/_shared/page/Page.tsx create mode 100644 frontend/appflowy_web_app/src/components/_shared/page/index.ts create mode 100644 frontend/appflowy_web_app/src/components/_shared/page/usePageInfo.tsx create mode 100644 frontend/appflowy_web_app/src/components/document/document_header/DocumentCover.tsx create mode 100644 frontend/appflowy_web_app/src/components/document/document_header/DocumentHeader.tsx create mode 100644 frontend/appflowy_web_app/src/components/document/document_header/default_cover.jpg create mode 100644 frontend/appflowy_web_app/src/components/document/document_header/index.ts create mode 100644 frontend/appflowy_web_app/src/components/document/document_header/useBlockCover.ts create mode 100644 frontend/appflowy_web_app/src/components/editor/command/index.ts create mode 100644 frontend/appflowy_web_app/src/components/folder/Folder.tsx create mode 100644 frontend/appflowy_web_app/src/components/folder/ViewItem.tsx create mode 100644 frontend/appflowy_web_app/src/components/folder/index.ts create mode 100644 frontend/appflowy_web_app/src/components/layout/layout.scss create mode 100644 frontend/appflowy_web_app/src/pages/FolderPage.tsx create mode 100644 frontend/appflowy_web_app/src/utils/log.ts create mode 100644 frontend/appflowy_web_app/src/utils/time.ts create mode 100644 frontend/appflowy_web_app/src/utils/url.ts diff --git a/.github/workflows/ios_ci.yaml b/.github/workflows/ios_ci.yaml index 72d4ce8b81..e6b6b741fd 100644 --- a/.github/workflows/ios_ci.yaml +++ b/.github/workflows/ios_ci.yaml @@ -8,6 +8,7 @@ on: - ".github/workflows/mobile_ci.yaml" - "frontend/**" - "!frontend/appflowy_tauri/**" + - "!frontend/appflowy_web_app/**" pull_request: branches: @@ -16,6 +17,7 @@ on: - ".github/workflows/mobile_ci.yaml" - "frontend/**" - "!frontend/appflowy_tauri/**" + - "!frontend/appflowy_web_app/**" env: FLUTTER_VERSION: "3.19.0" diff --git a/frontend/appflowy_web_app/cypress/support/component.ts b/frontend/appflowy_web_app/cypress/support/component.ts index ad7f96afeb..a6ca9728e3 100644 --- a/frontend/appflowy_web_app/cypress/support/component.ts +++ b/frontend/appflowy_web_app/cypress/support/component.ts @@ -31,7 +31,6 @@ declare global { interface Chainable { mount: typeof mount; mockAPI: () => void; - mockFullDocument: () => void; } } } diff --git a/frontend/appflowy_web_app/cypress/support/document.ts b/frontend/appflowy_web_app/cypress/support/document.ts index 35a5b4ae11..757974f14b 100644 --- a/frontend/appflowy_web_app/cypress/support/document.ts +++ b/frontend/appflowy_web_app/cypress/support/document.ts @@ -1,20 +1,7 @@ -import { BlockId, BlockType, YBlocks, YChildrenMap, YjsEditorKey, YTextMap } from '@/application/document.type'; -import { applyDocument } from 'src/application/ydoc/apply'; -import { JSDocumentService } from '@/application/services/js-services/document.service'; +import { BlockId, BlockType, YBlocks, YChildrenMap, YjsEditorKey, YTextMap } from '@/application/collab.type'; import { nanoid } from 'nanoid'; import * as Y from 'yjs'; -Cypress.Commands.add('mockFullDocument', () => { - cy.fixture('full_doc').then((docJson) => { - const collab = new Y.Doc(); - const state = new Uint8Array(docJson.data.doc_state); - - applyDocument(collab, state); - - cy.stub(JSDocumentService.prototype, 'openDocument').returns(Promise.resolve(collab)); - }); -}); - export class DocumentTest { public doc: Y.Doc; diff --git a/frontend/appflowy_web_app/package.json b/frontend/appflowy_web_app/package.json index 397fb90f52..1acc7d6e82 100644 --- a/frontend/appflowy_web_app/package.json +++ b/frontend/appflowy_web_app/package.json @@ -83,6 +83,7 @@ "ts-results": "^3.3.0", "unsplash-js": "^7.0.19", "utf8": "^3.0.0", + "validator": "^13.11.0", "valtio": "^1.12.1", "vite-plugin-wasm": "^3.3.0", "y-indexeddb": "9.0.12", @@ -110,6 +111,7 @@ "@types/react-window": "^1.8.8", "@types/utf8": "^3.0.1", "@types/uuid": "^9.0.1", + "@types/validator": "^13.11.9", "@typescript-eslint/eslint-plugin": "^7.2.0", "@typescript-eslint/parser": "^7.2.0", "@vitejs/plugin-react": "^4.2.1", diff --git a/frontend/appflowy_web_app/pnpm-lock.yaml b/frontend/appflowy_web_app/pnpm-lock.yaml index 0da4a7e4ed..b9fe83de2f 100644 --- a/frontend/appflowy_web_app/pnpm-lock.yaml +++ b/frontend/appflowy_web_app/pnpm-lock.yaml @@ -188,6 +188,9 @@ dependencies: utf8: specifier: ^3.0.0 version: 3.0.0 + validator: + specifier: ^13.11.0 + version: 13.11.0 valtio: specifier: ^1.12.1 version: 1.12.1(@types/react@18.2.66)(react@18.2.0) @@ -265,6 +268,9 @@ devDependencies: '@types/uuid': specifier: ^9.0.1 version: 9.0.1 + '@types/validator': + specifier: ^13.11.9 + version: 13.11.9 '@typescript-eslint/eslint-plugin': specifier: ^7.2.0 version: 7.2.0(@typescript-eslint/parser@7.2.0)(eslint@8.57.0)(typescript@4.9.5) @@ -2818,6 +2824,10 @@ packages: resolution: {integrity: sha512-rFT3ak0/2trgvp4yYZo5iKFEPsET7vKydKF+VRCxlQ9bpheehyAJH89dAkaLEq/j/RZXJIqcgsmPJKUP1Z28HA==} dev: true + /@types/validator@13.11.9: + resolution: {integrity: sha512-FCTsikRozryfayPuiI46QzH3fnrOoctTjvOYZkho9BTFLCOZ2rgZJHMOVgCOfttjPJcgOx52EpkY0CMfy87MIw==} + dev: true + /@types/warning@3.0.3: resolution: {integrity: sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q==} dev: false @@ -8483,6 +8493,11 @@ packages: '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 + /validator@13.11.0: + resolution: {integrity: sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ==} + engines: {node: '>= 0.10'} + dev: false + /valtio@1.12.1(@types/react@18.2.66)(react@18.2.0): resolution: {integrity: sha512-R0V4H86Xi2Pp7pmxN/EtV4Q6jr6PMN3t1IwxEvKUp6160r8FimvPh941oWyeK1iec/DTsh9Jb3Q+GputMS8SYg==} engines: {node: '>=12.20.0'} diff --git a/frontend/appflowy_web_app/src/application/collab.type.ts b/frontend/appflowy_web_app/src/application/collab.type.ts index 2459c8b3a7..0df2729749 100644 --- a/frontend/appflowy_web_app/src/application/collab.type.ts +++ b/frontend/appflowy_web_app/src/application/collab.type.ts @@ -1,14 +1,294 @@ -export enum CollabType { - Document = 0, - Database = 1, - WorkspaceDatabase = 2, - Folder = 3, - DatabaseRow = 4, - UserAwareness = 5, - Empty = 6, -} - -export enum CollabOrigin { - Local = 'local', - Remote = 'remote', -} \ No newline at end of file +import Y from 'yjs'; + +export type BlockId = string; + +export type ExternalId = string; + +export type ChildrenId = string; + +export type ViewId = string; + +export enum BlockType { + Paragraph = 'paragraph', + Page = 'page', + HeadingBlock = 'heading', + TodoListBlock = 'todo_list', + BulletedListBlock = 'bulleted_list', + NumberedListBlock = 'numbered_list', + ToggleListBlock = 'toggle_list', + CodeBlock = 'code', + EquationBlock = 'math_equation', + QuoteBlock = 'quote', + CalloutBlock = 'callout', + DividerBlock = 'divider', + ImageBlock = 'image', + GridBlock = 'grid', + OutlineBlock = 'outline', + TableBlock = 'table', + TableCell = 'table/cell', +} + +export enum InlineBlockType { + Formula = 'formula', + Mention = 'mention', +} + +export enum AlignType { + Left = 'left', + Center = 'center', + Right = 'right', +} + +export interface BlockData { + bg_color?: string; + font_color?: string; + align?: AlignType; +} + +export interface HeadingBlockData extends BlockData { + level: number; +} + +export interface NumberedListBlockData extends BlockData { + number: number; +} + +export interface TodoListBlockData extends BlockData { + checked: boolean; +} + +export interface ToggleListBlockData extends BlockData { + collapsed: boolean; +} + +export interface CodeBlockData extends BlockData { + language: string; +} + +export interface CalloutBlockData extends BlockData { + icon: string; +} + +export interface MathEquationBlockData extends BlockData { + formula?: string; +} + +export enum ImageType { + Local = 0, + Internal = 1, + External = 2, +} + +export interface ImageBlockData extends BlockData { + url?: string; + width?: number; + align?: AlignType; + image_type?: ImageType; + height?: number; +} + +export interface OutlineBlockData extends BlockData { + depth?: number; +} + +export interface TableBlockData extends BlockData { + colDefaultWidth: number; + colMinimumWidth: number; + colsHeight: number; + colsLen: number; + rowDefaultHeight: number; + rowsLen: number; +} + +export interface TableCellBlockData extends BlockData { + colPosition: number; + height: number; + rowPosition: number; + width: number; +} + +export enum MentionType { + PageRef = 'page', + Date = 'date', +} + +export interface Mention { + // inline page ref id + page_id?: string; + // reminder date ref id + date?: string; + reminder_id?: string; + reminder_option?: string; + + type: MentionType; +} + +export interface FolderMeta { + current_view: ViewId; + current_workspace: string; +} + +export enum CoverType { + Color = 'CoverType.color', + Image = 'CoverType.file', + Asset = 'CoverType.asset', +} + +export type PageCover = { + image_type?: ImageType; + cover_selection_type?: CoverType; + cover_selection?: string; +} | null; + +export enum ViewLayout { + Document = 0, + Grid = 1, + Board = 2, + Calendar = 3, +} + +export enum YjsEditorKey { + data_section = 'data', + document = 'document', + database = 'database', + workspace_database = 'databases', + folder = 'folder', + // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values + database_row = 'data', + user_awareness = 'user_awareness', + + // document + blocks = 'blocks', + page_id = 'page_id', + meta = 'meta', + children_map = 'children_map', + text_map = 'text_map', + text = 'text', + delta = 'delta', + block_id = 'id', + block_type = 'ty', + // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values + block_data = 'data', + block_parent = 'parent', + block_children = 'children', + block_external_id = 'external_id', + block_external_type = 'external_type', +} + +export enum YjsFolderKey { + views = 'views', + relation = 'relation', + section = 'section', + private = 'private', + favorite = 'favorite', + recent = 'recent', + trash = 'trash', + meta = 'meta', + current_view = 'current_view', + current_workspace = 'current_workspace', + id = 'id', + name = 'name', + icon = 'icon', + type = 'ty', + value = 'value', + layout = 'layout', +} + +export interface YDoc extends Y.Doc { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getMap(key: YjsEditorKey.data_section): YSharedRoot | any; +} + +export interface YSharedRoot extends Y.Map { + get(key: YjsEditorKey.document): YDocument; + + // eslint-disable-next-line @typescript-eslint/unified-signatures + get(key: YjsEditorKey.folder): YFolder; +} + +export interface YFolder extends Y.Map { + get(key: YjsFolderKey.views): YViews; + + // eslint-disable-next-line @typescript-eslint/unified-signatures + get(key: YjsFolderKey.meta): YFolderMeta; + + // eslint-disable-next-line @typescript-eslint/unified-signatures + get(key: YjsFolderKey.relation): YFolderRelation; + + // eslint-disable-next-line @typescript-eslint/unified-signatures + get(key: YjsFolderKey.section): YFolderSection; +} + +export interface YViews extends Y.Map { + get(key: ViewId): YView; +} + +export interface YView extends Y.Map { + get(key: YjsFolderKey.id): ViewId; + + get(key: YjsFolderKey.name): string; + + // eslint-disable-next-line @typescript-eslint/unified-signatures + get(key: YjsFolderKey.icon): string; + + // eslint-disable-next-line @typescript-eslint/unified-signatures + get(key: YjsFolderKey.layout): string; +} + +export interface YFolderRelation extends Y.Map { + get(key: ViewId): Y.Array; +} + +export interface YFolderMeta extends Y.Map { + get(key: YjsFolderKey.current_view | YjsFolderKey.current_workspace): string; +} + +export interface YFolderSection extends Y.Map { + get(key: YjsFolderKey.favorite | YjsFolderKey.private | YjsFolderKey.recent | YjsFolderKey.trash): YFolderSectionItem; +} + +export interface YFolderSectionItem extends Y.Map { + get(key: string): Y.Array; +} + +export interface YDocument extends Y.Map { + get(key: YjsEditorKey.blocks | YjsEditorKey.page_id | YjsEditorKey.meta): YBlocks | YMeta | string; +} + +export interface YBlocks extends Y.Map { + get(key: BlockId): Y.Map; +} + +export interface YMeta extends Y.Map { + get(key: YjsEditorKey.children_map | YjsEditorKey.text_map): YChildrenMap | YTextMap; +} + +export interface YChildrenMap extends Y.Map { + get(key: ChildrenId): Y.Array; +} + +export interface YTextMap extends Y.Map { + get(key: ExternalId): Y.Text; +} + +export enum CollabType { + Document = 0, + Database = 1, + WorkspaceDatabase = 2, + Folder = 3, + DatabaseRow = 4, + UserAwareness = 5, + Empty = 6, +} + +export enum CollabOrigin { + Local = 'local', + Remote = 'remote', +} + +export const layoutMap = { + [ViewLayout.Document]: 'document', + [ViewLayout.Grid]: 'grid', + [ViewLayout.Board]: 'board', + [ViewLayout.Calendar]: 'calendar', +}; diff --git a/frontend/appflowy_web_app/src/application/folder-yjs/context.ts b/frontend/appflowy_web_app/src/application/folder-yjs/context.ts new file mode 100644 index 0000000000..57c4d171df --- /dev/null +++ b/frontend/appflowy_web_app/src/application/folder-yjs/context.ts @@ -0,0 +1,8 @@ +import { YFolder } from '@/application/collab.type'; +import { createContext, useContext } from 'react'; + +export const FolderContext = createContext(null); + +export const useFolderContext = () => { + return useContext(FolderContext); +}; diff --git a/frontend/appflowy_web_app/src/application/folder-yjs/index.ts b/frontend/appflowy_web_app/src/application/folder-yjs/index.ts new file mode 100644 index 0000000000..f94cc509da --- /dev/null +++ b/frontend/appflowy_web_app/src/application/folder-yjs/index.ts @@ -0,0 +1,2 @@ +export * from './selector'; +export * from './context'; diff --git a/frontend/appflowy_web_app/src/application/folder-yjs/selector.ts b/frontend/appflowy_web_app/src/application/folder-yjs/selector.ts new file mode 100644 index 0000000000..295315874b --- /dev/null +++ b/frontend/appflowy_web_app/src/application/folder-yjs/selector.ts @@ -0,0 +1,62 @@ +import { YjsFolderKey, YView } from '@/application/collab.type'; +import { useFolderContext } from '@/application/folder-yjs/context'; +import { useEffect, useState } from 'react'; + +export function useViewsIdSelector() { + const folder = useFolderContext(); + const [viewsId, setViewsId] = useState([]); + + useEffect(() => { + if (!folder) return; + + const views = folder.get(YjsFolderKey.views); + const trash = folder.get(YjsFolderKey.section)?.get(YjsFolderKey.trash); + const meta = folder.get(YjsFolderKey.meta); + + console.log('folder', folder.toJSON()); + const collectIds = () => { + return Array.from(views.keys()).filter( + (id) => !trash?.has(id) && id !== meta?.get(YjsFolderKey.current_workspace) + ); + }; + + setViewsId(collectIds()); + const observerEvent = () => setViewsId(collectIds()); + + folder.observe(observerEvent); + + return () => { + folder.unobserve(observerEvent); + }; + }, [folder]); + + return { + viewsId, + }; +} + +export function useViewSelector(viewId: string) { + const folder = useFolderContext(); + const [clock, setClock] = useState(0); + const [view, setView] = useState(null); + + useEffect(() => { + if (!folder) return; + + const view = folder.get(YjsFolderKey.views)?.get(viewId); + + setView(view || null); + const observerEvent = () => setClock((prev) => prev + 1); + + view.observe(observerEvent); + + return () => { + view.unobserve(observerEvent); + }; + }, [folder, viewId]); + + return { + clock, + view, + }; +} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/db/index.ts b/frontend/appflowy_web_app/src/application/services/js-services/db/index.ts index f923aa528d..ebe8870c15 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/db/index.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/db/index.ts @@ -1,4 +1,4 @@ -import { YDoc } from '@/application/document.type'; +import { YDoc } from '@/application/collab.type'; import { getAuthInfo } from '@/application/services/js-services/storage'; import * as Y from 'yjs'; import { IndexeddbPersistence } from 'y-indexeddb'; diff --git a/frontend/appflowy_web_app/src/application/services/js-services/document.service.ts b/frontend/appflowy_web_app/src/application/services/js-services/document.service.ts index 2eb35138e8..1af92df8a0 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/document.service.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/document.service.ts @@ -1,8 +1,7 @@ -import { YDoc } from '@/application/document.type'; +import { CollabOrigin, CollabType, YDoc } from '@/application/collab.type'; import { getDocumentStorage } from '@/application/services/js-services/storage/document'; import { DocumentService } from '@/application/services/services.type'; import { APIService } from 'src/application/services/js-services/wasm'; -import { CollabOrigin, CollabType } from '@/application/collab.type'; import { applyDocument } from 'src/application/ydoc/apply'; export class JSDocumentService implements DocumentService { diff --git a/frontend/appflowy_web_app/src/application/services/js-services/folder.service.ts b/frontend/appflowy_web_app/src/application/services/js-services/folder.service.ts new file mode 100644 index 0000000000..796cd078d6 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/services/js-services/folder.service.ts @@ -0,0 +1,45 @@ +import { CollabOrigin, CollabType, YDoc } from '@/application/collab.type'; +import { getFolderStorage } from '@/application/services/js-services/storage/folder'; +import { FolderService } from '@/application/services/services.type'; +import { APIService } from 'src/application/services/js-services/wasm'; +import { applyDocument } from 'src/application/ydoc/apply'; + +export class JSFolderService implements FolderService { + constructor() { + // + } + + fetchFolder(workspaceId: string) { + return APIService.getCollab(workspaceId, workspaceId, CollabType.Folder); + } + + async openWorkspace(workspaceId: string): Promise { + const { doc, localExist } = await getFolderStorage(workspaceId); + const asyncApply = async () => { + const res = await this.fetchFolder(workspaceId); + + applyDocument(doc, res.state); + }; + + // If the document exists locally, apply the state asynchronously, + // otherwise, apply the state synchronously + if (localExist) { + void asyncApply(); + } else { + await asyncApply(); + } + + const handleUpdate = (update: Uint8Array, origin: CollabOrigin) => { + if (origin === CollabOrigin.Remote) { + return; + } + + // Send the update to the server + console.log('update', update); + }; + + doc.on('update', handleUpdate); + + return doc; + } +} 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 4f08b44bdf..3410c8d27e 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 @@ -3,10 +3,12 @@ import { AFServiceConfig, AuthService, DocumentService, + FolderService, UserService, } from '@/application/services/services.type'; import { JSUserService } from '@/application/services/js-services/user.service'; import { JSAuthService } from '@/application/services/js-services/auth.service'; +import { JSFolderService } from '@/application/services/js-services/folder.service'; import { JSDocumentService } from '@/application/services/js-services/document.service'; import { nanoid } from 'nanoid'; import { initAPIService } from '@/application/services/js-services/wasm/client_api'; @@ -18,6 +20,8 @@ export class AFClientService implements AFService { documentService: DocumentService; + folderService: FolderService; + private deviceId: string = nanoid(8); private clientId: string = 'web'; @@ -40,5 +44,6 @@ export class AFClientService implements AFService { this.authService = new JSAuthService(); this.userService = new JSUserService(); this.documentService = new JSDocumentService(); + this.folderService = new JSFolderService(); } } diff --git a/frontend/appflowy_web_app/src/application/services/js-services/storage/document.ts b/frontend/appflowy_web_app/src/application/services/js-services/storage/document.ts index de839314f2..0c1278d216 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/storage/document.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/storage/document.ts @@ -1,4 +1,4 @@ -import { YjsEditorKey } from '@/application/document.type'; +import { YjsEditorKey } from '@/application/collab.type'; import { openCollabDB } from '@/application/services/js-services/db'; import { getAuthInfo } from '@/application/services/js-services/storage/token'; diff --git a/frontend/appflowy_web_app/src/application/services/js-services/storage/folder.ts b/frontend/appflowy_web_app/src/application/services/js-services/storage/folder.ts new file mode 100644 index 0000000000..8d70df8d0a --- /dev/null +++ b/frontend/appflowy_web_app/src/application/services/js-services/storage/folder.ts @@ -0,0 +1,21 @@ +import { YjsEditorKey } from '@/application/collab.type'; +import { openCollabDB } from '@/application/services/js-services/db'; +import { getAuthInfo } from '@/application/services/js-services/storage/token'; + +export async function getFolderStorage(workspaceId: string) { + const docName = getDocName(workspaceId); + const doc = await openCollabDB(docName); + const localExist = doc.share.has(YjsEditorKey.data_section); + + return { + doc, + localExist, + }; +} + +export function getDocName(workspaceId: string) { + const { uuid } = getAuthInfo() || {}; + + if (!uuid) throw new Error('No user found'); + return `${uuid}_folder_${workspaceId}`; +} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/storage/token.ts b/frontend/appflowy_web_app/src/application/services/js-services/storage/token.ts index d6fcfe8644..e22f980423 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/storage/token.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/storage/token.ts @@ -1,36 +1,37 @@ -const tokenKey = 'token'; - -export function readTokenStr () { - return sessionStorage.getItem(tokenKey); -} - -export function getAuthInfo () { - const token = readTokenStr() || ''; - - try { - const info = JSON.parse(token); - - return { - uuid: info.user.id, - access_token: info.access_token, - email: info.user.email, - }; - } catch (e) { - return; - } -} - -export function writeToken (token: string) { - if (!token) { - invalidToken(); - return; - } - - sessionStorage.setItem(tokenKey, token); -} - -export function invalidToken () { - sessionStorage.removeItem(tokenKey); - window.location.reload(); -} - +import { notify } from '@/components/_shared/notify'; + +const tokenKey = 'token'; + +export function readTokenStr() { + return sessionStorage.getItem(tokenKey); +} + +export function getAuthInfo() { + const token = readTokenStr() || ''; + + try { + const info = JSON.parse(token); + + return { + uuid: info.user.id, + access_token: info.access_token, + email: info.user.email, + }; + } catch (e) { + return; + } +} + +export function writeToken(token: string) { + if (!token) { + invalidToken(); + return; + } + + sessionStorage.setItem(tokenKey, token); +} + +export function invalidToken() { + sessionStorage.removeItem(tokenKey); + notify.error('Invalid token, please login again'); +} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/user.service.ts b/frontend/appflowy_web_app/src/application/services/js-services/user.service.ts index 4b2f69c6af..88e8ba996a 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/user.service.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/user.service.ts @@ -1,8 +1,7 @@ import { UserService } from '@/application/services/services.type'; import { UserProfile } from '@/application/user.type'; -import { notify } from '@/components/_shared/notify'; import { APIService } from 'src/application/services/js-services/wasm'; -import { getAuthInfo, getSignInUser, setSignInUser } from '@/application/services/js-services/storage'; +import { getAuthInfo, getSignInUser, invalidToken, setSignInUser } from '@/application/services/js-services/storage'; import { asyncDataDecorator } from '@/application/services/js-services/decorator'; async function getUser() { @@ -12,8 +11,7 @@ async function getUser() { return user; } catch (e) { console.error(e); - notify.error('Failed to get user profile, please try refreshing the page'); - // invalidToken(); + invalidToken(); } } diff --git a/frontend/appflowy_web_app/src/application/services/js-services/wasm/client_api.ts b/frontend/appflowy_web_app/src/application/services/js-services/wasm/client_api.ts index ff9f50ebb7..48a76d1837 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/wasm/client_api.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/wasm/client_api.ts @@ -1,15 +1,17 @@ +import { CollabType } from '@/application/collab.type'; import { ClientAPI } from '@appflowyinc/client-api-wasm'; import { UserProfile } from '@/application/user.type'; import { AFCloudConfig } from '@/application/services/services.type'; import { invalidToken, readTokenStr, writeToken } from '@/application/services/js-services/storage'; -import { CollabType } from '@/application/collab.type'; let client: ClientAPI; -export function initAPIService (config: AFCloudConfig & { - deviceId: string; - clientId: string; -}) { +export function initAPIService( + config: AFCloudConfig & { + deviceId: string; + clientId: string; + } +) { window.refresh_token = writeToken; window.invalid_token = invalidToken; client = ClientAPI.new({ @@ -33,15 +35,15 @@ export function initAPIService (config: AFCloudConfig & { client.subscribe(); } -export function signIn (email: string, password: string) { +export function signIn(email: string, password: string) { return client.login(email, password); } -export function logout () { +export function logout() { return client.logout(); } -export async function getUser (): Promise { +export async function getUser(): Promise { try { const user = await client.get_user(); @@ -62,7 +64,7 @@ export async function getUser (): Promise { } } -export async function getCollab (workspaceId: string, object_id: string, collabType: CollabType) { +export async function getCollab(workspaceId: string, object_id: string, collabType: CollabType) { const res = await client.get_collab({ workspace_id: workspaceId, object_id: object_id, 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 bbeb1b39b8..d7d3ad069c 100644 --- a/frontend/appflowy_web_app/src/application/services/services.type.ts +++ b/frontend/appflowy_web_app/src/application/services/services.type.ts @@ -1,4 +1,4 @@ -import { YDoc } from '@/application/document.type'; +import { YDoc } from '@/application/collab.type'; import { ProviderType, SignUpWithEmailPasswordParams, UserProfile } from '@/application/user.type'; export interface AFService { @@ -7,6 +7,7 @@ export interface AFService { authService: AuthService; userService: UserService; documentService: DocumentService; + folderService: FolderService; } export interface AFServiceConfig { @@ -35,3 +36,7 @@ export interface UserService { getUserProfile: () => Promise; checkUser: () => Promise; } + +export interface FolderService { + openWorkspace: (workspaceId: string) => Promise; +} diff --git a/frontend/appflowy_web_app/src/application/services/tauri-services/folder.service.ts b/frontend/appflowy_web_app/src/application/services/tauri-services/folder.service.ts new file mode 100644 index 0000000000..868e6f1391 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/services/tauri-services/folder.service.ts @@ -0,0 +1,12 @@ +import { YDoc } from '@/application/collab.type'; +import { FolderService } from '@/application/services/services.type'; + +export class TauriFolderService implements FolderService { + constructor() { + // + } + + async openWorkspace(_workspaceId: string): Promise { + return Promise.reject('Not implemented'); + } +} diff --git a/frontend/appflowy_web_app/src/application/services/tauri-services/index.ts b/frontend/appflowy_web_app/src/application/services/tauri-services/index.ts index 2f91ab4b13..0f162ba36f 100644 --- a/frontend/appflowy_web_app/src/application/services/tauri-services/index.ts +++ b/frontend/appflowy_web_app/src/application/services/tauri-services/index.ts @@ -3,19 +3,28 @@ import { AFServiceConfig, AuthService, DocumentService, + FolderService, UserService, } from '@/application/services/services.type'; import { TauriAuthService } from '@/application/services/tauri-services/auth.service'; +import { TauriFolderService } from '@/application/services/tauri-services/folder.service'; import { TauriUserService } from '@/application/services/tauri-services/user.service'; import { TauriDocumentService } from '@/application/services/tauri-services/document.service'; import { nanoid } from 'nanoid'; export class AFClientService implements AFService { authService: AuthService; + userService: UserService; + documentService: DocumentService; + + folderService: FolderService; + private deviceId: string = nanoid(8); + private clientId: string = 'web'; + getDeviceID = (): string => { return this.deviceId; }; @@ -24,12 +33,13 @@ export class AFClientService implements AFService { return this.clientId; }; - constructor (config: AFServiceConfig) { + constructor(config: AFServiceConfig) { this.authService = new TauriAuthService(config.cloudConfig, { deviceId: this.deviceId, clientId: this.clientId, }); this.userService = new TauriUserService(); this.documentService = new TauriDocumentService(); + this.folderService = new TauriFolderService(); } } diff --git a/frontend/appflowy_web_app/src/application/slate-yjs/plugins/withYjs.ts b/frontend/appflowy_web_app/src/application/slate-yjs/plugins/withYjs.ts index 881a8e2e07..1484813ab1 100644 --- a/frontend/appflowy_web_app/src/application/slate-yjs/plugins/withYjs.ts +++ b/frontend/appflowy_web_app/src/application/slate-yjs/plugins/withYjs.ts @@ -1,10 +1,9 @@ -import { YjsEditorKey, YSharedRoot } from '@/application/document.type'; +import { CollabOrigin, YjsEditorKey, YSharedRoot } from '@/application/collab.type'; import { applySlateOp } from '@/application/slate-yjs/utils/applySlateOpts'; import { translateYjsEvent } from 'src/application/slate-yjs/utils/translateYjsEvent'; import { Editor, Operation, Descendant } from 'slate'; import Y, { YEvent, Transaction } from 'yjs'; import { yDocToSlateContent } from '@/application/slate-yjs/utils/convert'; -import { CollabOrigin } from '@/application/collab.type'; type LocalChange = { op: Operation; diff --git a/frontend/appflowy_web_app/src/application/slate-yjs/utils/convert.ts b/frontend/appflowy_web_app/src/application/slate-yjs/utils/convert.ts index 44565fa89a..ae8b6698e6 100644 --- a/frontend/appflowy_web_app/src/application/slate-yjs/utils/convert.ts +++ b/frontend/appflowy_web_app/src/application/slate-yjs/utils/convert.ts @@ -9,7 +9,7 @@ import { YTextMap, BlockData, BlockType, -} from '@/application/document.type'; +} from '@/application/collab.type'; import { getFontFamily } from '@/utils/font'; import { uniq } from 'lodash-es'; import { Element, Text } from 'slate'; @@ -128,7 +128,7 @@ export function yDocToSlateContent(doc: YDoc, includeRoot?: boolean): Element | ...rootNode, children: [ { - textId: root.toJSON().external_id, + textId: pageId, type: YjsEditorKey.text, children: [{ text: '' }], }, diff --git a/frontend/appflowy_web_app/src/application/slate-yjs/utils/translateYjsEvent/arrayEvent.ts b/frontend/appflowy_web_app/src/application/slate-yjs/utils/translateYjsEvent/arrayEvent.ts index 4524c442f1..8be1dbc297 100644 --- a/frontend/appflowy_web_app/src/application/slate-yjs/utils/translateYjsEvent/arrayEvent.ts +++ b/frontend/appflowy_web_app/src/application/slate-yjs/utils/translateYjsEvent/arrayEvent.ts @@ -1,4 +1,4 @@ -import { YSharedRoot } from '@/application/document.type'; +import { YSharedRoot } from '@/application/collab.type'; import * as Y from 'yjs'; import { Editor, Operation } from 'slate'; diff --git a/frontend/appflowy_web_app/src/application/slate-yjs/utils/translateYjsEvent/index.ts b/frontend/appflowy_web_app/src/application/slate-yjs/utils/translateYjsEvent/index.ts index 12014a3ff0..10af76fcde 100644 --- a/frontend/appflowy_web_app/src/application/slate-yjs/utils/translateYjsEvent/index.ts +++ b/frontend/appflowy_web_app/src/application/slate-yjs/utils/translateYjsEvent/index.ts @@ -1,34 +1,30 @@ -import { YSharedRoot } from '@/application/document.type'; -import { translateYArrayEvent } from '@/application/slate-yjs/utils/translateYjsEvent/arrayEvent'; -import { translateYMapEvent } from '@/application/slate-yjs/utils/translateYjsEvent/mapEvent'; -import { Editor, Operation } from 'slate'; -import * as Y from 'yjs'; -import { translateYTextEvent } from 'src/application/slate-yjs/utils/translateYjsEvent/textEvent'; - -/** - * Translate a yjs event into slate operations. The editor state has to match the - * yText state before the event occurred. - * - * @param sharedType - * @param op - */ -export function translateYjsEvent ( - sharedRoot: YSharedRoot, - editor: Editor, - event: Y.YEvent, -): Operation[] { - console.log('translateYjsEvent', event); - if (event instanceof Y.YMapEvent) { - return translateYMapEvent(sharedRoot, editor, event); - } - - if (event instanceof Y.YTextEvent) { - return translateYTextEvent(sharedRoot, editor, event); - } - - if (event instanceof Y.YArrayEvent) { - return translateYArrayEvent(sharedRoot, editor, event); - } - - throw new Error('Unexpected Y event type'); -} +import { YSharedRoot } from '@/application/collab.type'; +import { translateYArrayEvent } from '@/application/slate-yjs/utils/translateYjsEvent/arrayEvent'; +import { translateYMapEvent } from '@/application/slate-yjs/utils/translateYjsEvent/mapEvent'; +import { Editor, Operation } from 'slate'; +import * as Y from 'yjs'; +import { translateYTextEvent } from 'src/application/slate-yjs/utils/translateYjsEvent/textEvent'; + +/** + * Translate a yjs event into slate operations. The editor state has to match the + * yText state before the event occurred. + * + * @param sharedType + * @param op + */ +export function translateYjsEvent(sharedRoot: YSharedRoot, editor: Editor, event: Y.YEvent): Operation[] { + console.log('translateYjsEvent', event); + if (event instanceof Y.YMapEvent) { + return translateYMapEvent(sharedRoot, editor, event); + } + + if (event instanceof Y.YTextEvent) { + return translateYTextEvent(sharedRoot, editor, event); + } + + if (event instanceof Y.YArrayEvent) { + return translateYArrayEvent(sharedRoot, editor, event); + } + + throw new Error('Unexpected Y event type'); +} diff --git a/frontend/appflowy_web_app/src/application/slate-yjs/utils/translateYjsEvent/mapEvent.ts b/frontend/appflowy_web_app/src/application/slate-yjs/utils/translateYjsEvent/mapEvent.ts index d79c7d6d77..fd50bb6df8 100644 --- a/frontend/appflowy_web_app/src/application/slate-yjs/utils/translateYjsEvent/mapEvent.ts +++ b/frontend/appflowy_web_app/src/application/slate-yjs/utils/translateYjsEvent/mapEvent.ts @@ -1,4 +1,4 @@ -import { YSharedRoot } from '@/application/document.type'; +import { YSharedRoot } from '@/application/collab.type'; import * as Y from 'yjs'; import { Editor, Operation } from 'slate'; diff --git a/frontend/appflowy_web_app/src/application/ydoc/apply/__tests__/document.test.ts b/frontend/appflowy_web_app/src/application/ydoc/apply/__tests__/document.test.ts index 13ff8fe6db..512c28ae6a 100644 --- a/frontend/appflowy_web_app/src/application/ydoc/apply/__tests__/document.test.ts +++ b/frontend/appflowy_web_app/src/application/ydoc/apply/__tests__/document.test.ts @@ -1,4 +1,4 @@ -import { YjsEditorKey } from '@/application/document.type'; +import { YjsEditorKey } from '@/application/collab.type'; import { applyDocument } from '@/application/ydoc/apply'; import * as Y from 'yjs'; import * as docJson from '../../../../../cypress/fixtures/simple_doc.json'; diff --git a/frontend/appflowy_web_app/src/application/ydoc/apply/document.ts b/frontend/appflowy_web_app/src/application/ydoc/apply/document.ts index f79c08d68e..60d02d0450 100644 --- a/frontend/appflowy_web_app/src/application/ydoc/apply/document.ts +++ b/frontend/appflowy_web_app/src/application/ydoc/apply/document.ts @@ -1,5 +1,5 @@ -import * as Y from 'yjs'; import { CollabOrigin } from '@/application/collab.type'; +import * as Y from 'yjs'; /** * Apply doc state from server to client diff --git a/frontend/appflowy_web_app/src/assets/clock_alarm.svg b/frontend/appflowy_web_app/src/assets/clock_alarm.svg new file mode 100644 index 0000000000..33a5585ceb --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/clock_alarm.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/appflowy_web_app/src/assets/database/checkbox-check.svg b/frontend/appflowy_web_app/src/assets/database/checkbox-check.svg index 15632e4ea6..d2fc54c4b7 100644 --- a/frontend/appflowy_web_app/src/assets/database/checkbox-check.svg +++ b/frontend/appflowy_web_app/src/assets/database/checkbox-check.svg @@ -1,4 +1,4 @@ - - + + diff --git a/frontend/appflowy_web_app/src/assets/database/checkbox-uncheck.svg b/frontend/appflowy_web_app/src/assets/database/checkbox-uncheck.svg index 6c487795c6..3b3e17dd31 100644 --- a/frontend/appflowy_web_app/src/assets/database/checkbox-uncheck.svg +++ b/frontend/appflowy_web_app/src/assets/database/checkbox-uncheck.svg @@ -1,3 +1,3 @@ - + diff --git a/frontend/appflowy_web_app/src/components/_shared/context-provider/FolderProvider.tsx b/frontend/appflowy_web_app/src/components/_shared/context-provider/FolderProvider.tsx new file mode 100644 index 0000000000..be466fdc49 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/context-provider/FolderProvider.tsx @@ -0,0 +1,9 @@ +import { YFolder } from '@/application/collab.type'; +import { FolderContext } from '@/application/folder-yjs'; + +export const FolderProvider: React.FC<{ folder: YFolder | null; children?: React.ReactNode }> = ({ + folder, + children, +}) => { + return {children}; +}; diff --git a/frontend/appflowy_web_app/src/components/_shared/context-provider/IdProvider.tsx b/frontend/appflowy_web_app/src/components/_shared/context-provider/IdProvider.tsx index 98cbd4e2c5..789642420d 100644 --- a/frontend/appflowy_web_app/src/components/_shared/context-provider/IdProvider.tsx +++ b/frontend/appflowy_web_app/src/components/_shared/context-provider/IdProvider.tsx @@ -13,6 +13,8 @@ export const IdProvider = ({ children, ...props }: IdProviderProps & { children: return {children}; }; +const defaultIdValue = {} as IdProviderProps; + export function useId() { - return useContext(IdContext); + return useContext(IdContext) || defaultIdValue; } diff --git a/frontend/appflowy_web_app/src/components/_shared/not-found/RecordNotFound.tsx b/frontend/appflowy_web_app/src/components/_shared/not-found/RecordNotFound.tsx new file mode 100644 index 0000000000..00441e5281 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/not-found/RecordNotFound.tsx @@ -0,0 +1,29 @@ +import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from '@mui/material'; +import React from 'react'; +import { useNavigate } from 'react-router-dom'; + +export function RecordNotFound({ open, workspaceId }: { workspaceId: string; open: boolean }) { + const navigate = useNavigate(); + + return ( + + Oops.. something went wrong + + + Sorry, the document you are looking for does not exist. + + + + + + + ); +} + +export default RecordNotFound; diff --git a/frontend/appflowy_web_app/src/components/_shared/not-found/index.ts b/frontend/appflowy_web_app/src/components/_shared/not-found/index.ts new file mode 100644 index 0000000000..e4f431167c --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/not-found/index.ts @@ -0,0 +1 @@ +export * from './RecordNotFound'; diff --git a/frontend/appflowy_web_app/src/components/_shared/page/Page.tsx b/frontend/appflowy_web_app/src/components/_shared/page/Page.tsx new file mode 100644 index 0000000000..090c15d3b2 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/page/Page.tsx @@ -0,0 +1,30 @@ +import { YView } from '@/application/collab.type'; +import { usePageInfo } from '@/components/_shared/page/usePageInfo'; +import React from 'react'; + +export function Page({ + id, + onClick, + ...props +}: { + id: string; + onClick?: (view: YView) => void; + style?: React.CSSProperties; +}) { + const { view, icon, name } = usePageInfo(id); + + return ( +
{ + onClick && view && onClick(view); + }} + className={'flex items-center justify-center gap-2 overflow-hidden'} + {...props} + > +
{icon}
+
{name}
+
+ ); +} + +export default Page; diff --git a/frontend/appflowy_web_app/src/components/_shared/page/index.ts b/frontend/appflowy_web_app/src/components/_shared/page/index.ts new file mode 100644 index 0000000000..d9925d7520 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/page/index.ts @@ -0,0 +1 @@ +export * from './Page'; diff --git a/frontend/appflowy_web_app/src/components/_shared/page/usePageInfo.tsx b/frontend/appflowy_web_app/src/components/_shared/page/usePageInfo.tsx new file mode 100644 index 0000000000..4fec272b79 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/page/usePageInfo.tsx @@ -0,0 +1,45 @@ +import { ViewLayout, YjsFolderKey, YView } from '@/application/collab.type'; +import { useViewSelector } from '@/application/folder-yjs'; +import React, { useMemo } from 'react'; +import { ReactComponent as DocumentSvg } from '@/assets/document.svg'; +import { ReactComponent as GridSvg } from '@/assets/grid.svg'; +import { ReactComponent as BoardSvg } from '@/assets/board.svg'; +import { ReactComponent as CalendarSvg } from '@/assets/date.svg'; +import { useTranslation } from 'react-i18next'; + +export function usePageInfo(id: string) { + const { view } = useViewSelector(id); + + const layout = view?.get(YjsFolderKey.layout); + const icon = view?.get(YjsFolderKey.icon); + const name = view?.get(YjsFolderKey.name) || ''; + const iconObj = useMemo(() => { + try { + return JSON.parse(icon || ''); + } catch (e) { + return null; + } + }, [icon]); + const defaultIcon = useMemo(() => { + switch (parseInt(layout ?? '0')) { + case ViewLayout.Document: + return ; + case ViewLayout.Grid: + return ; + case ViewLayout.Board: + return ; + case ViewLayout.Calendar: + return ; + default: + return ; + } + }, [layout]); + + const { t } = useTranslation(); + + return { + icon: iconObj?.value || defaultIcon, + name: name || t('menuAppHeader.defaultNewPageName'), + view: view as YView, + }; +} diff --git a/frontend/appflowy_web_app/src/components/app/App.tsx b/frontend/appflowy_web_app/src/components/app/App.tsx index e56499079f..1504c99f07 100644 --- a/frontend/appflowy_web_app/src/components/app/App.tsx +++ b/frontend/appflowy_web_app/src/components/app/App.tsx @@ -1,3 +1,4 @@ +import FolderPage from '@/pages/FolderPage'; import { BrowserRouter, Route, Routes } from 'react-router-dom'; import ProtectedRoutes from '@/components/auth/ProtectedRoutes'; import LoginPage from '@/pages/LoginPage'; @@ -8,6 +9,7 @@ const AppMain = withAppWrapper(() => { return ( }> + } /> } /> } /> @@ -15,7 +17,7 @@ const AppMain = withAppWrapper(() => { ); }); -function App () { +function App() { return ( @@ -24,4 +26,3 @@ function App () { } export default App; - diff --git a/frontend/appflowy_web_app/src/components/app/AppTheme.tsx b/frontend/appflowy_web_app/src/components/app/AppTheme.tsx index 176adf539e..2d00bec2a3 100644 --- a/frontend/appflowy_web_app/src/components/app/AppTheme.tsx +++ b/frontend/appflowy_web_app/src/components/app/AppTheme.tsx @@ -48,44 +48,20 @@ function AppTheme({ children }: { children: React.ReactNode }) { color: 'var(--content-on-fill)', boxShadow: 'var(--shadow)', }, - containedPrimary: { - '&:hover': { - backgroundColor: 'var(--fill-default)', - }, - }, - containedInherit: { - color: 'var(--text-title)', - backgroundColor: isDark ? 'rgba(0, 0, 0, 0.4)' : 'rgba(255, 255, 255, 0.4)', - '&:hover': { - backgroundColor: 'var(--bg-body)', - boxShadow: 'var(--shadow)', - }, - }, - outlinedInherit: { - color: 'var(--text-title)', - borderColor: 'var(--line-border)', - '&:hover': { - boxShadow: 'var(--shadow)', - }, - }, }, }, MuiButtonBase: { - defaultProps: { - sx: { - '&.Mui-selected:hover': { - backgroundColor: 'var(--fill-list-hover)', - }, - }, - }, styleOverrides: { root: { - '&:hover': { - backgroundColor: 'var(--fill-list-hover)', - }, - '&:active': { - backgroundColor: 'var(--fill-list-hover)', + '&:not(.MuiButton-contained)': { + '&:hover': { + backgroundColor: 'var(--fill-list-hover)', + }, + '&:active': { + backgroundColor: 'var(--fill-list-hover)', + }, }, + borderRadius: '4px', padding: '2px', boxShadow: 'none', diff --git a/frontend/appflowy_web_app/src/components/auth/LoginButtonGroup.tsx b/frontend/appflowy_web_app/src/components/auth/LoginButtonGroup.tsx index cdc42611f0..5e437bd0f7 100644 --- a/frontend/appflowy_web_app/src/components/auth/LoginButtonGroup.tsx +++ b/frontend/appflowy_web_app/src/components/auth/LoginButtonGroup.tsx @@ -29,6 +29,7 @@ export const LoginButtonGroup = () => { {t('signIn.signInWithEmail')} - - {user ? ( -
- - {user.email} - -
- ) : ( - - )} +
{objectId && }
+ + + setAnchorEl(null)}> +
+ +
+
+ {t('signIn.or')} +
+
+ +
+
); } diff --git a/frontend/appflowy_web_app/src/components/layout/Layout.tsx b/frontend/appflowy_web_app/src/components/layout/Layout.tsx index ce92f37934..c10048aa82 100644 --- a/frontend/appflowy_web_app/src/components/layout/Layout.tsx +++ b/frontend/appflowy_web_app/src/components/layout/Layout.tsx @@ -1,10 +1,36 @@ +import { YFolder, YjsEditorKey } from '@/application/collab.type'; +import { FolderProvider } from '@/components/_shared/context-provider/FolderProvider'; +import { AFConfigContext } from '@/components/app/AppConfig'; import Header from '@/components/layout/Header'; import { AFScroller } from '@/components/_shared/scroller'; -import React from 'react'; +import React, { useCallback, useContext, useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import './layout.scss'; function Layout({ children }: { children: React.ReactNode }) { + const { workspaceId } = useParams(); + const folderService = useContext(AFConfigContext)?.service?.folderService; + const [folder, setFolder] = useState(null); + const getFolder = useCallback( + async (workspaceId: string) => { + const folder = (await folderService?.openWorkspace(workspaceId)) + ?.getMap(YjsEditorKey.data_section) + .get(YjsEditorKey.folder); + + if (!folder) return; + + setFolder(folder); + }, + [folderService] + ); + + useEffect(() => { + if (!workspaceId) return; + + void getFolder(workspaceId); + }, [getFolder, workspaceId]); return ( -
+
{children} -
+ ); } diff --git a/frontend/appflowy_web_app/src/components/layout/layout.scss b/frontend/appflowy_web_app/src/components/layout/layout.scss new file mode 100644 index 0000000000..4133489130 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/layout/layout.scss @@ -0,0 +1,86 @@ + +.sketch-picker { + background-color: var(--bg-body) !important; + border-color: transparent !important; + box-shadow: none !important; +} + +.sketch-picker .flexbox-fix { + border-color: var(--line-divider) !important; +} + +.sketch-picker [id^='rc-editable-input'] { + background-color: var(--bg-body) !important; + border-color: var(--line-divider) !important; + color: var(--text-title) !important; + box-shadow: var(--line-border) 0px 0px 0px 1px inset !important; +} + +.appflowy-date-picker-calendar { + width: 100%; + +} + +.grid-sticky-header::-webkit-scrollbar { + width: 0; + height: 0; +} + +.grid-scroll-container::-webkit-scrollbar { + width: 0; + height: 0; +} + + +.appflowy-scroll-container { + &::-webkit-scrollbar { + width: 0; + } +} + +.appflowy-scrollbar-thumb-horizontal, .appflowy-scrollbar-thumb-vertical { + background-color: var(--scrollbar-thumb); + border-radius: 4px; + opacity: 60%; +} + +.workspaces { + ::-webkit-scrollbar { + width: 0px; + } +} + + +.MuiPopover-root, .MuiPaper-root { + ::-webkit-scrollbar { + width: 0; + height: 0; + } +} + +.view-icon { + @apply flex w-fit cursor-pointer rounded-lg py-2 text-6xl; + font-family: "Apple Color Emoji", "Segoe UI Emoji", NotoColorEmoji, "Noto Color Emoji", "Segoe UI Symbol", "Android Emoji", EmojiSymbols; + line-height: 1em; + white-space: nowrap; + //&:hover { + // background-color: rgba(156, 156, 156, 0.20); + //} +} + +.theme-mode-item { + @apply relative flex h-[72px] w-[88px] cursor-pointer items-end justify-end rounded border hover:shadow; + background: linear-gradient(150.74deg, rgba(231, 231, 231, 0) 17.95%, #C5C5C5 95.51%); +} + +[data-dark-mode="true"] { + .theme-mode-item { + background: linear-gradient(150.74deg, rgba(128, 125, 125, 0) 17.95%, #4d4d4d 95.51%); + } +} + +.document-header { + .view-banner { + @apply items-center; + } +} diff --git a/frontend/appflowy_web_app/src/pages/FolderPage.tsx b/frontend/appflowy_web_app/src/pages/FolderPage.tsx new file mode 100644 index 0000000000..6381fe4ace --- /dev/null +++ b/frontend/appflowy_web_app/src/pages/FolderPage.tsx @@ -0,0 +1,8 @@ +import React from 'react'; +import { Folder } from 'src/components/folder'; + +function FolderPage() { + return ; +} + +export default FolderPage; diff --git a/frontend/appflowy_web_app/src/pages/LoginPage.tsx b/frontend/appflowy_web_app/src/pages/LoginPage.tsx index c81669a245..5d2dcceee4 100644 --- a/frontend/appflowy_web_app/src/pages/LoginPage.tsx +++ b/frontend/appflowy_web_app/src/pages/LoginPage.tsx @@ -1,22 +1,25 @@ -import React, { useEffect } from 'react'; -import Welcome from '@/components/auth/Welcome'; -import { useNavigate } from 'react-router-dom'; -import { useAppSelector } from '@/stores/store'; - -function LoginPage () { - const currentUser = useAppSelector((state) => state.currentUser); - const navigate = useNavigate(); - - useEffect(() => { - if (currentUser.isAuthenticated) { - const redirect = new URLSearchParams(window.location.search).get('redirect'); - - navigate(`${redirect || ''}`); - } - }, [currentUser, navigate]); - return ( - - ); -} - -export default LoginPage; \ No newline at end of file +import React, { useEffect } from 'react'; +import Welcome from '@/components/auth/Welcome'; +import { useNavigate } from 'react-router-dom'; +import { useAppSelector } from '@/stores/store'; + +function LoginPage() { + const currentUser = useAppSelector((state) => state.currentUser); + const navigate = useNavigate(); + + useEffect(() => { + if (currentUser.isAuthenticated) { + const redirect = new URLSearchParams(window.location.search).get('redirect'); + const workspaceId = currentUser.user?.workspaceId; + + if (!redirect || redirect === '/') { + return navigate(`/workspace/${workspaceId}`); + } + + navigate(`${redirect}`); + } + }, [currentUser, navigate]); + return ; +} + +export default LoginPage; diff --git a/frontend/appflowy_web_app/src/pages/ProductPage.tsx b/frontend/appflowy_web_app/src/pages/ProductPage.tsx index 3783a327ae..8080e339ef 100644 --- a/frontend/appflowy_web_app/src/pages/ProductPage.tsx +++ b/frontend/appflowy_web_app/src/pages/ProductPage.tsx @@ -16,7 +16,6 @@ const collabTypeMap: Record = { function ProductPage() { const { workspaceId, collabType, objectId } = useParams(); - const PageComponent = useMemo(() => { switch (collabType) { case URL_COLLAB_TYPE.DOCUMENT: diff --git a/frontend/appflowy_web_app/src/utils/log.ts b/frontend/appflowy_web_app/src/utils/log.ts new file mode 100644 index 0000000000..daccf21d0a --- /dev/null +++ b/frontend/appflowy_web_app/src/utils/log.ts @@ -0,0 +1,20 @@ +export class Log { + static error(...msg: unknown[]) { + console.error(...msg); + } + static info(...msg: unknown[]) { + console.info(...msg); + } + + static debug(...msg: unknown[]) { + console.debug(...msg); + } + + static trace(...msg: unknown[]) { + console.trace(...msg); + } + + static warn(...msg: unknown[]) { + console.warn(...msg); + } +} diff --git a/frontend/appflowy_web_app/src/utils/time.ts b/frontend/appflowy_web_app/src/utils/time.ts new file mode 100644 index 0000000000..3b6920fb34 --- /dev/null +++ b/frontend/appflowy_web_app/src/utils/time.ts @@ -0,0 +1,10 @@ +import dayjs from 'dayjs'; + +export enum DateFormat { + Date = 'MMM D, YYYY', + DateTime = 'MMM D, YYYY h:mm A', +} + +export function renderDate(date: string, format: DateFormat = DateFormat.Date): string { + return dayjs(date).format(format); +} diff --git a/frontend/appflowy_web_app/src/utils/url.ts b/frontend/appflowy_web_app/src/utils/url.ts new file mode 100644 index 0000000000..8d67f3583f --- /dev/null +++ b/frontend/appflowy_web_app/src/utils/url.ts @@ -0,0 +1,47 @@ +import { getPlatform } from '@/utils/platform'; +import validator from 'validator'; + +export const downloadPage = 'https://appflowy.io/download'; + +export const openAppFlowySchema = 'appflowy-flutter://'; + +export function isValidUrl(input: string) { + return validator.isURL(input, { require_protocol: true, require_host: false }); +} + +// Process the URL to make sure it's a valid URL +// If it's not a valid URL(eg: 'appflowy.io' or '192.168.1.2'), we'll add 'https://' to the URL +export function processUrl(input: string) { + let processedUrl = input; + + if (isValidUrl(input)) { + return processedUrl; + } + + const domain = input.split('/')[0]; + + if (validator.isIP(domain) || validator.isFQDN(domain)) { + processedUrl = `https://${input}`; + if (isValidUrl(processedUrl)) { + return processedUrl; + } + } + + return; +} + +export async function openUrl(url: string, target: string = '_current') { + const platform = getPlatform(); + + const newUrl = processUrl(url); + + if (!newUrl) return; + if (platform.isTauri) { + const { open } = await import('@tauri-apps/api/shell'); + + await open(newUrl); + return; + } + + window.open(newUrl, target); +}