diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml new file mode 100644 index 00000000..4caf96a2 --- /dev/null +++ b/.github/workflows/claude-code-review.yml @@ -0,0 +1,54 @@ +name: Claude Code Review + +on: + pull_request: + types: [opened, synchronize] + # Optional: Only run on specific file changes + # paths: + # - "src/**/*.ts" + # - "src/**/*.tsx" + # - "src/**/*.js" + # - "src/**/*.jsx" + +jobs: + claude-review: + # Optional: Filter by PR author + # if: | + # github.event.pull_request.user.login == 'external-contributor' || + # github.event.pull_request.user.login == 'new-developer' || + # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' + + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code Review + id: claude-review + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + prompt: | + Please review this pull request and provide feedback on: + - Code quality and best practices + - Potential bugs or issues + - Performance considerations + - Security concerns + - Test coverage + + Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback. + + Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR. + + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://docs.anthropic.com/en/docs/claude-code/sdk#command-line for available options + claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"' + diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 00000000..ae36c007 --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,50 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + # This is an optional setting that allows Claude to read CI results on PRs + additional_permissions: | + actions: read + + # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. + # prompt: 'Update the pull request description to include a summary of changes.' + + # Optional: Add claude_args to customize behavior and configuration + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://docs.anthropic.com/en/docs/claude-code/sdk#command-line for available options + # claude_args: '--model claude-opus-4-1-20250805 --allowed-tools Bash(gh pr:*)' + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..a3e74ea8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,54 @@ +# Claude Code Guidelines for AppFlowy-Web-Premium + +This document provides clear instructions for Claude Code when working with the AppFlowy-Web-Premium repository. + +## Project Overview + +AppFlowy-Web-Premium is the premium web version of AppFlowy built with modern web technologies. + +**Backend Integration**: The backend service is AppFlowy-Cloud-Premium. All API definitions are declared in the cloud repository under `libs/client-api` (Rust implementation). This web project must adhere to the client-api definitions. + +## Development Environment + +### Prerequisites +- Node.js with pnpm package manager +- Docker for containerized services +- TypeScript for type safety + +### Essential Commands + +```bash +# Development +pnpm install # Install dependencies +pnpm run dev # Start development server +pnpm run build # Build for production + +# Quality Assurance +npx tsc # TypeScript type checking +pnpm run lint # Code linting +pnpm test # Run unit tests +pnpm cypress run # Run end-to-end tests +``` + +## Protocol Buffers Integration + +The protobuf definitions must match those declared in the appflowy-cloud-premium repository. + +```bash +npx pbjs # Compile protobuf to JavaScript +npx pbts # Generate TypeScript definitions +``` + +## Development Guidelines + +### Mandatory Verification Steps +1. **Build verification**: Always run `pnpm run build` to ensure successful compilation +2. **Type checking**: Run `npx tsc` to verify TypeScript compilation +3. **Code consistency**: Follow established patterns in the existing codebase +4. **API compliance**: Ensure all implementations respect the client-api definitions from the cloud repository + +### Best Practices +- Maintain consistency with existing code patterns +- Verify protobuf definitions align with the backend +- Ask for clarification when implementation approach is uncertain +- Test thoroughly before considering work complete \ No newline at end of file diff --git a/cypress/e2e/page/create-delete-page.cy.ts b/cypress/e2e/page/create-delete-page.cy.ts index 8d06c740..6fc892ae 100644 --- a/cypress/e2e/page/create-delete-page.cy.ts +++ b/cypress/e2e/page/create-delete-page.cy.ts @@ -39,7 +39,18 @@ describe('Page Create and Delete Tests', () => { const authUtils = new AuthTestUtils(); authUtils.signInWithTestUrl(testEmail).then(() => { cy.url().should('include', '/app'); - cy.wait(3000); + + // Wait for the app to fully load + cy.task('log', 'Waiting for app to fully load...'); + + // Wait for WebSocket connection to establish + cy.wait(8000); + + // Now wait for the new page button to be available + cy.task('log', 'Looking for new page button...'); + cy.get('[data-testid="new-page-button"]', { timeout: 20000 }).should('exist').then(() => { + cy.task('log', 'New page button found!'); + }); // Step 2: Create a new page (robust flow that handles presence/absence of title input) cy.task('log', `Creating page with title: ${testPageName}`); diff --git a/cypress/support/page/flows.ts b/cypress/support/page/flows.ts index 370ed2ef..e646f2ec 100644 --- a/cypress/support/page/flows.ts +++ b/cypress/support/page/flows.ts @@ -148,13 +148,40 @@ export function createPage(pageName: string) { getModal() .should('be.visible') - .within(() => { - cy.get('[data-testid="space-item"]', { timeout: 10000 }).should('have.length.at.least', 1); - cy.get('[data-testid="space-item"]').first().click(); - cy.contains('button', 'Add').click(); + .then(($modal) => { + // Check if there are any space items available + const spaceItems = $modal.find('[data-testid="space-item"]'); + + if (spaceItems.length === 0) { + cy.task('log', 'No spaces found, need to create one first'); + + // Click "Create new space" button within the modal + cy.wrap($modal).within(() => { + cy.contains('button', /create.*space/i).click(); + }); + + cy.wait(1000); + + // Fill in the space creation form + cy.get('input[type="text"]').first().clear().type('Test Space'); + cy.contains('button', 'Create').click(); + cy.wait(3000); // Wait for space creation + + // The page should be created in the new space automatically + cy.task('log', 'Created new space and page'); + } else { + cy.task('log', `Found ${spaceItems.length} spaces, selecting the first one`); + + // Select the first space and create page + cy.wrap($modal).within(() => { + cy.get('[data-testid="space-item"]').first().click(); + cy.contains('button', 'Add').click(); + }); + + cy.wait(2000); + cy.task('log', 'Clicked Add button, initiating page creation...'); + } }); - cy.wait(2000); - cy.task('log', 'Clicked Add button, initiating page creation...'); // After clicking Add, the modal should close and we should navigate to the new page // Wait for the modal to disappear first - with retry logic for WebSocket connectivity issues diff --git a/scripts/system-token/primitive.json b/scripts/system-token/primitive.json index f8dea536..23395050 100644 --- a/scripts/system-token/primitive.json +++ b/scripts/system-token/primitive.json @@ -146,6 +146,10 @@ "$type": "color", "$value": "#00b5ff33", "$description": "Text Selected Effect" + }, + "alpha-blue-700-75": { + "$type": "color", + "$value": "#0078c0bf" } }, "Green": { @@ -326,6 +330,10 @@ "alpha-red-500-10": { "$type": "color", "$value": "#f336411a" + }, + "alpha-red-600-75": { + "$type": "color", + "$value": "#e71d32bf" } }, "Orange": { diff --git a/scripts/system-token/semantic.dark.json b/scripts/system-token/semantic.dark.json index d2b9c9f4..3f232351 100644 --- a/scripts/system-token/semantic.dark.json +++ b/scripts/system-token/semantic.dark.json @@ -20,6 +20,10 @@ "$type": "color", "$value": "{Neutral.white}" }, + "inverse": { + "$type": "color", + "$value": "{Neutral.1000}" + }, "action": { "$type": "color", "$value": "{Blue.500}" @@ -32,6 +36,10 @@ "$type": "color", "$value": "{Blue.500}" }, + "info-light": { + "$type": "color", + "$value": "{Blue.alpha-blue-700-75}" + }, "info-hover": { "$type": "color", "$value": "{Blue.400}" @@ -68,6 +76,10 @@ "$type": "color", "$value": "{Red.400}" }, + "error-light": { + "$type": "color", + "$value": "{Red.alpha-red-600-75}" + }, "error-hover": { "$type": "color", "$value": "{Red.300}" @@ -1456,6 +1468,18 @@ "$type": "color", "$value": "{Subtle_Color.Denim.400}", "$description": "Sidebar Shared Category" + }, + "text-event": { + "$type": "color", + "$value": "{Blue.200}" + }, + "filled-event": { + "$type": "color", + "$value": "{Blue.1000}" + }, + "filled-today": { + "$type": "color", + "$value": "#f36bb1" } }, "Spacing": { diff --git a/scripts/system-token/semantic.light.json b/scripts/system-token/semantic.light.json index 26aebf1b..5cc54398 100644 --- a/scripts/system-token/semantic.light.json +++ b/scripts/system-token/semantic.light.json @@ -20,6 +20,10 @@ "$type": "color", "$value": "{Neutral.white}" }, + "inverse": { + "$type": "color", + "$value": "{Neutral.white}" + }, "action": { "$type": "color", "$value": "{Blue.600}" @@ -32,6 +36,10 @@ "$type": "color", "$value": "{Blue.600}" }, + "info-light": { + "$type": "color", + "$value": "{Blue.alpha-blue-700-75}" + }, "info-hover": { "$type": "color", "$value": "{Blue.700}" @@ -68,6 +76,10 @@ "$type": "color", "$value": "{Red.600}" }, + "error-light": { + "$type": "color", + "$value": "{Red.alpha-red-600-75}" + }, "error-hover": { "$type": "color", "$value": "{Red.700}" @@ -1456,6 +1468,18 @@ "$type": "color", "$value": "{Subtle_Color.Denim.500}", "$description": "Sidebar Shared Category" + }, + "text-event": { + "$type": "color", + "$value": "{Blue.800}" + }, + "filled-event": { + "$type": "color", + "$value": "{Blue.100}" + }, + "filled-today": { + "$type": "color", + "$value": "#eb368f" } }, "Spacing": { diff --git a/src/@types/translations/en.json b/src/@types/translations/en.json index 7ec2e2c8..722bbf58 100644 --- a/src/@types/translations/en.json +++ b/src/@types/translations/en.json @@ -495,6 +495,16 @@ "checked": "Checked", "unchecked": "Unchecked" }, + "global-loading": { + "welcome": "Welcome!", + "installing": "Loading Workspace", + "steps": { + "checkingAuth": "Checking authentication", + "loadingData": "Loading workspace data", + "preparingInterface": "Preparing interface", + "finalizing": "Almost ready" + } + }, "label": { "welcome": "Welcome!", "firstName": "First Name", @@ -1977,6 +1987,7 @@ "fileUploadHintSuffix": "Browse", "networkHint": "Paste a file link", "networkUrlInvalid": "Invalid URL. Check the URL and try again.", + "noImages": "No images selected. Please try again.", "networkAction": "Embed", "fileTooBigError": "File size is too big, please upload a file with size less than 10MB", "renameFile": { @@ -2806,7 +2817,9 @@ }, "visitOurWebsite": "Visit our official website", "addMessagesToPage": "Add messages to page", - "addMessagesToPageDisabled": "No messages available" + "addMessagesToPageDisabled": "No messages available", + "accountSettings": "Account settings", + "startWeekOn": "Start week on" }, "globalComment": { "comments": "Comments", diff --git a/src/application/__tests__/user-metadata.test.ts b/src/application/__tests__/user-metadata.test.ts index 7eb3e8d6..8b03ab43 100644 --- a/src/application/__tests__/user-metadata.test.ts +++ b/src/application/__tests__/user-metadata.test.ts @@ -1,7 +1,7 @@ import { expect } from '@jest/globals'; import { toZonedTime } from 'date-fns-tz'; +import { DateFormat } from '../types'; import { - DateFormatType, MetadataDefaults, MetadataKey, MetadataUtils, @@ -18,28 +18,11 @@ describe('User Metadata', () => { jest.clearAllMocks(); }); - describe('MetadataKey enum', () => { - it('should have correct values', () => { - expect(MetadataKey.Timezone).toBe('timezone'); - expect(MetadataKey.Language).toBe('language'); - expect(MetadataKey.DateFormat).toBe('date_format'); - expect(MetadataKey.IconUrl).toBe('icon_url'); - }); - }); - - describe('DateFormatType enum', () => { - it('should have correct date format patterns', () => { - expect(DateFormatType.US).toBe('MM/DD/YYYY'); - expect(DateFormatType.EU).toBe('DD/MM/YYYY'); - expect(DateFormatType.ISO).toBe('YYYY-MM-DD'); - }); - }); - describe('MetadataDefaults', () => { it('should have default values for all metadata keys', () => { expect(MetadataDefaults[MetadataKey.Timezone]).toBe('UTC'); expect(MetadataDefaults[MetadataKey.Language]).toBe('en'); - expect(MetadataDefaults[MetadataKey.DateFormat]).toBe(DateFormatType.US); + expect(MetadataDefaults[MetadataKey.DateFormat]).toBe(DateFormat.US); expect(MetadataDefaults[MetadataKey.IconUrl]).toBe(''); }); }); @@ -62,8 +45,8 @@ describe('User Metadata', () => { }); it('should set date format', () => { - const result = builder.setDateFormat(DateFormatType.EU).build(); - expect(result[MetadataKey.DateFormat]).toBe('DD/MM/YYYY'); + const result = builder.setDateFormat(DateFormat.DayMonthYear).build(); + expect(result[MetadataKey.DateFormat]).toBe(4); }); it('should set icon URL', () => { @@ -71,24 +54,17 @@ describe('User Metadata', () => { expect(result[MetadataKey.IconUrl]).toBe('https://example.com/icon.png'); }); - it('should set custom metadata', () => { - const result = builder.setCustom('theme', 'dark').build(); - expect(result.theme).toBe('dark'); - }); - it('should chain multiple setters', () => { const result = builder .setTimezone('Europe/London') .setLanguage('en-GB') - .setDateFormat(DateFormatType.EU) - .setCustom('theme', 'light') + .setDateFormat(DateFormat.DayMonthYear) .build(); expect(result).toEqual({ [MetadataKey.Timezone]: 'Europe/London', [MetadataKey.Language]: 'en-GB', - [MetadataKey.DateFormat]: DateFormatType.EU, - theme: 'light', + [MetadataKey.DateFormat]: DateFormat.DayMonthYear, }); }); @@ -105,21 +81,21 @@ describe('User Metadata', () => { describe('MetadataUtils', () => { describe('detectDateFormat', () => { it('should detect US format for US locale', () => { - expect(MetadataUtils.detectDateFormat('en-US')).toBe(DateFormatType.US); - expect(MetadataUtils.detectDateFormat('en-CA')).toBe(DateFormatType.US); - expect(MetadataUtils.detectDateFormat('en-PH')).toBe(DateFormatType.US); + expect(MetadataUtils.detectDateFormat('en-US')).toBe(DateFormat.US); + expect(MetadataUtils.detectDateFormat('en-CA')).toBe(DateFormat.US); + expect(MetadataUtils.detectDateFormat('en-PH')).toBe(DateFormat.US); }); it('should detect EU format for European locales', () => { - expect(MetadataUtils.detectDateFormat('en-GB')).toBe(DateFormatType.EU); - expect(MetadataUtils.detectDateFormat('fr-FR')).toBe(DateFormatType.EU); - expect(MetadataUtils.detectDateFormat('de-DE')).toBe(DateFormatType.EU); + expect(MetadataUtils.detectDateFormat('en-GB')).toBe(DateFormat.DayMonthYear); + expect(MetadataUtils.detectDateFormat('fr-FR')).toBe(DateFormat.DayMonthYear); + expect(MetadataUtils.detectDateFormat('de-DE')).toBe(DateFormat.DayMonthYear); }); it('should detect ISO format for specific regions', () => { - expect(MetadataUtils.detectDateFormat('sv-SE')).toBe(DateFormatType.ISO); - expect(MetadataUtils.detectDateFormat('fi-FI')).toBe(DateFormatType.ISO); - expect(MetadataUtils.detectDateFormat('ko-KR')).toBe(DateFormatType.ISO); + expect(MetadataUtils.detectDateFormat('sv-SE')).toBe(DateFormat.ISO); + expect(MetadataUtils.detectDateFormat('fi-FI')).toBe(DateFormat.ISO); + expect(MetadataUtils.detectDateFormat('ko-KR')).toBe(DateFormat.ISO); }); it('should use browser locale as default', () => { @@ -129,7 +105,7 @@ describe('User Metadata', () => { configurable: true, }); - expect(MetadataUtils.detectDateFormat()).toBe(DateFormatType.US); + expect(MetadataUtils.detectDateFormat()).toBe(DateFormat.US); Object.defineProperty(navigator, 'language', { value: originalLanguage, @@ -138,8 +114,8 @@ describe('User Metadata', () => { }); it('should handle locale without region', () => { - expect(MetadataUtils.detectDateFormat('en')).toBe(DateFormatType.EU); - expect(MetadataUtils.detectDateFormat('fr')).toBe(DateFormatType.EU); + expect(MetadataUtils.detectDateFormat('en')).toBe(DateFormat.DayMonthYear); + expect(MetadataUtils.detectDateFormat('fr')).toBe(DateFormat.DayMonthYear); }); }); @@ -222,14 +198,12 @@ describe('User Metadata', () => { it('should merge multiple metadata objects', () => { const obj1 = { [MetadataKey.Timezone]: 'UTC' }; const obj2 = { [MetadataKey.Language]: 'en' }; - const obj3 = { custom: 'value' }; - const result = MetadataUtils.merge(obj1, obj2, obj3); + const result = MetadataUtils.merge(obj1, obj2); expect(result).toEqual({ [MetadataKey.Timezone]: 'UTC', [MetadataKey.Language]: 'en', - custom: 'value', }); }); @@ -336,4 +310,4 @@ describe('User Metadata', () => { }); }); }); -}); \ No newline at end of file +}); diff --git a/src/application/database-yjs/cell.parse.ts b/src/application/database-yjs/cell.parse.ts index b27762a0..a85bd7b9 100644 --- a/src/application/database-yjs/cell.parse.ts +++ b/src/application/database-yjs/cell.parse.ts @@ -2,7 +2,7 @@ import * as Y from 'yjs'; import { FieldType } from '@/application/database-yjs/database.type'; import { getDateCellStr, parseChecklistData, parseSelectOptionTypeOptions } from '@/application/database-yjs/fields'; -import { YDatabaseCell, YDatabaseField, YjsDatabaseKey } from '@/application/types'; +import { User, YDatabaseCell, YDatabaseField, YjsDatabaseKey } from '@/application/types'; import { Cell, DateTimeCell, FileMediaCell, FileMediaCellData } from './cell.type'; @@ -94,7 +94,7 @@ export function parseYDatabaseRelationCellToCell(cell: YDatabaseCell): Cell { }; } -export function getCellDataText(cell: YDatabaseCell, field: YDatabaseField): string { +export function getCellDataText(cell: YDatabaseCell, field: YDatabaseField, currentUser?: User): string { const type = parseInt(field.get(YjsDatabaseKey.type)); switch (type) { @@ -143,7 +143,7 @@ export function getCellDataText(cell: YDatabaseCell, field: YDatabaseField): str case FieldType.DateTime: { const dateCell = parseYDatabaseDateTimeCellToCell(cell); - return getDateCellStr({ cell: dateCell, field }); + return getDateCellStr({ cell: dateCell, field, currentUser }); } case FieldType.CreatedTime: diff --git a/src/application/database-yjs/cell.type.ts b/src/application/database-yjs/cell.type.ts index a98de4e7..a0ff414e 100644 --- a/src/application/database-yjs/cell.type.ts +++ b/src/application/database-yjs/cell.type.ts @@ -2,8 +2,7 @@ import React from 'react'; import * as Y from 'yjs'; import { FieldType } from '@/application/database-yjs/database.type'; -import { DateFormat, TimeFormat } from '@/application/database-yjs/index'; -import { FieldId, RowId } from '@/application/types'; +import { DateFormat, FieldId, RowId, TimeFormat } from '@/application/types'; export interface Cell { createdAt: number; diff --git a/src/application/database-yjs/dispatch.ts b/src/application/database-yjs/dispatch.ts index 8db17cda..e5f504c0 100644 --- a/src/application/database-yjs/dispatch.ts +++ b/src/application/database-yjs/dispatch.ts @@ -28,7 +28,6 @@ import { SortCondition, } from '@/application/database-yjs/database.type'; import { - DateFormat, getDateCellStr, getFieldName, isDate, @@ -37,12 +36,12 @@ import { RIGHTWARDS_ARROW, safeParseTimestamp, SelectOption, + SelectOptionColor, SelectTypeOption, - TimeFormat, } from '@/application/database-yjs/fields'; import { createCheckboxCell, getChecked } from '@/application/database-yjs/fields/checkbox/utils'; import { EnhancedBigStats } from '@/application/database-yjs/fields/number/EnhancedBigStats'; -import { createSelectOptionCell, getColorByOption } from '@/application/database-yjs/fields/select-option/utils'; +import { createSelectOptionCell } from '@/application/database-yjs/fields/select-option/utils'; import { createTextField } from '@/application/database-yjs/fields/text/utils'; import { dateFilterFillData, filterFillData, getDefaultFilterCondition } from '@/application/database-yjs/filter'; import { getOptionsFromRow, initialDatabaseRow } from '@/application/database-yjs/row'; @@ -51,8 +50,10 @@ import { useBoardLayoutSettings, useFieldSelector, useFieldType } from '@/applic import { executeOperations } from '@/application/slate-yjs/utils/yjs'; import { DatabaseViewLayout, + DateFormat, FieldId, RowId, + TimeFormat, UpdatePagePayload, ViewLayout, YDatabase, @@ -81,6 +82,7 @@ import { YMapFieldTypeOption, YSharedRoot, } from '@/application/types'; +import { useCurrentUser } from '@/components/main/app.hooks'; export function useResizeColumnWidthDispatch() { const database = useDatabase(); @@ -2232,6 +2234,7 @@ export function useSwitchPropertyType() { const database = useDatabase(); const sharedRoot = useSharedRoot(); const rowDocMap = useRowDocMap(); + const currentUser = useCurrentUser(); return useCallback( (fieldId: string, fieldType: FieldType) => { @@ -2284,8 +2287,6 @@ export function useSwitchPropertyType() { // Set default values for the type option if ([FieldType.CreatedTime, FieldType.LastEditedTime, FieldType.DateTime].includes(fieldType)) { // to DateTime - newTypeOption.set(YjsDatabaseKey.time_format, TimeFormat.TwentyFourHour); - newTypeOption.set(YjsDatabaseKey.date_format, DateFormat.Friendly); if (oldFieldType !== FieldType.DateTime) { newTypeOption.set(YjsDatabaseKey.include_time, true); } @@ -2337,11 +2338,11 @@ export function useSwitchPropertyType() { content = JSON.stringify({ disable_color: false, - options: Array.from(options).map((name) => { + options: Array.from(options).map((name, index) => { return { id: name, name, - color: getColorByOption(name), + color: Object.values(SelectOptionColor)[index % 10], }; }), }); @@ -2455,6 +2456,7 @@ export function useSwitchPropertyType() { newData = getDateCellStr({ cell: dateCell, field, + currentUser, }); break; @@ -2545,7 +2547,7 @@ export function useSwitchPropertyType() { 'switchPropertyType' ); }, - [database, sharedRoot, rowDocMap] + [database, sharedRoot, rowDocMap, currentUser] ); } diff --git a/src/application/database-yjs/fields/date/date.type.ts b/src/application/database-yjs/fields/date/date.type.ts index 463fed11..9bb15c79 100644 --- a/src/application/database-yjs/fields/date/date.type.ts +++ b/src/application/database-yjs/fields/date/date.type.ts @@ -1,18 +1,5 @@ import { Filter } from '@/application/database-yjs'; -export enum TimeFormat { - TwelveHour = 0, - TwentyFourHour = 1, -} - -export enum DateFormat { - Local = 0, - US = 1, - ISO = 2, - Friendly = 3, - DayMonthYear = 4, -} - export enum DateFilterCondition { DateStartsOn = 0, DateStartsBefore = 1, diff --git a/src/application/database-yjs/fields/date/utils.test.ts b/src/application/database-yjs/fields/date/utils.test.ts index 9d3821ba..c08d20ad 100644 --- a/src/application/database-yjs/fields/date/utils.test.ts +++ b/src/application/database-yjs/fields/date/utils.test.ts @@ -1,6 +1,6 @@ -import { getTimeFormat, getDateFormat } from './utils'; +import { DateFormat, TimeFormat } from '@/application/types'; +import { getDateFormat, getTimeFormat } from '@/utils/time'; import { expect } from '@jest/globals'; -import { DateFormat, TimeFormat } from '@/application/database-yjs'; describe('DateFormat', () => { it('should return time format', () => { diff --git a/src/application/database-yjs/fields/date/utils.ts b/src/application/database-yjs/fields/date/utils.ts index d6c9fd42..68116e37 100644 --- a/src/application/database-yjs/fields/date/utils.ts +++ b/src/application/database-yjs/fields/date/utils.ts @@ -1,37 +1,10 @@ import dayjs from 'dayjs'; -import { DateFormat, getTypeOptions, TimeFormat } from '@/application/database-yjs'; +import { getTypeOptions } from '@/application/database-yjs'; import { DateTimeCell } from '@/application/database-yjs/cell.type'; -import { YDatabaseField, YjsDatabaseKey } from '@/application/types'; -import { renderDate } from '@/utils/time'; - -export function getTimeFormat(timeFormat?: TimeFormat) { - switch (timeFormat) { - case TimeFormat.TwelveHour: - return 'h:mm A'; - case TimeFormat.TwentyFourHour: - return 'HH:mm'; - default: - return 'HH:mm'; - } -} - -export function getDateFormat(dateFormat?: DateFormat) { - switch (dateFormat) { - case DateFormat.Friendly: - return 'MMM DD, YYYY'; - case DateFormat.ISO: - return 'YYYY-MM-DD'; - case DateFormat.US: - return 'YYYY/MM/DD'; - case DateFormat.Local: - return 'MM/DD/YYYY'; - case DateFormat.DayMonthYear: - return 'DD/MM/YYYY'; - default: - return 'YYYY-MM-DD'; - } -} +import { DateFormat, TimeFormat, User, YDatabaseField, YjsDatabaseKey, YMapFieldTypeOption } from '@/application/types'; +import { MetadataKey } from '@/application/user-metadata'; +import { getDateFormat, getTimeFormat, renderDate } from '@/utils/time'; function getDateTimeStr({ timeStamp, @@ -59,39 +32,45 @@ function getDateTimeStr({ export const RIGHTWARDS_ARROW = '→'; -export function getRowTimeString(field: YDatabaseField, timeStamp: string) { +export function getRowTimeString(field: YDatabaseField, timeStamp: string, currentUser?: User) { const typeOption = getTypeOptions(field); + const typeOptionValue = getFieldDateTimeFormats(typeOption, currentUser); - const timeFormat = parseInt(typeOption.get(YjsDatabaseKey.time_format)) as TimeFormat; - const dateFormat = parseInt(typeOption.get(YjsDatabaseKey.date_format)) as DateFormat; const includeTime = typeOption.get(YjsDatabaseKey.include_time); - return getDateTimeStr({ timeStamp, includeTime, - typeOptionValue: { - timeFormat, - dateFormat, - }, + typeOptionValue, }); } -export function getDateCellStr({ cell, field }: { cell: DateTimeCell; field: YDatabaseField }) { +export function getFieldDateTimeFormats(typeOption: YMapFieldTypeOption, currentUser?: User) { + const typeOptionTimeFormat = typeOption.get(YjsDatabaseKey.time_format); + const typeOptionDateFormat = typeOption.get(YjsDatabaseKey.date_format); + + const dateFormat = typeOptionDateFormat + ? parseInt(typeOptionDateFormat) as DateFormat + : currentUser?.metadata?.[MetadataKey.DateFormat] as DateFormat ?? DateFormat.Local; + const timeFormat = typeOptionTimeFormat + ? parseInt(typeOptionTimeFormat) as TimeFormat + : currentUser?.metadata?.[MetadataKey.TimeFormat] as TimeFormat ?? TimeFormat.TwelveHour; + + return { + dateFormat, + timeFormat, + } +} + +export function getDateCellStr({ cell, field, currentUser }: { cell: DateTimeCell; field: YDatabaseField, currentUser?: User }) { const typeOptionMap = field.get(YjsDatabaseKey.type_option); const typeOption = typeOptionMap.get(String(cell.fieldType)); - const timeFormat = parseInt(typeOption.get(YjsDatabaseKey.time_format)) as TimeFormat; - const dateFormat = parseInt(typeOption.get(YjsDatabaseKey.date_format)) as DateFormat; + const typeOptionValue = getFieldDateTimeFormats(typeOption, currentUser); const startData = cell.data || ''; const includeTime = cell.includeTime; - const typeOptionValue = { - timeFormat, - dateFormat, - }; - const startDateTime = getDateTimeStr({ timeStamp: startData, includeTime, @@ -105,10 +84,10 @@ export function getDateCellStr({ cell, field }: { cell: DateTimeCell; field: YDa const endDateTime = endTimestamp && isRange ? getDateTimeStr({ - timeStamp: endTimestamp, - includeTime, - typeOptionValue, - }) + timeStamp: endTimestamp, + includeTime, + typeOptionValue, + }) : null; return [startDateTime, endDateTime].filter(Boolean).join(` ${RIGHTWARDS_ARROW} `); diff --git a/src/application/database-yjs/fields/select-option/utils.ts b/src/application/database-yjs/fields/select-option/utils.ts index 274c8fce..9b8d3531 100644 --- a/src/application/database-yjs/fields/select-option/utils.ts +++ b/src/application/database-yjs/fields/select-option/utils.ts @@ -1,4 +1,4 @@ -import { FieldType, SelectOptionColor } from '@/application/database-yjs'; +import { FieldType, SelectOption, SelectOptionColor } from '@/application/database-yjs'; import { YDatabaseCell, YjsDatabaseKey } from '@/application/types'; import { nanoid } from 'nanoid'; import * as Y from 'yjs'; @@ -19,24 +19,24 @@ export function generateOptionId () { return nanoid(6); } -export function getColorByOption (text: string): SelectOptionColor { - if (!text || text.length === 0) { - const colors = Object.values(SelectOptionColor); +export function getColorByOption (options: SelectOption[]): SelectOptionColor { + const colorFrequency = Array(10).fill(0); - return colors[Math.floor(Math.random() * colors.length)]; + for (const option of options) { + const colorIndex = Object.values(SelectOptionColor).indexOf(option.color); + + if (colorIndex < 10) { + colorFrequency[colorIndex]++; + } } - let hash = 0; + let minIndex = 0; - for (let i = 0; i < text.length; i++) { - hash = ((hash << 5) - hash) + text.charCodeAt(i); - hash = hash & hash; + for (let i = 1; i < colorFrequency.length; i++) { + if (colorFrequency[i] < colorFrequency[minIndex]) { + minIndex = i; + } } - hash = Math.abs(hash); - - const colors = Object.values(SelectOptionColor); - const colorIndex = hash % 10; - - return colors[colorIndex]; -} \ No newline at end of file + return Object.values(SelectOptionColor)?.[minIndex]; +} diff --git a/src/application/database-yjs/selector.ts b/src/application/database-yjs/selector.ts index 4f356426..4b159c80 100644 --- a/src/application/database-yjs/selector.ts +++ b/src/application/database-yjs/selector.ts @@ -13,15 +13,12 @@ import { useRowDocMap, } from '@/application/database-yjs/context'; import { - DateFormat, getDateCellStr, - getDateFormat, - getTimeFormat, + getFieldDateTimeFormats, getTypeOptions, parseRelationTypeOption, parseSelectOptionTypeOptions, SelectOption, - TimeFormat, } from '@/application/database-yjs/fields'; import { filterBy, parseFilter } from '@/application/database-yjs/filter'; import { groupByField } from '@/application/database-yjs/group'; @@ -38,7 +35,8 @@ import { YjsDatabaseKey, YjsEditorKey, } from '@/application/types'; -import { renderDate } from '@/utils/time'; +import { useCurrentUser } from '@/components/main/app.hooks'; +import { getDateFormat, getTimeFormat, renderDate } from '@/utils/time'; import { CalendarLayoutSetting, FieldType, FieldVisibility, Filter, RowMeta, SortCondition } from './database.type'; @@ -1145,28 +1143,32 @@ export const usePropertiesSelector = (isFilterHidden?: boolean) => { }; export const useDateTimeCellString = (cell: DateTimeCell | undefined, fieldId: string) => { + const currentUser = useCurrentUser(); const { field, clock } = useFieldSelector(fieldId); return useMemo(() => { if (!cell) return null; - return getDateCellStr({ cell, field }); + return getDateCellStr({ cell, field, currentUser }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [cell, field, clock]); + }, [cell, field, clock, currentUser]); }; export const useRowTimeString = (rowId: string, fieldId: string, attrName: string) => { + const currentUser = useCurrentUser(); const { field, clock } = useFieldSelector(fieldId); const typeOptionValue = useMemo(() => { const typeOption = getTypeOptions(field); + const { dateFormat, timeFormat } = getFieldDateTimeFormats(typeOption, currentUser); + return { - timeFormat: parseInt(typeOption.get(YjsDatabaseKey.time_format)) as TimeFormat, - dateFormat: parseInt(typeOption.get(YjsDatabaseKey.date_format)) as DateFormat, + dateFormat, + timeFormat, includeTime: typeOption.get(YjsDatabaseKey.include_time), }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [field, clock]); + }, [field, clock, currentUser?.metadata]); const getDateTimeStr = useCallback( (timeStamp: string, includeTime?: boolean) => { diff --git a/src/application/types.ts b/src/application/types.ts index 6fc67c1b..a3193710 100644 --- a/src/application/types.ts +++ b/src/application/types.ts @@ -344,8 +344,8 @@ export enum YjsDatabaseKey { include_time = 'include_time', is_range = 'is_range', reminder_id = 'reminder_id', - time_format = 'time_format', - date_format = 'date_format', + time_format = 'time_format_v2', + date_format = 'date_format_v2', calculations = 'calculations', field_id = 'field_id', calculation_value = 'calculation_value', @@ -686,11 +686,11 @@ export interface YMapFieldTypeOption extends Y.Map { // CreatedTime, LastEditedTime, DateTime // eslint-disable-next-line @typescript-eslint/unified-signatures - get(key: YjsDatabaseKey.time_format): string; + get(key: YjsDatabaseKey.time_format): string | undefined; // CreatedTime, LastEditedTime, DateTime // eslint-disable-next-line @typescript-eslint/unified-signatures - get(key: YjsDatabaseKey.date_format): string; + get(key: YjsDatabaseKey.date_format): string | undefined; // Relation get(key: YjsDatabaseKey.database_id): DatabaseId; @@ -699,7 +699,7 @@ export interface YMapFieldTypeOption extends Y.Map { // eslint-disable-next-line @typescript-eslint/unified-signatures get(key: YjsDatabaseKey.format): string; - // LastModified and CreatedTime + // LastEditedTime and CreatedTime get(key: YjsDatabaseKey.include_time): boolean; // AI Translate @@ -1086,6 +1086,7 @@ export interface ViewComponentProps { }>; updatePageIcon?: (viewId: string, icon: { ty: ViewIconType; value: string }) => Promise; updatePageName?: (viewId: string, name: string) => Promise; + currentUser?: User; } export interface CreatePagePayload { @@ -1206,3 +1207,16 @@ export interface MentionablePerson { invited: boolean; last_mentioned_at: string | null; } + +export enum DateFormat { + Local = 0, + US = 1, + ISO = 2, + Friendly = 3, + DayMonthYear = 4, +} + +export enum TimeFormat { + TwelveHour = 0, + TwentyFourHour = 1, +} diff --git a/src/application/user-metadata.ts b/src/application/user-metadata.ts index 85e863b1..7cf4f71f 100644 --- a/src/application/user-metadata.ts +++ b/src/application/user-metadata.ts @@ -1,4 +1,6 @@ import { toZonedTime } from 'date-fns-tz'; + +import { DateFormat, TimeFormat } from './types'; import { UserTimezone } from './user-timezone.types'; /** @@ -9,6 +11,8 @@ export enum MetadataKey { Timezone = 'timezone', Language = 'language', DateFormat = 'date_format', + TimeFormat = 'time_format', + StartWeekOn = 'start_week_on', IconUrl = 'icon_url', } @@ -18,18 +22,10 @@ export enum MetadataKey { export interface MetadataValues { [MetadataKey.Timezone]: string | UserTimezone; [MetadataKey.Language]: string; - [MetadataKey.DateFormat]: DateFormatType; + [MetadataKey.DateFormat]: DateFormat; + [MetadataKey.TimeFormat]: TimeFormat; + [MetadataKey.StartWeekOn]: number; [MetadataKey.IconUrl]: string; - [key: string]: string | DateFormatType | UserTimezone | Record | number | boolean | null | undefined; // Allow custom keys -} - -/** - * Supported date format types - */ -export enum DateFormatType { - US = 'MM/DD/YYYY', - EU = 'DD/MM/YYYY', - ISO = 'YYYY-MM-DD', } /** @@ -38,7 +34,9 @@ export enum DateFormatType { export const MetadataDefaults: Partial = { [MetadataKey.Timezone]: 'UTC', [MetadataKey.Language]: 'en', - [MetadataKey.DateFormat]: DateFormatType.US, + [MetadataKey.DateFormat]: DateFormat.US, + [MetadataKey.TimeFormat]: TimeFormat.TwelveHour, + [MetadataKey.StartWeekOn]: 0, [MetadataKey.IconUrl]: '', }; @@ -67,7 +65,7 @@ export class UserMetadataBuilder { /** * Set date format metadata */ - setDateFormat(format: DateFormatType): this { + setDateFormat(format: DateFormat): this { this.metadata[MetadataKey.DateFormat] = format; return this; } @@ -80,14 +78,6 @@ export class UserMetadataBuilder { return this; } - /** - * Set custom metadata - */ - setCustom(key: string, value: string | DateFormatType | UserTimezone | Record | number | boolean | null | undefined): this { - this.metadata[key] = value; - return this; - } - /** * Build the final metadata object */ @@ -103,21 +93,21 @@ export const MetadataUtils = { /** * Detect user's preferred date format based on locale */ - detectDateFormat(locale: string = navigator.language): DateFormatType { + detectDateFormat(locale: string = navigator.language): DateFormat { const region = locale.split('-')[1]?.toUpperCase() || locale.toUpperCase(); // US format countries if (['US', 'CA', 'PH'].includes(region)) { - return DateFormatType.US; + return DateFormat.US; } // ISO format preference if (['SE', 'FI', 'JP', 'KR', 'CN', 'TW', 'HK'].includes(region)) { - return DateFormatType.ISO; + return DateFormat.ISO; } // Default to EU format for most other countries - return DateFormatType.EU; + return DateFormat.DayMonthYear; }, /** @@ -153,10 +143,10 @@ export const MetadataUtils = { // Validate timezone if present - must be valid IANA timezone for chrono-tz compatibility if (metadata[MetadataKey.Timezone]) { const timezoneValue = metadata[MetadataKey.Timezone]; - + // Extract the timezone string whether it's a string or UserTimezone object - const timezone = typeof timezoneValue === 'string' - ? timezoneValue + const timezone = typeof timezoneValue === 'string' + ? timezoneValue : timezoneValue.timezone || timezoneValue.default_timezone; if (timezone) { @@ -196,4 +186,4 @@ export const MetadataUtils = { errors, }; }, -}; \ No newline at end of file +}; diff --git a/src/components/_shared/color-picker/ColorPicker.tsx b/src/components/_shared/color-picker/ColorPicker.tsx index b62cbfe2..0fcf217d 100644 --- a/src/components/_shared/color-picker/ColorPicker.tsx +++ b/src/components/_shared/color-picker/ColorPicker.tsx @@ -119,40 +119,40 @@ export function ColorPicker({ onEscape, onChange, disableFocus }: ColorPickerPro content: renderColorItem(t('editor.backgroundColorDefault'), '', ''), }, { - key: `bg-lime-${ColorEnum.Lime}`, - content: renderColorItem(t('editor.backgroundColorLime'), '', ColorEnum.Lime), + key: `bg-lime-${ColorEnum.Tint6}`, + content: renderColorItem(t('editor.backgroundColorLime'), '', ColorEnum.Tint6), }, { - key: `bg-aqua-${ColorEnum.Aqua}`, - content: renderColorItem(t('editor.backgroundColorAqua'), '', ColorEnum.Aqua), + key: `bg-aqua-${ColorEnum.Tint8}`, + content: renderColorItem(t('editor.backgroundColorAqua'), '', ColorEnum.Tint8), }, { - key: `bg-orange-${ColorEnum.Orange}`, - content: renderColorItem(t('editor.backgroundColorOrange'), '', ColorEnum.Orange), + key: `bg-orange-${ColorEnum.Tint4}`, + content: renderColorItem(t('editor.backgroundColorOrange'), '', ColorEnum.Tint4), }, { - key: `bg-yellow-${ColorEnum.Yellow}`, - content: renderColorItem(t('editor.backgroundColorYellow'), '', ColorEnum.Yellow), + key: `bg-yellow-${ColorEnum.Tint5}`, + content: renderColorItem(t('editor.backgroundColorYellow'), '', ColorEnum.Tint5), }, { - key: `bg-green-${ColorEnum.Green}`, - content: renderColorItem(t('editor.backgroundColorGreen'), '', ColorEnum.Green), + key: `bg-green-${ColorEnum.Tint7}`, + content: renderColorItem(t('editor.backgroundColorGreen'), '', ColorEnum.Tint7), }, { - key: `bg-blue-${ColorEnum.Blue}`, - content: renderColorItem(t('editor.backgroundColorBlue'), '', ColorEnum.Blue), + key: `bg-blue-${ColorEnum.Tint9}`, + content: renderColorItem(t('editor.backgroundColorBlue'), '', ColorEnum.Tint9), }, { - key: `bg-purple-${ColorEnum.Purple}`, - content: renderColorItem(t('editor.backgroundColorPurple'), '', ColorEnum.Purple), + key: `bg-purple-${ColorEnum.Tint1}`, + content: renderColorItem(t('editor.backgroundColorPurple'), '', ColorEnum.Tint1), }, { - key: `bg-pink-${ColorEnum.Pink}`, - content: renderColorItem(t('editor.backgroundColorPink'), '', ColorEnum.Pink), + key: `bg-pink-${ColorEnum.Tint2}`, + content: renderColorItem(t('editor.backgroundColorPink'), '', ColorEnum.Tint2), }, { - key: `bg-red-${ColorEnum.LightPink}`, - content: renderColorItem(t('editor.backgroundColorRed'), '', ColorEnum.LightPink), + key: `bg-red-${ColorEnum.Tint3}`, + content: renderColorItem(t('editor.backgroundColorRed'), '', ColorEnum.Tint3), }, ], }, diff --git a/src/components/_shared/color-picker/ColorTile.tsx b/src/components/_shared/color-picker/ColorTile.tsx index ee131e44..e324d1e7 100644 --- a/src/components/_shared/color-picker/ColorTile.tsx +++ b/src/components/_shared/color-picker/ColorTile.tsx @@ -23,7 +23,7 @@ export const ColorTile = forwardRef< return (
); }, [active, isText, value]); @@ -44,3 +44,11 @@ export const ColorTile = forwardRef<
); }); + +export function ColorTileIcon({ value }: { value: string }) { + return ( +
+
+
+ ); +} diff --git a/src/components/_shared/color-picker/CustomColorPicker.tsx b/src/components/_shared/color-picker/CustomColorPicker.tsx index cbc4fb8a..b4e66eaf 100644 --- a/src/components/_shared/color-picker/CustomColorPicker.tsx +++ b/src/components/_shared/color-picker/CustomColorPicker.tsx @@ -37,7 +37,7 @@ export function CustomColorPicker({
-
+
setColorHex(e.target.value)} diff --git a/src/components/_shared/file-dropzone/FileDropzone.tsx b/src/components/_shared/file-dropzone/FileDropzone.tsx index 93dbab4d..66f3962d 100644 --- a/src/components/_shared/file-dropzone/FileDropzone.tsx +++ b/src/components/_shared/file-dropzone/FileDropzone.tsx @@ -1,7 +1,6 @@ import React, { useState, useRef } from 'react'; import { useTranslation } from 'react-i18next'; - -import { ReactComponent as ImageIcon } from '@/assets/icons/image.svg'; +import { toast } from 'sonner'; interface FileDropzoneProps { onChange?: (files: File[]) => void; @@ -11,7 +10,7 @@ interface FileDropzoneProps { placeholder?: string | React.ReactNode; } -function FileDropzone ({ onChange, accept, multiple, disabled, placeholder }: FileDropzoneProps) { +function FileDropzone({ onChange, accept, multiple, disabled, placeholder }: FileDropzoneProps) { const { t } = useTranslation(); const [dragging, setDragging] = useState(false); const fileInputRef = useRef(null); @@ -33,10 +32,31 @@ function FileDropzone ({ onChange, accept, multiple, disabled, placeholder }: Fi event.stopPropagation(); setDragging(false); - if (event.dataTransfer.files && event.dataTransfer.files.length > 0) { - handleFiles(event.dataTransfer.files); - event.dataTransfer.clearData(); + const toastError = () => toast.error(t('document.plugins.file.noImages')); + + if (!event.dataTransfer.files || event.dataTransfer.files.length === 0) { + toastError(); + return; } + + const files = Array.from(event.dataTransfer.files); + + if (accept) { + const isEveryFileValid = files.every((file: File) => { + const acceptedTypes = accept.split(','); + + return acceptedTypes.some((type) => file.name.endsWith(type) || file.type === type); + }); + + if (!isEveryFileValid) { + toastError(); + event.dataTransfer.clearData(); + return; + } + } + + handleFiles(event.dataTransfer.files); + event.dataTransfer.clearData(); }; const handleDragOver = (event: React.DragEvent) => { @@ -64,9 +84,7 @@ function FileDropzone ({ onChange, accept, multiple, disabled, placeholder }: Fi return (
-
- -
- {placeholder || t('fileDropzone.dropFile')} -
+
+ {placeholder || ( + <> + {t('document.plugins.file.fileUploadHint')} + {t('document.plugins.file.fileUploadHintSuffix')} + + )}
voi ); const loadPhotos = useCallback(async (searchValue: string) => { - const pages = 4; - const perPage = 30; + const pages = 1; + const perPage = 18; const requests = Array.from({ length: pages }, (_, i) => searchValue ? unsplash.search.getPhotos({ @@ -103,16 +102,16 @@ export function Unsplash({ onDone, onEscape }: { onDone?: (value: string) => voi }, [debounceSearchPhotos, searchValue]); return ( -
- + {loading ? ( @@ -128,30 +127,17 @@ export function Unsplash({ onDone, onEscape }: { onDone?: (value: string) => voi
{photos.length > 0 ? ( <> -
+
{photos.map((photo) => ( -
-
- { - onDone?.(photo.full); - }} - src={photo.thumb} - alt={photo.alt ?? ''} - className={`absolute left-0 top-0 h-full w-[128px] cursor-pointer rounded object-cover transition-opacity hover:opacity-80`} - /> -
-
- by - { - void openUrl(photo.user.link); - }} - className={'ml-2 cursor-pointer underline underline-offset-[3px] hover:text-function-info'} - > - {photo.user.name} - -
+
+ { + onDone?.(photo.full); + }} + src={photo.thumb} + alt={photo.alt ?? ''} + className={`absolute left-0 top-0 h-full w-[128px] cursor-pointer rounded object-cover transition-opacity hover:opacity-80`} + />
))}
diff --git a/src/components/_shared/image-upload/UploadImage.tsx b/src/components/_shared/image-upload/UploadImage.tsx index 499ee713..fb2eed26 100644 --- a/src/components/_shared/image-upload/UploadImage.tsx +++ b/src/components/_shared/image-upload/UploadImage.tsx @@ -2,7 +2,6 @@ import FileDropzone from '@/components/_shared/file-dropzone/FileDropzone'; import LoadingDots from '@/components/_shared/LoadingDots'; import { notify } from '@/components/_shared/notify'; import React, { useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; export const ALLOWED_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp']; @@ -13,7 +12,6 @@ export function UploadImage({ onDone?: (url: string) => void; uploadAction?: (file: File) => Promise; }) { - const { t } = useTranslation(); const [loading, setLoading] = React.useState(false); const handleFileChange = useCallback( async (files: File[]) => { @@ -43,9 +41,8 @@ export function UploadImage({ ); return ( -
+
diff --git a/src/components/_shared/image-upload/UploadPopover.tsx b/src/components/_shared/image-upload/UploadPopover.tsx index 6deb9859..9873b81d 100644 --- a/src/components/_shared/image-upload/UploadPopover.tsx +++ b/src/components/_shared/image-upload/UploadPopover.tsx @@ -88,15 +88,18 @@ export function UploadPopover({ return ( {children} - -
+ +
{tabOptions.map((tab) => { const { key, label } = tab; @@ -107,7 +110,7 @@ export function UploadPopover({ {extra}
-
+
+ onOpenChange?.(false)} /> ); diff --git a/src/components/app/MainLayout.tsx b/src/components/app/MainLayout.tsx index 7e4b8181..458643e9 100644 --- a/src/components/app/MainLayout.tsx +++ b/src/components/app/MainLayout.tsx @@ -1,6 +1,8 @@ import { useMemo } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; +import { useOutlineDrawer } from '@/components/_shared/outline/outline.hooks'; +import { AFScroller } from '@/components/_shared/scroller'; import { useAIChatContext } from '@/components/ai-chat/AIChatProvider'; import { useViewErrorStatus } from '@/components/app/app.hooks'; import { ConnectBanner } from '@/components/app/ConnectBanner'; @@ -10,8 +12,6 @@ import SideBar from '@/components/app/SideBar'; import DeletedPageComponent from '@/components/error/PageHasBeenDeleted'; import RecordNotFound from '@/components/error/RecordNotFound'; import SomethingError from '@/components/error/SomethingError'; -import { useOutlineDrawer } from '@/components/_shared/outline/outline.hooks'; -import { AFScroller } from '@/components/_shared/scroller'; function MainLayout() { const { drawerOpened, drawerWidth, setDrawerWidth, toggleOpenDrawer } = useOutlineDrawer(); diff --git a/src/components/app/WorkspaceLoadingAnimation.tsx b/src/components/app/WorkspaceLoadingAnimation.tsx new file mode 100644 index 00000000..58657b13 --- /dev/null +++ b/src/components/app/WorkspaceLoadingAnimation.tsx @@ -0,0 +1,170 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import logoSvg from "@/assets/icons/logo.svg"; + +const LOADING_STEPS = [ + { key: "checkingAuth", duration: 800 }, + { key: "loadingData", duration: 600 }, + { key: "preparingInterface", duration: 700 }, + { key: "finalizing", duration: 500 }, +] as const; + +export function WorkspaceLoadingAnimation() { + const { t } = useTranslation(); + const [currentStep, setCurrentStep] = useState(0); + const [progress, setProgress] = useState(0); + const [isComplete, setIsComplete] = useState(false); + + useEffect(() => { + let timeoutId: NodeJS.Timeout; + let progressInterval: NodeJS.Timeout; + + const runStep = (stepIndex: number) => { + if (stepIndex >= LOADING_STEPS.length) { + setIsComplete(true); + return; + } + + setCurrentStep(stepIndex); + const step = LOADING_STEPS[stepIndex]; + + if (!step) return; // Prevent undefined error + + // Reset progress + setProgress(0); + + // Progressive progress animation + progressInterval = setInterval(() => { + setProgress((prev) => { + if (prev >= 100) { + clearInterval(progressInterval); + return 100; + } + + return prev + 100 / (step.duration / 50); // 50ms intervals + }); + }, 50); + + // Move to next step after completion + timeoutId = setTimeout(() => { + clearInterval(progressInterval); + runStep(stepIndex + 1); + }, step.duration); + }; + + runStep(0); + + return () => { + clearTimeout(timeoutId); + clearInterval(progressInterval); + }; + }, []); + + return ( +
+
+ {/* Logo Animation */} +
+
+
+ logo + {/* Glow effect */} +
+
+
+ + {/* Circular progress ring */} +
+ + {/* Background ring */} + + {/* Progress ring */} + + +
+
+ + {/* Main title */} +
+

+ {isComplete ? t("global-loading.welcome") : t("global-loading.installing")} +

+
+ + {/* Simple status text */} +
+

+ {!isComplete && t(`global-loading.steps.${LOADING_STEPS[currentStep]?.key}`)} +

+
+ + {/* Progress percentage */} +
+
+ {Math.round(currentStep * 25 + progress * 0.25)}% +
+
+
+ + {/* Background particles */} +
+ {[...Array(4)].map((_, i) => ( +
+ ))} +
+ + +
+ ); +} \ No newline at end of file diff --git a/src/components/app/app.hooks.tsx b/src/components/app/app.hooks.tsx index cd8350b9..d3836d1b 100644 --- a/src/components/app/app.hooks.tsx +++ b/src/components/app/app.hooks.tsx @@ -1,26 +1,14 @@ import EventEmitter from 'events'; -import { PromptDatabaseConfiguration } from '@appflowyinc/ai-chat'; -import { debounce, sortBy, uniqBy } from 'lodash-es'; -import React, { createContext, Suspense, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; -import { validate as uuidValidate } from 'uuid'; +import React, { createContext, useContext } from 'react'; import { Awareness } from 'y-protocols/awareness'; -import { APP_EVENTS } from '@/application/constants'; -import { FieldType } from '@/application/database-yjs'; -import { getCellDataText } from '@/application/database-yjs/cell.parse'; -import { openCollabDB, db } from '@/application/db'; -import { invalidToken, getTokenParsed } from '@/application/session/token'; import { AppendBreadcrumb, CreateFolderViewPayload, CreatePagePayload, CreateRowDoc, CreateSpacePayload, - DatabasePrompt, - DatabasePromptField, - DatabasePromptRow, DatabaseRelations, GenerateAISummaryRowPayload, GenerateAITranslateRowPayload, @@ -31,30 +19,22 @@ import { Subscription, TestDatabasePromptConfig, TextCount, - Types, UIVariant, UpdatePagePayload, UpdateSpacePayload, UserWorkspaceInfo, View, ViewIconType, - ViewLayout, - YDatabase, - YDoc, - YjsDatabaseKey, - YjsEditorKey, } from '@/application/types'; -import { AIChatProvider } from '@/components/ai-chat/AIChatProvider'; -import { AppOverlayProvider } from '@/components/app/app-overlay/AppOverlayProvider'; -import RequestAccess from '@/components/app/landing-pages/RequestAccess'; -import { AFConfigContext, useService } from '@/components/main/app.hooks'; -import { useAppflowyWebSocket, useBroadcastChannel, useSync } from '@/components/ws'; -import { findAncestors, findView, findViewByLayout } from '@/components/_shared/outline/utils'; -import { createDeduplicatedNoArgsRequest } from '@/utils/deduplicateRequest'; -import { notification } from '@/proto/messages'; +import { findView } from '@/components/_shared/outline/utils'; -const ViewModal = React.lazy(() => import('@/components/app/ViewModal')); +import { AuthInternalContext } from './contexts/AuthInternalContext'; +import { AppAuthLayer } from './layers/AppAuthLayer'; +import { AppBusinessLayer } from './layers/AppBusinessLayer'; +import { AppSyncLayer } from './layers/AppSyncLayer'; +import { WorkspaceLoadingAnimation } from './WorkspaceLoadingAnimation'; +// Main AppContext interface - kept identical to maintain backward compatibility export interface AppContextType { toView: (viewId: string, blockId?: string, keepSearch?: boolean) => Promise; loadViewMeta: LoadViewMeta; @@ -110,1317 +90,38 @@ export interface AppContextType { checkIfRowDocumentExists?: (databaseId: string, rowIds: string[]) => Promise; } -const USER_NO_ACCESS_CODE = [1024, 1012]; - +// Main AppContext - same as original export const AppContext = createContext(null); -export const AppProvider = ({ children }: { children: React.ReactNode }) => { - const isAuthenticated = useContext(AFConfigContext)?.isAuthenticated; - const params = useParams(); - const viewId = useMemo(() => { - const id = params.viewId; - - if (id && !uuidValidate(id)) return; - return id; - }, [params.viewId]); - const [awarenessMap, setAwarenessMap] = useState>({}); - const [openModalViewId, setOpenModalViewId] = useState(undefined); - const wordCountRef = useRef>({}); - - const [userWorkspaceInfo, setUserWorkspaceInfo] = useState(undefined); - - const currentWorkspaceId = useMemo( - () => params.workspaceId || userWorkspaceInfo?.selectedWorkspace.id, - [params.workspaceId, userWorkspaceInfo?.selectedWorkspace.id] - ); - const [workspaceDatabases, setWorkspaceDatabases] = useState(undefined); - const [outline, setOutline] = useState(); - const outlineRef = useRef(); - const [favoriteViews, setFavoriteViews] = useState(); - const [recentViews, setRecentViews] = useState(); - const [trashList, setTrashList] = React.useState(); - const eventEmitterRef = useRef(new EventEmitter()); - - const viewHasBeenDeleted = useMemo(() => { - if (!viewId) return false; - return trashList?.some((v) => v.view_id === viewId); - }, [trashList, viewId]); - const viewNotFound = useMemo(() => { - if (!viewId || !outline) return false; - return !findView(outline, viewId); - }, [outline, viewId]); - const setWordCount = useCallback((viewId: string, count: TextCount) => { - wordCountRef.current[viewId] = count; - }, []); - - const createdRowKeys = useRef([]); - const [requestAccessOpened, setRequestAccessOpened] = useState(false); - const [rendered, setRendered] = useState(false); - const service = useService()!; - const navigate = useNavigate(); - const webSocket = useAppflowyWebSocket({ - workspaceId: currentWorkspaceId!, - clientId: service.getClientId(), - deviceId: service.getDeviceId(), - }); - - const broadcastChannel = useBroadcastChannel(`workspace:${currentWorkspaceId!}`); - const { registerSyncContext, lastUpdatedCollab } = useSync(webSocket, broadcastChannel, eventEmitterRef.current); - - const reconnectWebSocket = useCallback(() => { - webSocket.reconnect(); - }, [webSocket]); - - useEffect(() => { - const currentEventEmitter = eventEmitterRef.current; - - currentEventEmitter.on(APP_EVENTS.RECONNECT_WEBSOCKET, reconnectWebSocket); - - return () => { - currentEventEmitter.off(APP_EVENTS.RECONNECT_WEBSOCKET, reconnectWebSocket); - }; - }, [reconnectWebSocket]); - - useEffect(() => { - const currentEventEmitter = eventEmitterRef.current; - - currentEventEmitter.emit(APP_EVENTS.WEBSOCKET_STATUS, webSocket.readyState); - }, [webSocket.readyState]); - - // Handle user profile change notifications - // This provides automatic UI updates when user profile changes occur via WebSocket. - // - // Notification Flow: - // 1. Server sends WorkspaceNotification with profileChange - // 2. useSync processes notification from WebSocket OR BroadcastChannel - // 3. useSync emits USER_PROFILE_CHANGED event via eventEmitter - // 4. This handler receives the event and updates local database - // 5. useLiveQuery in AppConfig detects database change - // 6. All components using currentUser automatically re-render with new data - // - // Multi-tab Support: - // - Active tab: WebSocket → useSync → this handler → database update - // - Other tabs: BroadcastChannel → useSync → this handler → database update - // - Result: All tabs show updated profile simultaneously - // - // UI Components that auto-update: - // - Workspace dropdown (shows email) - // - Collaboration user lists (shows names/avatars) - // - Any component using useCurrentUser() hook - useEffect(() => { - if (!isAuthenticated || !currentWorkspaceId) return; - - const currentEventEmitter = eventEmitterRef.current; - - const handleUserProfileChange = async (profileChange: notification.IUserProfileChange) => { - try { - console.log('Received user profile change notification:', profileChange); - - // Extract user ID from authentication token - const token = getTokenParsed(); - const userId = token?.user?.id; - - if (!userId) { - console.warn('No user ID found for profile update'); - return; - } - - // Retrieve current user data from local database cache - const existingUser = await db.users.get(userId); - - if (!existingUser) { - console.warn('No existing user found in database for profile update'); - return; - } - - // Merge notification changes with existing user data - // Only update fields that are present in the notification (selective update) - const updatedUser = { - ...existingUser, - // Update name if provided in notification - ...(profileChange.name !== undefined && { name: profileChange.name }), - // Update email if provided in notification - ...(profileChange.email !== undefined && { email: profileChange.email }), - }; - - // Update database cache - this triggers useLiveQuery to re-render all UI components - // displaying user profile information. No manual component updates needed. - await db.users.put(updatedUser, userId); - - console.log('User profile updated in database:', updatedUser); - } catch (error) { - console.error('Failed to handle user profile change notification:', error); - } - }; - - // Subscribe to user profile change notifications from the event system - currentEventEmitter.on(APP_EVENTS.USER_PROFILE_CHANGED, handleUserProfileChange); - - // Cleanup subscription when component unmounts or dependencies change - return () => { - currentEventEmitter.off(APP_EVENTS.USER_PROFILE_CHANGED, handleUserProfileChange); - }; - }, [isAuthenticated, currentWorkspaceId]); - - const onRendered = useCallback(() => { - setRendered(true); - }, []); - const logout = useCallback(() => { - invalidToken(); - navigate(`/login?redirectTo=${encodeURIComponent(window.location.href)}`); - }, [navigate]); - - // If the user is not authenticated, log out the user - useEffect(() => { - if (!isAuthenticated) { - logout(); - } - }, [isAuthenticated, logout]); - - useEffect(() => { - const rowKeys = createdRowKeys.current; - - createdRowKeys.current = []; - if (!rowKeys.length) return; - rowKeys.forEach((rowKey) => { - try { - service?.deleteRowDoc(rowKey); - } catch (e) { - console.error(e); - } - }); - }, [service, viewId]); - - const originalCrumbs = useMemo(() => { - if (!outline || !viewId) return []; - - return findAncestors(outline, viewId) || []; - }, [outline, viewId]); - - const [breadcrumbs, setBreadcrumbs] = useState(originalCrumbs); - - useEffect(() => { - setBreadcrumbs(originalCrumbs); - }, [originalCrumbs]); - - const appendBreadcrumb = useCallback((view?: View) => { - setBreadcrumbs((prev) => { - if (!view) { - return prev.slice(0, -1); - } - - const index = prev.findIndex((v) => v.view_id === view.view_id); - - if (index === -1) { - return [...prev, view]; - } - - const rest = prev.slice(0, index); - - return [...rest, view]; - }); - }, []); - - const loadViewMeta = useCallback( - async (viewId: string, callback?: (meta: View) => void) => { - const view = findView(outlineRef.current || [], viewId); - const deletedView = trashList?.find((v) => v.view_id === viewId); - - if (deletedView) { - return Promise.reject(deletedView); - } - - if (!view) { - return Promise.reject('View not found'); - } - - if (callback) { - callback({ - ...view, - database_relations: workspaceDatabases, - }); - } - - return { - ...view, - database_relations: workspaceDatabases, - }; - }, - [trashList, workspaceDatabases] - ); - - const toView = useCallback( - async (viewId: string, blockId?: string, keepSearch?: boolean) => { - let url = `/app/${currentWorkspaceId}/${viewId}`; - const view = await loadViewMeta(viewId); - - const searchParams = new URLSearchParams(keepSearch ? window.location.search : undefined); - - if (blockId) { - switch (view.layout) { - case ViewLayout.Document: - searchParams.set('blockId', blockId); - break; - case ViewLayout.Grid: - case ViewLayout.Board: - case ViewLayout.Calendar: - searchParams.set('r', blockId); - break; - default: - break; - } - } - - if (searchParams.toString()) { - url += `?${searchParams.toString()}`; - } - - navigate(url); - }, - [currentWorkspaceId, loadViewMeta, navigate] - ); - - const workspaceDatabaseDocMapRef = useRef>(new Map()); - const databaseStorageId = userWorkspaceInfo?.selectedWorkspace?.databaseStorageId; - - const registerWorkspaceDatabaseDoc = useCallback( - async (workspaceId: string, databaseStorageId: string) => { - const doc = await openCollabDB(databaseStorageId); - - doc.guid = databaseStorageId; - const { doc: workspaceDatabaseDoc } = registerSyncContext({ doc, collabType: Types.WorkspaceDatabase }); - - workspaceDatabaseDocMapRef.current.clear(); - workspaceDatabaseDocMapRef.current.set(workspaceId, workspaceDatabaseDoc); - }, - [registerSyncContext] - ); - - const getDatabaseId = useCallback( - async (id: string) => { - if (!currentWorkspaceId) return; - - if (databaseStorageId && !workspaceDatabaseDocMapRef.current.has(currentWorkspaceId)) { - await registerWorkspaceDatabaseDoc(currentWorkspaceId, databaseStorageId); - } - - return new Promise((resolve) => { - const sharedRoot = workspaceDatabaseDocMapRef.current.get(currentWorkspaceId)?.getMap(YjsEditorKey.data_section); - const observeEvent = () => { - const databases = sharedRoot?.toJSON()?.databases; - - const databaseId = databases?.find((database: { database_id: string; views: string[] }) => - database.views.find((view) => view === id) - )?.database_id; - - if (databaseId) { - resolve(databaseId); - } - }; - - observeEvent(); - - sharedRoot?.observeDeep(observeEvent); - - return () => { - sharedRoot?.unobserveDeep(observeEvent); - }; - }); - }, - [currentWorkspaceId, databaseStorageId, registerWorkspaceDatabaseDoc] - ); - - const loadView = useCallback( - async (id: string, _isSubDocument = false, loadAwareness = false) => { - try { - if (!service || !currentWorkspaceId) { - throw new Error('Service or workspace not found'); - } - - const res = await service?.getPageDoc(currentWorkspaceId, id); - - if (!res) { - throw new Error('View not found'); - } - - if (loadAwareness) { - // add recent pages when view is loaded - void (async () => { - try { - await service.addRecentPages(currentWorkspaceId, [id]); - } catch (e) { - console.error(e); - } - })(); - } - - const view = findView(outlineRef.current || [], id); - - const collabType = view - ? view?.layout === ViewLayout.Document - ? Types.Document - : Types.Database - : Types.Document; - - if (collabType === Types.Document) { - let awareness: Awareness | undefined; - - if (loadAwareness) { - setAwarenessMap((prev) => { - if (prev[id]) { - awareness = prev[id]; - return prev; - } - - awareness = new Awareness(res); - return { ...prev, [id]: awareness }; - }); - } - - const { doc } = registerSyncContext({ doc: res, collabType, awareness }); - - return doc; - } - - const databaseId = await getDatabaseId(id); - - if (!databaseId) { - throw new Error('Database not found'); - } - - res.guid = databaseId; - - const { doc } = registerSyncContext({ doc: res, collabType }); - - return doc; - - // eslint-disable-next-line - } catch (e: any) { - return Promise.reject(e); - } - }, - [service, currentWorkspaceId, getDatabaseId, registerSyncContext] - ); - - const createRowDoc = useCallback( - async (rowKey: string) => { - if (!currentWorkspaceId || !service) { - throw new Error('Failed to create row doc'); - } - - try { - const doc = await service.createRowDoc(rowKey); - - if (!doc) { - throw new Error('Failed to create row doc'); - } - - const rowId = rowKey.split('_rows_')[1]; - - if (!rowId) { - throw new Error('Failed to create row doc'); - } - - doc.guid = rowId; - const syncContext = registerSyncContext({ - doc, - collabType: Types.DatabaseRow, - }); - - createdRowKeys.current.push(rowKey); - return syncContext.doc; - } catch (e) { - return Promise.reject(e); - } - }, - [currentWorkspaceId, service, registerSyncContext] - ); - - const loadUserWorkspaceInfo = useCallback(async () => { - if (!service) return; - try { - const res = await service.getUserWorkspaceInfo(); - - setUserWorkspaceInfo(res); - return res; - } catch (e) { - console.error(e); - } - }, [service]); - - const loadOutline = useCallback( - async (workspaceId: string, force = true) => { - if (!service) return; - try { - const res = await service?.getAppOutline(workspaceId); - - if (!res) { - throw new Error('App outline not found'); - } - - setOutline(res); - - eventEmitterRef.current.emit(APP_EVENTS.OUTLINE_LOADED, res); - - outlineRef.current = res; - if (!force) return; - - const firstView = findViewByLayout(res, [ - ViewLayout.Document, - ViewLayout.Board, - ViewLayout.Grid, - ViewLayout.Calendar, - ]); - - if (!firstView) { - setRendered(true); - } - - try { - await service.openWorkspace(workspaceId); - const wId = window.location.pathname.split('/')[2]; - const pageId = window.location.pathname.split('/')[3]; - const search = window.location.search; - - // skip /app/trash and /app/*other-pages - if (wId && !uuidValidate(wId)) { - return; - } - - // skip /app/:workspaceId/:pageId - if (pageId && uuidValidate(pageId) && wId && uuidValidate(wId) && wId === workspaceId) { - return; - } - - const lastViewId = localStorage.getItem('last_view_id'); - - if (lastViewId && findView(res, lastViewId)) { - navigate(`/app/${workspaceId}/${lastViewId}${search}`); - } else if (firstView) { - navigate(`/app/${workspaceId}/${firstView.view_id}${search}`); - } - } catch (e) { - // do nothing - } - - // eslint-disable-next-line - } catch (e: any) { - console.error('App outline not found'); - if (USER_NO_ACCESS_CODE.includes(e.code)) { - setRequestAccessOpened(true); - return; - } - } - }, - [navigate, service] - ); - - const loadFavoriteViews = useCallback(async () => { - if (!service || !currentWorkspaceId) return; - try { - const res = await service?.getAppFavorites(currentWorkspaceId); - - if (!res) { - throw new Error('Favorite views not found'); - } - - setFavoriteViews(res); - return res; - } catch (e) { - console.error('Favorite views not found'); - } - }, [currentWorkspaceId, service]); - - const loadRecentViews = useCallback(async () => { - if (!service || !currentWorkspaceId) return; - try { - const res = await service?.getAppRecent(currentWorkspaceId); - - if (!res) { - throw new Error('Recent views not found'); - } - - const views = uniqBy(res, 'view_id'); - - setRecentViews( - views.filter((item) => { - return !item.extra?.is_space && findView(outline || [], item.view_id); - }) - ); - return views; - } catch (e) { - console.error('Recent views not found'); - } - }, [currentWorkspaceId, service, outline]); - - const loadTrash = useCallback( - async (currentWorkspaceId: string) => { - if (!service) return; - try { - const res = await service?.getAppTrash(currentWorkspaceId); - - if (!res) { - throw new Error('App trash not found'); - } - - setTrashList(sortBy(uniqBy(res, 'view_id'), 'last_edited_time').reverse()); - } catch (e) { - return Promise.reject('App trash not found'); - } - }, - [service] - ); - - useEffect(() => { - if (!currentWorkspaceId) return; - void loadOutline(currentWorkspaceId); - void (async () => { - try { - await loadTrash(currentWorkspaceId); - } catch (e) { - console.error(e); - } - })(); - }, [loadOutline, currentWorkspaceId, loadTrash]); - - const loadDatabaseRelations = useCallback(async () => { - if (!currentWorkspaceId || !service) { - return; - } - - const selectedWorkspace = userWorkspaceInfo?.selectedWorkspace; - - if (!selectedWorkspace) return; - - try { - const res = await service.getAppDatabaseViewRelations(currentWorkspaceId, selectedWorkspace.databaseStorageId); - - setWorkspaceDatabases(res); - return res; - } catch (e) { - console.error(e); - } - }, [currentWorkspaceId, service, userWorkspaceInfo?.selectedWorkspace]); - - useEffect(() => { - void loadUserWorkspaceInfo(); - }, [loadUserWorkspaceInfo]); - - useEffect(() => { - void loadDatabaseRelations(); - }, [loadDatabaseRelations]); - - const onChangeWorkspace = useCallback( - async (workspaceId: string) => { - if (!service) return; - if (userWorkspaceInfo && !userWorkspaceInfo.workspaces.some((w) => w.id === workspaceId)) { - window.location.href = `/app/${workspaceId}`; - return; - } - - await service.openWorkspace(workspaceId); - await loadUserWorkspaceInfo(); - localStorage.removeItem('last_view_id'); - setOutline(undefined); - outlineRef.current = undefined; - navigate(`/app/${workspaceId}`); - }, - [navigate, service, userWorkspaceInfo, loadUserWorkspaceInfo] - ); - - const addPage = useCallback( - async (parentViewId: string, payload: CreatePagePayload) => { - if (!currentWorkspaceId || !service) { - throw new Error('No workspace or service found'); - } - - try { - const viewId = await service.addAppPage(currentWorkspaceId, parentViewId, payload); - - await loadOutline(currentWorkspaceId, false); - - return viewId; - } catch (e) { - return Promise.reject(e); - } - }, - [currentWorkspaceId, service, loadOutline] - ); - - const openPageModal = useCallback((viewId: string) => { - setOpenModalViewId(viewId); - }, []); - - const deletePage = useCallback( - async (id: string) => { - if (!currentWorkspaceId || !service) { - throw new Error('No workspace or service found'); - } - - try { - await service.moveToTrash(currentWorkspaceId, id); - void loadTrash(currentWorkspaceId); - void loadOutline(currentWorkspaceId, false); - return; - } catch (e) { - return Promise.reject(e); - } - }, - [currentWorkspaceId, service, loadTrash, loadOutline] - ); - - const deleteTrash = useCallback( - async (viewId?: string) => { - if (!currentWorkspaceId || !service) { - throw new Error('No workspace or service found'); - } - - try { - await service.deleteTrash(currentWorkspaceId, viewId); - - void loadOutline(currentWorkspaceId, false); - return; - } catch (e) { - return Promise.reject(e); - } - }, - [currentWorkspaceId, service, loadOutline] - ); - - const restorePage = useCallback( - async (viewId?: string) => { - if (!currentWorkspaceId || !service) { - throw new Error('No workspace or service found'); - } - - try { - await service.restoreFromTrash(currentWorkspaceId, viewId); - - void loadOutline(currentWorkspaceId, false); - return; - } catch (e) { - return Promise.reject(e); - } - }, - [currentWorkspaceId, service, loadOutline] - ); - - const updatePage = useCallback( - async (viewId: string, payload: UpdatePagePayload) => { - if (!currentWorkspaceId || !service) { - throw new Error('No workspace or service found'); - } - - try { - await service.updateAppPage(currentWorkspaceId, viewId, payload); - - await loadOutline(currentWorkspaceId, false); - return; - } catch (e) { - return Promise.reject(e); - } - }, - [currentWorkspaceId, service, loadOutline] - ); - - const updatePageIcon = useCallback( - async (viewId: string, icon: { ty: ViewIconType; value: string }) => { - if (!currentWorkspaceId || !service) { - throw new Error('No workspace or service found'); - } - - try { - await service.updateAppPageIcon(currentWorkspaceId, viewId, icon); - - return; - } catch (e) { - return Promise.reject(e); - } - }, - [currentWorkspaceId, service] - ); - - const updatePageName = useCallback( - async (viewId: string, name: string) => { - if (!currentWorkspaceId || !service) { - throw new Error('No workspace or service found'); - } - - try { - await service.updateAppPageName(currentWorkspaceId, viewId, name); - - return; - } catch (e) { - return Promise.reject(e); - } - }, - [currentWorkspaceId, service] - ); - - const movePage = useCallback( - async (viewId: string, parentId: string, prevViewId?: string) => { - if (!currentWorkspaceId || !service) { - throw new Error('No workspace or service found'); - } - - try { - const lastChild = findView(outline || [], parentId)?.children?.slice(-1)[0]; - const prevId = prevViewId || lastChild?.view_id; - - await service.movePage(currentWorkspaceId, viewId, parentId, prevId); - - void loadOutline(currentWorkspaceId, false); - return; - } catch (e) { - return Promise.reject(e); - } - }, - [currentWorkspaceId, service, outline, loadOutline] - ); - - const loadViews = useCallback( - async (varient?: UIVariant) => { - if (!varient) { - return outline || []; - } - - if (varient === UIVariant.Favorite) { - if (favoriteViews && favoriteViews.length > 0) { - return favoriteViews || []; - } else { - return loadFavoriteViews(); - } - } - - if (varient === UIVariant.Recent) { - if (recentViews && recentViews.length > 0) { - return recentViews || []; - } else { - return loadRecentViews(); - } - } - - return []; - }, - [favoriteViews, loadFavoriteViews, loadRecentViews, outline, recentViews] - ); - - const createSpace = useCallback( - async (payload: CreateSpacePayload) => { - if (!currentWorkspaceId || !service) { - throw new Error('No workspace or service found'); - } - - try { - const res = await service.createSpace(currentWorkspaceId, payload); - - void loadOutline(currentWorkspaceId, false); - return res; - } catch (e) { - return Promise.reject(e); - } - }, - [currentWorkspaceId, service, loadOutline] - ); - - const updateSpace = useCallback( - async (payload: UpdateSpacePayload) => { - if (!currentWorkspaceId || !service) { - throw new Error('No workspace or service found'); - } - - try { - const res = await service.updateSpace(currentWorkspaceId, payload); - - void loadOutline(currentWorkspaceId, false); - return res; - } catch (e) { - return Promise.reject(e); - } - }, - [currentWorkspaceId, service, loadOutline] - ); - - const uploadFile = useCallback( - async (viewId: string, file: File, onProgress?: (n: number) => void) => { - if (!currentWorkspaceId || !service) { - throw new Error('No workspace or service found'); - } - - try { - const res = await service.uploadFile(currentWorkspaceId, viewId, file, onProgress); - - return res; - } catch (e) { - return Promise.reject(e); - } - }, - [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]); - - const publish = useCallback( - async (view: View, publishName?: string, visibleViewIds?: string[]) => { - if (!service || !currentWorkspaceId) return; - const viewId = view.view_id; - - await service.publishView(currentWorkspaceId, viewId, { - publish_name: publishName, - visible_database_view_ids: visibleViewIds, - }); - await loadOutline(currentWorkspaceId, false); - }, - [currentWorkspaceId, loadOutline, service] - ); - - const unpublish = useCallback( - async (viewId: string) => { - if (!service || !currentWorkspaceId) return; - await service.unpublishView(currentWorkspaceId, viewId); - await loadOutline(currentWorkspaceId, false); - }, - [currentWorkspaceId, loadOutline, service] - ); - - const refreshOutline = useCallback(async () => { - if (!currentWorkspaceId) return; - await loadOutline(currentWorkspaceId, false); - console.log(`Refreshed outline for workspace ${currentWorkspaceId}`); - }, [currentWorkspaceId, loadOutline]); - - // Refresh outline when a folder collab update is detected - const debouncedRefreshOutline = useMemo( - () => - debounce(() => { - void refreshOutline(); - }, 1000), - [refreshOutline] - ); - - useEffect(() => { - if (lastUpdatedCollab?.collabType === Types.Folder) { - return debouncedRefreshOutline(); - } - }, [lastUpdatedCollab, debouncedRefreshOutline]); - - const createFolderView = useCallback( - async (payload: CreateFolderViewPayload) => { - if (!currentWorkspaceId || !service) { - throw new Error('No workspace or service found'); - } - - try { - const res = await service.createFolderView(currentWorkspaceId, payload); - - await loadOutline(currentWorkspaceId, false); - return res; - } catch (e) { - return Promise.reject(e); - } - }, - [currentWorkspaceId, loadOutline, service] - ); - - const generateAISummaryForRow = useCallback( - async (payload: GenerateAISummaryRowPayload) => { - if (!currentWorkspaceId || !service) { - throw new Error('No workspace or service found'); - } - - try { - const res = await service.generateAISummaryForRow(currentWorkspaceId, payload); - - return res; - } catch (e) { - return Promise.reject(e); - } - }, - [currentWorkspaceId, service] - ); - - const generateAITranslateForRow = useCallback( - async (payload: GenerateAITranslateRowPayload) => { - if (!currentWorkspaceId || !service) { - throw new Error('No workspace or service found'); - } - - try { - const res = await service.generateAITranslateForRow(currentWorkspaceId, payload); - - return res; - } catch (e) { - return Promise.reject(e); - } - }, - [currentWorkspaceId, service] - ); - - const createOrphanedView = useCallback( - async (payload: { document_id: string }) => { - if (!currentWorkspaceId || !service) { - throw new Error('No workspace or service found'); - } - - try { - const res = await service.createOrphanedView(currentWorkspaceId, payload); - - return res; - } catch (e) { - return Promise.reject(e); - } - }, - [currentWorkspaceId, service] - ); - - const rowDocsRef = useRef>(new Map()); - - const getRows = useCallback( - async (viewId: string) => { - if (!currentWorkspaceId) return []; - - const doc = await loadView(viewId); - const database = doc?.getMap(YjsEditorKey.data_section)?.get(YjsEditorKey.database) as YDatabase; - const view = database.get(YjsDatabaseKey.views).get(viewId); - const rowOrders = view?.get(YjsDatabaseKey.row_orders) || []; - - const rowPromises = rowOrders - .map(async (row: { id: string }) => { - if (rowDocsRef.current.has(row.id)) { - return rowDocsRef.current.get(row.id); - } - - if (!createRowDoc) return; - - const rowKey = `${doc?.guid}_rows_${row.id}`; - const rowDoc = await createRowDoc(rowKey); - - const rowSharedRoot = rowDoc?.getMap(YjsEditorKey.data_section); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const data = rowSharedRoot?.get(YjsEditorKey.database_row) as { [fieldId: string]: any }; - - const databaseRow = { - id: row.id, - data, - }; - - rowDocsRef.current.set(row.id, databaseRow); - - return databaseRow; - }) - .filter((p): p is Promise => p !== undefined); - - return Promise.all(rowPromises); - }, - [createRowDoc, currentWorkspaceId, loadView] - ); - - const getFields = useCallback( - async (viewId: string) => { - if (!currentWorkspaceId) return []; - - const doc = await loadView(viewId); - const database = doc?.getMap(YjsEditorKey.data_section)?.get(YjsEditorKey.database) as YDatabase; - const fields = database.get(YjsDatabaseKey.fields); - - return Array.from(fields.entries()) - .map(([id, field]) => { - const isPrimary = field.get(YjsDatabaseKey.is_primary) || false; - const name = field.get(YjsDatabaseKey.name) || ''; - const fieldType = Number(field.get(YjsDatabaseKey.type)); - const isSelect = fieldType === FieldType.SingleSelect || fieldType === FieldType.MultiSelect; - - return { - id, - name, - fieldType, - isPrimary, - isSelect, - data: field, - }; - }) - .filter((f) => [FieldType.RichText, FieldType.SingleSelect, FieldType.MultiSelect].includes(f.fieldType)); - }, - [currentWorkspaceId, loadView] - ); - - const loadDatabasePromptsWithFields = useCallback( - async ( - config: PromptDatabaseConfiguration, - fields: { - id: string; - name: string; - isPrimary: boolean; - fieldType: FieldType; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - data: any; - }[] - ) => { - const titleField = fields.find((field) => field.id === config.titleFieldId); - - if (!titleField) { - throw new Error('Cannot find title field'); - } - - const contentField = fields.find((field) => field.id === config.contentFieldId); - - if (!contentField) { - throw new Error('Cannot find content field'); - } - - const exampleField = config.exampleFieldId - ? fields.find((field) => field.id === config.exampleFieldId) - : undefined; - - const categoryField = config.categoryFieldId - ? fields.find((field) => field.id === config.categoryFieldId) - : undefined; - - const rows = await getRows(config.databaseViewId); - - return rows - .map((row) => { - const cells = row?.data.get(YjsDatabaseKey.cells); - - const nameCell = cells?.get(titleField.id); - const name = nameCell && titleField ? getCellDataText(nameCell, titleField.data) : ''; - - const contentCell = cells?.get(contentField.id); - const content = contentCell && contentField ? getCellDataText(contentCell, contentField.data) : ''; - - if (!name || !content) return null; - - const exampleCell = exampleField ? cells?.get(exampleField.id) : null; - const example = exampleCell && exampleField ? getCellDataText(exampleCell, exampleField.data) : ''; - - const categoryCell = categoryField ? cells?.get(categoryField.id) : null; - const category = categoryCell && categoryField ? getCellDataText(categoryCell, categoryField.data) : ''; - - return { - id: row.id, - name, - content, - example, - category, - }; - }) - .filter((prompt): prompt is DatabasePrompt => prompt !== null); - }, - [getRows] - ); - - const loadDatabasePrompts = useCallback( - async ( - config: PromptDatabaseConfiguration - ): Promise<{ - rawDatabasePrompts: DatabasePrompt[]; - fields: DatabasePromptField[]; - }> => { - const fields = await getFields(config.databaseViewId); - - const rawDatabasePrompts = await loadDatabasePromptsWithFields(config, fields); - - return { - rawDatabasePrompts, - fields: fields.map((field) => ({ - id: field.id, - name: field.name, - isPrimary: field.isPrimary, - isSelect: field.fieldType === FieldType.SingleSelect || field.fieldType === FieldType.MultiSelect, - })), - }; - }, - [getFields, loadDatabasePromptsWithFields] - ); - - const testDatabasePromptConfig = useCallback( - async (viewId: string) => { - const fields = await getFields(viewId); - const titleField = fields.find((field) => field.isPrimary); - - if (!titleField) { - throw new Error('Cannot find primary field'); - } - - const contentField = fields.find( - (field) => - !field.isPrimary && - ((field.name.toLowerCase() === 'content' && field.fieldType === FieldType.RichText) || - field.fieldType === FieldType.RichText) - ); - - if (!contentField) { - throw new Error('Cannot find content field'); - } - - const exampleField = fields.find( - (field) => field.name.toLowerCase() === 'example' && field.fieldType === FieldType.RichText - ); - const categoryField = fields.find( - (field) => field.name.toLowerCase() === 'category' && field.fieldType === FieldType.RichText - ); - - const config: PromptDatabaseConfiguration = { - databaseViewId: viewId, - titleFieldId: titleField.id, - contentFieldId: contentField.id, - exampleFieldId: exampleField?.id || null, - categoryFieldId: categoryField?.id || null, - }; - - return { config, fields }; - }, - [getFields] - ); - - const mentionableUsersRef = useRef([]); - - const _loadMentionableUsers = useCallback(async () => { - if (!currentWorkspaceId || !service) { - throw new Error('No workspace or service found'); - } - - try { - const res = await service.getMentionableUsers(currentWorkspaceId); - - mentionableUsersRef.current = res; - - return res; - } catch (e) { - return Promise.reject(e); - } - }, [currentWorkspaceId, service]); - - const loadMentionableUsers = useMemo( - () => createDeduplicatedNoArgsRequest(_loadMentionableUsers), - [_loadMentionableUsers] - ); - - useEffect(() => { - void loadMentionableUsers(); - }, [loadMentionableUsers]); - - const getMentionUser = useCallback( - async (uuid: string) => { - if (mentionableUsersRef.current.length > 0) { - const user = mentionableUsersRef.current.find((user) => user.person_id === uuid); - - if (user) { - return user; - } - } - - try { - const res = await loadMentionableUsers(); - - return res.find((user: MentionablePerson) => user.person_id === uuid); - } catch (e) { - return Promise.reject(e); - } - }, - [loadMentionableUsers] - ); - - const checkIfRowDocumentExists = useCallback( - async (documentId: string) => { - if (!service || !currentWorkspaceId) { - throw new Error('No service found'); - } - - return service.checkIfCollabExists(currentWorkspaceId, documentId); - }, - [service, currentWorkspaceId] - ); +// Internal component to conditionally render sync and business layers only when workspace ID exists +const ConditionalWorkspaceLayers = ({ children }: { children: React.ReactNode }) => { + const authContext = useContext(AuthInternalContext); + const { currentWorkspaceId } = authContext || {}; + + // Show loading animation while workspace ID is being loaded + if (!currentWorkspaceId) { + return ; + } return ( - - - - {requestAccessOpened ? : children} - { - - { - setOpenModalViewId(undefined); - }} - /> - - } - - - + + {children} + ); }; +// Refactored AppProvider using layered architecture +// External API remains identical - all changes are internal +export const AppProvider = ({ children }: { children: React.ReactNode }) => { + return ( + + {children} + + ); +}; + +// All hooks remain identical to maintain backward compatibility + export function useViewErrorStatus() { const context = useContext(AppContext); diff --git a/src/components/app/components/AppContextConsumer.tsx b/src/components/app/components/AppContextConsumer.tsx new file mode 100644 index 00000000..925ea585 --- /dev/null +++ b/src/components/app/components/AppContextConsumer.tsx @@ -0,0 +1,49 @@ +import React, { memo, Suspense } from 'react'; +import { Awareness } from 'y-protocols/awareness'; + +import { AIChatProvider } from '@/components/ai-chat/AIChatProvider'; +import { AppOverlayProvider } from '@/components/app/app-overlay/AppOverlayProvider'; +import { AppContext } from '@/components/app/app.hooks'; +import RequestAccess from '@/components/app/landing-pages/RequestAccess'; + +import { useAllContextData } from '../hooks/useAllContextData'; + +const ViewModal = React.lazy(() => import('@/components/app/ViewModal')); + +interface AppContextConsumerProps { + children: React.ReactNode; + requestAccessOpened: boolean; + openModalViewId?: string; + setOpenModalViewId: (id: string | undefined) => void; + awarenessMap: Record; +} + +// Component that consumes all internal contexts and provides the unified AppContext +// This maintains the original AppContext API while using the new layered architecture internally +export const AppContextConsumer: React.FC = memo( + ({ children, requestAccessOpened, openModalViewId, setOpenModalViewId, awarenessMap }) => { + // Merge all layer data into the complete AppContextType + const allContextData = useAllContextData(awarenessMap); + + return ( + + + + {requestAccessOpened ? : children} + { + + { + setOpenModalViewId(undefined); + }} + /> + + } + + + + ); + } +); diff --git a/src/components/app/contexts/AuthInternalContext.ts b/src/components/app/contexts/AuthInternalContext.ts new file mode 100644 index 00000000..58a87771 --- /dev/null +++ b/src/components/app/contexts/AuthInternalContext.ts @@ -0,0 +1,27 @@ +import { createContext, useContext } from 'react'; + +import { UserWorkspaceInfo } from '@/application/types'; +import { AFService } from '@/application/services/services.type'; + +// Internal context for authentication layer +// This context is only used within the app provider layers +export interface AuthInternalContextType { + service: AFService | undefined; // Service instance from useService + userWorkspaceInfo?: UserWorkspaceInfo; + currentWorkspaceId?: string; + isAuthenticated: boolean; + onChangeWorkspace: (workspaceId: string) => Promise; +} + +export const AuthInternalContext = createContext(null); + +// Hook to access auth internal context +export function useAuthInternal() { + const context = useContext(AuthInternalContext); + + if (!context) { + throw new Error('useAuthInternal must be used within an AuthInternalProvider'); + } + + return context; +} \ No newline at end of file diff --git a/src/components/app/contexts/BusinessInternalContext.ts b/src/components/app/contexts/BusinessInternalContext.ts new file mode 100644 index 00000000..afd627ea --- /dev/null +++ b/src/components/app/contexts/BusinessInternalContext.ts @@ -0,0 +1,114 @@ +import { createContext, useContext } from 'react'; + +import { + View, + TextCount, + AppendBreadcrumb, + LoadView, + LoadViewMeta, + CreateRowDoc, + CreatePagePayload, + UpdatePagePayload, + ViewIconType, + CreateSpacePayload, + UpdateSpacePayload, + CreateFolderViewPayload, + GenerateAISummaryRowPayload, + GenerateAITranslateRowPayload, + LoadDatabasePrompts, + TestDatabasePromptConfig, + DatabaseRelations, + Subscription, + MentionablePerson, + UIVariant, +} from '@/application/types'; + +// Internal context for business layer +// This context is only used within the app provider layers +export interface BusinessInternalContextType { + // View and navigation + viewId?: string; + toView: (viewId: string, blockId?: string, keepSearch?: boolean) => Promise; + loadViewMeta: LoadViewMeta; + loadView: LoadView; + createRowDoc?: CreateRowDoc; + + // Outline and hierarchy + outline?: View[]; + breadcrumbs?: View[]; + appendBreadcrumb?: AppendBreadcrumb; + refreshOutline?: () => Promise; + + // Data views + favoriteViews?: View[]; + recentViews?: View[]; + trashList?: View[]; + loadFavoriteViews?: () => Promise; + loadRecentViews?: () => Promise; + loadTrash?: (workspaceId: string) => Promise; + loadViews?: (variant?: UIVariant) => Promise; + + // Page operations + addPage?: (parentId: string, payload: CreatePagePayload) => Promise; + deletePage?: (viewId: string) => Promise; + updatePage?: (viewId: string, payload: UpdatePagePayload) => Promise; + updatePageIcon?: (viewId: string, icon: { ty: ViewIconType; value: string }) => Promise; + updatePageName?: (viewId: string, name: string) => Promise; + movePage?: (viewId: string, parentId: string, prevViewId?: string) => Promise; + + // Trash operations + deleteTrash?: (viewId?: string) => Promise; + restorePage?: (viewId?: string) => Promise; + + // Space operations + createSpace?: (payload: CreateSpacePayload) => Promise; + updateSpace?: (payload: UpdateSpacePayload) => Promise; + createFolderView?: (payload: CreateFolderViewPayload) => Promise; + + // File operations + uploadFile?: (viewId: string, file: File, onProgress?: (n: number) => void) => Promise; + + // Publishing + getSubscriptions?: () => Promise; + publish?: (view: View, publishName?: string, visibleViewIds?: string[]) => Promise; + unpublish?: (viewId: string) => Promise; + + // AI operations + generateAISummaryForRow?: (payload: GenerateAISummaryRowPayload) => Promise; + generateAITranslateForRow?: (payload: GenerateAITranslateRowPayload) => Promise; + + // Database operations + loadDatabaseRelations?: () => Promise; + createOrphanedView?: (payload: { document_id: string }) => Promise; + loadDatabasePrompts?: LoadDatabasePrompts; + testDatabasePromptConfig?: TestDatabasePromptConfig; + checkIfRowDocumentExists?: (databaseId: string, rowIds: string[]) => Promise; + + // User operations + getMentionUser?: (uuid: string) => Promise; + + // UI state + rendered?: boolean; + onRendered?: () => void; + notFound?: boolean; + viewHasBeenDeleted?: boolean; + openPageModal?: (viewId: string) => void; + openPageModalViewId?: string; + + // Word count + wordCount?: Record; + setWordCount?: (viewId: string, count: TextCount) => void; +} + +export const BusinessInternalContext = createContext(null); + +// Hook to access business internal context +export function useBusinessInternal() { + const context = useContext(BusinessInternalContext); + + if (!context) { + throw new Error('useBusinessInternal must be used within a BusinessInternalProvider'); + } + + return context; +} \ No newline at end of file diff --git a/src/components/app/contexts/SyncInternalContext.ts b/src/components/app/contexts/SyncInternalContext.ts new file mode 100644 index 00000000..20a53f17 --- /dev/null +++ b/src/components/app/contexts/SyncInternalContext.ts @@ -0,0 +1,36 @@ +import { createContext, useContext } from 'react'; +import EventEmitter from 'events'; +import { Awareness } from 'y-protocols/awareness'; + +import { YDoc, Types } from '@/application/types'; +import { AppflowyWebSocketType } from '@/components/ws/useAppflowyWebSocket'; +import { BroadcastChannelType } from '@/components/ws/useBroadcastChannel'; +import { UpdateCollabInfo } from '@/components/ws/useSync'; + +// Internal context for synchronization layer +// This context is only used within the app provider layers +export interface SyncInternalContextType { + webSocket: AppflowyWebSocketType; // WebSocket connection from useAppflowyWebSocket + broadcastChannel: BroadcastChannelType; // BroadcastChannel from useBroadcastChannel + registerSyncContext: (params: { + doc: YDoc; + collabType: Types; + awareness?: Awareness; + }) => { doc: YDoc }; + eventEmitter: EventEmitter; + awarenessMap: Record; + lastUpdatedCollab: UpdateCollabInfo | null; +} + +export const SyncInternalContext = createContext(null); + +// Hook to access sync internal context +export function useSyncInternal() { + const context = useContext(SyncInternalContext); + + if (!context) { + throw new Error('useSyncInternal must be used within a SyncInternalProvider'); + } + + return context; +} \ No newline at end of file diff --git a/src/components/app/hooks/useAllContextData.ts b/src/components/app/hooks/useAllContextData.ts new file mode 100644 index 00000000..6eb886a1 --- /dev/null +++ b/src/components/app/hooks/useAllContextData.ts @@ -0,0 +1,41 @@ +import { useContext, useMemo } from 'react'; +import { Awareness } from 'y-protocols/awareness'; + +import { AppContextType } from '@/components/app/app.hooks'; + +import { useAuthInternal } from '../contexts/AuthInternalContext'; +import { BusinessInternalContext } from '../contexts/BusinessInternalContext'; +import { SyncInternalContext } from '../contexts/SyncInternalContext'; + +// Hook to merge all internal context data into the complete AppContextType +// This maintains backward compatibility with the original AppContext interface +export function useAllContextData(awarenessMap: Record): AppContextType { + const authData = useAuthInternal(); + // Use useContext directly since these may not exist when no workspace ID + const syncData = useContext(SyncInternalContext); + const businessData = useContext(BusinessInternalContext); + + + return useMemo(() => ({ + // From AuthInternalContext + ...authData, + + // From SyncInternalContext (may be null if no workspace ID) + eventEmitter: syncData?.eventEmitter, + awarenessMap: awarenessMap, // Use the awareness map from business layer + + // From BusinessInternalContext - all business operations and state (may be null if no workspace ID) + ...businessData, + + // Override fallbacks for required methods when no workspace is selected + toView: businessData?.toView || (async () => { + // No-op when no workspace is selected + }), + loadViewMeta: businessData?.loadViewMeta || (async () => { + throw new Error('No workspace selected'); + }), + loadView: businessData?.loadView || (async () => { + throw new Error('No workspace selected'); + }), + }), [authData, syncData, businessData, awarenessMap]); +} \ No newline at end of file diff --git a/src/components/app/hooks/useDatabaseOperations.ts b/src/components/app/hooks/useDatabaseOperations.ts new file mode 100644 index 00000000..9cf61fe2 --- /dev/null +++ b/src/components/app/hooks/useDatabaseOperations.ts @@ -0,0 +1,286 @@ +import { useCallback, useRef } from 'react'; +import { PromptDatabaseConfiguration } from '@appflowyinc/ai-chat'; + +import { + DatabasePrompt, + DatabasePromptField, + DatabasePromptRow, + GenerateAISummaryRowPayload, + GenerateAITranslateRowPayload, + YDoc, + YDatabase, + YjsEditorKey, + YjsDatabaseKey, +} from '@/application/types'; +import { FieldType } from '@/application/database-yjs'; +import { getCellDataText } from '@/application/database-yjs/cell.parse'; +import { useAuthInternal } from '../contexts/AuthInternalContext'; + +// Hook for managing database-related operations +export function useDatabaseOperations( + loadView?: (id: string, isSubDocument?: boolean, loadAwareness?: boolean) => Promise, + createRowDoc?: (rowKey: string) => Promise +) { + const { service, currentWorkspaceId } = useAuthInternal(); + + const rowDocsRef = useRef>(new Map()); + + // Generate AI summary for row + const generateAISummaryForRow = useCallback( + async (payload: GenerateAISummaryRowPayload) => { + if (!currentWorkspaceId || !service) { + throw new Error('No workspace or service found'); + } + + try { + const res = await service?.generateAISummaryForRow(currentWorkspaceId, payload); + + return res; + } catch (e) { + return Promise.reject(e); + } + }, + [currentWorkspaceId, service] + ); + + // Generate AI translation for row + const generateAITranslateForRow = useCallback( + async (payload: GenerateAITranslateRowPayload) => { + if (!currentWorkspaceId || !service) { + throw new Error('No workspace or service found'); + } + + try { + const res = await service?.generateAITranslateForRow(currentWorkspaceId, payload); + + return res; + } catch (e) { + return Promise.reject(e); + } + }, + [currentWorkspaceId, service] + ); + + // Get rows from database view + const getRows = useCallback( + async (viewId: string) => { + if (!currentWorkspaceId) return []; + + const doc = await loadView?.(viewId); + const database = doc?.getMap(YjsEditorKey.data_section)?.get(YjsEditorKey.database) as YDatabase; + const view = database.get(YjsDatabaseKey.views).get(viewId); + const rowOrders = view?.get(YjsDatabaseKey.row_orders) || []; + + const rowPromises = rowOrders + .map(async (row: { id: string }) => { + if (rowDocsRef.current.has(row.id)) { + return rowDocsRef.current.get(row.id); + } + + if (!createRowDoc) return; + + const rowKey = `${doc?.guid}_rows_${row.id}`; + const rowDoc = await createRowDoc(rowKey); + + const rowSharedRoot = rowDoc?.getMap(YjsEditorKey.data_section); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = rowSharedRoot?.get(YjsEditorKey.database_row) as { [fieldId: string]: any }; + + const databaseRow = { + id: row.id, + data, + }; + + rowDocsRef.current.set(row.id, databaseRow); + + return databaseRow; + }) + .filter((p): p is Promise => p !== undefined); + + return Promise.all(rowPromises); + }, + [createRowDoc, currentWorkspaceId, loadView] + ); + + // Get fields from database view + const getFields = useCallback( + async (viewId: string) => { + if (!currentWorkspaceId) return []; + + const doc = await loadView?.(viewId); + const database = doc?.getMap(YjsEditorKey.data_section)?.get(YjsEditorKey.database) as YDatabase; + const fields = database.get(YjsDatabaseKey.fields); + + return Array.from(fields.entries()) + .map(([id, field]) => { + const isPrimary = field.get(YjsDatabaseKey.is_primary) || false; + const name = field.get(YjsDatabaseKey.name) || ''; + const fieldType = Number(field.get(YjsDatabaseKey.type)); + const isSelect = fieldType === FieldType.SingleSelect || fieldType === FieldType.MultiSelect; + + return { + id, + name, + fieldType, + isPrimary, + isSelect, + data: field, + }; + }) + .filter((f) => [FieldType.RichText, FieldType.SingleSelect, FieldType.MultiSelect].includes(f.fieldType)); + }, + [currentWorkspaceId, loadView] + ); + + // Load database prompts with fields + const loadDatabasePromptsWithFields = useCallback( + async ( + config: PromptDatabaseConfiguration, + fields: { + id: string; + name: string; + isPrimary: boolean; + fieldType: FieldType; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: any; + }[] + ) => { + const titleField = fields.find((field) => field.id === config.titleFieldId); + + if (!titleField) { + throw new Error('Cannot find title field'); + } + + const contentField = fields.find((field) => field.id === config.contentFieldId); + + if (!contentField) { + throw new Error('Cannot find content field'); + } + + const exampleField = config.exampleFieldId + ? fields.find((field) => field.id === config.exampleFieldId) + : undefined; + + const categoryField = config.categoryFieldId + ? fields.find((field) => field.id === config.categoryFieldId) + : undefined; + + const rows = await getRows(config.databaseViewId); + + return rows + .map((row) => { + const cells = row?.data.get(YjsDatabaseKey.cells); + + const nameCell = cells?.get(titleField.id); + const name = nameCell && titleField ? getCellDataText(nameCell, titleField.data) : ''; + + const contentCell = cells?.get(contentField.id); + const content = contentCell && contentField ? getCellDataText(contentCell, contentField.data) : ''; + + if (!name || !content) return null; + + const exampleCell = exampleField ? cells?.get(exampleField.id) : null; + const example = exampleCell && exampleField ? getCellDataText(exampleCell, exampleField.data) : ''; + + const categoryCell = categoryField ? cells?.get(categoryField.id) : null; + const category = categoryCell && categoryField ? getCellDataText(categoryCell, categoryField.data) : ''; + + return { + id: row.id, + name, + content, + example, + category, + }; + }) + .filter((prompt): prompt is DatabasePrompt => prompt !== null); + }, + [getRows] + ); + + // Load database prompts + const loadDatabasePrompts = useCallback( + async ( + config: PromptDatabaseConfiguration + ): Promise<{ + rawDatabasePrompts: DatabasePrompt[]; + fields: DatabasePromptField[]; + }> => { + const fields = await getFields(config.databaseViewId); + + const rawDatabasePrompts = await loadDatabasePromptsWithFields(config, fields); + + return { + rawDatabasePrompts, + fields: fields.map((field) => ({ + id: field.id, + name: field.name, + isPrimary: field.isPrimary, + isSelect: field.fieldType === FieldType.SingleSelect || field.fieldType === FieldType.MultiSelect, + })), + }; + }, + [getFields, loadDatabasePromptsWithFields] + ); + + // Test database prompt configuration + const testDatabasePromptConfig = useCallback( + async (viewId: string) => { + const fields = await getFields(viewId); + const titleField = fields.find((field) => field.isPrimary); + + if (!titleField) { + throw new Error('Cannot find primary field'); + } + + const contentField = fields.find( + (field) => + !field.isPrimary && + ((field.name.toLowerCase() === 'content' && field.fieldType === FieldType.RichText) || + field.fieldType === FieldType.RichText) + ); + + if (!contentField) { + throw new Error('Cannot find content field'); + } + + const exampleField = fields.find( + (field) => field.name.toLowerCase() === 'example' && field.fieldType === FieldType.RichText + ); + const categoryField = fields.find( + (field) => field.name.toLowerCase() === 'category' && field.fieldType === FieldType.RichText + ); + + const config: PromptDatabaseConfiguration = { + databaseViewId: viewId, + titleFieldId: titleField.id, + contentFieldId: contentField.id, + exampleFieldId: exampleField?.id || null, + categoryFieldId: categoryField?.id || null, + }; + + return { config, fields }; + }, + [getFields] + ); + + // Check if row document exists + const checkIfRowDocumentExists = useCallback( + async (documentId: string) => { + if (!service || !currentWorkspaceId) { + throw new Error('No service found'); + } + + return service?.checkIfCollabExists(currentWorkspaceId, documentId) || Promise.resolve(); + }, + [service, currentWorkspaceId] + ); + + return { + generateAISummaryForRow, + generateAITranslateForRow, + loadDatabasePrompts, + testDatabasePromptConfig, + checkIfRowDocumentExists, + }; +} \ No newline at end of file diff --git a/src/components/app/hooks/usePageOperations.ts b/src/components/app/hooks/usePageOperations.ts new file mode 100644 index 00000000..f0887f55 --- /dev/null +++ b/src/components/app/hooks/usePageOperations.ts @@ -0,0 +1,317 @@ +import { useCallback } from 'react'; + +import { + CreatePagePayload, + UpdatePagePayload, + ViewIconType, + CreateSpacePayload, + UpdateSpacePayload, + CreateFolderViewPayload, + View, +} from '@/application/types'; +import { findView } from '@/components/_shared/outline/utils'; +import { useAuthInternal } from '../contexts/AuthInternalContext'; + +// Hook for managing page and space operations +export function usePageOperations(outline?: View[], loadOutline?: (workspaceId: string, force?: boolean) => Promise) { + const { service, currentWorkspaceId } = useAuthInternal(); + + // Add a new page + const addPage = useCallback( + async (parentViewId: string, payload: CreatePagePayload) => { + if (!currentWorkspaceId || !service) { + throw new Error('No workspace or service found'); + } + + try { + const viewId = await service?.addAppPage(currentWorkspaceId, parentViewId, payload); + + await loadOutline?.(currentWorkspaceId, false); + return viewId; + } catch (e) { + return Promise.reject(e); + } + }, + [currentWorkspaceId, service, loadOutline] + ); + + // Delete a page (move to trash) + const deletePage = useCallback( + async (id: string, loadTrash?: (workspaceId: string) => Promise) => { + if (!currentWorkspaceId || !service) { + throw new Error('No workspace or service found'); + } + + try { + await service?.moveToTrash(currentWorkspaceId, id); + void loadTrash?.(currentWorkspaceId); + void loadOutline?.(currentWorkspaceId, false); + return; + } catch (e) { + return Promise.reject(e); + } + }, + [currentWorkspaceId, service, loadOutline] + ); + + // Update page + const updatePage = useCallback( + async (viewId: string, payload: UpdatePagePayload) => { + if (!currentWorkspaceId || !service) { + throw new Error('No workspace or service found'); + } + + try { + await service?.updateAppPage(currentWorkspaceId, viewId, payload); + await loadOutline?.(currentWorkspaceId, false); + return; + } catch (e) { + return Promise.reject(e); + } + }, + [currentWorkspaceId, service, loadOutline] + ); + + // Update page icon + const updatePageIcon = useCallback( + async (viewId: string, icon: { ty: ViewIconType; value: string }) => { + if (!currentWorkspaceId || !service) { + throw new Error('No workspace or service found'); + } + + try { + await service?.updateAppPageIcon(currentWorkspaceId, viewId, icon); + return; + } catch (e) { + return Promise.reject(e); + } + }, + [currentWorkspaceId, service] + ); + + // Update page name + const updatePageName = useCallback( + async (viewId: string, name: string) => { + if (!currentWorkspaceId || !service) { + throw new Error('No workspace or service found'); + } + + try { + await service?.updateAppPageName(currentWorkspaceId, viewId, name); + return; + } catch (e) { + return Promise.reject(e); + } + }, + [currentWorkspaceId, service] + ); + + // Move page + const movePage = useCallback( + async (viewId: string, parentId: string, prevViewId?: string) => { + if (!currentWorkspaceId || !service) { + throw new Error('No workspace or service found'); + } + + try { + const lastChild = findView(outline || [], parentId)?.children?.slice(-1)[0]; + const prevId = prevViewId || lastChild?.view_id; + + await service?.movePage(currentWorkspaceId, viewId, parentId, prevId); + void loadOutline?.(currentWorkspaceId, false); + return; + } catch (e) { + return Promise.reject(e); + } + }, + [currentWorkspaceId, service, outline, loadOutline] + ); + + // Delete from trash permanently + const deleteTrash = useCallback( + async (viewId?: string) => { + if (!currentWorkspaceId || !service) { + throw new Error('No workspace or service found'); + } + + try { + await service?.deleteTrash(currentWorkspaceId, viewId); + void loadOutline?.(currentWorkspaceId, false); + return; + } catch (e) { + return Promise.reject(e); + } + }, + [currentWorkspaceId, service, loadOutline] + ); + + // Restore page from trash + const restorePage = useCallback( + async (viewId?: string) => { + if (!currentWorkspaceId || !service) { + throw new Error('No workspace or service found'); + } + + try { + await service?.restoreFromTrash(currentWorkspaceId, viewId); + void loadOutline?.(currentWorkspaceId, false); + return; + } catch (e) { + return Promise.reject(e); + } + }, + [currentWorkspaceId, service, loadOutline] + ); + + // Create space + const createSpace = useCallback( + async (payload: CreateSpacePayload) => { + if (!currentWorkspaceId || !service) { + throw new Error('No workspace or service found'); + } + + try { + const res = await service?.createSpace(currentWorkspaceId, payload); + + void loadOutline?.(currentWorkspaceId, false); + return res; + } catch (e) { + return Promise.reject(e); + } + }, + [currentWorkspaceId, service, loadOutline] + ); + + // Update space + const updateSpace = useCallback( + async (payload: UpdateSpacePayload) => { + if (!currentWorkspaceId || !service) { + throw new Error('No workspace or service found'); + } + + try { + const res = await service?.updateSpace(currentWorkspaceId, payload); + + void loadOutline?.(currentWorkspaceId, false); + return res; + } catch (e) { + return Promise.reject(e); + } + }, + [currentWorkspaceId, service, loadOutline] + ); + + // Create folder view + const createFolderView = useCallback( + async (payload: CreateFolderViewPayload) => { + if (!currentWorkspaceId || !service) { + throw new Error('No workspace or service found'); + } + + try { + const res = await service?.createFolderView(currentWorkspaceId, payload); + + await loadOutline?.(currentWorkspaceId, false); + return res; + } catch (e) { + return Promise.reject(e); + } + }, + [currentWorkspaceId, loadOutline, service] + ); + + // Upload file + const uploadFile = useCallback( + async (viewId: string, file: File, onProgress?: (n: number) => void) => { + if (!currentWorkspaceId || !service) { + throw new Error('No workspace or service found'); + } + + try { + const res = await service?.uploadFile(currentWorkspaceId, viewId, file, onProgress); + + return res; + } catch (e) { + return Promise.reject(e); + } + }, + [currentWorkspaceId, service] + ); + + // Get subscriptions + 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]); + + // Publish view + const publish = useCallback( + async (view: View, publishName?: string, visibleViewIds?: string[]) => { + if (!service || !currentWorkspaceId) return; + const viewId = view.view_id; + + await service?.publishView(currentWorkspaceId, viewId, { + publish_name: publishName, + visible_database_view_ids: visibleViewIds, + }); + await loadOutline?.(currentWorkspaceId, false); + }, + [currentWorkspaceId, loadOutline, service] + ); + + // Unpublish view + const unpublish = useCallback( + async (viewId: string) => { + if (!service || !currentWorkspaceId) return; + await service?.unpublishView(currentWorkspaceId, viewId); + await loadOutline?.(currentWorkspaceId, false); + }, + [currentWorkspaceId, loadOutline, service] + ); + + // Create orphaned view + const createOrphanedView = useCallback( + async (payload: { document_id: string }) => { + if (!currentWorkspaceId || !service) { + throw new Error('No workspace or service found'); + } + + try { + const res = await service?.createOrphanedView(currentWorkspaceId, payload); + + return res; + } catch (e) { + return Promise.reject(e); + } + }, + [currentWorkspaceId, service] + ); + + return { + addPage, + deletePage, + updatePage, + updatePageIcon, + updatePageName, + movePage, + deleteTrash, + restorePage, + createSpace, + updateSpace, + createFolderView, + uploadFile, + getSubscriptions, + publish, + unpublish, + createOrphanedView, + }; +} \ No newline at end of file diff --git a/src/components/app/hooks/useViewOperations.ts b/src/components/app/hooks/useViewOperations.ts new file mode 100644 index 00000000..bef3ed7c --- /dev/null +++ b/src/components/app/hooks/useViewOperations.ts @@ -0,0 +1,234 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Awareness } from 'y-protocols/awareness'; + +import { openCollabDB } from '@/application/db'; +import { Types, View, ViewLayout, YDoc, YjsEditorKey } from '@/application/types'; +import { findView } from '@/components/_shared/outline/utils'; + +import { useAuthInternal } from '../contexts/AuthInternalContext'; +import { useSyncInternal } from '../contexts/SyncInternalContext'; + +// Hook for managing view-related operations +export function useViewOperations() { + const { service, currentWorkspaceId, userWorkspaceInfo } = useAuthInternal(); + const { registerSyncContext } = useSyncInternal(); + const navigate = useNavigate(); + + const [awarenessMap, setAwarenessMap] = useState>({}); + const workspaceDatabaseDocMapRef = useRef>(new Map()); + const createdRowKeys = useRef([]); + + const databaseStorageId = userWorkspaceInfo?.selectedWorkspace?.databaseStorageId; + + // Register workspace database document for sync + const registerWorkspaceDatabaseDoc = useCallback( + async (workspaceId: string, databaseStorageId: string) => { + const doc = await openCollabDB(databaseStorageId); + + doc.guid = databaseStorageId; + const { doc: workspaceDatabaseDoc } = registerSyncContext({ doc, collabType: Types.WorkspaceDatabase }); + + workspaceDatabaseDocMapRef.current.clear(); + workspaceDatabaseDocMapRef.current.set(workspaceId, workspaceDatabaseDoc); + }, + [registerSyncContext] + ); + + // Get database ID for a view + const getDatabaseId = useCallback( + async (id: string) => { + if (!currentWorkspaceId) return; + + if (databaseStorageId && !workspaceDatabaseDocMapRef.current.has(currentWorkspaceId)) { + await registerWorkspaceDatabaseDoc(currentWorkspaceId, databaseStorageId); + } + + return new Promise((resolve) => { + const sharedRoot = workspaceDatabaseDocMapRef.current.get(currentWorkspaceId)?.getMap(YjsEditorKey.data_section); + const observeEvent = () => { + const databases = sharedRoot?.toJSON()?.databases; + + const databaseId = databases?.find((database: { database_id: string; views: string[] }) => + database.views.find((view) => view === id) + )?.database_id; + + if (databaseId) { + resolve(databaseId); + } + }; + + observeEvent(); + sharedRoot?.observeDeep(observeEvent); + + return () => { + sharedRoot?.unobserveDeep(observeEvent); + }; + }); + }, + [currentWorkspaceId, databaseStorageId, registerWorkspaceDatabaseDoc] + ); + + // Load view document + const loadView = useCallback( + async (id: string, _isSubDocument = false, loadAwareness = false, outline?: View[]) => { + try { + if (!service || !currentWorkspaceId) { + throw new Error('Service or workspace not found'); + } + + const res = await service?.getPageDoc(currentWorkspaceId, id); + + if (!res) { + throw new Error('View not found'); + } + + if (loadAwareness) { + // Add recent pages when view is loaded + void (async () => { + try { + await service.addRecentPages(currentWorkspaceId, [id]); + } catch (e) { + console.error(e); + } + })(); + } + + const view = findView(outline || [], id); + + const collabType = view + ? view?.layout === ViewLayout.Document + ? Types.Document + : Types.Database + : Types.Document; + + if (collabType === Types.Document) { + let awareness: Awareness | undefined; + + if (loadAwareness) { + setAwarenessMap((prev) => { + if (prev[id]) { + awareness = prev[id]; + return prev; + } + + awareness = new Awareness(res); + return { ...prev, [id]: awareness }; + }); + } + + const { doc } = registerSyncContext({ doc: res, collabType, awareness }); + + return doc; + } + + const databaseId = await getDatabaseId(id); + + if (!databaseId) { + throw new Error('Database not found'); + } + + res.guid = databaseId; + const { doc } = registerSyncContext({ doc: res, collabType }); + + return doc; + + } catch (e) { + return Promise.reject(e); + } + }, + [service, currentWorkspaceId, getDatabaseId, registerSyncContext] // Add dependencies to prevent re-creation of functions + ); + + + // Create row document + const createRowDoc = useCallback( + async (rowKey: string): Promise => { + if (!currentWorkspaceId || !service) { + throw new Error('Failed to create row doc'); + } + + try { + const doc = await service?.createRowDoc(rowKey); + + if (!doc) { + throw new Error('Failed to create row doc'); + } + + const rowId = rowKey.split('_rows_')[1]; + + if (!rowId) { + throw new Error('Failed to create row doc'); + } + + doc.guid = rowId; + const syncContext = registerSyncContext({ + doc, + collabType: Types.DatabaseRow, + }); + + createdRowKeys.current.push(rowKey); + return syncContext.doc; + } catch (e) { + return Promise.reject(e); + } + }, + [currentWorkspaceId, service, registerSyncContext] + ); + + // Navigate to view + const toView = useCallback( + async (viewId: string, blockId?: string, keepSearch?: boolean, loadViewMeta?: (viewId: string) => Promise) => { + let url = `/app/${currentWorkspaceId}/${viewId}`; + const view = await loadViewMeta?.(viewId); + + const searchParams = new URLSearchParams(keepSearch ? window.location.search : undefined); + + if (blockId && view) { + switch (view.layout) { + case ViewLayout.Document: + searchParams.set('blockId', blockId); + break; + case ViewLayout.Grid: + case ViewLayout.Board: + case ViewLayout.Calendar: + searchParams.set('r', blockId); + break; + default: + break; + } + } + + if (searchParams.toString()) { + url += `?${searchParams.toString()}`; + } + + navigate(url); + }, + [currentWorkspaceId, navigate] + ); + + // Clean up created row documents when view changes + useEffect(() => { + const rowKeys = createdRowKeys.current; + + createdRowKeys.current = []; + + if (!rowKeys.length) return; + + rowKeys.forEach((rowKey) => { + try { + service?.deleteRowDoc(rowKey); + } catch (e) { + console.error(e); + } + }); + }, [service, currentWorkspaceId]); // Changed from viewId to currentWorkspaceId + + return { + loadView, + createRowDoc, + toView, + awarenessMap, + }; +} \ No newline at end of file diff --git a/src/components/app/hooks/useWorkspaceData.ts b/src/components/app/hooks/useWorkspaceData.ts new file mode 100644 index 00000000..fb8062d6 --- /dev/null +++ b/src/components/app/hooks/useWorkspaceData.ts @@ -0,0 +1,278 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { sortBy, uniqBy } from 'lodash-es'; +import { validate as uuidValidate } from 'uuid'; + +import { View, DatabaseRelations, UIVariant, ViewLayout, MentionablePerson } from '@/application/types'; +import { findView, findViewByLayout } from '@/components/_shared/outline/utils'; +import { createDeduplicatedNoArgsRequest } from '@/utils/deduplicateRequest'; +import { useAuthInternal } from '../contexts/AuthInternalContext'; + +const USER_NO_ACCESS_CODE = [1024, 1012]; + +// Hook for managing workspace data (outline, favorites, recent, trash) +export function useWorkspaceData() { + const { service, currentWorkspaceId, userWorkspaceInfo } = useAuthInternal(); + const navigate = useNavigate(); + + const [outline, setOutline] = useState(); + const stableOutlineRef = useRef([]); + const [favoriteViews, setFavoriteViews] = useState(); + const [recentViews, setRecentViews] = useState(); + const [trashList, setTrashList] = useState(); + const [workspaceDatabases, setWorkspaceDatabases] = useState(undefined); + const [requestAccessOpened, setRequestAccessOpened] = useState(false); + + const mentionableUsersRef = useRef([]); + + // Load application outline + const loadOutline = useCallback( + async (workspaceId: string, force = true) => { + if (!service) return; + try { + const res = await service?.getAppOutline(workspaceId); + + if (!res) { + throw new Error('App outline not found'); + } + + stableOutlineRef.current = res; + setOutline(res); + + + if (!force) return; + + const firstView = findViewByLayout(res, [ + ViewLayout.Document, + ViewLayout.Board, + ViewLayout.Grid, + ViewLayout.Calendar, + ]); + + try { + await service.openWorkspace(workspaceId); + const wId = window.location.pathname.split('/')[2]; + const pageId = window.location.pathname.split('/')[3]; + const search = window.location.search; + + // Skip /app/trash and /app/*other-pages + if (wId && !uuidValidate(wId)) { + return; + } + + // Skip /app/:workspaceId/:pageId + if (pageId && uuidValidate(pageId) && wId && uuidValidate(wId) && wId === workspaceId) { + return; + } + + const lastViewId = localStorage.getItem('last_view_id'); + + if (lastViewId && findView(res, lastViewId)) { + navigate(`/app/${workspaceId}/${lastViewId}${search}`); + } else if (firstView) { + navigate(`/app/${workspaceId}/${firstView.view_id}${search}`); + } + } catch (e) { + // Do nothing + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (e: any) { + console.error('App outline not found'); + if (USER_NO_ACCESS_CODE.includes(e.code)) { + setRequestAccessOpened(true); + return; + } + } + }, + [navigate, service] + ); + + // Load favorite views + const loadFavoriteViews = useCallback(async () => { + if (!service || !currentWorkspaceId) return; + try { + const res = await service?.getAppFavorites(currentWorkspaceId); + + if (!res) { + throw new Error('Favorite views not found'); + } + + setFavoriteViews(res); + return res; + } catch (e) { + console.error('Favorite views not found'); + } + }, [currentWorkspaceId, service]); + + // Load recent views + const loadRecentViews = useCallback(async () => { + if (!service || !currentWorkspaceId) return; + try { + const res = await service?.getAppRecent(currentWorkspaceId); + + if (!res) { + throw new Error('Recent views not found'); + } + + const views = uniqBy(res, 'view_id') as unknown as View[]; + + setRecentViews( + views.filter((item: View) => { + return !item.extra?.is_space && findView(outline || [], item.view_id); + }) + ); + return views; + } catch (e) { + console.error('Recent views not found'); + } + }, [currentWorkspaceId, service, outline]); + + // Load trash list + const loadTrash = useCallback( + async (currentWorkspaceId: string) => { + if (!service) return; + try { + const res = await service?.getAppTrash(currentWorkspaceId); + + if (!res) { + throw new Error('App trash not found'); + } + + setTrashList(sortBy(uniqBy(res, 'view_id') as unknown as View[], 'last_edited_time').reverse()); + } catch (e) { + return Promise.reject('App trash not found'); + } + }, + [service] + ); + + // Load database relations + const loadDatabaseRelations = useCallback(async () => { + if (!currentWorkspaceId || !service) { + return; + } + + const selectedWorkspace = userWorkspaceInfo?.selectedWorkspace; + + if (!selectedWorkspace) return; + + try { + const res = await service?.getAppDatabaseViewRelations(currentWorkspaceId, selectedWorkspace.databaseStorageId); + + setWorkspaceDatabases(res); + return res; + } catch (e) { + console.error(e); + } + }, [currentWorkspaceId, service, userWorkspaceInfo?.selectedWorkspace]); + + // Load views based on variant + const loadViews = useCallback( + async (variant?: UIVariant) => { + if (!variant) { + return outline || []; + } + + if (variant === UIVariant.Favorite) { + if (favoriteViews && favoriteViews.length > 0) { + return favoriteViews || []; + } else { + return loadFavoriteViews(); + } + } + + if (variant === UIVariant.Recent) { + if (recentViews && recentViews.length > 0) { + return recentViews || []; + } else { + return loadRecentViews(); + } + } + + return []; + }, + [favoriteViews, loadFavoriteViews, loadRecentViews, outline, recentViews] + ); + + // Load mentionable users + const _loadMentionableUsers = useCallback(async () => { + if (!currentWorkspaceId || !service) { + throw new Error('No workspace or service found'); + } + + try { + const res = await service?.getMentionableUsers(currentWorkspaceId); + + if (res) { + mentionableUsersRef.current = res; + } + + return res || []; + } catch (e) { + return Promise.reject(e); + } + }, [currentWorkspaceId, service]); + + const loadMentionableUsers = useMemo(() => { + return createDeduplicatedNoArgsRequest(_loadMentionableUsers); + }, [_loadMentionableUsers]); + + // Get mention user + const getMentionUser = useCallback( + async (uuid: string) => { + if (mentionableUsersRef.current.length > 0) { + const user = mentionableUsersRef.current.find((user) => user.person_id === uuid); + + if (user) { + return user; + } + } + + try { + const res = await loadMentionableUsers(); + + return res.find((user: MentionablePerson) => user.person_id === uuid); + } catch (e) { + return Promise.reject(e); + } + }, + [loadMentionableUsers] + ); + + // Load data when workspace changes + useEffect(() => { + if (!currentWorkspaceId) return; + void loadOutline(currentWorkspaceId); + void (async () => { + try { + await loadTrash(currentWorkspaceId); + } catch (e) { + console.error(e); + } + })(); + }, [loadOutline, currentWorkspaceId, loadTrash]); + + // Load database relations + useEffect(() => { + void loadDatabaseRelations(); + }, [loadDatabaseRelations]); + + return { + outline, + favoriteViews, + recentViews, + trashList, + workspaceDatabases, + requestAccessOpened, + loadOutline, + loadFavoriteViews, + loadRecentViews, + loadTrash, + loadDatabaseRelations, + loadViews, + getMentionUser, + loadMentionableUsers, + stableOutlineRef, + }; +} diff --git a/src/components/app/layers/AppAuthLayer.tsx b/src/components/app/layers/AppAuthLayer.tsx new file mode 100644 index 00000000..dd848c40 --- /dev/null +++ b/src/components/app/layers/AppAuthLayer.tsx @@ -0,0 +1,92 @@ +import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; + +import { invalidToken } from '@/application/session/token'; +import { UserWorkspaceInfo } from '@/application/types'; +import { AFConfigContext, useService } from '@/components/main/app.hooks'; +import { AuthInternalContext, AuthInternalContextType } from '../contexts/AuthInternalContext'; + +interface AppAuthLayerProps { + children: React.ReactNode; +} + +// First layer: Authentication and service initialization +// Handles user authentication, workspace info, and service setup +// Does not depend on workspace ID - establishes basic authentication context +export const AppAuthLayer: React.FC = ({ children }) => { + const isAuthenticated = useContext(AFConfigContext)?.isAuthenticated; + const service = useService(); + const navigate = useNavigate(); + const params = useParams(); + + const [userWorkspaceInfo, setUserWorkspaceInfo] = useState(undefined); + + // Calculate current workspace ID from URL params or user info + const currentWorkspaceId = useMemo( + () => params.workspaceId || userWorkspaceInfo?.selectedWorkspace.id, + [params.workspaceId, userWorkspaceInfo?.selectedWorkspace.id] + ); + + // Handle user logout + const logout = useCallback(() => { + invalidToken(); + navigate(`/login?redirectTo=${encodeURIComponent(window.location.href)}`); + }, [navigate]); + + // Load user workspace information + const loadUserWorkspaceInfo = useCallback(async () => { + if (!service) return; + try { + const res = await service?.getUserWorkspaceInfo(); + + setUserWorkspaceInfo(res); + return res; + } catch (e) { + console.error(e); + } + }, [service]); + + // Handle workspace change + const onChangeWorkspace = useCallback( + async (workspaceId: string) => { + if (!service) return; + if (userWorkspaceInfo && !userWorkspaceInfo.workspaces.some((w) => w.id === workspaceId)) { + window.location.href = `/app/${workspaceId}`; + return; + } + + await service?.openWorkspace(workspaceId); + await loadUserWorkspaceInfo(); + localStorage.removeItem('last_view_id'); + navigate(`/app/${workspaceId}`); + }, + [navigate, service, userWorkspaceInfo, loadUserWorkspaceInfo] + ); + + // If the user is not authenticated, log out the user + useEffect(() => { + if (!isAuthenticated) { + logout(); + } + }, [isAuthenticated, logout]); + + // Load user workspace info on mount + useEffect(() => { + void loadUserWorkspaceInfo(); + }, [loadUserWorkspaceInfo]); + + // Context value for authentication layer + const authContextValue: AuthInternalContextType = useMemo(() => ({ + service, + userWorkspaceInfo, + currentWorkspaceId, + isAuthenticated: !!isAuthenticated, + onChangeWorkspace, + }), [service, userWorkspaceInfo, currentWorkspaceId, isAuthenticated, onChangeWorkspace]); + + return ( + + {children} + + ); +}; \ No newline at end of file diff --git a/src/components/app/layers/AppBusinessLayer.tsx b/src/components/app/layers/AppBusinessLayer.tsx new file mode 100644 index 00000000..1ca4a85a --- /dev/null +++ b/src/components/app/layers/AppBusinessLayer.tsx @@ -0,0 +1,330 @@ +import { debounce } from 'lodash-es'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { validate as uuidValidate } from 'uuid'; + +import { TextCount, Types, View } from '@/application/types'; +import { findAncestors, findView } from '@/components/_shared/outline/utils'; + +import { AppContextConsumer } from '../components/AppContextConsumer'; +import { useAuthInternal } from '../contexts/AuthInternalContext'; +import { BusinessInternalContext, BusinessInternalContextType } from '../contexts/BusinessInternalContext'; +import { useSyncInternal } from '../contexts/SyncInternalContext'; +import { useDatabaseOperations } from '../hooks/useDatabaseOperations'; +import { usePageOperations } from '../hooks/usePageOperations'; +import { useViewOperations } from '../hooks/useViewOperations'; +import { useWorkspaceData } from '../hooks/useWorkspaceData'; + +interface AppBusinessLayerProps { + children: React.ReactNode; +} + +// Third layer: Business logic operations +// Handles all business operations like outline management, page operations, database operations +// Depends on workspace ID and sync context from previous layers +export const AppBusinessLayer: React.FC = ({ children }) => { + const { currentWorkspaceId } = useAuthInternal(); + const { lastUpdatedCollab } = useSyncInternal(); + const params = useParams(); + + // UI state + const [rendered, setRendered] = useState(false); + const [openModalViewId, setOpenModalViewId] = useState(undefined); + const wordCountRef = useRef>({}); + + // Calculate view ID from params + const viewId = useMemo(() => { + const id = params.viewId; + + if (id && !uuidValidate(id)) return; + return id; + }, [params.viewId]); + + // Initialize workspace data management + const { + outline, + favoriteViews, + recentViews, + trashList, + workspaceDatabases, + requestAccessOpened, + loadOutline, + loadFavoriteViews, + loadRecentViews, + loadTrash, + loadDatabaseRelations, + loadViews, + getMentionUser, + loadMentionableUsers, + stableOutlineRef, + } = useWorkspaceData(); + + // Initialize view operations + const { loadView, createRowDoc, toView, awarenessMap } = useViewOperations(); + + // Initialize page operations + const pageOperations = usePageOperations(outline, loadOutline); + + // Initialize database operations + const databaseOperations = useDatabaseOperations(loadView, createRowDoc); + + // Check if current view has been deleted + const viewHasBeenDeleted = useMemo(() => { + if (!viewId) return false; + return trashList?.some((v) => v.view_id === viewId); + }, [trashList, viewId]); + + // Check if current view is not found + const viewNotFound = useMemo(() => { + if (!viewId || !outline) return false; + return !findView(outline, viewId); + }, [outline, viewId]); + + // Calculate breadcrumbs based on current view + const originalCrumbs = useMemo(() => { + if (!outline || !viewId) return []; + return findAncestors(outline, viewId) || []; + }, [outline, viewId]); + + const [breadcrumbs, setBreadcrumbs] = useState(originalCrumbs); + + // Update breadcrumbs when original crumbs change + useEffect(() => { + setBreadcrumbs(originalCrumbs); + }, [originalCrumbs]); + + // Handle breadcrumb manipulation + const appendBreadcrumb = useCallback((view?: View) => { + setBreadcrumbs((prev) => { + if (!view) { + return prev.slice(0, -1); + } + + const index = prev.findIndex((v) => v.view_id === view.view_id); + + if (index === -1) { + return [...prev, view]; + } + + const rest = prev.slice(0, index); + + return [...rest, view]; + }); + }, []); + + // Load view metadata + const loadViewMeta = useCallback( + async (viewId: string, callback?: (meta: View) => void) => { + const view = findView(outline || [], viewId); + const deletedView = trashList?.find((v) => v.view_id === viewId); + + if (deletedView) { + return Promise.reject(deletedView); + } + + if (!view) { + return Promise.reject('View not found'); + } + + if (callback) { + callback({ + ...view, + database_relations: workspaceDatabases, + }); + } + + return { + ...view, + database_relations: workspaceDatabases, + }; + }, + [outline, trashList, workspaceDatabases] + ); + + // Word count management + const setWordCount = useCallback((viewId: string, count: TextCount) => { + wordCountRef.current[viewId] = count; + }, []); + + // UI callbacks + const onRendered = useCallback(() => { + setRendered(true); + }, []); + + const openPageModal = useCallback((viewId: string) => { + setOpenModalViewId(viewId); + }, []); + + // Refresh outline + const refreshOutline = useCallback(async () => { + if (!currentWorkspaceId) return; + await loadOutline(currentWorkspaceId, false); + console.log(`Refreshed outline for workspace ${currentWorkspaceId}`); + }, [currentWorkspaceId, loadOutline]); + + // Debounced outline refresh for folder updates + const debouncedRefreshOutline = useMemo( + () => + debounce(() => { + void refreshOutline(); + }, 1000), + [refreshOutline] + ); + + // Refresh outline when a folder collab update is detected + useEffect(() => { + if (lastUpdatedCollab?.collabType === Types.Folder) { + return debouncedRefreshOutline(); + } + }, [lastUpdatedCollab, debouncedRefreshOutline]); + + // Load mentionable users on mount + useEffect(() => { + void loadMentionableUsers(); + }, [loadMentionableUsers]); + + // Enhanced toView that uses loadViewMeta + const enhancedToView = useCallback( + async (viewId: string, blockId?: string, keepSearch?: boolean) => { + return toView(viewId, blockId, keepSearch, loadViewMeta); + }, + [toView, loadViewMeta] + ); + + // Enhanced loadView with outline context + const enhancedLoadView = useCallback( + async (id: string, isSubDocument = false, loadAwareness = false) => { + return loadView(id, isSubDocument, loadAwareness, stableOutlineRef.current); + }, + [loadView, stableOutlineRef] + ); + + // Enhanced deletePage with loadTrash + const enhancedDeletePage = useCallback( + async (viewId: string) => { + return pageOperations.deletePage(viewId, loadTrash); + }, + [pageOperations, loadTrash] + ); + + // Business context value + const businessContextValue: BusinessInternalContextType = useMemo( + () => ({ + // View and navigation + viewId, + toView: enhancedToView, + loadViewMeta, + loadView: enhancedLoadView, + createRowDoc, + + // Outline and hierarchy + outline, + breadcrumbs, + appendBreadcrumb, + refreshOutline, + + // Data views + favoriteViews, + recentViews, + trashList, + loadFavoriteViews, + loadRecentViews, + loadTrash, + loadViews, + + // Page operations + addPage: pageOperations.addPage, + deletePage: enhancedDeletePage, + updatePage: pageOperations.updatePage, + updatePageIcon: pageOperations.updatePageIcon, + updatePageName: pageOperations.updatePageName, + movePage: pageOperations.movePage, + + // Trash operations + deleteTrash: pageOperations.deleteTrash, + restorePage: pageOperations.restorePage, + + // Space operations + createSpace: pageOperations.createSpace, + updateSpace: pageOperations.updateSpace, + createFolderView: pageOperations.createFolderView, + + // File operations + uploadFile: pageOperations.uploadFile, + + // Publishing + getSubscriptions: pageOperations.getSubscriptions, + publish: pageOperations.publish, + unpublish: pageOperations.unpublish, + + // AI operations + generateAISummaryForRow: databaseOperations.generateAISummaryForRow, + generateAITranslateForRow: databaseOperations.generateAITranslateForRow, + + // Database operations + loadDatabaseRelations, + createOrphanedView: pageOperations.createOrphanedView, + loadDatabasePrompts: databaseOperations.loadDatabasePrompts, + testDatabasePromptConfig: databaseOperations.testDatabasePromptConfig, + checkIfRowDocumentExists: databaseOperations.checkIfRowDocumentExists, + + // User operations + getMentionUser, + + // UI state + rendered, + onRendered, + notFound: viewNotFound, + viewHasBeenDeleted, + openPageModal, + openPageModalViewId: openModalViewId, + + // Word count + wordCount: wordCountRef.current, + setWordCount, + }), + [ + viewId, + enhancedToView, + loadViewMeta, + enhancedLoadView, + createRowDoc, + outline, + breadcrumbs, + appendBreadcrumb, + refreshOutline, + favoriteViews, + recentViews, + trashList, + loadFavoriteViews, + loadRecentViews, + loadTrash, + loadViews, + pageOperations, + enhancedDeletePage, + loadDatabaseRelations, + databaseOperations, + getMentionUser, + rendered, + onRendered, + viewNotFound, + viewHasBeenDeleted, + openPageModal, + openModalViewId, + setWordCount, + ] + ); + + return ( + + + {children} + + + ); +}; diff --git a/src/components/app/layers/AppSyncLayer.tsx b/src/components/app/layers/AppSyncLayer.tsx new file mode 100644 index 00000000..d7c5b55d --- /dev/null +++ b/src/components/app/layers/AppSyncLayer.tsx @@ -0,0 +1,138 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import EventEmitter from 'events'; +import { Awareness } from 'y-protocols/awareness'; + +import { APP_EVENTS } from '@/application/constants'; +import { getTokenParsed } from '@/application/session/token'; +import { db } from '@/application/db'; +import { useAppflowyWebSocket, useBroadcastChannel, useSync } from '@/components/ws'; +import { SyncInternalContext, SyncInternalContextType } from '../contexts/SyncInternalContext'; +import { useAuthInternal } from '../contexts/AuthInternalContext'; +import { notification } from '@/proto/messages'; + +interface AppSyncLayerProps { + children: React.ReactNode; +} + +// Second layer: WebSocket connection and synchronization +// Handles WebSocket connection, broadcast channel, sync context, and event management +// Depends on workspace ID and service from auth layer +export const AppSyncLayer: React.FC = ({ children }) => { + const { service, currentWorkspaceId, isAuthenticated } = useAuthInternal(); + const [awarenessMap] = useState>({}); + const eventEmitterRef = useRef(new EventEmitter()); + + // Initialize WebSocket connection - currentWorkspaceId and service are guaranteed to exist when this component renders + const webSocket = useAppflowyWebSocket({ + workspaceId: currentWorkspaceId!, + clientId: service!.getClientId(), + deviceId: service!.getDeviceId(), + }); + + // Initialize broadcast channel for multi-tab communication + const broadcastChannel = useBroadcastChannel(`workspace:${currentWorkspaceId!}`); + + // Initialize sync context for collaborative editing + const { registerSyncContext, lastUpdatedCollab } = useSync(webSocket, broadcastChannel, eventEmitterRef.current); + + // Handle WebSocket reconnection + const reconnectWebSocket = useCallback(() => { + webSocket.reconnect(); + }, [webSocket]); + + // Set up WebSocket reconnection event listener + useEffect(() => { + const currentEventEmitter = eventEmitterRef.current; + + currentEventEmitter.on(APP_EVENTS.RECONNECT_WEBSOCKET, reconnectWebSocket); + + return () => { + currentEventEmitter.off(APP_EVENTS.RECONNECT_WEBSOCKET, reconnectWebSocket); + }; + }, [reconnectWebSocket]); + + // Emit WebSocket status changes + useEffect(() => { + const currentEventEmitter = eventEmitterRef.current; + + currentEventEmitter.emit(APP_EVENTS.WEBSOCKET_STATUS, webSocket.readyState); + }, [webSocket]); + + // Handle user profile change notifications + // This provides automatic UI updates when user profile changes occur via WebSocket. + // + // Notification Flow: + // 1. Server sends WorkspaceNotification with profileChange + // 2. useSync processes notification from WebSocket OR BroadcastChannel + // 3. useSync emits USER_PROFILE_CHANGED event via eventEmitter + // 4. This handler receives the event and updates local database + // 5. useLiveQuery in AppConfig detects database change + // 6. All components using currentUser automatically re-render with new data + // + // Multi-tab Support: + // - Active tab: WebSocket → useSync → this handler → database update + // - Other tabs: BroadcastChannel → useSync → this handler → database update + // - Result: All tabs show updated profile simultaneously + // + // UI Components that auto-update: + // - Workspace dropdown (shows email) + // - Collaboration user lists (shows names/avatars) + // - Any component using useCurrentUser() hook + useEffect(() => { + if (!isAuthenticated || !currentWorkspaceId) return; + + const currentEventEmitter = eventEmitterRef.current; + + const handleUserProfileChange = async (profileChange: notification.IUserProfileChange) => { + try { + console.log('Received user profile change notification:', profileChange); + + // Extract user ID from authentication token + const token = getTokenParsed(); + const userId = token?.user?.id; + + if (!userId) { + console.warn('No user ID found for profile update'); + return; + } + + // Retrieve current user data from local database cache + const existingUser = await db.users.get(userId); + + if (!existingUser) { + console.warn('No existing user found in database for profile update'); + return; + } + + const updatedUser = service?.getCurrentUser(); + + console.log('User profile updated in database:', updatedUser); + } catch (error) { + console.error('Failed to handle user profile change notification:', error); + } + }; + + // Subscribe to user profile change notifications from the event system + currentEventEmitter.on(APP_EVENTS.USER_PROFILE_CHANGED, handleUserProfileChange); + + // Cleanup subscription when component unmounts or dependencies change + return () => { + currentEventEmitter.off(APP_EVENTS.USER_PROFILE_CHANGED, handleUserProfileChange); + }; + }, [isAuthenticated, currentWorkspaceId, service]); + + // Context value for synchronization layer + const syncContextValue: SyncInternalContextType = useMemo( + () => ({ + webSocket, + broadcastChannel, + registerSyncContext, + eventEmitter: eventEmitterRef.current, + awarenessMap, + lastUpdatedCollab, + }), + [webSocket, broadcastChannel, registerSyncContext, awarenessMap, lastUpdatedCollab] + ); + + return {children}; +}; diff --git a/src/components/app/workspaces/AccountSettings.tsx b/src/components/app/workspaces/AccountSettings.tsx new file mode 100644 index 00000000..d0909b9f --- /dev/null +++ b/src/components/app/workspaces/AccountSettings.tsx @@ -0,0 +1,270 @@ +import dayjs from 'dayjs'; +import { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { DateFormat, TimeFormat } from '@/application/types'; +import { MetadataKey } from '@/application/user-metadata'; +import { ReactComponent as ChevronDownIcon } from '@/assets/icons/alt_arrow_down.svg'; +import { useCurrentUser, useService } from '@/components/main/app.hooks'; +import { Dialog, DialogContent, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, +} from '@/components/ui/dropdown-menu'; +import { cn } from '@/lib/utils'; + +export function AccountSettings({ children }: { children?: React.ReactNode }) { + const { t } = useTranslation(); + const currentUser = useCurrentUser(); + const service = useService(); + + const handleSelectDateFormat = useCallback( + async (dateFormat: number) => { + if (!service || !currentUser?.metadata) return; + + await service?.updateUserProfile({ ...currentUser.metadata, [MetadataKey.DateFormat]: dateFormat }); + }, + [currentUser, service] + ); + + const handleSelectTimeFormat = useCallback( + async (timeFormat: number) => { + if (!service || !currentUser?.metadata) return; + + await service?.updateUserProfile({ ...currentUser.metadata, [MetadataKey.TimeFormat]: timeFormat }); + }, + [currentUser, service] + ); + + const handleSelectStartWeekOn = useCallback( + async (startWeekOn: number) => { + if (!service || !currentUser?.metadata) return; + + await service?.updateUserProfile({ ...currentUser.metadata, [MetadataKey.StartWeekOn]: startWeekOn }); + }, + [currentUser, service] + ); + + if (!currentUser || !service) { + return <>; + } + + const dateFormat = Number(currentUser?.metadata?.[MetadataKey.DateFormat] as DateFormat) || DateFormat.Local; + const timeFormat = Number(currentUser?.metadata?.[MetadataKey.TimeFormat] as TimeFormat) || TimeFormat.TwelveHour; + const startWeekOn = Number(currentUser?.metadata?.[MetadataKey.StartWeekOn] as number) || 0; + + return ( + + {children} + + {t('web.accountSettings')} +
+ + + +
+
+
+ ); +} + +function DateFormatDropdown({ dateFormat, onSelect }: { dateFormat: number; onSelect: (dateFormat: number) => void }) { + const { t } = useTranslation(); + + const [isOpen, setIsOpen] = useState(false); + + const dateFormats = useMemo( + () => [ + { + value: DateFormat.Local, + label: t('grid.field.dateFormatLocal'), + }, + { + label: t('grid.field.dateFormatUS'), + value: DateFormat.US, + }, + { + label: t('grid.field.dateFormatISO'), + value: DateFormat.ISO, + }, + { + label: t('grid.field.dateFormatFriendly'), + value: DateFormat.Friendly, + }, + { + label: t('grid.field.dateFormatDayMonthYear'), + value: DateFormat.DayMonthYear, + }, + ], + [t] + ); + + const value = dateFormats.find((format) => format.value === dateFormat); + + return ( +
+ {t('grid.field.dateFormat')} +
+ + { + e.preventDefault(); + e.stopPropagation(); + }} + onClick={() => setIsOpen((prev) => !prev)} + > +
+ e.preventDefault()}> + {value?.label || t('settings.workspacePage.dateTime.dateFormat.label')} + + +
+
+ + onSelect(Number(value))}> + {dateFormats.map((item) => ( + + {item.label} + + ))} + + +
+
+
+ ); +} + +function TimeFormatDropdown({ timeFormat, onSelect }: { timeFormat: number; onSelect: (timeFormat: number) => void }) { + const { t } = useTranslation(); + + const [isOpen, setIsOpen] = useState(false); + + const timeFormats = useMemo( + () => [ + { + value: TimeFormat.TwelveHour, + label: t('grid.field.timeFormatTwelveHour'), + }, + { + label: t('grid.field.timeFormatTwentyFourHour'), + value: TimeFormat.TwentyFourHour, + }, + ], + [t] + ); + + const value = timeFormats.find((format) => format.value === timeFormat); + + return ( +
+ {t('grid.field.timeFormat')} +
+ + { + e.preventDefault(); + e.stopPropagation(); + }} + onClick={() => setIsOpen((prev) => !prev)} + > +
+ e.preventDefault()}> + {value?.label || t('grid.field.timeFormatTwelveHour')} + + +
+
+ + onSelect(Number(value))}> + {timeFormats.map((item) => ( + + {item.label} + + ))} + + +
+
+
+ ); +} + +function StartWeekOnDropdown({ + startWeekOn, + onSelect, +}: { + startWeekOn: number; + onSelect: (startWeekOn: number) => void; +}) { + const { t } = useTranslation(); + + const [isOpen, setIsOpen] = useState(false); + + const daysOfWeek = [ + { + value: 0, + label: dayjs().day(0).format('dddd'), + }, + { + value: 1, + label: dayjs().day(1).format('dddd'), + }, + ] as const; + + const value = daysOfWeek.find((format) => format.value === startWeekOn); + + return ( +
+ {t('web.startWeekOn')} +
+ + { + e.preventDefault(); + e.stopPropagation(); + }} + onClick={() => setIsOpen((prev) => !prev)} + > +
+ e.preventDefault()}> + {value?.label || t('grid.field.timeFormatTwelveHour')} + + +
+
+ + onSelect(Number(value))}> + {daysOfWeek.map((item) => ( + + {item.label} + + ))} + + +
+
+
+ ); +} diff --git a/src/components/app/workspaces/Workspaces.tsx b/src/components/app/workspaces/Workspaces.tsx index 3c8408ee..a3deae4a 100644 --- a/src/components/app/workspaces/Workspaces.tsx +++ b/src/components/app/workspaces/Workspaces.tsx @@ -6,11 +6,13 @@ import { invalidToken } from '@/application/session/token'; import { Workspace } from '@/application/types'; import { ReactComponent as UpgradeAIMaxIcon } from '@/assets/icons/ai.svg'; import { ReactComponent as ChevronDownIcon } from '@/assets/icons/alt_arrow_down.svg'; +import { ReactComponent as ChevronRightIcon } from '@/assets/icons/alt_arrow_right.svg'; import { ReactComponent as TipIcon } from '@/assets/icons/help.svg'; import { ReactComponent as AddUserIcon } from '@/assets/icons/invite_user.svg'; import { ReactComponent as LogoutIcon } from '@/assets/icons/logout.svg'; import { ReactComponent as AddIcon } from '@/assets/icons/plus.svg'; import { ReactComponent as ImportIcon } from '@/assets/icons/save_as.svg'; +import { ReactComponent as SettingsIcon } from '@/assets/icons/settings.svg'; import { ReactComponent as UpgradeIcon } from '@/assets/icons/upgrade.svg'; import { useAppHandlers, useCurrentWorkspaceId, useUserWorkspaceInfo } from '@/components/app/app.hooks'; import CurrentWorkspace from '@/components/app/workspaces/CurrentWorkspace'; @@ -37,6 +39,8 @@ import Import from '@/components/_shared/more-actions/importer/Import'; import { notify } from '@/components/_shared/notify'; import { openUrl } from '@/utils/url'; +import { AccountSettings } from './AccountSettings'; + export function Workspaces() { const { t } = useTranslation(); const userWorkspaceInfo = useUserWorkspaceInfo(); @@ -212,6 +216,13 @@ export function Workspaces() { + + e.preventDefault()}> + +
{t('web.accountSettings')}
+ +
+
{t('button.logout')} diff --git a/src/components/database/components/board/group/AddGroupColumn.tsx b/src/components/database/components/board/group/AddGroupColumn.tsx index eacf974c..311809fa 100644 --- a/src/components/database/components/board/group/AddGroupColumn.tsx +++ b/src/components/database/components/board/group/AddGroupColumn.tsx @@ -10,6 +10,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; import { createHotkey, HOT_KEY_NAME } from '@/utils/hotkeys'; +import { useSelectFieldOptions } from '@/application/database-yjs'; function AddGroupColumn({ fieldId }: { fieldId: string; groupId: string }) { @@ -18,6 +19,7 @@ function AddGroupColumn({ fieldId }: { fieldId: string; groupId: string }) { const [isCreating, setIsCreating] = useState(false); const [value, setValue] = useState(''); + const options = useSelectFieldOptions(fieldId); const addOption = useAddSelectOption(fieldId); const [focused, setFocused] = useState(false); @@ -59,7 +61,7 @@ function AddGroupColumn({ fieldId }: { fieldId: string; groupId: string }) { addOption({ id: value, name: value, - color: getColorByOption(value), + color: getColorByOption(options), }); setIsCreating(false); setValue(''); @@ -91,7 +93,7 @@ function AddGroupColumn({ fieldId }: { fieldId: string; groupId: string }) { addOption({ id: value, name: value, - color: getColorByOption(value), + color: getColorByOption(options), }); }} size={'sm'} diff --git a/src/components/database/components/cell/ai-text/AITextCellActions.tsx b/src/components/database/components/cell/ai-text/AITextCellActions.tsx index a6e3f030..78d7b844 100644 --- a/src/components/database/components/cell/ai-text/AITextCellActions.tsx +++ b/src/components/database/components/cell/ai-text/AITextCellActions.tsx @@ -17,6 +17,7 @@ import { languageTexts, parseAITranslateTypeOption } from '@/application/databas import { GenerateAITranslateRowPayload, YDatabaseCell, YDatabaseField, YjsDatabaseKey } from '@/application/types'; import { ReactComponent as AIIcon } from '@/assets/icons/ai_improve_writing.svg'; import { ReactComponent as CopyIcon } from '@/assets/icons/copy.svg'; +import { useCurrentUser } from '@/components/main/app.hooks'; import { Button } from '@/components/ui/button'; import { Progress } from '@/components/ui/progress'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; @@ -56,18 +57,20 @@ function AITextCellActions({ const row = useRowData(rowId); const updateCell = useUpdateCellDispatch(rowId, fieldId); const { generateAITranslateForRow, generateAISummaryForRow } = useDatabaseContext(); + const currentUser = useCurrentUser(); const getCellData = useCallback( (cell: YDatabaseCell, field: YDatabaseField) => { + if (!currentUser) return ''; const type = Number(field?.get(YjsDatabaseKey.type)); if (type === FieldType.CreatedTime) { - return getRowTimeString(field, row.get(YjsDatabaseKey.created_at)) || ''; + return getRowTimeString(field, row.get(YjsDatabaseKey.created_at), currentUser) || ''; } else if (type === FieldType.LastEditedTime) { - return getRowTimeString(field, row.get(YjsDatabaseKey.last_modified)) || ''; + return getRowTimeString(field, row.get(YjsDatabaseKey.last_modified), currentUser) || ''; } else if (cell && ![FieldType.AISummaries, FieldType.AITranslations].includes(type)) { try { - return getCellDataText(cell, field); + return getCellDataText(cell, field, currentUser); } catch (e) { console.error(e); return ''; @@ -76,7 +79,7 @@ function AITextCellActions({ return ''; }, - [row] + [currentUser, row] ); const handleGenerateSummary = useCallback(async () => { diff --git a/src/components/database/components/cell/date/DateTimeCellPicker.tsx b/src/components/database/components/cell/date/DateTimeCellPicker.tsx index 443b1907..9b9d1d71 100644 --- a/src/components/database/components/cell/date/DateTimeCellPicker.tsx +++ b/src/components/database/components/cell/date/DateTimeCellPicker.tsx @@ -4,20 +4,19 @@ import { useCallback, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { - DateFormat, - getDateFormat, - getTimeFormat, - getTypeOptions, TimeFormat, + getFieldDateTimeFormats, + getTypeOptions, useFieldSelector, } from '@/application/database-yjs'; import { DateTimeCell } from '@/application/database-yjs/cell.type'; import { useUpdateCellDispatch } from '@/application/database-yjs/dispatch'; -import { YjsDatabaseKey } from '@/application/types'; +import { MetadataKey } from '@/application/user-metadata'; import { ReactComponent as ChevronRight } from '@/assets/icons/alt_arrow_right.svg'; import { ReactComponent as DateSvg } from '@/assets/icons/date.svg'; import { ReactComponent as TimeIcon } from '@/assets/icons/time.svg'; import DateTimeFormatMenu from '@/components/database/components/cell/date/DateTimeFormatMenu'; import DateTimeInput from '@/components/database/components/cell/date/DateTimeInput'; +import { useCurrentUser } from '@/components/main/app.hooks'; import { Calendar } from '@/components/ui/calendar'; import { dropdownMenuItemVariants } from '@/components/ui/dropdown-menu'; import { @@ -28,6 +27,7 @@ import { import { Separator } from '@/components/ui/separator'; import { Switch } from '@/components/ui/switch'; import { cn } from '@/lib/utils'; +import { getDateFormat, getTimeFormat } from '@/utils/time'; function DateTimeCellPicker ({ open, onOpenChange, cell, fieldId, rowId }: { open: boolean; @@ -36,6 +36,7 @@ function DateTimeCellPicker ({ open, onOpenChange, cell, fieldId, rowId }: { fieldId: string; rowId: string; }) { + const currentUser = useCurrentUser(); const { t } = useTranslation(); const [isRange, setIsRange] = useState(() => { @@ -56,13 +57,20 @@ function DateTimeCellPicker ({ open, onOpenChange, cell, fieldId, rowId }: { const typeOptionValue = useMemo(() => { const typeOption = getTypeOptions(field); + const { dateFormat, timeFormat } = getFieldDateTimeFormats(typeOption, currentUser); return { - timeFormat: getTimeFormat(Number(typeOption.get(YjsDatabaseKey.time_format)) as TimeFormat), - dateFormat: getDateFormat(Number(typeOption.get(YjsDatabaseKey.date_format)) as DateFormat), + dateFormat: getDateFormat(dateFormat), + timeFormat: getTimeFormat(timeFormat), }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [field, clock]); + }, [field, clock, currentUser?.metadata]); + + const weekStartsOn = useMemo(() => { + const value = Number(currentUser?.metadata?.[MetadataKey.StartWeekOn]) || 0; + + return value >= 0 && value <= 6 ? (value as 0 | 1 | 2 | 3 | 4 | 5 | 6) : 0; + }, [currentUser?.metadata]); const updateCell = useUpdateCellDispatch(rowId, fieldId); @@ -193,6 +201,7 @@ function DateTimeCellPicker ({ open, onOpenChange, cell, fieldId, rowId }: { showOutsideDays month={month} onMonthChange={onMonthChange} + weekStartsOn={weekStartsOn} {...(isRange ? { mode: 'range', selected: dateRange, diff --git a/src/components/database/components/cell/select-option/SelectOptionCellMenu.tsx b/src/components/database/components/cell/select-option/SelectOptionCellMenu.tsx index 59d137b8..bb3a7648 100644 --- a/src/components/database/components/cell/select-option/SelectOptionCellMenu.tsx +++ b/src/components/database/components/cell/select-option/SelectOptionCellMenu.tsx @@ -121,13 +121,13 @@ function SelectOptionCellMenu ({ open, onOpenChange, fieldId, rowId, selectOptio const newOption: SelectOption = { id: searchValue, name: searchValue, - color: getColorByOption(searchValue), + color: getColorByOption(typeOption?.options || []), }; onCreateOption(newOption); setSearchValue(''); handleSelectOption(newOption.id); - }, [handleSelectOption, onCreateOption]); + }, [handleSelectOption, onCreateOption, typeOption]); const handleEnter = useCallback(() => { const hoveredId = hoveredIdRef.current; diff --git a/src/components/database/components/conditions/PropertiesMenu.tsx b/src/components/database/components/conditions/PropertiesMenu.tsx index 47731152..2db15795 100644 --- a/src/components/database/components/conditions/PropertiesMenu.tsx +++ b/src/components/database/components/conditions/PropertiesMenu.tsx @@ -26,7 +26,7 @@ function PropertiesMenu({ children?: React.ReactNode; asChild?: boolean; }) { - const { properties } = usePropertiesSelector(true); + const { properties } = usePropertiesSelector(false); const [searchInput, setSearchInput] = useState(''); const filteredProperties = useMemo(() => { diff --git a/src/components/database/components/database-row/DatabaseRowSubDocument.tsx b/src/components/database/components/database-row/DatabaseRowSubDocument.tsx index 6af975fa..564dbdcf 100644 --- a/src/components/database/components/database-row/DatabaseRowSubDocument.tsx +++ b/src/components/database/components/database-row/DatabaseRowSubDocument.tsx @@ -23,8 +23,9 @@ import { YjsDatabaseKey, YjsEditorKey, } from '@/application/types'; -import { Editor } from '@/components/editor'; import { EditorSkeleton } from '@/components/_shared/skeleton/EditorSkeleton'; +import { Editor } from '@/components/editor'; +import { useCurrentUser } from '@/components/main/app.hooks'; export const DatabaseRowSubDocument = memo(({ rowId }: { rowId: string }) => { const meta = useRowMetaSelector(rowId); @@ -34,6 +35,8 @@ export const DatabaseRowSubDocument = memo(({ rowId }: { rowId: string }) => { const database = useDatabase(); const row = useRowData(rowId) as YDatabaseRow | undefined; const checkIfRowDocumentExists = context.checkIfRowDocumentExists; + const { createOrphanedView, loadView } = context; + const currentUser = useCurrentUser(); const getCellData = useCallback( (cell: YDatabaseCell, field: YDatabaseField) => { @@ -41,12 +44,12 @@ export const DatabaseRowSubDocument = memo(({ rowId }: { rowId: string }) => { const type = Number(field?.get(YjsDatabaseKey.type)); if (type === FieldType.CreatedTime) { - return getRowTimeString(field, row.get(YjsDatabaseKey.created_at)) || ''; + return getRowTimeString(field, row.get(YjsDatabaseKey.created_at), currentUser) || ''; } else if (type === FieldType.LastEditedTime) { - return getRowTimeString(field, row.get(YjsDatabaseKey.last_modified)) || ''; + return getRowTimeString(field, row.get(YjsDatabaseKey.last_modified), currentUser) || ''; } else if (cell) { try { - return getCellDataText(cell, field); + return getCellDataText(cell, field, currentUser); } catch (e) { console.error(e); return ''; @@ -55,7 +58,7 @@ export const DatabaseRowSubDocument = memo(({ rowId }: { rowId: string }) => { return ''; }, - [row] + [row, currentUser] ); const properties = useMemo(() => { @@ -82,7 +85,6 @@ export const DatabaseRowSubDocument = memo(({ rowId }: { rowId: string }) => { return obj; }, [database, getCellData, row]); - const { createOrphanedView, loadView } = context; const updateRowMeta = useUpdateRowMetaDispatch(rowId); const [loading, setLoading] = useState(true); diff --git a/src/components/database/components/filters/filter-menu/DateTimeFilterDatePicker.tsx b/src/components/database/components/filters/filter-menu/DateTimeFilterDatePicker.tsx index 0a5ebddd..42b0a515 100644 --- a/src/components/database/components/filters/filter-menu/DateTimeFilterDatePicker.tsx +++ b/src/components/database/components/filters/filter-menu/DateTimeFilterDatePicker.tsx @@ -1,22 +1,26 @@ import dayjs from 'dayjs'; import { useCallback, useMemo, useState } from 'react'; -import { - DateFilter, - DateFilterCondition, - DateFormat, - getDateFormat, - getTimeFormat, - TimeFormat, -} from '@/application/database-yjs'; +import { DateFilter, DateFilterCondition } from '@/application/database-yjs'; import { useUpdateFilter } from '@/application/database-yjs/dispatch'; +import { DateFormat, TimeFormat } from '@/application/types'; +import { MetadataKey } from '@/application/user-metadata'; import DateTimeInput from '@/components/database/components/cell/date/DateTimeInput'; +import { useCurrentUser } from '@/components/main/app.hooks'; import { Button } from '@/components/ui/button'; import { Calendar } from '@/components/ui/calendar'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; -import { renderDate } from '@/utils/time'; +import { getDateFormat, getTimeFormat, renderDate } from '@/utils/time'; function DateTimeFilterDatePicker({ filter }: { filter: DateFilter }) { + const currentUser = useCurrentUser(); + + const weekStartsOn = useMemo(() => { + const value = Number(currentUser?.metadata?.[MetadataKey.StartWeekOn]) || 0; + + return value >= 0 && value <= 6 ? (value as 0 | 1 | 2 | 3 | 4 | 5 | 6) : 0; + }, [currentUser?.metadata]); + const isRange = useMemo(() => { return [DateFilterCondition.DateStartsBetween, DateFilterCondition.DateEndsBetween].includes(filter.condition); }, [filter.condition]); @@ -74,9 +78,11 @@ function DateTimeFilterDatePicker({ filter }: { filter: DateFilter }) { const { timestamp, end, start } = filter; if (isRange && start && end) { - return `${renderDate(start.toString(), getDateFormat(DateFormat.Local), true)} - ${renderDate( + const dateFormat = currentUser?.metadata?.[MetadataKey.DateFormat] as DateFormat | DateFormat.Local; + + return `${renderDate(start.toString(), getDateFormat(dateFormat), true)} - ${renderDate( end.toString(), - getDateFormat(DateFormat.Local), + getDateFormat(dateFormat), true )}`; } @@ -84,7 +90,7 @@ function DateTimeFilterDatePicker({ filter }: { filter: DateFilter }) { if (!timestamp) return ''; return renderDate(timestamp.toString(), getDateFormat(DateFormat.Local), true); - }, [filter, isRange]); + }, [filter, isRange, currentUser?.metadata]); const [month, setMonth] = useState(undefined); @@ -135,6 +141,7 @@ function DateTimeFilterDatePicker({ filter }: { filter: DateFilter }) { showOutsideDays month={month} onMonthChange={onMonthChange} + weekStartsOn={weekStartsOn} {...(isRange ? { mode: 'range', diff --git a/src/components/database/components/filters/overview/DateFilterContentOverview.tsx b/src/components/database/components/filters/overview/DateFilterContentOverview.tsx index c8fdcb98..dd47010a 100644 --- a/src/components/database/components/filters/overview/DateFilterContentOverview.tsx +++ b/src/components/database/components/filters/overview/DateFilterContentOverview.tsx @@ -1,10 +1,16 @@ -import { DateFilter, DateFilterCondition, DateFormat, getDateFormat } from '@/application/database-yjs'; import dayjs from 'dayjs'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -function DateFilterContentOverview ({ filter }: { filter: DateFilter }) { +import { DateFilter, DateFilterCondition, } from '@/application/database-yjs'; +import { DateFormat } from '@/application/types'; +import { MetadataKey } from '@/application/user-metadata'; +import { getDateFormat } from '@/utils/time'; +import { useCurrentUser } from '@/components/main/app.hooks'; + +function DateFilterContentOverview({ filter }: { filter: DateFilter }) { const { t } = useTranslation(); + const currentUser = useCurrentUser(); const value = useMemo(() => { let startStr = ''; @@ -18,7 +24,8 @@ function DateFilterContentOverview ({ filter }: { filter: DateFilter }) { endStr = dayjs.unix(end).format(format); } - const timestamp = filter.timestamp ? dayjs.unix(filter.timestamp).format(getDateFormat(DateFormat.Local)) : ''; + const dateFormat = currentUser?.metadata?.[MetadataKey.DateFormat] as DateFormat | DateFormat.Local; + const timestamp = filter.timestamp ? dayjs.unix(filter.timestamp).format(getDateFormat(dateFormat)) : ''; switch (filter.condition) { case DateFilterCondition.DateStartsOn: @@ -48,7 +55,7 @@ function DateFilterContentOverview ({ filter }: { filter: DateFilter }) { default: return ''; } - }, [filter, t]); + }, [filter, t, currentUser?.metadata]); return <>{value}; } diff --git a/src/components/database/components/header/DatabaseRowHeader.tsx b/src/components/database/components/header/DatabaseRowHeader.tsx index 3afd37d2..b543c00b 100644 --- a/src/components/database/components/header/DatabaseRowHeader.tsx +++ b/src/components/database/components/header/DatabaseRowHeader.tsx @@ -168,7 +168,12 @@ function DatabaseRowHeader({ rowId, appendBreadcrumb }: { rowId: string; appendB > {renderCoverImage(cover)} {!readOnly && ( - + )}
)} diff --git a/src/components/database/components/property/date/DateTimeFormatGroup.tsx b/src/components/database/components/property/date/DateTimeFormatGroup.tsx index fd1160c9..31c45f15 100644 --- a/src/components/database/components/property/date/DateTimeFormatGroup.tsx +++ b/src/components/database/components/property/date/DateTimeFormatGroup.tsx @@ -1,6 +1,5 @@ -import { DateFormat, TimeFormat } from '@/application/database-yjs'; import { useUpdateDateTimeFieldFormat } from '@/application/database-yjs/dispatch'; -import { YjsDatabaseKey } from '@/application/types'; +import { DateFormat, TimeFormat, YjsDatabaseKey } from '@/application/types'; import { useFieldTypeOption } from '@/components/database/components/cell/Cell.hooks'; import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuItemTick, @@ -18,8 +17,11 @@ function DateTimeFormatGroup ({ }) { const { t } = useTranslation(); const typeOption = useFieldTypeOption(fieldId); - const selectedDateFormat = Number(typeOption.get(YjsDatabaseKey.date_format)); - const selectedTimeFormat = Number(typeOption.get(YjsDatabaseKey.time_format)); + const typeOptionDateFormat = typeOption.get(YjsDatabaseKey.date_format); + const typeOptionTimeFormat = typeOption.get(YjsDatabaseKey.time_format); + + const selectedDateFormat = typeOptionDateFormat !== undefined ? Number(typeOptionDateFormat) : undefined; + const selectedTimeFormat = typeOptionTimeFormat !== undefined ? Number(typeOptionTimeFormat) : undefined; const updateFormat = useUpdateDateTimeFieldFormat(fieldId); const dateFormats = useMemo(() => [{ diff --git a/src/components/database/components/property/select/AddAnOption.tsx b/src/components/database/components/property/select/AddAnOption.tsx index e9a25f80..60650334 100644 --- a/src/components/database/components/property/select/AddAnOption.tsx +++ b/src/components/database/components/property/select/AddAnOption.tsx @@ -7,7 +7,8 @@ import { useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ReactComponent as AddIcon } from '@/assets/icons/plus.svg'; -function AddAnOption ({ onAdd }: { +function AddAnOption ({ options, onAdd }: { + options: SelectOption[]; onAdd: (option: SelectOption) => void; }) { const { t } = useTranslation(); @@ -19,11 +20,11 @@ function AddAnOption ({ onAdd }: { onAdd({ id: generateOptionId(), name: value, - color: getColorByOption(value), + color: getColorByOption(options), }); setValue(''); } - }, [onAdd, value]); + }, [onAdd, options, value]); return ( {t('grid.field.optionTitle')} - + diff --git a/src/components/editor/components/block-popover/index.tsx b/src/components/editor/components/block-popover/index.tsx index 1027846e..5f2dec78 100644 --- a/src/components/editor/components/block-popover/index.tsx +++ b/src/components/editor/components/block-popover/index.tsx @@ -23,20 +23,14 @@ const defaultOrigins: Origins = { }; function BlockPopover() { - const { - open, - anchorEl, - close, - type, - blockId, - } = usePopoverContext(); + const { open, anchorEl, close, type, blockId } = usePopoverContext(); const { setSelectedBlockIds } = useEditorContext(); const editor = useSlateStatic() as YjsEditor; const [origins, setOrigins] = React.useState(defaultOrigins); const handleClose = useCallback(() => { window.getSelection()?.removeAllRanges(); - if(!blockId) return; + if (!blockId) return; const entry = findSlateEntryByBlockId(editor, blockId); @@ -50,28 +44,16 @@ function BlockPopover() { }, [blockId, close, editor]); const content = useMemo(() => { - if(!blockId) return; - switch(type) { + if (!blockId) return; + switch (type) { case BlockType.FileBlock: - return ; + return ; case BlockType.ImageBlock: - return ; + return ; case BlockType.EquationBlock: - return ; + return ; case BlockType.VideoBlock: - return ; + return ; default: return null; } @@ -80,8 +62,7 @@ function BlockPopover() { const paperRef = useRef(null); useEffect(() => { - if(blockId) { - + if (blockId) { setSelectedBlockIds?.([blockId]); } else { setSelectedBlockIds?.([]); @@ -89,18 +70,24 @@ function BlockPopover() { }, [blockId, setSelectedBlockIds]); useEffect(() => { - if(!open) return; + if (!open) return; editor.deselect(); }, [open, editor]); useEffect(() => { const panelPosition = anchorEl?.getBoundingClientRect(); - if(open && panelPosition) { - const origins = calculateOptimalOrigins({ - top: panelPosition.bottom, - left: panelPosition.left, - }, 560, (type === BlockType.ImageBlock || type === BlockType.VideoBlock) ? 400 : 200, defaultOrigins, 16); + if (open && panelPosition) { + const origins = calculateOptimalOrigins( + { + top: panelPosition.bottom, + left: panelPosition.left, + }, + type === BlockType.ImageBlock || type === BlockType.VideoBlock ? 400 : 560, + type === BlockType.ImageBlock || type === BlockType.VideoBlock ? 366 : 200, + defaultOrigins, + 16 + ); setOrigins({ transformOrigin: { @@ -115,22 +102,24 @@ function BlockPopover() { } }, [open, anchorEl, type]); - return - {content} - ; + return ( + + {content} + + ); } -export default BlockPopover; \ No newline at end of file +export default BlockPopover; diff --git a/src/components/editor/components/leaf/mention/MentionDate.tsx b/src/components/editor/components/leaf/mention/MentionDate.tsx index bec2ac50..49b3de32 100644 --- a/src/components/editor/components/leaf/mention/MentionDate.tsx +++ b/src/components/editor/components/leaf/mention/MentionDate.tsx @@ -1,12 +1,20 @@ -import { ReactComponent as DateSvg } from '@/assets/icons/date.svg'; -import { ReactComponent as ReminderSvg } from '@/assets/icons/reminder_clock.svg'; -import { renderDate } from '@/utils/time'; import { useMemo } from 'react'; +import { DateFormat } from '@/application/types'; +import { MetadataKey } from '@/application/user-metadata'; +import { ReactComponent as DateSvg } from '@/assets/icons/date.svg'; +import { ReactComponent as ReminderSvg } from '@/assets/icons/reminder_clock.svg'; +import { useCurrentUser } from '@/components/main/app.hooks'; +import { getDateFormat, renderDate } from '@/utils/time'; + function MentionDate({ date, reminder }: { date: string; reminder?: { id: string; option: string } }) { - const dateFormat = useMemo(() => { - return renderDate(date, 'MMM D, YYYY'); - }, [date]); + const currentUser = useCurrentUser(); + + const formattedDate = useMemo(() => { + const dateFormat = (currentUser?.metadata?.[MetadataKey.DateFormat] as DateFormat) ?? DateFormat.Local; + + return renderDate(date, getDateFormat(dateFormat)); + }, [currentUser?.metadata, date]); return ( @ - {dateFormat} + {formattedDate} {reminder ? : } diff --git a/src/components/editor/components/toolbar/block-controls/Color.tsx b/src/components/editor/components/toolbar/block-controls/Color.tsx new file mode 100644 index 00000000..b54d1a23 --- /dev/null +++ b/src/components/editor/components/toolbar/block-controls/Color.tsx @@ -0,0 +1,235 @@ +import { Button, Divider } from '@mui/material'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSlateStatic } from 'slate-react'; + +import { YjsEditor } from '@/application/slate-yjs'; +import { CustomEditor } from '@/application/slate-yjs/command'; +import { SubscriptionPlan } from '@/application/types'; +import { ColorTile, ColorTileIcon } from '@/components/_shared/color-picker'; +import { Origins, Popover } from '@/components/_shared/popover'; +import { BlockNode } from '@/components/editor/editor.type'; +import { useEditorContext } from '@/components/editor/EditorContext'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { ColorEnum, renderColor } from '@/utils/color'; + +const origins: Origins = { + anchorOrigin: { + vertical: 'top', + horizontal: 'right', + }, + transformOrigin: { + vertical: 'top', + horizontal: 'left', + }, +}; + +function Color({ node, onSelectColor }: { node: BlockNode; onSelectColor: () => void }) { + const { getSubscriptions } = useEditorContext(); + const [open, setOpen] = useState(false); + const ref = useRef(null); + const { t } = useTranslation(); + const editor = useSlateStatic() as YjsEditor; + const blockId = node.blockId; + const [originalColor, setOriginalColor] = useState(node.data?.bgColor || ''); + + const [activeSubscriptionPlan, setActiveSubscriptionPlan] = useState(null); + const isPro = activeSubscriptionPlan === SubscriptionPlan.Pro; + + const loadSubscription = useCallback(async () => { + try { + const subscriptions = await getSubscriptions?.(); + + if (!subscriptions || subscriptions.length === 0) { + setActiveSubscriptionPlan(SubscriptionPlan.Free); + return; + } + + const subscription = subscriptions[0]; + + setActiveSubscriptionPlan(subscription?.plan || SubscriptionPlan.Free); + } catch (e) { + setActiveSubscriptionPlan(SubscriptionPlan.Free); + console.error(e); + } + }, [getSubscriptions]); + + useEffect(() => { + void loadSubscription(); + }, [loadSubscription]); + + const builtinColors = useMemo(() => { + return isPro + ? [ + { + color: '', + label: t('colors.default'), + }, + { + color: ColorEnum.Tint1, + label: t('colors.mauve'), + }, + { + color: ColorEnum.Tint11, + label: t('colors.lavender'), + }, + { + color: ColorEnum.Tint2, + label: t('colors.lilac'), + }, + { + color: ColorEnum.Tint12, + label: t('colors.mallow'), + }, + { + color: ColorEnum.Tint3, + label: t('colors.camellia'), + }, + { + color: ColorEnum.Tint13, + label: t('colors.rose'), + }, + { + color: ColorEnum.Tint4, + label: t('colors.papaya'), + }, + { + color: ColorEnum.Tint5, + label: t('colors.mango'), + }, + { + color: ColorEnum.Tint14, + label: t('colors.lemon'), + }, + { + color: ColorEnum.Tint6, + label: t('colors.olive'), + }, + { + color: ColorEnum.Tint7, + label: t('colors.grass'), + }, + { + color: ColorEnum.Tint8, + label: t('colors.jade'), + }, + { + color: ColorEnum.Tint9, + label: t('colors.azure'), + }, + { + color: ColorEnum.Tint10, + label: t('colors.iron'), + }, + ] + : [ + { + color: '', + label: t('colors.default'), + }, + { + color: ColorEnum.Tint1, + label: t('colors.mauve'), + }, + { + color: ColorEnum.Tint2, + label: t('colors.lilac'), + }, + { + color: ColorEnum.Tint3, + label: t('colors.camellia'), + }, + { + color: ColorEnum.Tint4, + label: t('colors.papaya'), + }, + { + color: ColorEnum.Tint5, + label: t('colors.mango'), + }, + { + color: ColorEnum.Tint6, + label: t('colors.olive'), + }, + { + color: ColorEnum.Tint7, + label: t('colors.grass'), + }, + { + color: ColorEnum.Tint8, + label: t('colors.jade'), + }, + { + color: ColorEnum.Tint9, + label: t('colors.azure'), + }, + ]; + }, [isPro, t]); + + const icon = useMemo(() => { + return ; + }, [originalColor]); + + const handlePickColor = useCallback( + (bgColor: string) => { + if (bgColor === originalColor) { + CustomEditor.setBlockData(editor, blockId, { + bgColor: '', + }); + return; + } + + CustomEditor.setBlockData(editor, blockId, { + bgColor, + }); + + setOriginalColor(bgColor); + }, + [blockId, editor, originalColor] + ); + + return ( + <> + + + setOpen(false)} {...origins}> +
+
+ {t('editor.backgroundColor')} +
+
+ {builtinColors.map((color, index) => ( + + {color.label} + + { + handlePickColor(color.color); + setOpen(false); + onSelectColor(); + }} + /> + + + ))} +
+
+
+ + ); +} + +export default Color; diff --git a/src/components/editor/components/toolbar/block-controls/ControlActions.tsx b/src/components/editor/components/toolbar/block-controls/ControlActions.tsx index c2237d42..8cf9e8bb 100644 --- a/src/components/editor/components/toolbar/block-controls/ControlActions.tsx +++ b/src/components/editor/components/toolbar/block-controls/ControlActions.tsx @@ -33,7 +33,6 @@ function ControlActions({ setOpenMenu, blockId }: { } = usePanelContext(); const onAdded = useCallback(() => { - setTimeout(() => { const rect = getRangeRect(); diff --git a/src/components/editor/components/toolbar/block-controls/ControlsMenu.tsx b/src/components/editor/components/toolbar/block-controls/ControlsMenu.tsx index e91f18cc..64bcd2d2 100644 --- a/src/components/editor/components/toolbar/block-controls/ControlsMenu.tsx +++ b/src/components/editor/components/toolbar/block-controls/ControlsMenu.tsx @@ -7,7 +7,7 @@ import { ReactComponent as DeleteIcon } from '@/assets/icons/delete.svg'; import { notify } from '@/components/_shared/notify'; import { Popover } from '@/components/_shared/popover'; import Depth from '@/components/editor/components/toolbar/block-controls/Depth'; -import { OutlineNode } from '@/components/editor/editor.type'; +import { BlockNode, OutlineNode } from '@/components/editor/editor.type'; import { useEditorContext } from '@/components/editor/EditorContext'; import { copyTextToClipboard } from '@/utils/copy'; import { Button } from '@mui/material'; @@ -16,12 +16,12 @@ import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { ReactEditor, useSlateStatic } from 'slate-react'; import { findSlateEntryByBlockId } from '@/application/slate-yjs/utils/editor'; +import Color from './Color'; const popoverProps: Partial = { transformOrigin: { vertical: 'center', horizontal: 'right', - }, anchorOrigin: { vertical: 'center', @@ -32,7 +32,11 @@ const popoverProps: Partial = { disableEnforceFocus: true, }; -function ControlsMenu({ open, onClose, anchorEl }: { +function ControlsMenu({ + open, + onClose, + anchorEl, +}: { open: boolean; onClose: () => void; anchorEl: HTMLElement | null; @@ -50,54 +54,61 @@ function ControlsMenu({ open, onClose, anchorEl }: { const { t } = useTranslation(); const options = useMemo(() => { - return [{ - key: 'delete', - content: t('button.delete'), - icon: , - onClick: () => { - selectedBlockIds?.forEach((blockId) => { - CustomEditor.deleteBlock(editor, blockId); - }); + return [ + { + key: 'delete', + content: t('button.delete'), + icon: , + onClick: () => { + selectedBlockIds?.forEach((blockId) => { + CustomEditor.deleteBlock(editor, blockId); + }); + }, }, - }, { - key: 'duplicate', - content: t('button.duplicate'), - icon: , - onClick: () => { - const newBlockIds: string[] = []; - const prevId = selectedBlockIds?.[selectedBlockIds.length - 1]; + { + key: 'duplicate', + content: t('button.duplicate'), + icon: , + onClick: () => { + const newBlockIds: string[] = []; + const prevId = selectedBlockIds?.[selectedBlockIds.length - 1]; - selectedBlockIds?.forEach((blockId, index) => { - const newBlockId = CustomEditor.duplicateBlock(editor, blockId, index === 0 ? prevId : newBlockIds[index - 1]); + selectedBlockIds?.forEach((blockId, index) => { + const newBlockId = CustomEditor.duplicateBlock( + editor, + blockId, + index === 0 ? prevId : newBlockIds[index - 1] + ); - newBlockId && newBlockIds.push(newBlockId); - }); + newBlockId && newBlockIds.push(newBlockId); + }); - ReactEditor.focus(editor); - const entry = findSlateEntryByBlockId(editor, newBlockIds[0]); + ReactEditor.focus(editor); + const entry = findSlateEntryByBlockId(editor, newBlockIds[0]); - if(!entry) return; + if (!entry) return; - const [, path] = entry; - - editor.select(editor.start(path)); + const [, path] = entry; + editor.select(editor.start(path)); + }, }, - }, onlySingleBlockSelected && { - key: 'copyLinkToBlock', - content: t('document.plugins.optionAction.copyLinkToBlock'), - icon: , - onClick: async () => { - const blockId = selectedBlockIds?.[0]; + onlySingleBlockSelected && { + key: 'copyLinkToBlock', + content: t('document.plugins.optionAction.copyLinkToBlock'), + icon: , + onClick: async () => { + const blockId = selectedBlockIds?.[0]; - const url = new URL(window.location.href); + const url = new URL(window.location.href); - url.searchParams.set('blockId', blockId); + url.searchParams.set('blockId', blockId); - await copyTextToClipboard(url.toString()); - notify.success(t('shareAction.copyLinkToBlockSuccess')); + await copyTextToClipboard(url.toString()); + notify.success(t('shareAction.copyLinkToBlockSuccess')); + }, }, - }].filter(Boolean) as { + ].filter(Boolean) as { key: string; content: string; icon: JSX.Element; @@ -120,13 +131,9 @@ function ControlsMenu({ open, onClose, anchorEl }: { onClose(); }} open={open} - {...popoverProps} > -
+
{options.map((option) => { return (
); } -export default ControlsMenu; \ No newline at end of file +export default ControlsMenu; diff --git a/src/components/editor/components/toolbar/block-controls/Depth.tsx b/src/components/editor/components/toolbar/block-controls/Depth.tsx index 4b5a0f63..e5654787 100644 --- a/src/components/editor/components/toolbar/block-controls/Depth.tsx +++ b/src/components/editor/components/toolbar/block-controls/Depth.tsx @@ -15,7 +15,7 @@ const origins: Origins = { }, transformOrigin: { vertical: 'top', - horizontal: -16, + horizontal: 'left', }, }; diff --git a/src/components/editor/components/toolbar/selection-toolbar/actions/BgColor.tsx b/src/components/editor/components/toolbar/selection-toolbar/actions/BgColor.tsx index cd53072c..b497632c 100644 --- a/src/components/editor/components/toolbar/selection-toolbar/actions/BgColor.tsx +++ b/src/components/editor/components/toolbar/selection-toolbar/actions/BgColor.tsx @@ -45,6 +45,7 @@ function BgColor({ const [activeSubscriptionPlan, setActiveSubscriptionPlan] = useState(null); const isPro = activeSubscriptionPlan === SubscriptionPlan.Pro; + const maxCustomColors = isPro ? 9 : 4; const loadSubscription = useCallback(async () => { try { @@ -75,24 +76,24 @@ function BgColor({ try { const recentParsed: string[] = recent ? JSON.parse(recent) : []; - setRecentColors(recentParsed.slice(0, 6)); + setRecentColors(recentParsed.slice(0, 5)); } catch (e) { console.error('Failed to parse recent colors:', e); } try { const customParsed: string[] = custom ? JSON.parse(custom) : []; - let updatedCustomColors = customParsed.slice(0, 5); + let updatedCustomColors = customParsed.slice(0, maxCustomColors); if (singleColor !== undefined && isCustomColor(singleColor) && !updatedCustomColors.includes(singleColor)) { - updatedCustomColors = [singleColor, ...updatedCustomColors].slice(0, 5); + updatedCustomColors = [singleColor, ...updatedCustomColors].slice(0, maxCustomColors); } setCustomColors(updatedCustomColors); } catch (e) { console.error('Failed to parse recent colors:', e); } - }, [isCustomColor, singleColor]); + }, [isCustomColor, maxCustomColors, singleColor]); useEffect(() => { if (!visible && isOpen) { @@ -159,12 +160,12 @@ function BgColor({ return; } - const updatedCustomColors = [...customColors, color].slice(-5); + const updatedCustomColors = [...customColors, color].slice(-maxCustomColors); setCustomColors(updatedCustomColors); localStorage.setItem('custom-bg-colors', JSON.stringify(updatedCustomColors)); }, - [customColors] + [customColors, maxCustomColors] ); const saveRecentColors = useCallback(() => { @@ -175,7 +176,7 @@ function BgColor({ const color = recentColorToSave.current; if (color && color !== initialColor.current) { - const updated = [color, ...recentColors.filter((c) => c !== color)].slice(0, 6); + const updated = [color, ...recentColors.filter((c) => c !== color)].slice(0, 5); localStorage.setItem('recent-bg-colors', JSON.stringify(updated)); } @@ -191,44 +192,60 @@ function BgColor({ color: '', }, { - label: t('colors.smoke'), - color: 'bg-color-19', - }, - { - label: t('colors.mallow'), - color: 'bg-color-17', + label: t('colors.mauve'), + color: 'bg-color-14', }, { label: t('colors.lavender'), color: 'bg-color-15', }, { - label: t('colors.denim'), - color: 'bg-color-13', + label: t('colors.lilac'), + color: 'bg-color-16', }, { - label: t('colors.aqua'), - color: 'bg-color-11', + label: t('colors.mallow'), + color: 'bg-color-17', }, { - label: t('colors.forest'), - color: 'bg-color-9', + label: t('colors.camellia'), + color: 'bg-color-18', }, { - label: t('colors.lime'), - color: 'bg-color-7', + label: t('colors.rose'), + color: 'bg-color-1', + }, + { + label: t('colors.papaya'), + color: 'bg-color-2', + }, + { + label: t('colors.mango'), + color: 'bg-color-4', }, { label: t('colors.lemon'), color: 'bg-color-5', }, { - label: t('colors.tangerine'), - color: 'bg-color-3', + label: t('colors.olive'), + color: 'bg-color-6', }, { - label: t('colors.rose'), - color: 'bg-color-1', + label: t('colors.grass'), + color: 'bg-color-8', + }, + { + label: t('colors.jade'), + color: 'bg-color-10', + }, + { + label: t('colors.azure'), + color: 'bg-color-12', + }, + { + label: t('colors.iron'), + color: 'bg-color-20', }, ] : [ @@ -237,32 +254,40 @@ function BgColor({ color: '', }, { - label: t('colors.smoke'), - color: 'bg-color-19', + label: t('colors.mauve'), + color: 'bg-color-14', }, { - label: t('colors.mallow'), - color: 'bg-color-17', + label: t('colors.lilac'), + color: 'bg-color-16', }, { - label: t('colors.lavender'), - color: 'bg-color-15', + label: t('colors.camellia'), + color: 'bg-color-18', }, { - label: t('colors.aqua'), - color: 'bg-color-11', + label: t('colors.papaya'), + color: 'bg-color-2', }, { - label: t('colors.lime'), - color: 'bg-color-7', + label: t('colors.mango'), + color: 'bg-color-4', }, { - label: t('colors.lemon'), - color: 'bg-color-5', + label: t('colors.olive'), + color: 'bg-color-6', }, { - label: t('colors.tangerine'), - color: 'bg-color-3', + label: t('colors.grass'), + color: 'bg-color-8', + }, + { + label: t('colors.jade'), + color: 'bg-color-10', + }, + { + label: t('colors.azure'), + color: 'bg-color-12', }, ]; }, [isPro, t]); @@ -292,7 +317,7 @@ function BgColor({ } return ( -
+
{recentColors.length > 0 && ( <>
{t('colors.recent')}
@@ -326,7 +351,7 @@ function BgColor({
{t('colors.custom')}
-
+
{customColors.map((color, index) => ( e.stopPropagation()} diff --git a/src/components/editor/components/toolbar/selection-toolbar/actions/TextColor.tsx b/src/components/editor/components/toolbar/selection-toolbar/actions/TextColor.tsx index f4abb520..c3dab47c 100644 --- a/src/components/editor/components/toolbar/selection-toolbar/actions/TextColor.tsx +++ b/src/components/editor/components/toolbar/selection-toolbar/actions/TextColor.tsx @@ -45,6 +45,7 @@ function TextColor({ const [activeSubscriptionPlan, setActiveSubscriptionPlan] = useState(null); const isPro = activeSubscriptionPlan === SubscriptionPlan.Pro; + const maxCustomColors = isPro ? 9 : 4; const loadSubscription = useCallback(async () => { try { @@ -75,24 +76,24 @@ function TextColor({ try { const recentParsed: string[] = recent ? JSON.parse(recent) : []; - setRecentColors(recentParsed.slice(0, 6)); + setRecentColors(recentParsed.slice(0, 5)); } catch (e) { console.error('Failed to parse recent colors:', e); } try { const customParsed: string[] = custom ? JSON.parse(custom) : []; - let updatedCustomColors = customParsed.slice(0, 5); + let updatedCustomColors = customParsed.slice(0, maxCustomColors); if (singleColor !== undefined && isCustomColor(singleColor) && !updatedCustomColors.includes(singleColor)) { - updatedCustomColors = [singleColor, ...updatedCustomColors].slice(0, 5); + updatedCustomColors = [singleColor, ...updatedCustomColors].slice(0, maxCustomColors); } setCustomColors(updatedCustomColors); } catch (e) { console.error('Failed to parse recent colors:', e); } - }, [isCustomColor, singleColor]); + }, [isCustomColor, maxCustomColors, singleColor]); useEffect(() => { if (!visible && isOpen) { @@ -159,12 +160,12 @@ function TextColor({ return; } - const updatedCustomColors = [...customColors, color].slice(-5); + const updatedCustomColors = [...customColors, color].slice(-maxCustomColors); setCustomColors(updatedCustomColors); localStorage.setItem('custom-text-colors', JSON.stringify(updatedCustomColors)); }, - [customColors] + [customColors, maxCustomColors] ); const saveRecentColors = useCallback(() => { @@ -175,7 +176,7 @@ function TextColor({ const color = recentColorToSave.current; if (color && color !== initialColor.current) { - const updated = [color, ...recentColors.filter((c) => c !== color)].slice(0, 6); + const updated = [color, ...recentColors.filter((c) => c !== color)].slice(0, 5); localStorage.setItem('recent-text-colors', JSON.stringify(updated)); } @@ -191,44 +192,60 @@ function TextColor({ color: '', }, { - label: t('colors.smoke'), - color: 'text-color-19', - }, - { - label: t('colors.mallow'), - color: 'text-color-17', + label: t('colors.mauve'), + color: 'text-color-14', }, { label: t('colors.lavender'), color: 'text-color-15', }, { - label: t('colors.denim'), - color: 'text-color-13', + label: t('colors.lilac'), + color: 'text-color-16', }, { - label: t('colors.aqua'), - color: 'text-color-11', + label: t('colors.mallow'), + color: 'text-color-17', }, { - label: t('colors.forest'), - color: 'text-color-9', + label: t('colors.camellia'), + color: 'text-color-18', }, { - label: t('colors.lime'), - color: 'text-color-7', + label: t('colors.rose'), + color: 'text-color-1', + }, + { + label: t('colors.papaya'), + color: 'text-color-2', + }, + { + label: t('colors.mango'), + color: 'text-color-4', }, { label: t('colors.lemon'), color: 'text-color-5', }, { - label: t('colors.tangerine'), - color: 'text-color-3', + label: t('colors.olive'), + color: 'text-color-6', }, { - label: t('colors.rose'), - color: 'text-color-1', + label: t('colors.grass'), + color: 'text-color-8', + }, + { + label: t('colors.jade'), + color: 'text-color-10', + }, + { + label: t('colors.azure'), + color: 'text-color-12', + }, + { + label: t('colors.iron'), + color: 'text-color-20', }, ] : [ @@ -237,32 +254,40 @@ function TextColor({ color: '', }, { - label: t('colors.smoke'), - color: 'text-color-19', + label: t('colors.mauve'), + color: 'text-color-14', }, { - label: t('colors.mallow'), - color: 'text-color-17', + label: t('colors.lilac'), + color: 'text-color-16', }, { - label: t('colors.lavender'), - color: 'text-color-15', + label: t('colors.camellia'), + color: 'text-color-18', }, { - label: t('colors.aqua'), - color: 'text-color-11', + label: t('colors.papaya'), + color: 'text-color-2', }, { - label: t('colors.lime'), - color: 'text-color-7', + label: t('colors.mango'), + color: 'text-color-4', }, { - label: t('colors.lemon'), - color: 'text-color-5', + label: t('colors.olive'), + color: 'text-color-6', }, { - label: t('colors.tangerine'), - color: 'text-color-3', + label: t('colors.grass'), + color: 'text-color-8', + }, + { + label: t('colors.jade'), + color: 'text-color-10', + }, + { + label: t('colors.azure'), + color: 'text-color-12', }, ]; }, [isPro, t]); @@ -291,7 +316,7 @@ function TextColor({ } return ( -
+
{recentColors.length > 0 && ( <>
{t('colors.recent')}
@@ -327,7 +352,7 @@ function TextColor({
{t('colors.custom')}
-
+
{customColors.map((color, index) => ( e.stopPropagation()} diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index 437a842f..1dd20b88 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -7,6 +7,8 @@ import { ReactComponent as ChevronRightIcon } from '@/assets/icons/alt_arrow_rig import { ReactComponent as CheckIcon } from '@/assets/icons/tick.svg'; import { cn } from '@/lib/utils'; +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + function DropdownMenu({ ...props }: React.ComponentProps) { return ; } @@ -112,6 +114,28 @@ const DropdownMenuItem = forwardRef< ); }); +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + {children} + +)); + +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + function DropdownMenuLabel({ className, inset, @@ -245,6 +269,7 @@ export { DropdownMenuGroup, DropdownMenuItem, DropdownMenuItemTick, + DropdownMenuRadioItem, dropdownMenuItemVariants, DropdownMenuLabel, DropdownMenuPortal, @@ -254,4 +279,5 @@ export { DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, + DropdownMenuRadioGroup, }; diff --git a/src/components/view-meta/CoverColors.tsx b/src/components/view-meta/CoverColors.tsx index f8f5e0db..ac75f542 100644 --- a/src/components/view-meta/CoverColors.tsx +++ b/src/components/view-meta/CoverColors.tsx @@ -1,33 +1,56 @@ -import { IconButton } from '@mui/material'; -import { ColorEnum, colorMap } from '@/utils/color'; +import { ColorEnum, colorMap, GradientEnum, gradientMap } from '@/utils/color'; + +import { ColorTile } from '../_shared/color-picker'; const colors = [ - [ColorEnum.Purple, colorMap[ColorEnum.Purple]], - [ColorEnum.Pink, colorMap[ColorEnum.Pink]], - [ColorEnum.LightPink, colorMap[ColorEnum.LightPink]], - [ColorEnum.Orange, colorMap[ColorEnum.Orange]], - [ColorEnum.Yellow, colorMap[ColorEnum.Yellow]], - [ColorEnum.Lime, colorMap[ColorEnum.Lime]], - [ColorEnum.Green, colorMap[ColorEnum.Green]], - [ColorEnum.Aqua, colorMap[ColorEnum.Aqua]], - [ColorEnum.Blue, colorMap[ColorEnum.Blue]], + [ColorEnum.Tint1, colorMap[ColorEnum.Tint1]], + [ColorEnum.Tint2, colorMap[ColorEnum.Tint2]], + [ColorEnum.Tint3, colorMap[ColorEnum.Tint3]], + [ColorEnum.Tint4, colorMap[ColorEnum.Tint4]], + [ColorEnum.Tint5, colorMap[ColorEnum.Tint5]], + [ColorEnum.Tint6, colorMap[ColorEnum.Tint6]], + [ColorEnum.Tint7, colorMap[ColorEnum.Tint7]], + [ColorEnum.Tint8, colorMap[ColorEnum.Tint8]], + [ColorEnum.Tint9, colorMap[ColorEnum.Tint9]], + [ColorEnum.Tint10, colorMap[ColorEnum.Tint10]], ]; -function Colors ({ onDone }: { onDone?: (value: string) => void }) { +const gradients = [ + [GradientEnum.gradient1, gradientMap[GradientEnum.gradient1]], + [GradientEnum.gradient2, gradientMap[GradientEnum.gradient2]], + [GradientEnum.gradient3, gradientMap[GradientEnum.gradient3]], + [GradientEnum.gradient4, gradientMap[GradientEnum.gradient4]], + [GradientEnum.gradient5, gradientMap[GradientEnum.gradient5]], + [GradientEnum.gradient6, gradientMap[GradientEnum.gradient6]], + [GradientEnum.gradient7, gradientMap[GradientEnum.gradient7]], + [GradientEnum.gradient8, gradientMap[GradientEnum.gradient8]], + [GradientEnum.gradient9, gradientMap[GradientEnum.gradient9]], + [GradientEnum.gradient10, gradientMap[GradientEnum.gradient10]], +]; + +function Colors({ + isPro, + selectedColor, + onDone, +}: { + isPro: boolean; + selectedColor?: string; + onDone?: (value: string) => void; +}) { return ( -
- {colors.map(([name, value]) => ( - onDone?.(name)} - > -
- - ))} +
+
+ {colors.map(([name, value]) => ( + onDone?.(name)} /> + ))} +
+ {isPro && ( +
+ {gradients.map(([name, value]) => ( + onDone?.(name)} /> + ))} +
+ )}
); } diff --git a/src/components/view-meta/CoverPopover.tsx b/src/components/view-meta/CoverPopover.tsx index 0c1b73e7..9d4d8958 100644 --- a/src/components/view-meta/CoverPopover.tsx +++ b/src/components/view-meta/CoverPopover.tsx @@ -1,38 +1,66 @@ -import { CoverType, ViewMetaCover } from '@/application/types'; +import { CoverType, SubscriptionPlan, ViewMetaCover } from '@/application/types'; import { useAppHandlers, useAppViewId, useOpenModalViewId } from '@/components/app/app.hooks'; -import React, { useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { EmbedLink, Unsplash, UploadPopover, TabOption, TAB_KEY, UploadImage } from '@/components/_shared/image-upload'; import { useTranslation } from 'react-i18next'; import Colors from './CoverColors'; +import { GradientEnum } from '@/utils/color'; -function CoverPopover ({ +function CoverPopover({ + coverValue, open, onOpenChange, onUpdateCover, children, }: { + coverValue?: string; open: boolean; onOpenChange: (open: boolean) => void; onUpdateCover?: (cover: ViewMetaCover) => void; children: React.ReactNode; }) { const { t } = useTranslation(); - const { - uploadFile, - } = useAppHandlers(); + const { uploadFile, getSubscriptions } = useAppHandlers(); const appViewId = useAppViewId(); const modalViewId = useOpenModalViewId(); const viewId = modalViewId || appViewId; + const [activeSubscriptionPlan, setActiveSubscriptionPlan] = useState(null); + const isPro = activeSubscriptionPlan === SubscriptionPlan.Pro; + + const loadSubscription = useCallback(async () => { + try { + const subscriptions = await getSubscriptions?.(); + + if (!subscriptions || subscriptions.length === 0) { + setActiveSubscriptionPlan(SubscriptionPlan.Free); + return; + } + + const subscription = subscriptions[0]; + + setActiveSubscriptionPlan(subscription?.plan || SubscriptionPlan.Free); + } catch (e) { + setActiveSubscriptionPlan(SubscriptionPlan.Free); + console.error(e); + } + }, [getSubscriptions]); + + useEffect(() => { + void loadSubscription(); + }, [loadSubscription]); + const tabOptions: TabOption[] = useMemo(() => { return [ { label: t('document.plugins.cover.colors'), key: TAB_KEY.Colors, - Component: Colors, + Component: (props) => , onDone: (value: string) => { + const isGradient = Object.values(GradientEnum).includes(value as GradientEnum); + onUpdateCover?.({ - type: CoverType.NormalColor, + type: isGradient ? CoverType.GradientColor : CoverType.NormalColor, value, }); }, @@ -77,14 +105,12 @@ function CoverPopover ({ }, }, ]; - }, [onOpenChange, onUpdateCover, t, uploadFile, viewId]); + }, [coverValue, isPro, onOpenChange, onUpdateCover, t, uploadFile, viewId]); return ( - {children} + + {children} + ); } diff --git a/src/components/view-meta/TitleEditable.tsx b/src/components/view-meta/TitleEditable.tsx index 752ba73c..3d4f4ecd 100644 --- a/src/components/view-meta/TitleEditable.tsx +++ b/src/components/view-meta/TitleEditable.tsx @@ -52,12 +52,14 @@ function TitleEditable({ onUpdateName, onEnter, onFocus, + autoFocus = true, }: { viewId: string; name: string; onUpdateName: (name: string) => void; onEnter?: (text: string) => void; onFocus?: () => void; + autoFocus?: boolean; }) { const { t } = useTranslation(); @@ -75,7 +77,7 @@ function TitleEditable({ pendingUpdate: null, updateId: null, }); - const [isEditing, setIsEditing] = useState(false); + const [isFocused, setIsFocused] = useState(false); const contentRef = useRef(null); const cursorPositionRef = useRef(0); @@ -142,11 +144,11 @@ function TitleEditable({ newName: name, pendingUpdate: updateStateRef.current.pendingUpdate, lastConfirmedName: updateStateRef.current.lastConfirmedName, - isEditing, + isFocused, }); - // If editing, ignore all remote updates - if (isEditing) { + // If focused, ignore all remote updates + if (isFocused) { return; } @@ -204,7 +206,7 @@ function TitleEditable({ if (contentRef.current) { contentRef.current.textContent = name; } - }, [name, isEditing, smartSendUpdate]); + }, [name, isFocused, smartSendUpdate]); const focusedTextbox = useCallback(() => { const contentBox = contentRef.current; @@ -216,7 +218,7 @@ function TitleEditable({ textbox?.focus(); }, [viewId]); - // Initialize settings + // Initialize content and handle autoFocus useEffect(() => { const contentBox = contentRef.current; @@ -234,24 +236,23 @@ function TitleEditable({ contentBox.textContent = updateStateRef.current.localName; initialEditValueRef.current = updateStateRef.current.localName; - // Use requestAnimationFrame for better timing - const focusAndPosition = () => { + // Ensure focus if autoFocus is true + if (autoFocus) { + // Use requestAnimationFrame for next paint cycle to ensure DOM is fully ready requestAnimationFrame(() => { - console.log('[TitleEditable] Focusing element'); - contentBox.focus(); - - // Move cursor to end - if (contentBox.textContent !== '') { - requestAnimationFrame(() => { - setCursorPosition(contentBox, contentBox.textContent?.length || 0); - console.log('[TitleEditable] Cursor positioned at end'); - }); + // Double-check the element still exists and is in the document + if (contentBox && document.contains(contentBox)) { + contentBox.focus(); + // Move cursor to end if there's content + if (contentBox.textContent) { + setCursorPosition(contentBox, contentBox.textContent.length); + } } }); - }; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // Only execute once when component mounts - autoFocus is intentionally not in deps - focusAndPosition(); - }, []); // Only execute once when component mounts return (
{ console.log('[TitleEditable] Focus event triggered'); - + // Record initial value when starting to edit if (contentRef.current) { initialEditValueRef.current = contentRef.current.textContent || ''; } - setIsEditing(true); + setIsFocused(true); onFocus?.(); }} onBlur={() => { @@ -299,10 +300,10 @@ function TitleEditable({ } } - // Delay setting editing state to avoid issues with rapid focus switching - setTimeout(() => { - setIsEditing(false); - }, 100); + // Use microtask to avoid race conditions + void Promise.resolve().then(() => { + setIsFocused(false); + }); }} onInput={() => { if (!contentRef.current) return; @@ -341,7 +342,7 @@ function TitleEditable({ if (offset >= currentText.length || offset <= 0) { // Cursor at end or position inaccurate, keep all text - setIsEditing(false); + setIsFocused(false); updateStateRef.current = { ...updateStateRef.current, localName: currentText, @@ -354,7 +355,7 @@ function TitleEditable({ const afterText = currentText.slice(offset); contentRef.current.textContent = beforeText; - setIsEditing(false); + setIsFocused(false); updateStateRef.current = { ...updateStateRef.current, localName: beforeText, @@ -370,7 +371,7 @@ function TitleEditable({ // Escape key: complete editing and save current content const currentText = contentRef.current.textContent || ''; - setIsEditing(false); + setIsFocused(false); updateStateRef.current = { ...updateStateRef.current, localName: currentText, diff --git a/src/components/view-meta/ViewCover.tsx b/src/components/view-meta/ViewCover.tsx index dea482b3..bf01e346 100644 --- a/src/components/view-meta/ViewCover.tsx +++ b/src/components/view-meta/ViewCover.tsx @@ -68,6 +68,7 @@ function ViewCover({ {!readOnly && ( void; onUpdateCover: (cover: ViewMetaCover) => void; fullWidth?: boolean; }, - ref: React.ForwardedRef, + ref: React.ForwardedRef ) { const { t } = useTranslation(); const [showPopover, setShowPopover] = useState(false); return ( <> -
+
+ + {t('document.plugins.cover.removeCover')} + + + +
-
); diff --git a/src/components/view-meta/ViewMetaPreview.tsx b/src/components/view-meta/ViewMetaPreview.tsx index e0884376..b8b7a6a9 100644 --- a/src/components/view-meta/ViewMetaPreview.tsx +++ b/src/components/view-meta/ViewMetaPreview.tsx @@ -7,6 +7,7 @@ import ViewCover from '@/components/view-meta/ViewCover'; import { CustomIconPopover } from '@/components/_shared/cutsom-icon'; import { notify } from '@/components/_shared/notify'; import PageIcon from '@/components/_shared/view-icon/PageIcon'; +import { ColorEnum } from '@/utils/color'; const AddIconCover = lazy(() => import('@/components/view-meta/AddIconCover')); @@ -192,8 +193,8 @@ export function ViewMetaPreview({ onUpdateIcon={handleUpdateIcon} onAddCover={() => { void handleUpdateCover({ - type: CoverType.BuildInImage, - value: '1', + type: CoverType.NormalColor, + value: ColorEnum.Tint1, }); }} maxWidth={maxWidth} diff --git a/src/pages/AppPage.tsx b/src/pages/AppPage.tsx index 67071cf1..bebfca08 100644 --- a/src/pages/AppPage.tsx +++ b/src/pages/AppPage.tsx @@ -1,6 +1,8 @@ import React, { lazy, memo, Suspense, useCallback, useContext, useEffect, useMemo } from 'react'; import { UIVariant, ViewLayout, ViewMetaProps, YDoc } from '@/application/types'; +import Help from '@/components/_shared/help/Help'; +import { findView } from '@/components/_shared/outline/utils'; import { AIChat } from '@/components/ai-chat'; import { AppContext, @@ -13,8 +15,6 @@ import DatabaseView from '@/components/app/DatabaseView'; import { Document } from '@/components/document'; import RecordNotFound from '@/components/error/RecordNotFound'; import { useService } from '@/components/main/app.hooks'; -import Help from '@/components/_shared/help/Help'; -import { findView } from '@/components/_shared/outline/utils'; import { getPlatform } from '@/utils/platform'; const ViewHelmet = lazy(() => import('@/components/_shared/helmet/ViewHelmet')); diff --git a/src/styles/global.css b/src/styles/global.css index 0b043ea3..93c74f98 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -31,3 +31,17 @@ animation: progress-container 1s linear infinite; } } + +@keyframes float { + + 0%, + 100% { + transform: translateY(0px) scale(1); + opacity: 0.2; + } + + 50% { + transform: translateY(-15px) scale(1.1); + opacity: 0.4; + } +} \ No newline at end of file diff --git a/src/styles/variables/dark.variables.css b/src/styles/variables/dark.variables.css index 4196e68b..80b2b930 100644 --- a/src/styles/variables/dark.variables.css +++ b/src/styles/variables/dark.variables.css @@ -78,6 +78,16 @@ --gradient5: linear-gradient(56.2deg, #5749CA 0%, #BB4A97 100%); --gradient6: linear-gradient(180deg, #036FFA 0%, #00B8E5 100%); --gradient7: linear-gradient(38.2deg, #F0C6CF 0%, #DECCE2 40.4754%, #CAD3F9 100%); + --new-gradient1: linear-gradient(180deg, #6DD5FF 0%, #D0A2FF 100%); + --new-gradient2: linear-gradient(180deg, #D0A2FF 0%, #FF84BF 100%); + --new-gradient3: linear-gradient(180deg, #FF84BF 0%, #FFDD7B 100%); + --new-gradient4: linear-gradient(180deg, #FFDD7B 0%, #87FFAB 100%); + --new-gradient5: linear-gradient(180deg, #89D7FE 0%, #7A81FF 100%); + --new-gradient6: linear-gradient(180deg, #00B5FF 0%, #9225FF 100%); + --new-gradient7: linear-gradient(180deg, #9327FF 0%, #E7348A 100%); + --new-gradient8: linear-gradient(180deg, #E3006D 0%, #FFBD00 100%); + --new-gradient9: linear-gradient(180deg, #FFBD00 0%, #00BC38 100%); + --new-gradient10: linear-gradient(180deg, #1CF8E3 0%, #4B32FE 100%); --bg-header: #1a202ccc; --bg-footer: #00000000; --note-header: #232b38; @@ -85,5 +95,4 @@ --billing-primary-hover: #7A2EBF; --ai-primary: #D08EED; --writer-placeholder: #49CFF4; - -} \ No newline at end of file +} diff --git a/src/styles/variables/light.variables.css b/src/styles/variables/light.variables.css index ce8f1ee9..f39e545f 100644 --- a/src/styles/variables/light.variables.css +++ b/src/styles/variables/light.variables.css @@ -80,6 +80,16 @@ --gradient5: linear-gradient(56.2deg, #5749CA 0%, #BB4A97 100%); --gradient6: linear-gradient(180deg, #036FFA 0%, #00B8E5 100%); --gradient7: linear-gradient(38.2deg, #F0C6CF 0%, #DECCE2 40.4754%, #CAD3F9 100%); + --new-gradient1: linear-gradient(180deg, #6DD5FF 0%, #D0A2FF 100%); + --new-gradient2: linear-gradient(180deg, #D0A2FF 0%, #FF84BF 100%); + --new-gradient3: linear-gradient(180deg, #FF84BF 0%, #FFDD7B 100%); + --new-gradient4: linear-gradient(180deg, #FFDD7B 0%, #87FFAB 100%); + --new-gradient5: linear-gradient(180deg, #89D7FE 0%, #7A81FF 100%); + --new-gradient6: linear-gradient(180deg, #00B5FF 0%, #9225FF 100%); + --new-gradient7: linear-gradient(180deg, #9327FF 0%, #E7348A 100%); + --new-gradient8: linear-gradient(180deg, #E3006D 0%, #FFBD00 100%); + --new-gradient9: linear-gradient(180deg, #FFBD00 0%, #00BC38 100%); + --new-gradient10: linear-gradient(180deg, #1CF8E3 0%, #4B32FE 100%); --bg-header: #FFFFFFCC; --bg-footer: #FFFFFFCC; --note-header: #EDEFF3; diff --git a/src/styles/variables/semantic.dark.css b/src/styles/variables/semantic.dark.css index 90ba4315..e8a23718 100644 --- a/src/styles/variables/semantic.dark.css +++ b/src/styles/variables/semantic.dark.css @@ -3,7 +3,7 @@ * AUTO-GENERATED FILE - DO NOT EDIT DIRECTLY * * Generated from: semantic.dark.json - * Generated time: 2025-08-19T02:49:46.811Z + * Generated time: 2025-08-27T10:50:43.202Z * * To modify these values, edit the source JSON files and run the token conversion script: * node scripts/system-token/convert-tokens.cjs @@ -15,9 +15,11 @@ --text-tertiary: #6f748c; --text-quaternary: #21232a; --text-on-fill: #ffffff; + --text-inverse: #21232a; --text-action: #00b5ff; --text-action-hover: #4ec1ff; --text-info: #00b5ff; + --text-info-light: #0078c0bf; --text-info-hover: #4ec1ff; --text-info-on-fill: #a9e2ff; --text-success: #48a982; @@ -27,6 +29,7 @@ --text-warning-hover: #ffa02e; --text-warning-on-fill: #ffe4ab; --text-error: #ff5050; + --text-error-light: #e71d32bf; --text-error-hover: #ff7d87; --text-error-on-fill: #ffa5b4; --text-featured: #e1b3ff; @@ -343,6 +346,9 @@ --brand-lemon: #ffce00; --other-colors-text-highlight: #003c77; --other-colors-icon-shared: #9ab6ed; + --other-colors-text-event: #a9e2ff; + --other-colors-filled-event: #003c77; + --other-colors-filled-today: #f36bb1; --spacing-spacing-0: 0px; --spacing-spacing-xs: 4px; --spacing-spacing-s: 6px; diff --git a/src/styles/variables/semantic.light.css b/src/styles/variables/semantic.light.css index b0abaf96..b43cd2da 100644 --- a/src/styles/variables/semantic.light.css +++ b/src/styles/variables/semantic.light.css @@ -3,7 +3,7 @@ * AUTO-GENERATED FILE - DO NOT EDIT DIRECTLY * * Generated from: semantic.light.json - * Generated time: 2025-08-19T02:49:46.804Z + * Generated time: 2025-08-27T10:50:43.196Z * * To modify these values, edit the source JSON files and run the token conversion script: * node scripts/system-token/convert-tokens.cjs @@ -15,9 +15,11 @@ --text-tertiary: #989eb7; --text-quaternary: #e4e8f5; --text-on-fill: #ffffff; + --text-inverse: #ffffff; --text-action: #0092d6; --text-action-hover: #0078c0; --text-info: #0092d6; + --text-info-light: #0078c0bf; --text-info-hover: #0078c0; --text-info-on-fill: #0078c0; --text-success: #248569; @@ -27,6 +29,7 @@ --text-warning-hover: #b75f17; --text-warning-on-fill: #b75f17; --text-error: #e71d32; + --text-error-light: #e71d32bf; --text-error-hover: #ad1625; --text-error-on-fill: #ad1625; --text-featured: #9327ff; @@ -343,6 +346,9 @@ --brand-lemon: #ffce00; --other-colors-text-highlight: #a9e2ff; --other-colors-icon-shared: #3267d1; + --other-colors-text-event: #0065a9; + --other-colors-filled-event: #e3f6ff; + --other-colors-filled-today: #eb368f; --spacing-spacing-0: 0px; --spacing-spacing-xs: 4px; --spacing-spacing-s: 6px; diff --git a/src/utils/color.ts b/src/utils/color.ts index dc610226..4d7ba675 100644 --- a/src/utils/color.ts +++ b/src/utils/color.ts @@ -1,13 +1,18 @@ export enum ColorEnum { - Purple = 'appflowy_them_color_tint1', - Pink = 'appflowy_them_color_tint2', - LightPink = 'appflowy_them_color_tint3', - Orange = 'appflowy_them_color_tint4', - Yellow = 'appflowy_them_color_tint5', - Lime = 'appflowy_them_color_tint6', - Green = 'appflowy_them_color_tint7', - Aqua = 'appflowy_them_color_tint8', - Blue = 'appflowy_them_color_tint9', + Tint1 = 'appflowy_them_color_tint1', + Tint2 = 'appflowy_them_color_tint2', + Tint3 = 'appflowy_them_color_tint3', + Tint4 = 'appflowy_them_color_tint4', + Tint5 = 'appflowy_them_color_tint5', + Tint6 = 'appflowy_them_color_tint6', + Tint7 = 'appflowy_them_color_tint7', + Tint8 = 'appflowy_them_color_tint8', + Tint9 = 'appflowy_them_color_tint9', + Tint10 = 'appflowy_them_color_tint10', + Tint11 = 'appflowy_them_color_tint11', + Tint12 = 'appflowy_them_color_tint12', + Tint13 = 'appflowy_them_color_tint13', + Tint14 = 'appflowy_them_color_tint14', DefaultText = 'text-default', DefaultBackground = 'bg-default', TextColor1 = 'text-color-1', @@ -60,18 +65,26 @@ export enum GradientEnum { gradient5 = 'appflowy_them_color_gradient5', gradient6 = 'appflowy_them_color_gradient6', gradient7 = 'appflowy_them_color_gradient7', + gradient8 = 'appflowy_them_color_gradient8', + gradient9 = 'appflowy_them_color_gradient9', + gradient10 = 'appflowy_them_color_gradient10', } export const colorMap = { - [ColorEnum.Purple]: 'var(--tint-purple)', - [ColorEnum.Pink]: 'var(--tint-pink)', - [ColorEnum.LightPink]: 'var(--tint-red)', - [ColorEnum.Orange]: 'var(--tint-orange)', - [ColorEnum.Yellow]: 'var(--tint-yellow)', - [ColorEnum.Lime]: 'var(--tint-lime)', - [ColorEnum.Green]: 'var(--tint-green)', - [ColorEnum.Aqua]: 'var(--tint-aqua)', - [ColorEnum.Blue]: 'var(--tint-blue)', + [ColorEnum.Tint1]: 'var(--palette-bg-color-14)', + [ColorEnum.Tint2]: 'var(--palette-bg-color-16)', + [ColorEnum.Tint3]: 'var(--palette-bg-color-18)', + [ColorEnum.Tint4]: 'var(--palette-bg-color-2)', + [ColorEnum.Tint5]: 'var(--palette-bg-color-4)', + [ColorEnum.Tint6]: 'var(--palette-bg-color-6)', + [ColorEnum.Tint7]: 'var(--palette-bg-color-8)', + [ColorEnum.Tint8]: 'var(--palette-bg-color-10)', + [ColorEnum.Tint9]: 'var(--palette-bg-color-12)', + [ColorEnum.Tint10]: 'var(--palette-bg-color-20)', + [ColorEnum.Tint11]: 'var(--palette-bg-color-15)', + [ColorEnum.Tint12]: 'var(--palette-bg-color-17)', + [ColorEnum.Tint13]: 'var(--palette-bg-color-1)', + [ColorEnum.Tint14]: 'var(--palette-bg-color-5)', [ColorEnum.DefaultText]: 'var(--text-primary)', [ColorEnum.DefaultBackground]: '', [ColorEnum.TextColor1]: 'var(--palette-text-color-1)', @@ -117,13 +130,16 @@ export const colorMap = { }; export const gradientMap = { - [GradientEnum.gradient1]: 'var(--gradient1)', - [GradientEnum.gradient2]: 'var(--gradient2)', - [GradientEnum.gradient3]: 'var(--gradient3)', - [GradientEnum.gradient4]: 'var(--gradient4)', - [GradientEnum.gradient5]: 'var(--gradient5)', - [GradientEnum.gradient6]: 'var(--gradient6)', - [GradientEnum.gradient7]: 'var(--gradient7)', + [GradientEnum.gradient1]: 'var(--new-gradient1)', + [GradientEnum.gradient2]: 'var(--new-gradient2)', + [GradientEnum.gradient3]: 'var(--new-gradient3)', + [GradientEnum.gradient4]: 'var(--new-gradient4)', + [GradientEnum.gradient5]: 'var(--new-gradient5)', + [GradientEnum.gradient6]: 'var(--new-gradient6)', + [GradientEnum.gradient7]: 'var(--new-gradient7)', + [GradientEnum.gradient8]: 'var(--new-gradient8)', + [GradientEnum.gradient9]: 'var(--new-gradient9)', + [GradientEnum.gradient10]: 'var(--new-gradient10)', }; // Convert ARGB to RGBA diff --git a/src/utils/time.ts b/src/utils/time.ts index fa45f690..c6145436 100644 --- a/src/utils/time.ts +++ b/src/utils/time.ts @@ -1,3 +1,4 @@ +import { DateFormat, TimeFormat } from '@/application/types'; import dayjs from 'dayjs'; export function renderDate(date: string | number, format: string, isUnix?: boolean): string { @@ -61,3 +62,31 @@ export function isTimestampBetweenRange(timestamp: string, startTimestamp: strin return dateUnix >= startUnix && dateUnix <= endUnix; } + +export function getTimeFormat(timeFormat?: TimeFormat) { + switch (timeFormat) { + case TimeFormat.TwelveHour: + return 'h:mm A'; + case TimeFormat.TwentyFourHour: + return 'HH:mm'; + default: + return 'HH:mm'; + } +} + +export function getDateFormat(dateFormat?: DateFormat) { + switch (dateFormat) { + case DateFormat.Friendly: + return 'MMM DD, YYYY'; + case DateFormat.ISO: + return 'YYYY-MM-DD'; + case DateFormat.US: + return 'YYYY/MM/DD'; + case DateFormat.Local: + return 'MM/DD/YYYY'; + case DateFormat.DayMonthYear: + return 'DD/MM/YYYY'; + default: + return 'YYYY-MM-DD'; + } +} diff --git a/tailwind/new-colors.cjs b/tailwind/new-colors.cjs index 0ebcf647..1d9f3e9a 100644 --- a/tailwind/new-colors.cjs +++ b/tailwind/new-colors.cjs @@ -3,7 +3,7 @@ * AUTO-GENERATED FILE - DO NOT EDIT DIRECTLY * * This file is auto-generated by convert-tokens.cjs script - * Generation time: 2025-08-19T02:49:46.812Z + * Generation time: 2025-08-27T10:50:43.203Z * * To modify these colors, edit the source JSON files and run the token conversion script: * node scripts/system-token/convert-tokens.cjs @@ -18,9 +18,11 @@ module.exports = { "tertiary": "var(--text-tertiary)", "quaternary": "var(--text-quaternary)", "on-fill": "var(--text-on-fill)", + "inverse": "var(--text-inverse)", "action": "var(--text-action)", "action-hover": "var(--text-action-hover)", "info": "var(--text-info)", + "info-light": "var(--text-info-light)", "info-hover": "var(--text-info-hover)", "info-on-fill": "var(--text-info-on-fill)", "success": "var(--text-success)", @@ -30,6 +32,7 @@ module.exports = { "warning-hover": "var(--text-warning-hover)", "warning-on-fill": "var(--text-warning-on-fill)", "error": "var(--text-error)", + "error-light": "var(--text-error-light)", "error-hover": "var(--text-error-hover)", "error-on-fill": "var(--text-error-on-fill)", "featured": "var(--text-featured)", @@ -375,6 +378,9 @@ module.exports = { }, "other": { "colors-text-highlight": "var(--other-colors-text-highlight)", - "colors-icon-shared": "var(--other-colors-icon-shared)" + "colors-icon-shared": "var(--other-colors-icon-shared)", + "colors-text-event": "var(--other-colors-text-event)", + "colors-filled-event": "var(--other-colors-filled-event)", + "colors-filled-today": "var(--other-colors-filled-today)" } };