diff --git a/src/@types/translations/en.json b/src/@types/translations/en.json index 779d921f..11d1335b 100644 --- a/src/@types/translations/en.json +++ b/src/@types/translations/en.json @@ -2131,8 +2131,8 @@ "networkTab": "Embed link", "placeholderText": "Upload or embed a file", "placeholderDragging": "Drop the file to upload", - "dropFileToUpload": "Drop a file to upload", - "fileUploadHint": "Drag & drop a file or click to ", + "dropFileToUpload": "Drop a file to upload or ", + "fileUploadHint": "Drag & drop a file or ", "fileUploadHintSuffix": "Browse", "networkHint": "Paste a file link", "networkUrlInvalid": "Invalid URL. Check the URL and try again.", @@ -2392,7 +2392,9 @@ }, "referencedCalendarPrefix": "View of", "quickJumpYear": "Jump to", - "duplicateEvent": "Duplicate event" + "duplicateEvent": "Duplicate event", + "deleteEventPrompt_one": "Are you sure you want to delete this event? This action cannot be undone.", + "deleteEventPrompt_other": "Are you sure you want to delete these events? This action cannot be undone." }, "errorDialog": { "title": "@:appName Error", diff --git a/src/application/constants.ts b/src/application/constants.ts index 9625495f..4b08ff21 100644 --- a/src/application/constants.ts +++ b/src/application/constants.ts @@ -6,7 +6,8 @@ export const ERROR_CODE = { INVALID_LINK: 1068, ALREADY_JOINED: 1073, NOT_INVITEE_OF_INVITATION: 1041, - NOT_HAS_PERMISSION: 1012 + NOT_HAS_PERMISSION: 1012, + USER_UNAUTHORIZED: 1024 }; export const APP_EVENTS = { diff --git a/src/application/database-yjs/context.ts b/src/application/database-yjs/context.ts index 1e329693..51710afd 100644 --- a/src/application/database-yjs/context.ts +++ b/src/application/database-yjs/context.ts @@ -1,3 +1,5 @@ +import EventEmitter from 'events'; + import { AxiosInstance } from 'axios'; import { createContext, useContext } from 'react'; @@ -15,6 +17,7 @@ import { Subscription, TestDatabasePromptConfig, TimeFormat, + UIVariant, UpdatePagePayload, View, YDatabase, @@ -24,9 +27,9 @@ import { YjsEditorKey, YSharedRoot, } from '@/application/types'; -import EventEmitter from 'events'; -import { useCurrentUser } from '@/components/main/app.hooks'; +import { CalendarViewType } from '@/components/database/fullcalendar/types'; import { DefaultTimeSetting, MetadataKey } from '@/application/user-metadata'; +import { useCurrentUser } from '@/components/main/app.hooks'; export interface DatabaseContextState { readOnly: boolean; @@ -63,6 +66,10 @@ export interface DatabaseContextState { eventEmitter?: EventEmitter; getSubscriptions?: (() => Promise) | undefined; getViewIdFromDatabaseId?: (databaseId: string) => string | null; + variant?: UIVariant; + // Calendar view type map: viewId -> CalendarViewType + calendarViewTypeMap?: Map; + setCalendarViewType?: (viewId: string, viewType: CalendarViewType) => void; } export const DatabaseContext = createContext(null); diff --git a/src/application/database-yjs/database.type.ts b/src/application/database-yjs/database.type.ts index f12dbca7..993e60e5 100644 --- a/src/application/database-yjs/database.type.ts +++ b/src/application/database-yjs/database.type.ts @@ -66,6 +66,7 @@ export interface CalendarLayoutSetting { showWeekends: boolean; layout: CalendarLayout; numberOfDays: number; + use24Hour: boolean; } export enum RowMetaKey { diff --git a/src/application/database-yjs/dispatch.ts b/src/application/database-yjs/dispatch.ts index 3bf3a39c..abd9ba1a 100644 --- a/src/application/database-yjs/dispatch.ts +++ b/src/application/database-yjs/dispatch.ts @@ -1212,7 +1212,11 @@ export function useNewRowDispatch() { } >; tailing?: boolean; - }) => { + }) => { + if (!currentView) { + throw new Error('Current view not found'); + } + if (!createRow) { throw new Error('No createRow function'); } @@ -1229,8 +1233,6 @@ export function useNewRowDispatch() { const cells = row.get(YjsDatabaseKey.cells); - - if (filters) { filters.toArray().forEach((filter) => { const cell = new Y.Map() as YDatabaseCell; @@ -1241,7 +1243,7 @@ export function useNewRowDispatch() { return; } - if (isCalendar && calendarSetting.fieldId === fieldId) { + if (isCalendar && calendarSetting?.fieldId === fieldId) { shouldOpenRowModal = true; } @@ -1344,12 +1346,16 @@ export function useNewRowDispatch() { return rowId; }, - [calendarSetting.fieldId, createRow, database, filters, guid, isCalendar, navigateToRow, sharedRoot, viewId] + [calendarSetting, createRow, currentView, database, filters, guid, isCalendar, navigateToRow, sharedRoot, viewId] ); } -export function useCreateCalendarEvent(fieldId: string) { +export function useCreateCalendarEvent() { const newRowDispatch = useNewRowDispatch(); + const currentView = useDatabaseView(); + const defaultTimeSetting = useDefaultTimeSetting(); + const enhanceCalendarLayoutByFieldExists = useEnhanceCalendarLayoutByFieldExists(); + const calendarSetting = useCalendarLayoutSetting(); return useCallback( async ({ @@ -1361,10 +1367,46 @@ export function useCreateCalendarEvent(fieldId: string) { endTimestamp?: string; includeTime?: boolean; }) => { + if (!currentView) { + throw new Error('Current view not found'); + } + + // Create or ensure correct date field before creating the event + const fieldOrders = currentView.get(YjsDatabaseKey.field_orders); + const validFieldId = () => { + if (!calendarSetting || !calendarSetting.fieldId) { + return false; + } + + return fieldOrders.toArray().some((fieldOrder) => fieldOrder.id === calendarSetting.fieldId); + } + + let finalFieldId = calendarSetting?.fieldId; + + if (!validFieldId()) { + const dateField: YDatabaseField | undefined = enhanceCalendarLayoutByFieldExists(fieldOrders); + const createdFieldId = dateField?.get(YjsDatabaseKey.id); + + if (!createdFieldId) { + throw new Error(`Date field not found`); + } + + const newCalendarSetting = generateCalendarLayoutSettings(createdFieldId, defaultTimeSetting); + + currentView.set(YjsDatabaseKey.layout_settings, newCalendarSetting); + + // Use the created field ID for the event + finalFieldId = createdFieldId; + } + + if (!finalFieldId) { + throw new Error(`Field ID not found`); + } + const rowId = await newRowDispatch({ tailing: true, cellsData: { - [fieldId]: { + [finalFieldId]: { data: startTimestamp, endTimestamp, isRange: !!endTimestamp, @@ -1375,7 +1417,7 @@ export function useCreateCalendarEvent(fieldId: string) { return rowId; }, - [fieldId, newRowDispatch] + [newRowDispatch, currentView, defaultTimeSetting, enhanceCalendarLayoutByFieldExists, calendarSetting] ); } @@ -2158,11 +2200,67 @@ function generateCalendarLayoutSettings(fieldId: FieldId, defaultTimeSetting: De return layoutSettings; } +function useEnhanceCalendarLayoutByFieldExists() { + const database = useDatabase(); + const fields = database.get(YjsDatabaseKey.fields); + + const sharedRoot = useSharedRoot(); + + return useCallback((fieldOrders: YDatabaseFieldOrders) => { + // find date field in all views + let dateField: YDatabaseField | undefined; + + fieldOrders.forEach((fieldOrder) => { + const field = fields?.get(fieldOrder.id); + + if ( + !dateField && + [FieldType.DateTime].includes( + Number(field?.get(YjsDatabaseKey.type)) + ) + ) { + dateField = field; + } + }); + + // if no date field, create a new one + if (!dateField) { + const fieldId = nanoid(6); + + dateField = createField(FieldType.DateTime, fieldId); + + const typeOptionMap = generateDateTimeFieldTypeOptions(); + + dateField.set(YjsDatabaseKey.type_option, typeOptionMap); + fields.set(fieldId, dateField); + + executeOperationWithAllViews( + sharedRoot, + database, + (view) => { + const fieldOrders = view?.get(YjsDatabaseKey.field_orders); + + fieldOrders.push([ + { + id: fieldId, + }, + ]); + }, + 'newDateTimeField' + ); + } + + return dateField; + }, [database, fields, sharedRoot]) + +} + export function useAddDatabaseView() { const { iidIndex, createFolderView } = useDatabaseContext(); const database = useDatabase(); const sharedRoot = useSharedRoot(); + const enhanceCalendarLayoutByFieldExists = useEnhanceCalendarLayoutByFieldExists(); const defaultTimeSetting = useDefaultTimeSetting(); return useCallback( @@ -2194,52 +2292,10 @@ export function useAddDatabaseView() { const refView = database.get(YjsDatabaseKey.views)?.get(iidIndex); const refRowOrders = refView.get(YjsDatabaseKey.row_orders); const refFieldOrders = refView.get(YjsDatabaseKey.field_orders); - const fields = database.get(YjsDatabaseKey.fields); // find date field in all views - let dateField: YDatabaseField | undefined; + const dateField: YDatabaseField | undefined = enhanceCalendarLayoutByFieldExists(refFieldOrders); - if (layout === DatabaseViewLayout.Calendar) { - refFieldOrders.forEach((fieldOrder) => { - const field = fields?.get(fieldOrder.id); - - if ( - !dateField && - [FieldType.DateTime].includes( - Number(field?.get(YjsDatabaseKey.type)) - ) - ) { - dateField = field; - } - }); - - // if no date field, create a new one - if (!dateField) { - const fieldId = nanoid(6); - - dateField = createField(FieldType.DateTime, fieldId); - - const typeOptionMap = generateDateTimeFieldTypeOptions(); - - dateField.set(YjsDatabaseKey.type_option, typeOptionMap); - fields.set(fieldId, dateField); - - executeOperationWithAllViews( - sharedRoot, - database, - (view) => { - const fieldOrders = view?.get(YjsDatabaseKey.field_orders); - - fieldOrders.push([ - { - id: fieldId, - }, - ]); - }, - 'newDateTimeField' - ); - } - } executeOperations( sharedRoot, @@ -2309,7 +2365,7 @@ export function useAddDatabaseView() { ); return newViewId; }, - [createFolderView, database, defaultTimeSetting, iidIndex, sharedRoot] + [createFolderView, database, defaultTimeSetting, enhanceCalendarLayoutByFieldExists, iidIndex, sharedRoot] ); } @@ -2317,6 +2373,9 @@ export function useUpdateDatabaseLayout(viewId: string) { const database = useDatabase(); const sharedRoot = useSharedRoot(); + const enhanceCalendarLayoutByFieldExists = useEnhanceCalendarLayoutByFieldExists(); + const defaultTimeSetting = useDefaultTimeSetting(); + return useCallback( (layout: DatabaseViewLayout) => { executeOperations( @@ -2333,8 +2392,9 @@ export function useUpdateDatabaseLayout(viewId: string) { return; } + const fieldOrders = view.get(YjsDatabaseKey.field_orders); + if (layout === DatabaseViewLayout.Board) { - const fieldOrders = view.get(YjsDatabaseKey.field_orders); const groups = generateBoardGroup(database, fieldOrders); const settings = generateBoardSetting(database); const layoutSettings = generateBoardLayoutSettings(); @@ -2344,13 +2404,28 @@ export function useUpdateDatabaseLayout(viewId: string) { view.set(YjsDatabaseKey.layout_settings, layoutSettings); } + if (layout === DatabaseViewLayout.Calendar) { + // find date field in all views + const dateField: YDatabaseField | undefined = enhanceCalendarLayoutByFieldExists(fieldOrders); + const fieldId = dateField?.get(YjsDatabaseKey.id); + + if (!fieldId) { + throw new Error(`Date field not found`); + } + + const layoutSettings = generateCalendarLayoutSettings(fieldId, defaultTimeSetting); + + view.set(YjsDatabaseKey.layout_settings, layoutSettings); + + } + view.set(YjsDatabaseKey.layout, layout); }, ], 'updateDatabaseLayout' ); }, - [database, sharedRoot, viewId] + [database, defaultTimeSetting, enhanceCalendarLayoutByFieldExists, sharedRoot, viewId] ); } diff --git a/src/application/database-yjs/selector.ts b/src/application/database-yjs/selector.ts index ec31b241..026741c8 100644 --- a/src/application/database-yjs/selector.ts +++ b/src/application/database-yjs/selector.ts @@ -28,12 +28,14 @@ import { DatabaseViewLayout, FieldId, SortId, + TimeFormat, YDatabase, YDatabaseMetas, YDatabaseRow, YDoc, YjsDatabaseKey, YjsEditorKey, + YSharedRoot, } from '@/application/types'; import { MetadataKey } from '@/application/user-metadata'; import { useCurrentUser } from '@/components/main/app.hooks'; @@ -850,7 +852,7 @@ export interface CalendarEvent { export function useCalendarEventsSelector() { const setting = useCalendarLayoutSetting(); - const filedId = setting.fieldId; + const filedId = setting?.fieldId || ''; const { field } = useFieldSelector(filedId); const primaryFieldId = usePrimaryFieldId(); const rowOrders = useRowOrdersSelector(); @@ -859,7 +861,7 @@ export function useCalendarEventsSelector() { const [emptyEvents, setEmptyEvents] = useState([]); useEffect(() => { - if (!field || !rowOrders || !rows) return; + if (!field || !rowOrders || !rows || !filedId) return; const fieldType = Number(field?.get(YjsDatabaseKey.type)) as FieldType; if (![FieldType.DateTime, FieldType.LastEditedTime, FieldType.CreatedTime].includes(fieldType) || !primaryFieldId) return; @@ -879,12 +881,13 @@ export function useCalendarEventsSelector() { if (!doc) return; - const rowSharedRoot = doc.getMap(YjsEditorKey.data_section); - const databbaseRow = rowSharedRoot.get(YjsEditorKey.database_row); + const rowSharedRoot = doc.getMap(YjsEditorKey.data_section) as YSharedRoot; + const databbaseRow = rowSharedRoot?.get(YjsEditorKey.database_row); - const rowCreatedTime = databbaseRow.get(YjsDatabaseKey.created_at); - const rowLastEditedTime = databbaseRow.get(YjsDatabaseKey.last_modified); + if (!databbaseRow) return; + const rowCreatedTime = databbaseRow.get(YjsDatabaseKey.created_at).toString(); + const rowLastEditedTime = databbaseRow.get(YjsDatabaseKey.last_modified).toString(); const value = cell ? parseYDatabaseCellToCell(cell) as DateTimeCell : undefined; @@ -948,6 +951,7 @@ export function useCalendarEventsSelector() { if (!rowDoc) return; rowDoc.getMap(YjsEditorKey.data_section).observeDeep(debouncedObserverEvent); }); + return () => { debouncedObserverEvent.cancel(); field?.unobserveDeep(observerEvent); @@ -968,37 +972,35 @@ export function useCalendarLayoutSetting() { const currentUser = useCurrentUser(); const startWeekOn = Number(currentUser?.metadata?.[MetadataKey.StartWeekOn] || 0); - const view = useDatabaseView(); - const layoutSetting = view?.get(YjsDatabaseKey.layout_settings)?.get('2'); - const [setting, setSetting] = useState({ - fieldId: '', - firstDayOfWeek: startWeekOn, - showWeekNumbers: true, - showWeekends: true, - layout: 0, - numberOfDays: 7 - }); + const timeFormat = currentUser?.metadata?.[MetadataKey.TimeFormat] || TimeFormat.TwelveHour; + const database = useDatabase(); + + const [setting, setSetting] = useState(null); + const viewId = useDatabaseViewId(); useEffect(() => { + const view = database.get(YjsDatabaseKey.views)?.get(viewId); const observerHandler = () => { + const layoutSetting = view?.get(YjsDatabaseKey.layout_settings)?.get('2'); const firstDayOfWeek = layoutSetting?.get(YjsDatabaseKey.first_day_of_week) === undefined ? startWeekOn : Number(layoutSetting?.get(YjsDatabaseKey.first_day_of_week) || 0); setSetting({ - fieldId: layoutSetting?.get(YjsDatabaseKey.field_id) as string, + fieldId: layoutSetting?.get(YjsDatabaseKey.field_id), firstDayOfWeek, showWeekNumbers: Boolean(layoutSetting?.get(YjsDatabaseKey.show_week_numbers)), showWeekends: Boolean(layoutSetting?.get(YjsDatabaseKey.show_weekends)), layout: Number(layoutSetting?.get(YjsDatabaseKey.layout_ty)), numberOfDays: layoutSetting?.get(YjsDatabaseKey.number_of_days) || 7, + use24Hour: timeFormat === TimeFormat.TwentyFourHour, }); }; observerHandler(); - layoutSetting?.observe(observerHandler); + view?.observeDeep(observerHandler); return () => { - layoutSetting?.unobserve(observerHandler); + view?.unobserveDeep(observerHandler); }; - }, [layoutSetting, startWeekOn]); + }, [startWeekOn, timeFormat, database, viewId]); return setting; } diff --git a/src/application/services/js-services/http/http_api.ts b/src/application/services/js-services/http/http_api.ts index 41fbee4a..8fe60c81 100644 --- a/src/application/services/js-services/http/http_api.ts +++ b/src/application/services/js-services/http/http_api.ts @@ -1,10 +1,10 @@ -import { RepeatedChatMessage } from '@/components/chat'; -import axios, { AxiosInstance } from 'axios'; +import axios, { AxiosInstance, AxiosResponse } from 'axios'; import dayjs from 'dayjs'; import { omit } from 'lodash-es'; import { nanoid } from 'nanoid'; import { GlobalComment, Reaction } from '@/application/comment.type'; +import { ERROR_CODE } from '@/application/constants'; import { initGrantService, refreshToken } from '@/application/services/js-services/http/gotrue'; import { blobToBytes } from '@/application/services/js-services/http/utils'; import { AFCloudConfig } from '@/application/services/services.type'; @@ -58,6 +58,7 @@ import { WorkspaceMember, } from '@/application/types'; import { notify } from '@/components/_shared/notify'; +import { RepeatedChatMessage } from '@/components/chat'; export * from './gotrue'; @@ -118,7 +119,7 @@ export function initAPIService(config: AFCloudConfig) { } ); - axiosInstance.interceptors.response.use(async (response) => { + const handleUnauthorized = async (response: AxiosResponse) => { const status = response.status; if (status === 401) { @@ -139,7 +140,9 @@ export function initAPIService(config: AFCloudConfig) { } return response; - }); + }; + + axiosInstance.interceptors.response.use(undefined, handleUnauthorized); } export async function signInWithUrl(url: string) { @@ -231,6 +234,11 @@ export async function getCurrentUser(): Promise { }; } + if (data?.code === ERROR_CODE.USER_UNAUTHORIZED) { + invalidToken(); + return Promise.reject(new Error('User unauthorized')); + } + return Promise.reject(data); } diff --git a/src/components/_shared/breadcrumb/BreadcrumbItem.tsx b/src/components/_shared/breadcrumb/BreadcrumbItem.tsx index 801f3cd7..2c61606a 100644 --- a/src/components/_shared/breadcrumb/BreadcrumbItem.tsx +++ b/src/components/_shared/breadcrumb/BreadcrumbItem.tsx @@ -1,11 +1,13 @@ +import { Tooltip } from '@mui/material'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSearchParams } from 'react-router-dom'; + import { UIVariant, View } from '@/application/types'; import { notify } from '@/components/_shared/notify'; import PageIcon from '@/components/_shared/view-icon/PageIcon'; import PublishIcon from '@/components/_shared/view-icon/PublishIcon'; import SpaceIcon from '@/components/_shared/view-icon/SpaceIcon'; -import { Tooltip } from '@mui/material'; -import { useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; function BreadcrumbItem({ crumb, @@ -36,14 +38,18 @@ function BreadcrumbItem({ return classList.join(' '); }, [disableClick, extra?.is_space, is_published, variant]); + const [search] = useSearchParams(); + return (
{ if (disableClick || extra?.is_space || (!is_published && variant === 'publish')) return; + const subviewId = search.get('v'); + try { - await toView?.(view_id); + await toView?.(subviewId || view_id); // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (e: any) { notify.error(e.message); diff --git a/src/components/_shared/file-dropzone/FileDropzone.tsx b/src/components/_shared/file-dropzone/FileDropzone.tsx index b52711e1..a9643956 100644 --- a/src/components/_shared/file-dropzone/FileDropzone.tsx +++ b/src/components/_shared/file-dropzone/FileDropzone.tsx @@ -108,7 +108,7 @@ function FileDropzone({ onChange, accept, multiple, disabled, placeholder, loadi {placeholder || ( <> {t('document.plugins.file.fileUploadHint')} - {t('document.plugins.file.fileUploadHintSuffix')} + click to {t('document.plugins.file.fileUploadHintSuffix')} )}
diff --git a/src/components/_shared/image-upload/UploadImage.tsx b/src/components/_shared/image-upload/UploadImage.tsx index 769f23df..7ae7e813 100644 --- a/src/components/_shared/image-upload/UploadImage.tsx +++ b/src/components/_shared/image-upload/UploadImage.tsx @@ -1,6 +1,7 @@ +import React, { useCallback } from 'react'; + import FileDropzone from '@/components/_shared/file-dropzone/FileDropzone'; import { notify } from '@/components/_shared/notify'; -import React, { useCallback } from 'react'; export const ALLOWED_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp']; @@ -23,7 +24,6 @@ export function UploadImage({ const url = await uploadAction?.(file); if (!url) { - onDone?.(URL.createObjectURL(file)); return; } @@ -31,7 +31,6 @@ export function UploadImage({ // eslint-disable-next-line } catch (e: any) { notify.error(e.message); - onDone?.(URL.createObjectURL(file)); } finally { setLoading(false); } @@ -41,11 +40,7 @@ export function UploadImage({ return (
- +
); } diff --git a/src/components/app/WorkspaceLoadingAnimation.tsx b/src/components/app/WorkspaceLoadingAnimation.tsx index 58657b13..eceb5895 100644 --- a/src/components/app/WorkspaceLoadingAnimation.tsx +++ b/src/components/app/WorkspaceLoadingAnimation.tsx @@ -1,82 +1,54 @@ -import { useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; +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; +import logoSvg from '@/assets/icons/logo.svg'; +import { useCurrentUser } from '@/components/main/app.hooks'; export function WorkspaceLoadingAnimation() { const { t } = useTranslation(); - const [currentStep, setCurrentStep] = useState(0); + const currentUser = useCurrentUser(); const [progress, setProgress] = useState(0); - const [isComplete, setIsComplete] = useState(false); + const [rotation, setRotation] = useState(0); + + // Determine loading state based on available data + const isLoadingUser = !currentUser; + const targetProgress = isLoadingUser ? 25 : 75; useEffect(() => { - let timeoutId: NodeJS.Timeout; - let progressInterval: NodeJS.Timeout; + const progressInterval = setInterval(() => { + setProgress((prev) => { + const diff = targetProgress - prev; - const runStep = (stepIndex: number) => { - if (stepIndex >= LOADING_STEPS.length) { - setIsComplete(true); - return; - } + if (Math.abs(diff) < 1) return targetProgress; + return prev + diff * 0.1; + }); + }, 50); - 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); + const rotationInterval = setInterval(() => { + setRotation((prev) => (prev + 2) % 360); + }, 50); return () => { - clearTimeout(timeoutId); clearInterval(progressInterval); + clearInterval(rotationInterval); }; - }, []); + }, [targetProgress]); return ( -
-
+
+
{/* Logo Animation */} -
-
+
-
- logo +
+ logo {/* Glow effect */}
{/* Circular progress ring */} -
- +
+ {/* Background ring */} {/* Progress ring */}
{/* Main title */} -
-

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

+ {t('global-loading.installing')}

{/* Simple status text */} -
-

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

+

+ {isLoadingUser ? 'Loading user profile...' : 'Loading workspace data...'}

{/* Progress percentage */} -
-
- {Math.round(currentStep * 25 + progress * 0.25)}% +
+
+ {Math.round(progress)}%
{/* 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 29c547a2..de5c1846 100644 --- a/src/components/app/app.hooks.tsx +++ b/src/components/app/app.hooks.tsx @@ -97,10 +97,10 @@ export const AppContext = createContext(null); // 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 || {}; + const { userWorkspaceInfo } = authContext || {}; // Show loading animation while workspace ID is being loaded - if (!currentWorkspaceId) { + if (!userWorkspaceInfo) { return ; } diff --git a/src/components/app/hooks/useViewOperations.ts b/src/components/app/hooks/useViewOperations.ts index a8c54f68..42735db3 100644 --- a/src/components/app/hooks/useViewOperations.ts +++ b/src/components/app/hooks/useViewOperations.ts @@ -102,7 +102,7 @@ export function useViewOperations() { // Load view document const loadView = useCallback( - async (id: string, _isSubDocument = false, loadAwareness = false, outline?: View[]) => { + async (id: string, isSubDocument = false, loadAwareness = false, outline?: View[]) => { try { if (!service || !currentWorkspaceId) { throw new Error('Service or workspace not found'); @@ -127,11 +127,23 @@ export function useViewOperations() { const view = findView(outline || [], id); - const collabType = view - ? view?.layout === ViewLayout.Document - ? Types.Document - : Types.Database - : Types.Document; + let collabType = isSubDocument ? Types.Document : null; + + switch (view?.layout) { + case ViewLayout.Document: + collabType = Types.Document; + break; + case ViewLayout.Grid: + case ViewLayout.Board: + case ViewLayout.Calendar: + collabType = Types.Database; + break; + } + + if (collabType === null) { + return Promise.reject(new Error('Invalid view layout')); + } + if (collabType === Types.Document) { let awareness: Awareness | undefined; diff --git a/src/components/database/Database.tsx b/src/components/database/Database.tsx index 59c9437e..3d461bc1 100644 --- a/src/components/database/Database.tsx +++ b/src/components/database/Database.tsx @@ -17,6 +17,7 @@ import { import { DatabaseRow } from '@/components/database/DatabaseRow'; import DatabaseRowModal from '@/components/database/DatabaseRowModal'; import DatabaseViews from '@/components/database/DatabaseViews'; +import { CalendarViewType } from '@/components/database/fullcalendar/types'; import { DatabaseContextProvider } from './DatabaseContext'; @@ -129,6 +130,18 @@ function Database(props: Database2Props) { const [openModalRowDatabaseDoc, setOpenModalRowDatabaseDoc] = useState(null); const [openModalRowDocMap, setOpenModalRowDocMap] = useState | null>(null); + // Calendar view type map state + const [calendarViewTypeMap, setCalendarViewTypeMap] = useState>(() => new Map()); + + const setCalendarViewType = useCallback((viewId: string, viewType: CalendarViewType) => { + setCalendarViewTypeMap((prev) => { + const newMap = new Map(prev); + + newMap.set(viewId, viewType); + return newMap; + }); + }, []); + const handleOpenRow = useCallback( async (rowId: string, viewId?: string) => { if (readOnly) { @@ -191,6 +204,8 @@ function Database(props: Database2Props) { rowDocMap={rowDocMap} readOnly={readOnly} createRowDoc={createNewRowDoc} + calendarViewTypeMap={calendarViewTypeMap} + setCalendarViewType={setCalendarViewType} > {rowId ? ( @@ -217,6 +232,8 @@ function Database(props: Database2Props) { navigateToRow={handleOpenRow} readOnly={readOnly} createRowDoc={createNewRowDoc} + calendarViewTypeMap={calendarViewTypeMap} + setCalendarViewType={setCalendarViewType} > { return Math.max( 0, - viewIds.findIndex((id) => id === viewId), + viewIds.findIndex((id) => id === viewId) ); }, [viewId, viewIds]); @@ -42,7 +45,6 @@ function DatabaseViews ({ }, []); const [openFilterId, setOpenFilterId] = useState(); - const activeView = useMemo(() => { return childViews[value]; }, [childViews, value]); @@ -64,36 +66,69 @@ function DatabaseViews ({ }, [activeView]); const view = useMemo(() => { + // 使用 viewId 和 layout 的组合作为 key,确保在任一变化时都有动画 + const animationKey = `${layout}-${viewId}`; + switch (layout) { case DatabaseViewLayout.Grid: - return ; + return ( + + + + ); case DatabaseViewLayout.Board: - return ; + return ( + + + + ); case DatabaseViewLayout.Calendar: - return ; + return ( + + + + ); } - }, [layout]); + }, [layout, viewId]); const skeleton = useMemo(() => { switch (layout) { case DatabaseViewLayout.Grid: - return ; + return ; case DatabaseViewLayout.Board: - return ; + return ; case DatabaseViewLayout.Calendar: - return ; + return ; default: return null; } @@ -120,7 +155,11 @@ function DatabaseViews ({
- {view} + + + {view} + +
diff --git a/src/components/database/calendar/Calendar.hooks.ts b/src/components/database/calendar/Calendar.hooks.ts deleted file mode 100644 index b3ec0145..00000000 --- a/src/components/database/calendar/Calendar.hooks.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { useCalendarEventsSelector, useCalendarLayoutSetting } from '@/application/database-yjs'; -import { useCallback, useEffect, useMemo } from 'react'; -import { dayjsLocalizer } from 'react-big-calendar'; -import dayjs from 'dayjs'; -import en from 'dayjs/locale/en'; - -export function useCalendarSetup() { - const layoutSetting = useCalendarLayoutSetting(); - const { events, emptyEvents } = useCalendarEventsSelector(); - - const dayPropGetter = useCallback((date: Date) => { - const day = date.getDay(); - - return { - className: `day-${day}`, - }; - }, []); - - useEffect(() => { - dayjs.locale({ - ...en, - weekStart: layoutSetting.firstDayOfWeek, - }); - }, [layoutSetting]); - - const localizer = useMemo(() => dayjsLocalizer(dayjs), []); - - const formats = useMemo(() => { - return { - weekdayFormat: 'ddd', - }; - }, []); - - return { - localizer, - formats, - dayPropGetter, - events, - emptyEvents, - }; -} diff --git a/src/components/database/calendar/Calendar.tsx b/src/components/database/calendar/Calendar.tsx deleted file mode 100644 index b24ac5c0..00000000 --- a/src/components/database/calendar/Calendar.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { useDatabaseContext } from '@/application/database-yjs'; -import { useCalendarSetup } from '@/components/database/calendar/Calendar.hooks'; -import { Toolbar, Event } from '@/components/database/components/calendar'; -import { useConditionsContext } from '@/components/database/components/conditions/context'; -import { debounce } from 'lodash-es'; -import { useEffect, useRef } from 'react'; -import { Calendar as BigCalendar } from 'react-big-calendar'; -import './calendar.scss'; - -export function Calendar() { - const { dayPropGetter, localizer, formats, events, emptyEvents } = useCalendarSetup(); - const { paddingStart, paddingEnd, isDocumentBlock } = useDatabaseContext(); - const ref = useRef(null); - const onRendered = useDatabaseContext().onRendered; - const conditionsContext = useConditionsContext(); - const expanded = conditionsContext?.expanded ?? false; - - useEffect(() => { - const el = ref.current; - - if (!el) return; - - const onResize = debounce(() => { - - onRendered?.(); - }, 200); - - onResize(); - - if (!isDocumentBlock) return; - el.addEventListener('resize', onResize); - - return () => { - el.removeEventListener('resize', onResize); - }; - - }, [onRendered, expanded, isDocumentBlock]); - return ( -
- , - eventWrapper: Event, - }} - events={events} - views={['month']} - localizer={localizer} - formats={formats} - dayPropGetter={dayPropGetter} - showMultiDayTimes={true} - step={1} - showAllEvents={true} - /> -
- ); -} - -export default Calendar; diff --git a/src/components/database/calendar/calendar.scss b/src/components/database/calendar/calendar.scss deleted file mode 100644 index 94e8ee45..00000000 --- a/src/components/database/calendar/calendar.scss +++ /dev/null @@ -1,105 +0,0 @@ -@use "src/styles/mixin.scss"; - -$today-highlight-bg: transparent; -@import 'react-big-calendar/lib/sass/styles'; -@import 'react-big-calendar/lib/addons/dragAndDrop/styles'; // if using DnD - -.rbc-calendar { - font-size: 12px; -} - -.rbc-button-link { - @apply rounded-full w-[20px] h-[20px] my-1.5 cursor-default; -} - - -.rbc-date-cell, .rbc-header { - min-width: 97px; - font-size: 14px; -} - -.rbc-date-cell.rbc-now { - - color: var(--content-on-fill); - - .rbc-button-link { - background-color: var(--function-error); - } -} - -.rbc-calendar { - height: fit-content; - @apply w-full overflow-x-scroll; - @include mixin.scrollbar-style; - -} - -.rbc-month-view { - border: none; - height: fit-content; - - .rbc-month-row { - border: 1px solid var(--border-primary); - border-top: none; - min-width: 1200px; - @apply max-sm:w-[600vw]; - } - - -} - - -.rbc-day-bg + .rbc-day-bg { - border-left-color: var(--border-primary); -} - -.rbc-month-header { - height: 40px; - position: sticky; - top: 0; - background: var(--bg-body); - z-index: 50; - min-width: 1200px; - - @apply max-sm:w-[600vw]; - - .rbc-header { - border: none; - border-bottom: 1px solid var(--border-primary); - @apply flex items-end py-2 justify-center font-normal text-text-secondary bg-background-primary; - - } -} - -html[thumbnail="true"] { - .rbc-month-row, .rbc-month-header { - width: 100% !important; - min-width: 100% !important; - } -} - -.rbc-month-row .rbc-row-bg { - .rbc-off-range-bg { - background-color: transparent; - color: var(--text-secondary); - } - - .rbc-day-bg.day-0, .rbc-day-bg.day-6 { - background-color: var(--fill-list-active); - } -} - -.rbc-month-row { - display: inline-table !important; - flex: 0 0 0 !important; - min-height: 97px !important; - height: fit-content; - table-layout: fixed; - width: 100%; -} - -.event-properties { - .property-label { - @apply text-text-secondary; - } -} \ No newline at end of file diff --git a/src/components/database/calendar/index.ts b/src/components/database/calendar/index.ts deleted file mode 100644 index 59e83476..00000000 --- a/src/components/database/calendar/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { lazy } from 'react'; - -export const Calendar = lazy(() => import('./Calendar')); diff --git a/src/components/database/components/calendar/event/Event.tsx b/src/components/database/components/calendar/event/Event.tsx deleted file mode 100644 index e0a6d318..00000000 --- a/src/components/database/components/calendar/event/Event.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { CalendarEvent, useDatabaseContext, useFieldsSelector } from '@/application/database-yjs'; -import EventPaper from '@/components/database/components/calendar/event/EventPaper'; -import CardField from '@/components/database/components/field/CardField'; -import { RichTooltip } from '@/components/_shared/popover'; -import React from 'react'; -import { EventWrapperProps } from 'react-big-calendar'; - -export function Event({ event }: EventWrapperProps) { - const { id } = event; - const [rowId] = id.split(':'); - const showFields = useFieldsSelector(); - - const navigateToRow = useDatabaseContext().navigateToRow; - const [open, setOpen] = React.useState(false); - - return ( -
- } open={open} placement='right' onClose={() => setOpen(false)}> -
{ - if (window.innerWidth < 768) { - navigateToRow?.(rowId); - } else { - setOpen((prev) => !prev); - } - }} - className={ - 'flex min-h-[24px] cursor-pointer flex-col gap-2 rounded-md border border-border-primary bg-background-primary p-2 text-xs shadow-sm hover:bg-fill-list-active hover:shadow' - } - > - {showFields.map((field) => { - return ; - })} -
-
-
- ); -} - -export default Event; diff --git a/src/components/database/components/calendar/event/EventPaper.tsx b/src/components/database/components/calendar/event/EventPaper.tsx deleted file mode 100644 index f8e386ab..00000000 --- a/src/components/database/components/calendar/event/EventPaper.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { useFieldsSelector, usePrimaryFieldId } from '@/application/database-yjs'; -import EventPaperTitle from '@/components/database/components/calendar/event/EventPaperTitle'; -import OpenAction from '@/components/database/components/database-row/OpenAction'; -import { Property } from '@/components/database/components/property'; - -function EventPaper ({ rowId }: { rowId: string }) { - const primaryFieldId = usePrimaryFieldId(); - - const fields = useFieldsSelector().filter((column) => column.fieldId !== primaryFieldId); - - return ( -
-
-
- -
-
- {primaryFieldId && } - {fields.map((field) => { - return ; - })} -
-
-
- ); -} - -export default EventPaper; \ No newline at end of file diff --git a/src/components/database/components/calendar/event/EventPaperTitle.tsx b/src/components/database/components/calendar/event/EventPaperTitle.tsx deleted file mode 100644 index 8c4e9613..00000000 --- a/src/components/database/components/calendar/event/EventPaperTitle.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { useCellSelector } from '@/application/database-yjs'; -import { TextCell } from '@/application/database-yjs/cell.type'; -import { TextProperty } from '@/components/database/components/property/text'; - -function EventPaperTitle ({ fieldId, rowId }: { fieldId: string; rowId: string }) { - const cell = useCellSelector({ - fieldId, - rowId, - }); - - return ; -} - -export default EventPaperTitle; diff --git a/src/components/database/components/calendar/event/index.ts b/src/components/database/components/calendar/event/index.ts deleted file mode 100644 index e59a1198..00000000 --- a/src/components/database/components/calendar/event/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Event'; diff --git a/src/components/database/components/calendar/index.ts b/src/components/database/components/calendar/index.ts deleted file mode 100644 index 7b631093..00000000 --- a/src/components/database/components/calendar/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './toolbar'; -export * from './event'; diff --git a/src/components/database/components/calendar/toolbar/NoDate.tsx b/src/components/database/components/calendar/toolbar/NoDate.tsx deleted file mode 100644 index 4cbfb899..00000000 --- a/src/components/database/components/calendar/toolbar/NoDate.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { CalendarEvent } from '@/application/database-yjs'; -import NoDateRow from '@/components/database/components/calendar/toolbar/NoDateRow'; -import { RichTooltip } from '@/components/_shared/popover'; -import { Button } from '@mui/material'; -import React, { useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; - -function NoDate({ emptyEvents }: { emptyEvents: CalendarEvent[] }) { - const [open, setOpen] = React.useState(false); - const { t } = useTranslation(); - const content = useMemo(() => { - return ( -
- {/*
{t('calendar.settings.clickToOpen')}
*/} - {emptyEvents.map((event) => { - const rowId = event.id.split(':')[0]; - - return ; - })} -
- ); - }, [emptyEvents]); - - return ( - { - setOpen(false); - }} - > - - - ); -} - -export default NoDate; diff --git a/src/components/database/components/calendar/toolbar/NoDateRow.tsx b/src/components/database/components/calendar/toolbar/NoDateRow.tsx deleted file mode 100644 index 5e9ce536..00000000 --- a/src/components/database/components/calendar/toolbar/NoDateRow.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { useCellSelector, usePrimaryFieldId } from '@/application/database-yjs'; -import { Cell } from '@/components/database/components/cell'; -import { useTranslation } from 'react-i18next'; - -function NoDateRow({ rowId }: { rowId: string }) { - // const navigateToRow = useNavigateToRow(); - const primaryFieldId = usePrimaryFieldId(); - const cell = useCellSelector({ - rowId, - fieldId: primaryFieldId || '', - }); - const { t } = useTranslation(); - - if (!primaryFieldId || !cell?.data) { - return
{t('grid.row.titlePlaceholder')}
; - } - - return ( -
{ - // navigateToRow?.(rowId); - // }} - className={'w-full hover:text-text-action'} - > - -
- ); -} - -export default NoDateRow; diff --git a/src/components/database/components/calendar/toolbar/Toolbar.tsx b/src/components/database/components/calendar/toolbar/Toolbar.tsx deleted file mode 100644 index b7af9616..00000000 --- a/src/components/database/components/calendar/toolbar/Toolbar.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { CalendarEvent } from '@/application/database-yjs'; -import { ReactComponent as DownArrow } from '@/assets/icons/alt_arrow_down.svg'; -import { ReactComponent as LeftArrow } from '@/assets/icons/alt_arrow_left.svg'; -import { ReactComponent as RightArrow } from '@/assets/icons/alt_arrow_right.svg'; -import NoDate from '@/components/database/components/calendar/toolbar/NoDate'; -import { IconButton } from '@mui/material'; -import Button from '@mui/material/Button'; -import dayjs from 'dayjs'; -import { useMemo } from 'react'; -import { ToolbarProps } from 'react-big-calendar'; - -import { useTranslation } from 'react-i18next'; - -interface ExtendedToolbarProps extends ToolbarProps { - emptyEvents: CalendarEvent[]; -} - -export function Toolbar({ onNavigate, date, emptyEvents }: ExtendedToolbarProps) { - const dateStr = useMemo(() => dayjs(date).format('MMM YYYY'), [date]); - const { t } = useTranslation(); - - return ( -
-
{dateStr}
-
- onNavigate('PREV')}> - - - - onNavigate('NEXT')}> - - - - -
-
- ); -} - -export default Toolbar; diff --git a/src/components/database/components/calendar/toolbar/index.ts b/src/components/database/components/calendar/toolbar/index.ts deleted file mode 100644 index 7c643033..00000000 --- a/src/components/database/components/calendar/toolbar/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Toolbar'; diff --git a/src/components/database/components/cell/file-media/FileMediaUpload.tsx b/src/components/database/components/cell/file-media/FileMediaUpload.tsx index 43b67fcf..46572656 100644 --- a/src/components/database/components/cell/file-media/FileMediaUpload.tsx +++ b/src/components/database/components/cell/file-media/FileMediaUpload.tsx @@ -46,7 +46,7 @@ function FileMediaUpload({ return await uploadFile(file); // eslint-disable-next-line } catch (e: any) { - console.error(e); + toast.error(e.message); return; } }, @@ -86,16 +86,17 @@ function FileMediaUpload({ const items = urls.map((url, index) => { const file = files[index]; + if (!url) return; return { file_type: getFileMediaType(file.name), id: crypto.randomUUID(), name: file.name, upload_type: FileMediaUploadType.CloudMedia, - url, + url: url?.toString(), } as FileMediaCellDataItem; }); - addItems(items); + addItems(items.filter((item): item is FileMediaCellDataItem => item !== undefined)); onClose?.(); } finally { setUploading(false); @@ -131,7 +132,7 @@ function FileMediaUpload({ multiple={true} placeholder={
- {t('grid.media.dragAndDropFiles')} + {t('grid.media.dragAndDropFiles')}click to {t('grid.media.browse')}
} diff --git a/src/components/database/components/cell/file-media/PreviewImage.tsx b/src/components/database/components/cell/file-media/PreviewImage.tsx index 6515c5f8..16acea4d 100644 --- a/src/components/database/components/cell/file-media/PreviewImage.tsx +++ b/src/components/database/components/cell/file-media/PreviewImage.tsx @@ -11,7 +11,8 @@ function PreviewImage({ file, onClick }: { file: FileMediaCellDataItem; onClick: const thumb = useMemo(() => { let fileUrl = file.url; - if (!isURL(file.url)) { + if (!fileUrl) return ''; + if (!isURL(fileUrl)) { fileUrl = getConfigValue('APPFLOWY_BASE_URL', '') + '/api/file_storage/' + workspaceId + '/v1/blob/' + file.url; } diff --git a/src/components/database/components/cell/file-media/UnPreviewFile.tsx b/src/components/database/components/cell/file-media/UnPreviewFile.tsx index 52339cc9..898f4bd0 100644 --- a/src/components/database/components/cell/file-media/UnPreviewFile.tsx +++ b/src/components/database/components/cell/file-media/UnPreviewFile.tsx @@ -20,7 +20,7 @@ function UnPreviewFile({ file }: { file: FileMediaCellDataItem }) { className={'cursor-pointer rounded-[4px] bg-fill-content-hover text-icon-secondary'} onClick={(e) => { e.stopPropagation(); - if (isURL(file.url)) { + if (file.url && isURL(file.url)) { void openUrl(file.url, '_blank'); return; } diff --git a/src/components/database/components/database-row/DeleteRowConfirm.tsx b/src/components/database/components/database-row/DeleteRowConfirm.tsx index fa0d11f8..2bf687d2 100644 --- a/src/components/database/components/database-row/DeleteRowConfirm.tsx +++ b/src/components/database/components/database-row/DeleteRowConfirm.tsx @@ -1,6 +1,8 @@ import { useTranslation } from 'react-i18next'; +import { useDatabaseViewLayout } from '@/application/database-yjs'; import { useBulkDeleteRowDispatch } from '@/application/database-yjs/dispatch'; +import { DatabaseViewLayout } from '@/application/types'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -26,6 +28,7 @@ export function DeleteRowConfirm({ const { t } = useTranslation(); const deleteRowsDispatch = useBulkDeleteRowDispatch(); + const layout = useDatabaseViewLayout(); const handleDelete = () => { deleteRowsDispatch(rowIds); onDeleted?.(); @@ -56,7 +59,11 @@ export function DeleteRowConfirm({ {t('grid.row.delete')} - {t('grid.row.deleteRowPrompt', { count: rowIds?.length || 0 })} + + {t(layout === DatabaseViewLayout.Calendar ? 'calendar.deleteEventPrompt' : 'grid.row.deleteRowPrompt', { + count: rowIds?.length || 0, + })} + + currentView === view.key && '!bg-fill-content-hover text-text-primary' + )} + > + {view.label} + + + + {view.label} {view.shortcutLabel} + + ))}
- + @@ -164,12 +213,23 @@ export const CustomToolbar = memo( - {t('calendar.navigation.previous', { view: label })} + + {t('calendar.navigation.previous', { view: label })}{' '} + {createHotKeyLabel(HOT_KEY_NAME.CALENDAR_PREV)} + - + + + + + + {t('calendar.navigation.today')}{' '} + {createHotKeyLabel(HOT_KEY_NAME.CALENDAR_TODAY)} + + @@ -177,7 +237,10 @@ export const CustomToolbar = memo( - {t('calendar.navigation.next', { view: label })} + + {t('calendar.navigation.next', { view: label })}{' '} + {createHotKeyLabel(HOT_KEY_NAME.CALENDAR_NEXT)} +
diff --git a/src/components/database/fullcalendar/FullCalendar.hooks.ts b/src/components/database/fullcalendar/FullCalendar.hooks.ts index af533fb5..9b96f512 100644 --- a/src/components/database/fullcalendar/FullCalendar.hooks.ts +++ b/src/components/database/fullcalendar/FullCalendar.hooks.ts @@ -2,9 +2,10 @@ import { sortBy } from 'lodash-es'; import { useMemo } from 'react'; import { useCalendarEventsSelector, useCalendarLayoutSetting } from '@/application/database-yjs'; +import { CalendarViewType } from '@/components/database/fullcalendar/types'; import { correctAllDayEndForDisplay } from '@/utils/time'; -export function useFullCalendarSetup(newEventRowIds: Set, openEventRowId: string | null, updateEventRowIds: Set) { +export function useFullCalendarSetup(newEventRowIds: Set, openEventRowId: string | null, updateEventRowIds: Set, currentView: CalendarViewType) { const layoutSetting = useCalendarLayoutSetting(); const { events, emptyEvents } = useCalendarEventsSelector(); @@ -59,12 +60,12 @@ export function useFullCalendarSetup(newEventRowIds: Set, openEventRowId }; }); - return sortBy(processedEvents, ['allDay', 'isMultipleDayEvent', 'start', 'title']); - }, [events, newEventRowIds, openEventRowId, updateEventRowIds]); + return sortBy(processedEvents, currentView === CalendarViewType.TIME_GRID_WEEK ? [] : ['allDay', 'isMultipleDayEvent', 'start', 'title']); + }, [currentView, events, newEventRowIds, openEventRowId, updateEventRowIds]); return { events: fullCalendarEvents, emptyEvents, - firstDayOfWeek: layoutSetting.firstDayOfWeek, + firstDayOfWeek: layoutSetting?.firstDayOfWeek || 0, }; } diff --git a/src/components/database/fullcalendar/FullCalendar.styles.scss b/src/components/database/fullcalendar/FullCalendar.styles.scss index c5ef0368..9aa470c7 100644 --- a/src/components/database/fullcalendar/FullCalendar.styles.scss +++ b/src/components/database/fullcalendar/FullCalendar.styles.scss @@ -238,6 +238,7 @@ .fc-event { overflow: hidden; + cursor: pointer; } .fc-daygrid-day-events .fc-event { @@ -431,7 +432,7 @@ height: 8px; @apply bg-other-colors-filled-today; border-radius: 50%; - z-index: 11; + z-index: 50; } } @@ -450,7 +451,7 @@ border: none !important; width: 58px !important; height: auto !important; - text-align: right; + text-align: center; } /* Custom horizontal time indicator line */ @@ -503,22 +504,26 @@ .fc-event { @apply border-0 bg-transparent !important; border-color: transparent !important; + box-shadow: none !important; .fc-event-main { @apply bg-transparent px-0 !important; } + .fc-event-title { @apply bg-transparent truncate !important; } - - + } .fc-event-past { &:not(:hover):not(.fc-event-open) { - .event-inner { + .event-inner, .event-title { + opacity: 0.50 !important; + } + .time-slot { opacity: 0.50 !important; } .event-line { @@ -527,6 +532,12 @@ } } + .fc-timegrid-event.fc-event-past:not(:hover):not(.fc-event-open) > .fc-event-main { + &:before { + opacity: 0.30 !important; + } + } + // .fc-daygrid-day, .fc-daygrid-day.fc-day-today { // &:hover { // @apply bg-fill-info-light; @@ -588,6 +599,7 @@ height: auto !important; min-height: auto !important; max-height: none !important; + margin-right: 2px; &:has(.event-no-end) { height: 22px !important; @@ -678,80 +690,7 @@ @apply border-none rounded-200 cursor-pointer; } - .fc-popover { - @apply bg-background-primary shadow-popover rounded-400 border-none; - min-width: 240px !important; - z-index: 60 !important; - - .fc-event { - @apply bg-transparent !important; - } - - - .fc-popover-header { - @apply bg-transparent border-none px-2 py-2; - - .fc-popover-title { - @apply text-sm font-medium text-text-primary; - } - - .fc-popover-close { - @apply text-text-secondary hover:text-text-primary bg-transparent border-none; - @apply h-5 w-5 p-0 flex items-center justify-center rounded-200; - @apply hover:bg-fill-content; - - &::before { - content: '×'; - @apply font-normal; - line-height: 0.7; - font-size: 18px; - height: 20px; - width: 20px; - } - } - } - - &.fc-day-today { - .fc-popover-header { - .fc-popover-title { - @apply relative pl-4; - &:before { - @apply absolute left-0 top-1/2 -translate-y-1/2 bg-other-colors-filled-today rounded-full; - content: ' '; - height: 10px; - width: 10px; - } - } - } - } - - .fc-popover-body { - @apply p-2 pt-0; - max-height: 240px; - overflow-y: auto; - scrollbar-width: thin; - scrollbar-color: theme('colors.border.primary') transparent; - - &::-webkit-scrollbar { - width: 6px; - } - - &::-webkit-scrollbar-track { - @apply bg-transparent; - } - - &::-webkit-scrollbar-thumb { - @apply bg-border-primary rounded-full; - - &:hover { - @apply bg-border-primary-hover; - } - } - - - } - } - + /* ============================================= 9. Drag and Drop Styles ============================================= */ @@ -766,7 +705,8 @@ } } - + + .fc-event-dragging { transform: scale(1.001) !important; transition: opacity 0.2s ease, transform 0.2s ease !important; @@ -856,12 +796,14 @@ body > .fc-event[style*="z-index"] { &:not(.fc-timegrid-event) .event-content { @apply border !bg-fill-theme-thick !border-border-theme-thick !text-text-inverse; - .event-inner { + .event-inner, .event-title, .event-icon { @apply text-text-inverse !important; + @apply opacity-100 !important; } .time-slot { @apply text-text-inverse !important; + @apply opacity-100 !important; } } @@ -871,16 +813,27 @@ body > .fc-event[style*="z-index"] { .event-content { @apply bg-fill-theme-thick text-text-inverse !important; - .event-inner { + .event-inner, .event-title, .event-icon { @apply text-text-inverse !important; + @apply opacity-100 !important; } .time-slot { @apply text-text-inverse !important; + @apply opacity-100 !important; } } } } + + &.fc-popover-event.event-hovered { + .event-content { + @apply bg-other-colors-filled-event-hover !important; + } + .time-event-content { + @apply bg-fill-content-hover !important; + } + } } /* ============================================= @@ -913,20 +866,34 @@ body > .fc-event[style*="z-index"] { } } +.event-time-icon .event-icon { + @apply text-text-primary opacity-65; +} + +.fc-scrollgrid-sync-table { + @apply overflow-hidden; +} +.event-icon { + @apply opacity-80; +} + body { .fc-event.fc-event-dragging { border: none !important; transform: scale(1.001) !important; opacity: 1 !important; + cursor: grabbing; &.fc-nodate-event { box-shadow: 0 2px 16px 0 var(--shadow-color); + opacity: 1 !important; } &:not(.fc-timegrid-event) .event-content { @apply border !bg-fill-theme-thick !border-border-theme-thick !text-text-inverse; - .event-inner { + .event-inner, .event-time-icon .event-icon { @apply text-text-inverse !important; + opacity: 1; } .time-slot { @@ -935,12 +902,16 @@ body { } &.fc-timegrid-event { - @apply border !bg-fill-theme-thick !border-border-theme-thick !text-text-inverse; + @apply text-text-inverse; + + &:hover { + @apply bg-fill-theme-thick !important; + } .event-content { - @apply bg-fill-theme-thick text-text-inverse !important; + @apply text-text-inverse !important; - .event-inner { + .event-inner, .event-title, .event-icon { @apply text-text-inverse !important; } diff --git a/src/components/database/fullcalendar/FullCalendar.tsx b/src/components/database/fullcalendar/FullCalendar.tsx index c32d18f7..4a7b69d3 100644 --- a/src/components/database/fullcalendar/FullCalendar.tsx +++ b/src/components/database/fullcalendar/FullCalendar.tsx @@ -43,6 +43,20 @@ function Calendar() { const [slideDirection, setSlideDirection] = useState<'up' | 'down' | null>(null); const prevMonthRef = useRef(''); + // Drag state management + const [draggingRowId, setDraggingRowId] = useState(null); + + // Drag handlers + const handleDragStart = useCallback((rowId: string) => { + console.debug('🎯 Drag started for rowId:', rowId); + setDraggingRowId(rowId); + }, []); + + const handleDragEnd = useCallback(() => { + console.debug('🎯 Drag ended'); + setDraggingRowId(null); + }, []); + // Mobile detection const [isMobile, setIsMobile] = useState(false); @@ -55,7 +69,6 @@ function Calendar() { // Handle calendar data changes from CalendarContent const handleCalendarDataChange = useCallback((data: CalendarData) => { - // 当calendar数据变化时,检查月份变化并设置动画方向 if (data.calendarApi) { const currentDate = dayjs(data.calendarApi.getDate()); const currentMonth = currentDate.format('MMMM YYYY'); @@ -98,6 +111,9 @@ function Calendar() { onViewChange={calendarData.handleViewChange} slideDirection={slideDirection} emptyEvents={calendarData.emptyEvents} + onDragStart={handleDragStart} + draggingRowId={draggingRowId} + onDragEnd={handleDragEnd} />

)} @@ -113,7 +129,11 @@ function Calendar() { )} {/* Calendar content without toolbar */} - + {/* Sticky toolbar and week header via DatabaseStickyTopOverlay */} {calendarData?.showStickyToolbar && ( @@ -124,6 +144,9 @@ function Calendar() { onViewChange={calendarData.handleViewChange} slideDirection={slideDirection} emptyEvents={calendarData.emptyEvents} + onDragStart={handleDragStart} + draggingRowId={draggingRowId} + onDragEnd={handleDragEnd} /> void; + draggingRowId?: string | null; + onDragEnd?: () => void; } -export const NoDateButton = memo(({ emptyEvents, isWeekView }: NoDateButtonProps) => { - const [open, setOpen] = useState(false); - const { t } = useTranslation(); - const primaryFieldId = usePrimaryFieldId(); +export const NoDateButton = memo( + ({ onDragEnd, emptyEvents, isWeekView, onDragStart, draggingRowId }: NoDateButtonProps) => { + const [open, setOpen] = useState(false); + const { t } = useTranslation(); + const primaryFieldId = usePrimaryFieldId(); - if (emptyEvents.length === 0 || !primaryFieldId) { - return null; - } + if (emptyEvents.length === 0 || !primaryFieldId) { + return null; + } - return ( - - - + + { + e.preventDefault(); }} - onKeyDown={(e) => { - if (e.key === 'Escape') { - setOpen(false); - } + onMouseUp={() => { + onDragEnd?.(); }} - size='sm' - variant='ghost' - className='no-date-button gap-1 overflow-hidden whitespace-nowrap' + onPointerDownOutside={(e) => { + const target = e.target as HTMLElement; + + if (target.closest('.MuiDialog-root')) return; + setOpen(false); + }} + className='appflowy-scroller max-h-[360px] w-[260px] overflow-y-auto p-2' > - {`${t('calendar.settings.noDateTitle')} (${emptyEvents.length})`} - - - - { - e.preventDefault(); - }} - onPointerDownOutside={(e) => { - const target = e.target as HTMLElement; +
+ {t('calendar.settings.noDatePopoverTitle')} +
+ {emptyEvents.map((event) => { + const rowId = event.id; - if (target.closest('.MuiDialogContent-root')) return; - setOpen(false); - }} - className='appflowy-scroller max-h-[360px] w-[260px] overflow-y-auto p-2' - > - {t('calendar.settings.noDatePopoverTitle')} -
- {emptyEvents.map((event) => { - const rowId = event.id; + return ( + + ); + })} +
+
+ + + ); + } +); - return ; - })} -
-
-
- ); -}); - -NoDateButton.displayName = 'NoDateButton'; - -export default NoDateButton; \ No newline at end of file +export default NoDateButton; diff --git a/src/components/database/fullcalendar/NoDateRow.tsx b/src/components/database/fullcalendar/NoDateRow.tsx index 067f365f..07648a69 100644 --- a/src/components/database/fullcalendar/NoDateRow.tsx +++ b/src/components/database/fullcalendar/NoDateRow.tsx @@ -2,19 +2,24 @@ import { Draggable } from '@fullcalendar/interaction'; import { useEffect, useRef } from 'react'; import { useCellSelector, useDatabaseContext } from '@/application/database-yjs'; +import { useReadOnly } from '@/application/database-yjs/context'; import { ReactComponent as DragIcon } from '@/assets/icons/drag.svg'; import { Cell } from '@/components/database/components/cell'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; + interface NoDateRowProps { rowId: string; primaryFieldId: string; isWeekView: boolean; + onDragStart?: (rowId: string) => void; + isDragging?: boolean; } -export function NoDateRow({ rowId, primaryFieldId, isWeekView }: NoDateRowProps) { +export function NoDateRow({ rowId, primaryFieldId, isWeekView, onDragStart, isDragging = false }: NoDateRowProps) { const toRow = useDatabaseContext()?.navigateToRow; + const readOnly = useReadOnly(); const cell = useCellSelector({ rowId, fieldId: primaryFieldId || '', @@ -25,7 +30,7 @@ export function NoDateRow({ rowId, primaryFieldId, isWeekView }: NoDateRowProps) useEffect(() => { const element = dragRef.current; - if (!element) return; + if (!element || readOnly) return; console.debug('🎯 Creating optimized Draggable for rowId:', rowId); @@ -46,7 +51,7 @@ export function NoDateRow({ rowId, primaryFieldId, isWeekView }: NoDateRowProps) console.debug('🎯 Destroying optimized Draggable for rowId:', rowId); draggable.destroy(); }; - }, [rowId, cell?.data, isWeekView]); + }, [rowId, cell?.data, isWeekView, readOnly]); return (
{ + console.debug('🎯 Mouse down on rowId:', rowId); + onDragStart?.(rowId); + }} + style={{ + opacity: isDragging ? 0.4 : 1, + transition: 'opacity 0.2s ease-in-out', + }} > -
- -
- + {!readOnly && ( + + +
+ +
+
+ + Drag to the calendar + +
+ )} + +
{ @@ -85,7 +108,9 @@ export function NoDateRow({ rowId, primaryFieldId, isWeekView }: NoDateRowProps) />
- {`Click to open ${cell?.data?.toString() || 'event'}`} + {`Click to open ${ + cell?.data?.toString() || 'event' + }`}
); diff --git a/src/components/database/fullcalendar/StickyCalendarToolbar.tsx b/src/components/database/fullcalendar/StickyCalendarToolbar.tsx index 2f57a3df..fb18e18b 100644 --- a/src/components/database/fullcalendar/StickyCalendarToolbar.tsx +++ b/src/components/database/fullcalendar/StickyCalendarToolbar.tsx @@ -15,13 +15,16 @@ interface StickyCalendarToolbarProps { onViewChange: (view: CalendarViewType) => void; slideDirection?: 'up' | 'down' | null; emptyEvents?: CalendarEvent[]; + onDragStart?: (rowId: string) => void; + draggingRowId?: string | null; + onDragEnd?: () => void; } /** * Sticky calendar toolbar component that wraps CustomToolbar * Used for both normal and sticky positioning with proper spacing */ -export function StickyCalendarToolbar({ calendar, currentView, onViewChange, slideDirection, emptyEvents = [] }: StickyCalendarToolbarProps) { +export function StickyCalendarToolbar(props: StickyCalendarToolbarProps) { const { paddingStart, paddingEnd } = useDatabaseContext(); // Memoized style object matching calendar spacing @@ -41,13 +44,7 @@ export function StickyCalendarToolbar({ calendar, currentView, onViewChange, sli return (
- +
); } \ No newline at end of file diff --git a/src/components/database/fullcalendar/StickyWeekHeader.tsx b/src/components/database/fullcalendar/StickyWeekHeader.tsx index 25117a1e..097a77f6 100644 --- a/src/components/database/fullcalendar/StickyWeekHeader.tsx +++ b/src/components/database/fullcalendar/StickyWeekHeader.tsx @@ -154,7 +154,7 @@ export function StickyWeekHeader({ className='today-date' style={{ backgroundColor: 'var(--other-colors-filled-today)', - color: 'var(--text-on-fill)', + color: 'var(--text-inverse)', borderRadius: '6px', width: '24px', height: '24px', diff --git a/src/components/database/fullcalendar/event/EventDisplay.tsx b/src/components/database/fullcalendar/event/EventDisplay.tsx index 04ff58bf..e0515ff1 100644 --- a/src/components/database/fullcalendar/event/EventDisplay.tsx +++ b/src/components/database/fullcalendar/event/EventDisplay.tsx @@ -1,5 +1,4 @@ import { EventApi, EventContentArg } from '@fullcalendar/core'; -import { useEffect, useState } from 'react'; import { MonthAllDayEvent, @@ -16,7 +15,6 @@ interface EventDisplayProps { isWeekView?: boolean; showLeftIndicator?: boolean; className?: string; - isHiddenFirst?: boolean; } export function EventDisplay({ @@ -26,21 +24,8 @@ export function EventDisplay({ isWeekView = false, showLeftIndicator = true, className, - isHiddenFirst = false, }: EventDisplayProps) { const rowId = event.extendedProps?.rowId; - const [showBling, setShowBling] = useState(isHiddenFirst); - - useEffect(() => { - if (isHiddenFirst) { - setShowBling(true); - const timer = setTimeout(() => { - setShowBling(false); - }, 1000); - - return () => clearTimeout(timer); - } - }, [isHiddenFirst]); if (!rowId) return null; @@ -61,12 +46,7 @@ export function EventDisplay({ const EventComponent = getEventComponent(); return ( -
+
void; onGotoDate: (date: Date) => void; }) { + const readOnly = useReadOnly(); const primaryFieldId = usePrimaryFieldId(); const { setOpenEventRowId, markEventAsNew, markEventAsUpdate } = useEventContext(); const duplicateRowDispatch = useDuplicateRowDispatch(); @@ -94,23 +96,27 @@ function EventPopoverContent({
{/* Duplicate button */} - - - - - {t('calendar.duplicateEvent')} - + {!readOnly && ( + + + + + {t('calendar.duplicateEvent')} + + )} {/* Delete button */} - - - - - {t('calendar.deleteEvent')} - + {!readOnly && ( + + + + + {t('calendar.deleteEvent')} + + )} {/* Open page button */} diff --git a/src/components/database/fullcalendar/event/EventWithPopover.tsx b/src/components/database/fullcalendar/event/EventWithPopover.tsx index b6c1c34c..1940df4f 100644 --- a/src/components/database/fullcalendar/event/EventWithPopover.tsx +++ b/src/components/database/fullcalendar/event/EventWithPopover.tsx @@ -12,10 +12,9 @@ interface EventWithPopoverProps { event: EventApi; eventInfo: EventContentArg; isWeekView?: boolean; - isHiddenFirst?: boolean; } -export const EventWithPopover = memo(({ event, eventInfo, isWeekView = false, isHiddenFirst = false }: EventWithPopoverProps) => { +export const EventWithPopover = memo(({ event, eventInfo, isWeekView = false }: EventWithPopoverProps) => { const [isOpen, setIsOpen] = useState(false); const { clearNewEvent, setOpenEventRowId, clearUpdateEvent } = useEventContext(); const rowId = event.id; @@ -69,16 +68,32 @@ export const EventWithPopover = memo(({ event, eventInfo, isWeekView = false, is ); return ( - - -
- -
-
- - - -
+
+ { + handleOpenChange(true); + }} + event={event} + eventInfo={eventInfo} + isWeekView={isWeekView} + /> + {isOpen && ( + + +
+
+ + {isOpen && } + +
+ )} +
); }); diff --git a/src/components/database/fullcalendar/event/MoreLinkContent.tsx b/src/components/database/fullcalendar/event/MoreLinkContent.tsx index e61396d4..55e84d9f 100644 --- a/src/components/database/fullcalendar/event/MoreLinkContent.tsx +++ b/src/components/database/fullcalendar/event/MoreLinkContent.tsx @@ -1,16 +1,17 @@ import { CalendarApi, MoreLinkArg, MoreLinkContentArg } from "@fullcalendar/core"; -import { useState } from "react"; -import { useTranslation } from "react-i18next"; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; -import { - Popover, - PopoverContent, - PopoverTrigger -} from '@/components/ui/popover'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; -import { MoreLinkPopoverContent } from "./MoreLinkPopoverContent"; - -export function MoreLinkContent({ data, moreLinkInfo, calendar, onClose }: { +import { MoreLinkPopoverContent } from './MoreLinkPopoverContent'; + +export function MoreLinkContent({ + data, + moreLinkInfo, + calendar, + onClose, +}: { moreLinkInfo?: MoreLinkArg; data: MoreLinkContentArg; calendar: CalendarApi; @@ -21,18 +22,62 @@ export function MoreLinkContent({ data, moreLinkInfo, calendar, onClose }: { const [open, setOpen] = useState(false); - return { - if (!open) { - onClose() - } + const [width, setWidth] = useState(0); - setOpen(open) - }}> - {t('calendar.more', { num })} - - {moreLinkInfo && { - setOpen(false) - }} />} - - + useEffect(() => { + setWidth(Math.max(window.innerWidth / 7, 180)); + }, [open]); + + return ( +
+
{ + setOpen(true); + }} + className='w-full rounded-200 p-1 text-left text-text-primary focus-within:outline-none hover:bg-fill-content-hover' + > + {' '} + {t('calendar.more', { num })} +
+ {open && ( + { + if (!open) { + onClose(); + } + + setOpen(open); + }} + > + + + {moreLinkInfo && ( + { + setOpen(false); + }} + /> + )} + + + )} +
+ ); } \ No newline at end of file diff --git a/src/components/database/fullcalendar/event/MoreLinkPopoverContent.tsx b/src/components/database/fullcalendar/event/MoreLinkPopoverContent.tsx index 1ae0f533..b21b7be4 100644 --- a/src/components/database/fullcalendar/event/MoreLinkPopoverContent.tsx +++ b/src/components/database/fullcalendar/event/MoreLinkPopoverContent.tsx @@ -1,21 +1,25 @@ import { CalendarApi, EventContentArg, MoreLinkArg } from "@fullcalendar/core"; import { Draggable } from "@fullcalendar/interaction"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from 'react'; -import { ReactComponent as CloseIcon } from "@/assets/icons/close.svg"; -import { dayCellContent } from "@/components/database/fullcalendar/utils/dayCellContent"; -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; +import { ReactComponent as CloseIcon } from '@/assets/icons/close.svg'; +import { dayCellContent } from '@/components/database/fullcalendar/utils/dayCellContent'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; -import EventWithPopover from "./EventWithPopover"; +import EventWithPopover from './EventWithPopover'; - -export function MoreLinkPopoverContent({moreLinkInfo, calendar, onClose}: { +export function MoreLinkPopoverContent({ + moreLinkInfo, + calendar, + onClose, +}: { moreLinkInfo: MoreLinkArg; calendar: CalendarApi; onClose: () => void; }) { const { allSegs, date, view, hiddenSegs } = moreLinkInfo; + const [hoverEvent, setHoverEvent] = useState(null); const dragContainerRef = useRef(null); // Use dayCellContent for consistent date formatting @@ -49,6 +53,16 @@ export function MoreLinkPopoverContent({moreLinkInfo, calendar, onClose}: { }; }, []); + useEffect(() => { + if (hiddenSegs && hiddenSegs.length) { + setHoverEvent(hiddenSegs[0].event.extendedProps.rowId); + + setTimeout(() => { + setHoverEvent(null); + }, 1000); + } + }, [hiddenSegs]); + return ( <>
@@ -64,9 +78,6 @@ export function MoreLinkPopoverContent({moreLinkInfo, calendar, onClose}: { className='appflowy-scroller flex max-h-[140px] flex-col gap-0.5 overflow-y-auto px-2 pb-2' > {allSegs.map((seg) => { - // Check if this segment is the first hidden segment - const isHiddenFirst = hiddenSegs.length > 0 && hiddenSegs[0].event.id === seg.event.id; - // Construct EventContentArg-like object for EventWithPopover const eventInfo: EventContentArg = { event: seg.event, @@ -91,10 +102,14 @@ export function MoreLinkPopoverContent({moreLinkInfo, calendar, onClose}: { return (
- +
); })} diff --git a/src/components/database/fullcalendar/event/components/EventIconButton.tsx b/src/components/database/fullcalendar/event/components/EventIconButton.tsx index 0d3029ee..579016e1 100644 --- a/src/components/database/fullcalendar/event/components/EventIconButton.tsx +++ b/src/components/database/fullcalendar/event/components/EventIconButton.tsx @@ -1,3 +1,5 @@ +import { useState } from 'react'; + import { CustomIconPopover } from '@/components/_shared/cutsom-icon'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; @@ -14,28 +16,48 @@ interface EventIconButtonProps { export function EventIconButton({ rowId, readOnly = false, iconSize, className }: EventIconButtonProps) { const { showIcon, isFlag, onSelectIcon, removeIcon, renderIcon } = useEventIcon(rowId); - if (!showIcon) return null; + const [open, setOpen] = useState(false); + const icon = renderIcon(iconSize); + + if (!showIcon || !icon) return null; return ( - { - onSelectIcon(icon.value); - }} - removeIcon={removeIcon} - enable={Boolean(!readOnly && showIcon)} - > +
- + {open && ( + { + onSelectIcon(icon.value); + }} + removeIcon={removeIcon} + enable={Boolean(!readOnly && showIcon)} + > +
+ + )} +
); } diff --git a/src/components/database/fullcalendar/event/components/MonthMultiDayTimedEvent.tsx b/src/components/database/fullcalendar/event/components/MonthMultiDayTimedEvent.tsx index d4cb149a..6c3d9cb1 100644 --- a/src/components/database/fullcalendar/event/components/MonthMultiDayTimedEvent.tsx +++ b/src/components/database/fullcalendar/event/components/MonthMultiDayTimedEvent.tsx @@ -3,6 +3,7 @@ import dayjs from 'dayjs'; import { useCallback } from 'react'; import { cn } from '@/lib/utils'; +import { useTimeFormat } from '@/components/database/fullcalendar/hooks'; import { EventIconButton } from './EventIconButton'; @@ -15,17 +16,6 @@ interface MonthMultiDayTimedEventProps { rowId: string; } -const formatTimeDisplay = (date: Date): string => { - const time = dayjs(date); - const minutes = time.minute(); - - if (minutes === 0) { - return time.format('h A').toLowerCase(); - } else { - return time.format('h:mm A').toLowerCase(); - } -}; - export function MonthMultiDayTimedEvent({ event, eventInfo, @@ -34,6 +24,7 @@ export function MonthMultiDayTimedEvent({ className, rowId, }: MonthMultiDayTimedEventProps) { + const { formatTimeDisplay } = useTimeFormat(); const isEventStart = eventInfo.isStart; const isEventEnd = eventInfo.isEnd; @@ -121,12 +112,16 @@ export function MonthMultiDayTimedEvent({
{isEventStart && event.start && ( - {formatTimeDisplay(event.start)} + + {formatTimeDisplay(event.start)} + )} {isEventEnd && event.end && !isEventStart && ( - {formatTimeDisplay(event.end)} + + {formatTimeDisplay(event.end)} + )} - + {getDisplayContent()}
diff --git a/src/components/database/fullcalendar/event/components/MonthTimedEvent.tsx b/src/components/database/fullcalendar/event/components/MonthTimedEvent.tsx index 8440d56c..ae2467c3 100644 --- a/src/components/database/fullcalendar/event/components/MonthTimedEvent.tsx +++ b/src/components/database/fullcalendar/event/components/MonthTimedEvent.tsx @@ -2,6 +2,7 @@ import { EventApi, EventContentArg } from '@fullcalendar/core'; import dayjs from 'dayjs'; import { cn } from '@/lib/utils'; +import { useTimeFormat } from '@/components/database/fullcalendar/hooks'; import { EventIconButton } from './EventIconButton'; @@ -14,18 +15,8 @@ interface MonthTimedEventProps { rowId: string; } -const formatTimeDisplay = (date: Date): string => { - const time = dayjs(date); - const minutes = time.minute(); - - if (minutes === 0) { - return time.format('h A').toLowerCase(); - } else { - return time.format('h:mm A').toLowerCase(); - } -}; - export function MonthTimedEvent({ event, onClick, showLeftIndicator = true, className, rowId }: MonthTimedEventProps) { + const { formatTimeDisplay } = useTimeFormat(); const handleClick = () => { onClick?.(event); }; @@ -37,7 +28,7 @@ export function MonthTimedEvent({ event, onClick, showLeftIndicator = true, clas return (
{event.start && ( - + {formatTimeDisplay(event.start)} )}
- + {event.title || 'Untitled'}
diff --git a/src/components/database/fullcalendar/event/components/WeekAllDayEvent.tsx b/src/components/database/fullcalendar/event/components/WeekAllDayEvent.tsx index 6d682ca6..2b11b94a 100644 --- a/src/components/database/fullcalendar/event/components/WeekAllDayEvent.tsx +++ b/src/components/database/fullcalendar/event/components/WeekAllDayEvent.tsx @@ -98,12 +98,11 @@ export function WeekAllDayEvent({ 'event-content relative flex h-full max-h-full min-h-[22px] w-full cursor-pointer flex-col items-center overflow-hidden text-xs font-medium hover:bg-other-colors-filled-event-hover', 'bg-other-colors-filled-event text-other-colors-text-event', 'transition-shadow duration-200', - 'flex border border-transparent', - segmentConfig.leftArrow ? 'left-arrow pl-2' : 'pl-1', - segmentConfig.rightArrow ? 'right-arrow pr-2.5' : 'pr-1', + 'flex border border-transparent pl-0.5', + segmentConfig.leftArrow ? 'left-arrow pl-2' : 'pl-0.5', + segmentConfig.rightArrow ? 'right-arrow pr-2.5' : 'pr-0.5', 'py-0', segmentConfig.className, - 'pl-1.5', className )} onClick={handleClick} @@ -119,7 +118,7 @@ export function WeekAllDayEvent({ }} >
- {showLeftIndicator && !hideLine &&
} + {showLeftIndicator && !hideLine &&
}
{renderAllDayEvent}
diff --git a/src/components/database/fullcalendar/event/components/WeekTimedEvent.tsx b/src/components/database/fullcalendar/event/components/WeekTimedEvent.tsx index 7c61bc93..d1e188cb 100644 --- a/src/components/database/fullcalendar/event/components/WeekTimedEvent.tsx +++ b/src/components/database/fullcalendar/event/components/WeekTimedEvent.tsx @@ -2,6 +2,7 @@ import { EventApi, EventContentArg } from '@fullcalendar/core'; import dayjs from 'dayjs'; import { useCallback, useMemo } from 'react'; +import { useTimeFormat } from '@/components/database/fullcalendar/hooks'; import { cn } from '@/lib/utils'; import { EventIconButton } from './EventIconButton'; @@ -15,24 +16,8 @@ interface WeekTimedEventProps { rowId: string; } -const formatTimeDisplay = (date: Date): string => { - const time = dayjs(date); - const minutes = time.minute(); - - if (minutes === 0) { - return time.format('h A').toLowerCase(); - } else { - return time.format('h:mm A').toLowerCase(); - } -}; - -export function WeekTimedEvent({ - event, - eventInfo, - onClick, - className, - rowId, -}: WeekTimedEventProps) { +export function WeekTimedEvent({ event, eventInfo, onClick, className, rowId }: WeekTimedEventProps) { + const { formatTimeDisplay } = useTimeFormat(); const isEventStart = eventInfo.isStart; const isEventEnd = eventInfo.isEnd; const isRange = event.extendedProps.isRange; @@ -60,17 +45,17 @@ export function WeekTimedEvent({ if (isShortEvent) { // For short events (< 30 minutes), use single line layout with minimum height return ( -
+
- {getDisplayContent()} + {getDisplayContent()}, -
+
{isEventStart && event.start && {formatTimeDisplay(event.start)}} {isEventStart && -} @@ -84,16 +69,18 @@ export function WeekTimedEvent({ return (
-
+
- {getDisplayContent()} + {getDisplayContent()} {moreThanHalfHour ? '' : ','}
-
+
{isEventStart && event.start && {formatTimeDisplay(event.start)}} {isRange && ( @@ -122,7 +109,7 @@ export function WeekTimedEvent({
); - }, [event.end, event.start, getDisplayContent, isEventStart, rowId, isRange]); + }, [event.end, event.start, rowId, getDisplayContent, isEventStart, formatTimeDisplay, isRange]); const isShortEvent = event.end && dayjs(event.end).diff(dayjs(event.start), 'minute') < 30; const isCompactLayout = !event.end || isShortEvent; @@ -130,10 +117,11 @@ export function WeekTimedEvent({ return (
); } diff --git a/src/components/database/fullcalendar/hooks/index.ts b/src/components/database/fullcalendar/hooks/index.ts index c3e6f78f..fd0ec70f 100644 --- a/src/components/database/fullcalendar/hooks/index.ts +++ b/src/components/database/fullcalendar/hooks/index.ts @@ -8,4 +8,6 @@ export { useCalendarEvents } from './useCalendarEvents'; export { useAddButton } from './useAddButton'; export { useDynamicDayMaxEventRows } from './useDynamicDayMaxEventRows'; export { useScrollDetection } from './useScrollDetection'; -export { useCurrentTimeIndicator } from './useCurrentTimeIndicator'; \ No newline at end of file +export { useCurrentTimeIndicator } from './useCurrentTimeIndicator'; +export { useTimeFormat } from './useTimeFormat'; +export { useCalendarKeyboardShortcuts } from './useCalendarKeyboardShortcuts'; \ No newline at end of file diff --git a/src/components/database/fullcalendar/hooks/useCalendarEvents.ts b/src/components/database/fullcalendar/hooks/useCalendarEvents.ts index 6b09db69..4a7a4366 100644 --- a/src/components/database/fullcalendar/hooks/useCalendarEvents.ts +++ b/src/components/database/fullcalendar/hooks/useCalendarEvents.ts @@ -1,10 +1,11 @@ import { DateSelectArg, EventDropArg } from '@fullcalendar/core'; import { EventResizeDoneArg } from '@fullcalendar/interaction'; +import dayjs from 'dayjs'; import { useCallback } from 'react'; import { useCalendarLayoutSetting, useCreateCalendarEvent, useUpdateStartEndTimeCell } from '@/application/database-yjs'; -import { dateToUnixTimestamp, correctAllDayEndForStorage } from '@/utils/time'; +import { correctAllDayEndForStorage, dateToUnixTimestamp } from '@/utils/time'; import { CalendarViewType } from '../types'; @@ -14,8 +15,9 @@ import { CalendarViewType } from '../types'; */ export function useCalendarEvents() { const calendarSetting = useCalendarLayoutSetting(); - const fieldId = calendarSetting.fieldId; - const createCalendarEvent = useCreateCalendarEvent(fieldId); + const fieldId = calendarSetting?.fieldId || ''; + + const createCalendarEvent = useCreateCalendarEvent(); const updateCell = useUpdateStartEndTimeCell(); // Create a function that can update any event's time directly @@ -41,12 +43,19 @@ export function useCalendarEvents() { throw new Error('Invalid event ID format'); } + const start = dropInfo.event.start; + + if (!start) { + throw new Error('Invalid event start date'); + } + // Convert dates to Unix timestamps - const startTimestamp = dateToUnixTimestamp(dropInfo.event.start!); + const startTimestamp = dateToUnixTimestamp(start); const isAllDay = dropInfo.event.allDay; // For all-day events, correct end time for storage if needed - const endDate = dropInfo.event.end; + const endDate = dropInfo.event.end ? dropInfo.event.end : dayjs(new Date(start)).add(1, 'hour').toDate(); + const correctedEndDate = isAllDay && endDate ? correctAllDayEndForStorage(endDate) : endDate; const endTimestamp = correctedEndDate ? dateToUnixTimestamp(correctedEndDate) : undefined; diff --git a/src/components/database/fullcalendar/hooks/useCalendarHandlers.ts b/src/components/database/fullcalendar/hooks/useCalendarHandlers.ts index 698989ea..2e2da456 100644 --- a/src/components/database/fullcalendar/hooks/useCalendarHandlers.ts +++ b/src/components/database/fullcalendar/hooks/useCalendarHandlers.ts @@ -1,4 +1,6 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; + +import { useDatabaseContext, useDatabaseViewId } from '@/application/database-yjs'; import { CalendarViewType } from '../types'; @@ -11,7 +13,13 @@ import type { CalendarApi, DatesSetArg, MoreLinkArg } from '@fullcalendar/core'; * Centralizes all calendar interaction logic */ export function useCalendarHandlers() { - const [currentView, setCurrentView] = useState(CalendarViewType.DAY_GRID_MONTH); + // Get current view from context + const { calendarViewTypeMap } = useDatabaseContext(); + const viewId = useDatabaseViewId(); + const currentView: CalendarViewType = useMemo(() => { + return calendarViewTypeMap?.get(viewId) || CalendarViewType.DAY_GRID_MONTH + }, [calendarViewTypeMap, viewId]); + const [calendarTitle, setCalendarTitle] = useState(''); const [morelinkInfo, setMorelinkInfo] = useState(undefined); const [, setCurrentDateRange] = useState<{ start: Date; end: Date } | null>(null); @@ -27,15 +35,12 @@ export function useCalendarHandlers() { // Navigate to today calendarApi.today(); - - setCurrentView(view); } }, []); // Handle calendar date range changes const handleDatesSet = useCallback((dateInfo: DatesSetArg, _calendarApi: CalendarApi | null) => { setCalendarTitle(dateInfo.view.title); - setCurrentView(dateInfo.view.type as CalendarViewType); setCurrentDateRange({ start: dateInfo.start, end: dateInfo.end, diff --git a/src/components/database/fullcalendar/hooks/useCalendarKeyboardShortcuts.ts b/src/components/database/fullcalendar/hooks/useCalendarKeyboardShortcuts.ts new file mode 100644 index 00000000..d608c3de --- /dev/null +++ b/src/components/database/fullcalendar/hooks/useCalendarKeyboardShortcuts.ts @@ -0,0 +1,80 @@ +import { CalendarApi } from '@fullcalendar/core'; +import { useCallback, useEffect } from 'react'; + +import { createHotkey, HOT_KEY_NAME, isInputElement } from '@/utils/hotkeys'; + +import { CalendarViewType } from '../types'; + +interface UseCalendarKeyboardShortcutsProps { + calendar: CalendarApi | null; + currentView: CalendarViewType; + onViewChange?: (view: CalendarViewType) => void; + onPrev?: () => void; + onNext?: () => void; + onToday?: () => void; +} + +export const useCalendarKeyboardShortcuts = ({ + calendar, + onViewChange, + onPrev, + onNext, + onToday, +}: UseCalendarKeyboardShortcutsProps) => { + const isMonthViewHotkey = createHotkey(HOT_KEY_NAME.CALENDAR_MONTH_VIEW); + const isWeekViewHotkey = createHotkey(HOT_KEY_NAME.CALENDAR_WEEK_VIEW); + const isPrevHotkey = createHotkey(HOT_KEY_NAME.CALENDAR_PREV); + const isNextHotkey = createHotkey(HOT_KEY_NAME.CALENDAR_NEXT); + const isTodayHotkey = createHotkey(HOT_KEY_NAME.CALENDAR_TODAY); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (isInputElement()) { + return; + } + + if (isMonthViewHotkey(event)) { + event.preventDefault(); + onViewChange?.(CalendarViewType.DAY_GRID_MONTH); + return; + } + + if (isWeekViewHotkey(event)) { + event.preventDefault(); + onViewChange?.(CalendarViewType.TIME_GRID_WEEK); + return; + } + + if (isPrevHotkey(event)) { + event.preventDefault(); + + onPrev?.(); + return; + } + + if (isNextHotkey(event)) { + event.preventDefault(); + + onNext?.(); + return; + } + + if (isTodayHotkey(event)) { + event.preventDefault(); + onToday?.(); + return; + } + }, + [onViewChange, onPrev, onNext, onToday, isMonthViewHotkey, isWeekViewHotkey, isPrevHotkey, isNextHotkey, isTodayHotkey] + ); + + useEffect(() => { + if (!calendar) return; + + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [calendar, handleKeyDown]); +}; \ No newline at end of file diff --git a/src/components/database/fullcalendar/hooks/useCalendarPermissions.ts b/src/components/database/fullcalendar/hooks/useCalendarPermissions.ts index 7feff3ef..b9ed2753 100644 --- a/src/components/database/fullcalendar/hooks/useCalendarPermissions.ts +++ b/src/components/database/fullcalendar/hooks/useCalendarPermissions.ts @@ -12,7 +12,7 @@ import { YjsDatabaseKey } from '@/application/types'; export function useCalendarPermissions() { const readOnly = useReadOnly(); const calendarSetting = useCalendarLayoutSetting(); - const { field: layoutField } = useFieldSelector(calendarSetting.fieldId); + const { field: layoutField } = useFieldSelector(calendarSetting?.fieldId || ''); const newRowDispatch = useNewRowDispatch(); // Get field type diff --git a/src/components/database/fullcalendar/hooks/useCalendarStickyHeader.ts b/src/components/database/fullcalendar/hooks/useCalendarStickyHeader.ts index ae85ab5a..5e1cb508 100644 --- a/src/components/database/fullcalendar/hooks/useCalendarStickyHeader.ts +++ b/src/components/database/fullcalendar/hooks/useCalendarStickyHeader.ts @@ -45,12 +45,17 @@ export function useCalendarStickyHeader(calendarApi: CalendarApi | null, toolbar if (!scrollElement) return; const handleScroll = () => { + const parent = parentRef.current; + + if (!parent) return; updateToolbarOffset(); // Show sticky toolbar when normal toolbar reaches the app header area // App header is approximately 48px, so show sticky when toolbar goes above 48px from top const APP_HEADER_HEIGHT = 48; - const shouldShow = toolbarOffsetRef.current <= APP_HEADER_HEIGHT; + const bottom = parent.getBoundingClientRect().bottom ?? 0; + + const shouldShow = toolbarOffsetRef.current <= APP_HEADER_HEIGHT && bottom - 200 >= APP_HEADER_HEIGHT; setShowStickyToolbar(shouldShow); }; @@ -94,6 +99,12 @@ export function useCalendarStickyHeader(calendarApi: CalendarApi | null, toolbar }; }, [getScrollElement, updateToolbarOffset]); + useEffect(() => { + return () => { + setShowStickyToolbar(false); + } + }, []) + return { parentRef, showStickyToolbar, diff --git a/src/components/database/fullcalendar/hooks/useCurrentTimeIndicator.ts b/src/components/database/fullcalendar/hooks/useCurrentTimeIndicator.ts index e9673e19..58452d47 100644 --- a/src/components/database/fullcalendar/hooks/useCurrentTimeIndicator.ts +++ b/src/components/database/fullcalendar/hooks/useCurrentTimeIndicator.ts @@ -1,5 +1,7 @@ import { useEffect, useRef } from 'react'; +import { useCalendarLayoutSetting } from '@/application/database-yjs'; + import { CalendarViewType } from '../types'; import type { CalendarApi } from '@fullcalendar/core'; @@ -11,6 +13,9 @@ import type { CalendarApi } from '@fullcalendar/core'; export function useCurrentTimeIndicator(calendarApi: CalendarApi | null, currentView: CalendarViewType) { const intervalRef = useRef(null); + const isCurrentWeekRef = useRef(false); + const setting = useCalendarLayoutSetting(); + useEffect(() => { if (!calendarApi || currentView !== CalendarViewType.TIME_GRID_WEEK) { // Clear interval if not in week view @@ -32,33 +37,74 @@ export function useCurrentTimeIndicator(calendarApi: CalendarApi | null, current return; } + const isCurrentWeek = () => { + if (!calendarApi) return false; + + const now = new Date(); + const currentViewStart = calendarApi.view.activeStart; + const currentViewEnd = calendarApi.view.activeEnd; + + // Check if current time falls within the displayed week range + return now >= currentViewStart && now < currentViewEnd; + }; + const updateTimeLabel = () => { - // Find FullCalendar's native now indicator arrow element - const nowIndicatorArrow = document.querySelector('.fc-timegrid-now-indicator-arrow') as HTMLElement; - - if (nowIndicatorArrow) { - // Get current time - const now = new Date(); - const timeString = now.toLocaleTimeString('en-US', { - hour: 'numeric', - minute: '2-digit', - hour12: true - }); - const [time, period] = timeString.split(' '); + // Check if we're viewing the current week + if (!isCurrentWeek()) { + isCurrentWeekRef.current = false; + // Remove horizontal line if not in current week + const existingLine = document.querySelector('.custom-now-indicator-line'); + + if (existingLine) { + existingLine.remove(); + // Reset time slot visibility on unmount + resetTimeSlotVisibility(); + } - // Set the content directly to the arrow element - nowIndicatorArrow.innerHTML = `${time}${period}`; - - // Handle dynamic time slot visibility - handleTimeSlotVisibility(now); - - // Create or update the horizontal line across the week view - createHorizontalTimeLine(nowIndicatorArrow); + return; } + + isCurrentWeekRef.current = true; + + // We're in the current week, try to find and update the time indicator + const tryUpdateTimeIndicator = (retryCount = 0) => { + const nowIndicatorArrow = document.querySelector('.fc-timegrid-now-indicator-arrow') as HTMLElement; + + if (nowIndicatorArrow) { + // Get current time + const now = new Date(); + const timeString = now.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: !setting?.use24Hour + }); + const [time, period] = timeString.split(' '); + // Set the content directly to the arrow element + + if (period) { + nowIndicatorArrow.innerHTML = `${time}${period}`; + + } else { + nowIndicatorArrow.innerHTML = `${time}`; + } + + // Handle dynamic time slot visibility + handleTimeSlotVisibility(now); + + // Create or update the horizontal line across the week view + createHorizontalTimeLine(nowIndicatorArrow); + } else if (retryCount < 3) { + // Retry after a short delay if element not found yet + setTimeout(() => tryUpdateTimeIndicator(retryCount + 1), 100); + } + }; + + tryUpdateTimeIndicator(); }; const handleTimeSlotVisibility = (currentTime: Date) => { + if (!isCurrentWeekRef.current) return; const currentHour = currentTime.getHours(); const currentMinute = currentTime.getMinutes(); @@ -148,16 +194,25 @@ export function useCurrentTimeIndicator(calendarApi: CalendarApi | null, current // Initial update updateTimeLabel(); - // Update 15s to keep the time accurate + // Update every 15s to keep the time accurate intervalRef.current = setInterval(updateTimeLabel, 15000); + // Listen for date range changes (when user navigates to different weeks) + const handleDatesSet = () => { + updateTimeLabel(); + }; + + calendarApi.on('datesSet', handleDatesSet); + return () => { if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; } + + calendarApi.off('datesSet', handleDatesSet); }; - }, [calendarApi, currentView]); + }, [calendarApi, currentView, setting]); // Cleanup on unmount useEffect(() => { @@ -186,11 +241,7 @@ export function useCurrentTimeIndicator(calendarApi: CalendarApi | null, current const timeSlot = document.querySelector(`[data-time="${timeString}"]`) as HTMLElement; if (timeSlot) { - const timeLabel = timeSlot.querySelector('.fc-timegrid-slot-label') as HTMLElement; - - if (timeLabel) { - timeLabel.style.opacity = '1'; - } + timeSlot.classList.remove('hidden-text'); } } }; diff --git a/src/components/database/fullcalendar/hooks/useScrollNavigation.ts b/src/components/database/fullcalendar/hooks/useScrollNavigation.ts index 5e792709..4074c18b 100644 --- a/src/components/database/fullcalendar/hooks/useScrollNavigation.ts +++ b/src/components/database/fullcalendar/hooks/useScrollNavigation.ts @@ -45,12 +45,10 @@ export function useScrollNavigation(currentView: CalendarViewType, calendarApi: if (direction === 'up') { const isAtTop = scrollTop <= tolerance; - console.debug(`📅 Boundary check UP: scrollTop=${scrollTop}, tolerance=${tolerance}, isAtTop=${isAtTop}`); return isAtTop; } else { const isAtBottom = scrollTop + clientHeight >= scrollHeight - tolerance; - console.debug(`📅 Boundary check DOWN: scrollTop=${scrollTop}, clientHeight=${clientHeight}, scrollHeight=${scrollHeight}, isAtBottom=${isAtBottom}`); return isAtBottom; } }, [getScrollElement]); @@ -117,7 +115,6 @@ export function useScrollNavigation(currentView: CalendarViewType, calendarApi: // Check if we're in cooldown period if (isInCooldown()) { - console.debug(`📅 In cooldown period, ignoring scroll`); return; } @@ -125,7 +122,6 @@ export function useScrollNavigation(currentView: CalendarViewType, calendarApi: if (!isAccumulating.current) { isAccumulating.current = true; scrollAccumulator.current = 0; - console.debug(`📅 Started accumulating scroll at ${direction} boundary`); } // Only accumulate significant scroll amounts to reduce sensitivity @@ -133,18 +129,16 @@ export function useScrollNavigation(currentView: CalendarViewType, calendarApi: if (deltaY >= 8) { // Only count scrolls >= 8px to reduce sensitivity scrollAccumulator.current += deltaY; - console.debug(`📅 Scroll accumulated: ${Math.round(scrollAccumulator.current)}px (threshold: ${SCROLL_THRESHOLD}px, delta: ${deltaY}px)`); // Check if threshold is reached if (scrollAccumulator.current >= SCROLL_THRESHOLD) { - console.debug(`📅 Threshold reached! Triggering navigation ${direction}`); // Set cooldown time and reset accumulator lastTriggerTime.current = Date.now(); resetScrollAccumulator(); navigateMonth(direction); } } else { - console.debug(`📅 Ignoring small scroll: ${deltaY}px`); + // console.debug(`📅 Ignoring small scroll: ${deltaY}px`); } } else if (isAccumulating.current) { // Not at boundary anymore - reset accumulator diff --git a/src/components/database/fullcalendar/hooks/useTimeFormat.tsx b/src/components/database/fullcalendar/hooks/useTimeFormat.tsx new file mode 100644 index 00000000..bc9781a7 --- /dev/null +++ b/src/components/database/fullcalendar/hooks/useTimeFormat.tsx @@ -0,0 +1,72 @@ +import dayjs from 'dayjs'; +import { useCallback } from 'react'; + +import { TimeFormat } from '@/application/types'; +import { useCurrentUser } from '@/components/main/app.hooks'; +import { MetadataKey } from '@/application/user-metadata'; + +/** + * Hook for consistent time formatting across calendar components + */ +export function useTimeFormat() { + const currentUser = useCurrentUser(); + + // Get user's time format preference, defaulting to 12-hour format + const userTimeFormat = currentUser?.metadata?.[MetadataKey.TimeFormat] as TimeFormat ?? TimeFormat.TwelveHour; + const use24Hour = userTimeFormat === TimeFormat.TwentyFourHour; + + /** + * Format time display according to user preference + * @param date - Date to format + * @returns Formatted time string + */ + const formatTimeDisplay = useCallback((date: Date): string => { + const time = dayjs(date); + const minutes = time.minute(); + + if (use24Hour) { + // 24-hour format: "14:00" or "14:30" + return time.format('HH:mm'); + } else { + // 12-hour format: "2 pm" or "2:30 pm" (lowercase) + if (minutes === 0) { + return time.format('h A').toLowerCase(); + } else { + return time.format('h:mm A').toLowerCase(); + } + } + }, [use24Hour]); + + /** + * Format time for slot labels in calendar view + * @param date - Date to format + * @returns Formatted time for slot labels + */ + const formatSlotLabel = useCallback((date: Date) => { + const hour = date.getHours(); + + if (use24Hour) { + return ( + + {dayjs(date).format('HH:mm')} + + ); + } else { + const period = hour >= 12 ? 'PM' : 'AM'; + const displayHour = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour; + + return ( + + {displayHour} + {period} + + ); + } + }, [use24Hour]); + + return { + use24Hour, + formatTimeDisplay, + formatSlotLabel, + }; +} \ No newline at end of file diff --git a/src/components/database/fullcalendar/utils/dayCellContent.tsx b/src/components/database/fullcalendar/utils/dayCellContent.tsx index 5ccacb07..2e749a85 100644 --- a/src/components/database/fullcalendar/utils/dayCellContent.tsx +++ b/src/components/database/fullcalendar/utils/dayCellContent.tsx @@ -18,11 +18,11 @@ export const dayCellContent = (args: DayCellContentArgs) => { return (
- {(dayNumber === 1 || isPopover) ? monthName : null} + {dayNumber === 1 || isPopover ? monthName : null} diff --git a/src/components/editor/components/blocks/database/DatabaseBlock.tsx b/src/components/editor/components/blocks/database/DatabaseBlock.tsx index cb3dedc5..4321797d 100644 --- a/src/components/editor/components/blocks/database/DatabaseBlock.tsx +++ b/src/components/editor/components/blocks/database/DatabaseBlock.tsx @@ -1,18 +1,18 @@ -import CircularProgress from '@mui/material/CircularProgress'; -import { forwardRef, memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; +import { forwardRef, memo, useCallback, useEffect, useRef, useState } from 'react'; import { Element } from 'slate'; -import { ReactEditor, useReadOnly, useSlateStatic } from 'slate-react'; +import { useReadOnly, useSlateStatic } from 'slate-react'; -import { View, YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/types'; -import { Database } from '@/components/database'; +import { DatabaseContextState } from '@/application/database-yjs'; +import { YjsEditorKey, YSharedRoot } from '@/application/types'; import { DatabaseNode, EditorElementProps } from '@/components/editor/editor.type'; import { useEditorContext } from '@/components/editor/EditorContext'; -import { getScrollParent } from '@/components/global-comment/utils'; + +import { DatabaseContent } from './components/DatabaseContent'; +import { useDatabaseLoading } from './hooks/useDatabaseLoading'; +import { useResizePositioning } from './hooks/useResizePositioning'; export const DatabaseBlock = memo( forwardRef>(({ node, children, ...attributes }, ref) => { - const { t } = useTranslation(); const viewId = node.data.view_id; const context = useEditorContext(); const workspaceId = context.workspaceId; @@ -20,92 +20,16 @@ export const DatabaseBlock = memo( const loadView = context?.loadView; const createRowDoc = context?.createRowDoc; - const [notFound, setNotFound] = useState(false); - const [doc, setDoc] = useState(null); + const [hasDatabase, setHasDatabase] = useState(false); + const containerRef = useRef(null); + const editor = useSlateStatic(); + const readOnly = useReadOnly() || editor.isElementReadOnly(node as unknown as Element); - useEffect(() => { - if (!viewId) return; - void (async () => { - try { - const view = await loadView?.(viewId); - - if (!view) { - throw new Error('View not found'); - } - - setDoc(view); - } catch (e) { - setNotFound(true); - } - })(); - }, [viewId, loadView]); - - const [selectedViewId, setSelectedViewId] = useState(viewId); - const [visibleViewIds, setVisibleViewIds] = useState([]); - const [iidName, setIidName] = useState(''); - - const viewIdsRef = useRef([viewId]); - - useEffect(() => { - viewIdsRef.current = visibleViewIds; - }, [visibleViewIds]); - - const updateVisibleViewIds = useCallback(async (meta: View | null) => { - if (!meta) { - return; - } - - const viewIds = meta.children.map((v) => v.view_id) || []; - - viewIds.unshift(meta.view_id); - - setIidName(meta.name); - - setVisibleViewIds(viewIds); - }, []); - - const loadViewMeta = useCallback( - async (id: string, callback?: (meta: View | null) => void) => { - if (id === viewId) { - try { - const meta = await context?.loadViewMeta?.(viewId, updateVisibleViewIds); - - if (meta) { - await updateVisibleViewIds(meta); - return meta; - } - } catch (e) { - setNotFound(true); - } - - return Promise.reject(new Error('View not found')); - } else { - const meta = await context?.loadViewMeta?.(id, callback); - - if (meta) { - return meta; - } - - return Promise.reject(new Error('View not found')); - } - }, - [context, updateVisibleViewIds, viewId] - ); - - useLayoutEffect(() => { - void loadViewMeta(viewId).then(() => { - if (!viewIdsRef.current.includes(viewId) && viewIdsRef.current.length > 0) { - setSelectedViewId(viewIdsRef.current[0]); - } else { - setSelectedViewId(viewId); - } - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const onChangeView = useCallback((viewId: string) => { - setSelectedViewId(viewId); - }, []); + const { notFound, doc, selectedViewId, visibleViewIds, iidName, onChangeView, loadViewMeta } = useDatabaseLoading({ + viewId, + loadView, + loadViewMeta: context?.loadViewMeta, + }); const handleNavigateToRow = useCallback( async (rowId: string) => { @@ -114,107 +38,62 @@ export const DatabaseBlock = memo( }, [navigateToView, viewId] ); - const editor = useSlateStatic(); - const readOnly = useReadOnly() || editor.isElementReadOnly(node as unknown as Element); - const containerRef = useRef(null); - const selectedView = useMemo(() => { - const database = doc?.getMap(YjsEditorKey.data_section)?.get(YjsEditorKey.database); - - return database?.get(YjsDatabaseKey.views)?.get(selectedViewId); - }, [doc, selectedViewId]); - - const [paddingStart, setPaddingStart] = useState(0); - const [paddingEnd, setPaddingEnd] = useState(0); - const [width, setWidth] = useState(0); + const { paddingStart, paddingEnd, width } = useResizePositioning({ + editor, + node: node as unknown as Element, + }); useEffect(() => { - const dom = ReactEditor.toDOMNode(editor, node); + const sharedRoot = doc?.getMap(YjsEditorKey.data_section) as YSharedRoot; - const scrollContainer = dom.closest('.appflowy-scroll-container') || (getScrollParent(dom) as HTMLElement); + if (!sharedRoot) return; - if (!dom || !scrollContainer) return; - - const onResize = () => { - const rect = scrollContainer.getBoundingClientRect(); - const blockRect = dom.getBoundingClientRect(); - - const offsetLeft = blockRect.left - rect.left; - const offsetRight = rect.right - blockRect.right; - - setWidth(rect.width); - setPaddingStart(offsetLeft); - setPaddingEnd(offsetRight); + const setStatus = () => { + setHasDatabase(!!sharedRoot.get(YjsEditorKey.database)); }; - onResize(); + setStatus(); + sharedRoot.observe(setStatus); - const resizeObserver = new ResizeObserver(onResize); - - resizeObserver.observe(scrollContainer); return () => { - resizeObserver.disconnect(); + sharedRoot.unobserve(setStatus); }; - }, [editor, selectedView, node]); + }, [doc]); return ( - <> -
-
- {children} -
- -
- {selectedViewId && doc ? ( -
- -
- ) : ( -
- {notFound ? ( - <> -
{t('publish.hasNotBeenPublished')}
- - ) : ( - - )} -
- )} -
+
+
+ {children}
- +
+ +
+
); }), (prevProps, nextProps) => prevProps.node.data.view_id === nextProps.node.data.view_id diff --git a/src/components/editor/components/blocks/database/components/DatabaseContent.tsx b/src/components/editor/components/blocks/database/components/DatabaseContent.tsx new file mode 100644 index 00000000..ebe07abf --- /dev/null +++ b/src/components/editor/components/blocks/database/components/DatabaseContent.tsx @@ -0,0 +1,95 @@ +import CircularProgress from '@mui/material/CircularProgress'; +import { useTranslation } from 'react-i18next'; + +import { DatabaseContextState } from '@/application/database-yjs'; +import { LoadView, LoadViewMeta, UIVariant, YDoc } from '@/application/types'; +import { Database } from '@/components/database'; + +interface DatabaseContentProps { + selectedViewId: string | null; + hasDatabase: boolean; + notFound: boolean; + paddingStart: number; + paddingEnd: number; + width: number; + doc: YDoc | null; + workspaceId: string; + viewId: string; + createRowDoc?: (rowId: string) => Promise; + loadView?: LoadView; + navigateToView?: (viewId: string, rowId?: string) => Promise; + onOpenRowPage: (rowId: string) => Promise; + loadViewMeta: LoadViewMeta; + iidName: string; + visibleViewIds: string[]; + onChangeView: (viewId: string) => void; + context: DatabaseContextState; +} + +export const DatabaseContent = ({ + selectedViewId, + hasDatabase, + notFound, + paddingStart, + paddingEnd, + width, + doc, + workspaceId, + viewId, + createRowDoc, + loadView, + navigateToView, + onOpenRowPage, + loadViewMeta, + iidName, + visibleViewIds, + onChangeView, + context, +}: DatabaseContentProps) => { + const { t } = useTranslation(); + const isPublishVarient = context?.variant === UIVariant.Publish; + + if (selectedViewId && doc && hasDatabase && !notFound) { + return ( +
+ +
+ ); + } + + return ( +
+ {notFound ? ( +
+ {isPublishVarient ? t('publish.hasNotBeenPublished') : 'Something went wrong'} +
+ ) : ( + + )} +
+ ); +}; \ No newline at end of file diff --git a/src/components/editor/components/blocks/database/hooks/useDatabaseLoading.ts b/src/components/editor/components/blocks/database/hooks/useDatabaseLoading.ts new file mode 100644 index 00000000..ff068d91 --- /dev/null +++ b/src/components/editor/components/blocks/database/hooks/useDatabaseLoading.ts @@ -0,0 +1,116 @@ +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; + +import { View, YDoc } from '@/application/types'; + +import { useRetryFunction } from './useRetryFunction'; + +interface UseDatabaseLoadingProps { + viewId: string; + loadView?: (viewId: string) => Promise; + loadViewMeta?: (viewId: string, callback?: (meta: View | null) => void) => Promise; +} + +export const useDatabaseLoading = ({ viewId, loadView, loadViewMeta }: UseDatabaseLoadingProps) => { + const [notFound, setNotFound] = useState(false); + const [doc, setDoc] = useState(null); + const [selectedViewId, setSelectedViewId] = useState(viewId); + const [visibleViewIds, setVisibleViewIds] = useState([]); + const [iidName, setIidName] = useState(''); + + const viewIdsRef = useRef([viewId]); + + const handleError = useCallback(() => { + setNotFound(true); + }, []); + + const retryLoadView = useRetryFunction(loadView, handleError); + const retryLoadViewMeta = useRetryFunction(loadViewMeta, handleError); + + const updateVisibleViewIds = useCallback(async (meta: View | null) => { + if (!meta) { + return; + } + + const viewIds = meta.children.map((v) => v.view_id) || []; + + viewIds.unshift(meta.view_id); + + setIidName(meta.name); + setVisibleViewIds(viewIds); + }, []); + + const loadViewMetaWithCallback = useCallback( + async (id: string, callback?: (meta: View | null) => void) => { + if (id === viewId) { + const meta = await retryLoadViewMeta(viewId, updateVisibleViewIds); + + if (meta) { + await updateVisibleViewIds(meta); + setNotFound(false); + return meta; + } + + return Promise.reject(new Error('View not found')); + } else { + const meta = await retryLoadViewMeta(id, callback); + + if (meta) { + setNotFound(false); + return meta; + } + + return Promise.reject(new Error('View not found')); + } + }, + [retryLoadViewMeta, updateVisibleViewIds, viewId] + ); + + const onChangeView = useCallback((viewId: string) => { + setSelectedViewId(viewId); + }, []); + + useEffect(() => { + if (!viewId) return; + + const loadViewData = async () => { + try { + const view = await retryLoadView(viewId); + + setDoc(view); + setNotFound(false); + } catch (error) { + setNotFound(true); + } + }; + + void loadViewData(); + }, [viewId, retryLoadView]); + + useEffect(() => { + viewIdsRef.current = visibleViewIds; + }, [visibleViewIds]); + + useLayoutEffect(() => { + void loadViewMetaWithCallback(viewId).then(() => { + if (!viewIdsRef.current.includes(viewId) && viewIdsRef.current.length > 0) { + setSelectedViewId(viewIdsRef.current[0]); + } else { + setSelectedViewId(viewId); + } + + setNotFound(false); + }).catch(() => { + setNotFound(true); + }); + }, [loadViewMetaWithCallback, viewId]); + + return { + notFound, + doc, + selectedViewId, + visibleViewIds, + iidName, + onChangeView, + loadViewMeta: loadViewMetaWithCallback, + }; +}; \ No newline at end of file diff --git a/src/components/editor/components/blocks/database/hooks/useResizePositioning.ts b/src/components/editor/components/blocks/database/hooks/useResizePositioning.ts new file mode 100644 index 00000000..c299fc22 --- /dev/null +++ b/src/components/editor/components/blocks/database/hooks/useResizePositioning.ts @@ -0,0 +1,51 @@ +import { useEffect, useState } from 'react'; +import { Editor, Element } from 'slate'; +import { ReactEditor } from 'slate-react'; + +import { getScrollParent } from '@/components/global-comment/utils'; + +interface UseResizePositioningProps { + editor: Editor; + node: Element; +} + +export const useResizePositioning = ({ editor, node }: UseResizePositioningProps) => { + const [paddingStart, setPaddingStart] = useState(0); + const [paddingEnd, setPaddingEnd] = useState(0); + const [width, setWidth] = useState(0); + + useEffect(() => { + const dom = ReactEditor.toDOMNode(editor, node); + const scrollContainer = dom.closest('.appflowy-scroll-container') || (getScrollParent(dom) as HTMLElement); + + if (!dom || !scrollContainer) return; + + const onResize = () => { + const rect = scrollContainer.getBoundingClientRect(); + const blockRect = dom.getBoundingClientRect(); + + const offsetLeft = blockRect.left - rect.left; + const offsetRight = rect.right - blockRect.right; + + setWidth(rect.width); + setPaddingStart(offsetLeft); + setPaddingEnd(offsetRight); + }; + + onResize(); + + const resizeObserver = new ResizeObserver(onResize); + + resizeObserver.observe(scrollContainer); + + return () => { + resizeObserver.disconnect(); + }; + }, [editor, node]); + + return { + paddingStart, + paddingEnd, + width, + }; +}; \ No newline at end of file diff --git a/src/components/editor/components/blocks/database/hooks/useRetryFunction.ts b/src/components/editor/components/blocks/database/hooks/useRetryFunction.ts new file mode 100644 index 00000000..75587243 --- /dev/null +++ b/src/components/editor/components/blocks/database/hooks/useRetryFunction.ts @@ -0,0 +1,48 @@ +import { useCallback } from 'react'; + +const RETRY_CONFIG = { + maxAttempts: 3, + retryDelay: 10000, +}; + +// eslint-disable-next-line +export const useRetryFunction = ( + fn: ((...args: T) => Promise) | undefined, + onError: () => void +) => { + const retryFunction = useCallback( + async (...args: T): Promise => { + let attempt = 1; + + const executeWithRetry = async (): Promise => { + try { + if (!fn) { + throw new Error('Function not available'); + } + + const result = await fn(...args); + + if (!result) { + throw new Error('No result returned'); + } + + return result; + } catch (error) { + if (attempt < RETRY_CONFIG.maxAttempts) { + attempt++; + await new Promise(resolve => setTimeout(resolve, RETRY_CONFIG.retryDelay)); + return executeWithRetry(); + } else { + onError(); + throw error; + } + } + }; + + return executeWithRetry(); + }, + [fn, onError] + ); + + return retryFunction; +}; \ No newline at end of file diff --git a/src/components/editor/components/blocks/gallery/GalleryBlock.tsx b/src/components/editor/components/blocks/gallery/GalleryBlock.tsx index cf61dee1..f4110037 100644 --- a/src/components/editor/components/blocks/gallery/GalleryBlock.tsx +++ b/src/components/editor/components/blocks/gallery/GalleryBlock.tsx @@ -5,13 +5,13 @@ import isURL from 'validator/lib/isURL'; import { GalleryLayout } from '@/application/types'; import { ReactComponent as GalleryIcon } from '@/assets/icons/gallery.svg'; +import { GalleryPreview } from '@/components/_shared/gallery-preview'; +import { notify } from '@/components/_shared/notify'; import Carousel from '@/components/editor/components/blocks/gallery/Carousel'; import GalleryToolbar from '@/components/editor/components/blocks/gallery/GalleryToolbar'; import ImageGallery from '@/components/editor/components/blocks/gallery/ImageGallery'; import { EditorElementProps, GalleryBlockNode } from '@/components/editor/editor.type'; import { useEditorContext } from '@/components/editor/EditorContext'; -import { GalleryPreview } from '@/components/_shared/gallery-preview'; -import { notify } from '@/components/_shared/notify'; import { copyTextToClipboard } from '@/utils/copy'; import { getConfigValue } from '@/utils/runtime-config'; @@ -31,23 +31,31 @@ const GalleryBlock = memo( }, [attributes.className]); const photos = useMemo(() => { - return images.map((image) => { - let imageUrl = image.url; + return images + .map((image) => { + let imageUrl = image.url; - if (!isURL(image.url)) { - imageUrl = getConfigValue('APPFLOWY_BASE_URL', '') + '/api/file_storage/' + workspaceId + '/v1/blob/' + image.url; - } + if (!imageUrl) return null; + if (!isURL(image.url)) { + imageUrl = + getConfigValue('APPFLOWY_BASE_URL', '') + '/api/file_storage/' + workspaceId + '/v1/blob/' + image.url; + } - const url = new URL(imageUrl); + const url = new URL(imageUrl); - url.searchParams.set('auto', 'format'); - url.searchParams.set('fit', 'crop'); - return { - src: imageUrl, - thumb: url.toString() + '&w=240&q=80', - responsive: [url.toString() + '&w=480&q=80 480', url.toString() + '&w=800&q=80 800'].join(', '), - }; - }); + url.searchParams.set('auto', 'format'); + url.searchParams.set('fit', 'crop'); + return { + src: imageUrl, + thumb: url.toString() + '&w=240&q=80', + responsive: [url.toString() + '&w=480&q=80 480', url.toString() + '&w=800&q=80 800'].join(', '), + }; + }) + .filter(Boolean) as { + src: string; + thumb: string; + responsive: string; + }[]; }, [images, workspaceId]); const handleOpenPreview = useCallback(() => { diff --git a/src/components/login/LoginAuth.tsx b/src/components/login/LoginAuth.tsx index dd7145a4..b8af13ce 100644 --- a/src/components/login/LoginAuth.tsx +++ b/src/components/login/LoginAuth.tsx @@ -1,11 +1,11 @@ import { getRedirectTo } from '@/application/session/sign_in'; import { NormalModal } from '@/components/_shared/modal'; import { AFConfigContext } from '@/components/main/app.hooks'; -import LinearBuffer from '@/components/login/LinearBuffer'; import { useContext, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { ReactComponent as ErrorIcon } from '@/assets/icons/error.svg'; +import { WorkspaceLoadingAnimation } from '@/components/app/WorkspaceLoadingAnimation'; function LoginAuth () { const service = useContext(AFConfigContext)?.service; @@ -36,7 +36,7 @@ function LoginAuth () { <> {loading ? (
- +
) : null} { sendMessage(message); postMessage(message); diff --git a/src/utils/hotkeys.ts b/src/utils/hotkeys.ts index e06174bc..35b15d57 100644 --- a/src/utils/hotkeys.ts +++ b/src/utils/hotkeys.ts @@ -69,6 +69,14 @@ export enum HOT_KEY_NAME { CREATE_CARD_BEFORE = 'create-card-before', MOVE_CARD_PREV_COLUMN = 'move-card-prev-column', MOVE_CARD_NEXT_COLUMN = 'move-card-next-column', + /** + * Calendar shortcuts + */ + CALENDAR_MONTH_VIEW = 'calendar-month-view', + CALENDAR_WEEK_VIEW = 'calendar-week-view', + CALENDAR_PREV = 'calendar-prev', + CALENDAR_NEXT = 'calendar-next', + CALENDAR_TODAY = 'calendar-today', } const defaultHotKeys = { @@ -124,6 +132,11 @@ const defaultHotKeys = { [HOT_KEY_NAME.CREATE_CARD_BEFORE]: ['shift+mod+up'], [HOT_KEY_NAME.MOVE_CARD_PREV_COLUMN]: [','], [HOT_KEY_NAME.MOVE_CARD_NEXT_COLUMN]: ['.'], + [HOT_KEY_NAME.CALENDAR_MONTH_VIEW]: ['m'], + [HOT_KEY_NAME.CALENDAR_WEEK_VIEW]: ['w'], + [HOT_KEY_NAME.CALENDAR_PREV]: ['k'], + [HOT_KEY_NAME.CALENDAR_NEXT]: ['j'], + [HOT_KEY_NAME.CALENDAR_TODAY]: ['t'], }; const replaceModifier = (hotkey: string) => { @@ -168,3 +181,15 @@ export const createHotKeyLabel = (hotkeyName: HOT_KEY_NAME, customHotKeys?: Reco ) .join(' / '); }; + +export const isInputElement = (): boolean => { + const activeElement = document.activeElement; + + if (!activeElement) return false; + + const tagName = activeElement.tagName.toLowerCase(); + const isEditable = activeElement.hasAttribute('contenteditable'); + const isInput = ['input', 'textarea', 'select'].includes(tagName); + + return isInput || isEditable; +};