From 5fbb079950796839eb3bfc4525d55cf84bb7bded Mon Sep 17 00:00:00 2001 From: Uno Date: Tue, 9 Dec 2025 22:35:39 +0800 Subject: [PATCH 01/19] feat: space layout --- .../src/features/base/base.service.ts | 22 +- .../src/features/space/space.service.ts | 22 +- .../src/features/trash/trash.service.ts | 8 +- .../user/last-visit/last-visit.service.ts | 33 +- .../src/types/i18n.generated.ts | 10 + .../base/base-side-bar/BaseNodeTree.tsx | 323 +++++++++--------- .../features/app/blocks/base/hooks/index.ts | 1 + .../app/blocks/base/hooks/useLastVisitBase.ts | 39 +++ .../features/app/blocks/space/BaseItem.tsx | 206 +++++++++++ .../features/app/blocks/space/BaseList.tsx | 142 ++++++++ .../app/blocks/space/RecentlyBase.tsx | 49 +-- .../app/blocks/space/SharedBasePage.tsx | 51 +-- .../features/app/blocks/space/SpaceCard.tsx | 4 +- .../app/blocks/space/SpaceInnerPage.tsx | 16 +- .../features/app/blocks/space/hooks/index.ts | 1 + .../app/blocks/space/hooks/useSpaceList.ts | 12 + .../blocks/space/space-side-bar/PinList.tsx | 142 ++++---- .../space-side-bar/SpaceInnerSideBar.tsx | 97 ++++++ .../blocks/space/space-side-bar/SpaceList.tsx | 11 +- .../space/space-side-bar/SpaceSideBar.tsx | 4 +- .../space/space-side-bar/SpaceSwitcher.tsx | 299 ++++++++++++++++ .../app/blocks/trash/SpaceInnerTrashPage.tsx | 249 ++++++++++++++ .../app/blocks/trash/SpaceTrashPage.tsx | 109 +----- .../app/components/space/SpaceAvatar.tsx | 45 +++ .../features/app/layouts/SharedBaseLayout.tsx | 74 ++++ .../features/app/layouts/SpaceInnerLayout.tsx | 62 ++++ .../features/app/layouts/SpaceTrashLayout.tsx | 74 ++++ apps/nextjs-app/src/pages/space/[spaceId].tsx | 4 +- .../src/pages/space/[spaceId]/trash.tsx | 40 +++ apps/nextjs-app/src/pages/space/index.tsx | 21 +- .../src/pages/space/shared-base.tsx | 4 +- apps/nextjs-app/src/pages/space/trash.tsx | 4 +- .../common-i18n/src/locales/en/common.json | 4 +- .../common-i18n/src/locales/en/space.json | 8 + .../common-i18n/src/locales/zh/common.json | 4 +- .../common-i18n/src/locales/zh/space.json | 8 + packages/openapi/src/base/get.ts | 9 + packages/openapi/src/trash/get.ts | 1 + packages/openapi/src/user/last-visit/get.ts | 1 + packages/sdk/src/model/base.ts | 3 + packages/ui-lib/src/shadcn/ui/accordion.tsx | 8 +- packages/ui-lib/src/shadcn/ui/tree.tsx | 4 +- 42 files changed, 1793 insertions(+), 435 deletions(-) create mode 100644 apps/nextjs-app/src/features/app/blocks/base/hooks/index.ts create mode 100644 apps/nextjs-app/src/features/app/blocks/base/hooks/useLastVisitBase.ts create mode 100644 apps/nextjs-app/src/features/app/blocks/space/BaseItem.tsx create mode 100644 apps/nextjs-app/src/features/app/blocks/space/BaseList.tsx create mode 100644 apps/nextjs-app/src/features/app/blocks/space/hooks/index.ts create mode 100644 apps/nextjs-app/src/features/app/blocks/space/hooks/useSpaceList.ts create mode 100644 apps/nextjs-app/src/features/app/blocks/space/space-side-bar/SpaceInnerSideBar.tsx create mode 100644 apps/nextjs-app/src/features/app/blocks/space/space-side-bar/SpaceSwitcher.tsx create mode 100644 apps/nextjs-app/src/features/app/blocks/trash/SpaceInnerTrashPage.tsx create mode 100644 apps/nextjs-app/src/features/app/components/space/SpaceAvatar.tsx create mode 100644 apps/nextjs-app/src/features/app/layouts/SharedBaseLayout.tsx create mode 100644 apps/nextjs-app/src/features/app/layouts/SpaceInnerLayout.tsx create mode 100644 apps/nextjs-app/src/features/app/layouts/SpaceTrashLayout.tsx create mode 100644 apps/nextjs-app/src/pages/space/[spaceId]/trash.tsx diff --git a/apps/nestjs-backend/src/features/base/base.service.ts b/apps/nestjs-backend/src/features/base/base.service.ts index 00a70f697e..88b86e756d 100644 --- a/apps/nestjs-backend/src/features/base/base.service.ts +++ b/apps/nestjs-backend/src/features/base/base.service.ts @@ -12,6 +12,7 @@ import type { IUpdateBaseRo, IUpdateOrderRo, } from '@teable/openapi'; +import { keyBy } from 'lodash'; import { ClsService } from 'nestjs-cls'; import { IThresholdConfig, ThresholdConfig } from '../../configs/threshold.config'; import { CustomHttpException } from '../../custom.exception'; @@ -20,6 +21,7 @@ import { IDbProvider } from '../../db-provider/db.provider.interface'; import type { IClsStore } from '../../types/cls'; import { getMaxLevelRole } from '../../utils/get-max-level-role'; import { updateOrder } from '../../utils/update-order'; +import { getPublicFullStorageUrl } from '../attachments/plugins/utils'; import { PermissionService } from '../auth/permission.service'; import { CollaboratorService } from '../collaborator/collaborator.service'; import { GraphService } from '../graph/graph.service'; @@ -52,6 +54,7 @@ export class BaseService { name: true, icon: true, spaceId: true, + createdBy: true, }, where: { id: baseId, @@ -101,6 +104,8 @@ export class BaseService { order: true, spaceId: true, icon: true, + createdBy: true, + lastModifiedTime: true, }, where: { deletedTime: null, @@ -122,9 +127,24 @@ export class BaseService { }, orderBy: [{ spaceId: 'asc' }, { order: 'asc' }], }); + + const createUserList = await this.prismaService.user.findMany({ + where: { id: { in: baseList.map((base) => base.createdBy) } }, + select: { id: true, name: true, avatar: true }, + }); + const createUserMap = keyBy(createUserList, 'id'); + return baseList.map((base) => { const role = roleMap[base.id] || roleMap[base.spaceId]; - return { ...base, role }; + const createUser = createUserMap[base.createdBy]; + return { + ...base, + role, + createdUser: { + ...createUser, + avatar: createUser?.avatar && getPublicFullStorageUrl(createUser.avatar), + }, + }; }); } diff --git a/apps/nestjs-backend/src/features/space/space.service.ts b/apps/nestjs-backend/src/features/space/space.service.ts index 9c7d14dbc9..57b22c73c4 100644 --- a/apps/nestjs-backend/src/features/space/space.service.ts +++ b/apps/nestjs-backend/src/features/space/space.service.ts @@ -19,19 +19,19 @@ import type { IUpdateSpaceRo, } from '@teable/openapi'; import { ResourceType, CollaboratorType, PrincipalType, IntegrationType } from '@teable/openapi'; -import { map } from 'lodash'; +import { keyBy, map } from 'lodash'; import { ClsService } from 'nestjs-cls'; import { ThresholdConfig, IThresholdConfig } from '../../configs/threshold.config'; import { CustomHttpException } from '../../custom.exception'; import { PerformanceCache, PerformanceCacheService } from '../../performance-cache'; import { generateIntegrationCacheKey } from '../../performance-cache/generate-keys'; import type { IClsStore } from '../../types/cls'; +import { getPublicFullStorageUrl } from '../attachments/plugins/utils'; import { PermissionService } from '../auth/permission.service'; import { BaseService } from '../base/base.service'; import { CollaboratorService } from '../collaborator/collaborator.service'; import { SettingOpenApiService } from '../setting/open-api/setting-open-api.service'; import { SettingService } from '../setting/setting.service'; - @Injectable() export class SpaceService { constructor( @@ -267,6 +267,8 @@ export class SpaceService { order: true, spaceId: true, icon: true, + createdBy: true, + lastModifiedTime: true, }, where: { spaceId, @@ -277,9 +279,23 @@ export class SpaceService { }, }); + const createUserList = await this.prismaService.user.findMany({ + where: { id: { in: baseList.map((base) => base.createdBy) } }, + select: { id: true, name: true, avatar: true }, + }); + const createUserMap = keyBy(createUserList, 'id'); + return baseList.map((base) => { const role = roleMap[base.id] || roleMap[base.spaceId]; - return { ...base, role }; + const createUser = createUserMap[base.createdBy]; + return { + ...base, + role, + createdUser: { + ...createUser, + avatar: createUser?.avatar && getPublicFullStorageUrl(createUser.avatar), + }, + }; }); } diff --git a/apps/nestjs-backend/src/features/trash/trash.service.ts b/apps/nestjs-backend/src/features/trash/trash.service.ts index e4eda27ad7..8b5d997303 100644 --- a/apps/nestjs-backend/src/features/trash/trash.service.ts +++ b/apps/nestjs-backend/src/features/trash/trash.service.ts @@ -90,13 +90,13 @@ export class TrashService { } async getTrash(trashRo: ITrashRo) { - const { resourceType } = trashRo; + const { resourceType, spaceId } = trashRo; switch (resourceType) { case ResourceType.Space: return await this.getSpaceTrash(); case ResourceType.Base: - return await this.getBaseTrash(); + return await this.getBaseTrash(spaceId); default: throw new CustomHttpException( `Invalid resource type ${resourceType}`, @@ -150,10 +150,10 @@ export class TrashService { }; } - private async getBaseTrash() { + private async getBaseTrash(spaceId?: string) { const { bases } = await this.getAuthorizedSpacesAndBases(); const baseIds = bases.map((base) => base.id); - const spaceIds = bases.map((base) => base.spaceId); + const spaceIds = spaceId ? [spaceId] : bases.map((base) => base.spaceId); const baseIdMap = keyBy(bases, 'id'); const trashedSpaces = await this.prismaService.trash.findMany({ diff --git a/apps/nestjs-backend/src/features/user/last-visit/last-visit.service.ts b/apps/nestjs-backend/src/features/user/last-visit/last-visit.service.ts index 963498d80d..5b476164ac 100644 --- a/apps/nestjs-backend/src/features/user/last-visit/last-visit.service.ts +++ b/apps/nestjs-backend/src/features/user/last-visit/last-visit.service.ts @@ -79,6 +79,33 @@ export class LastVisitService { }; } + async spaceVisit(userId: string, parentResourceId: string) { + const lastVisit = await this.prismaService.userLastVisit.findFirst({ + where: { + userId, + parentResourceId, + resourceType: LastVisitResourceType.Space, + }, + orderBy: { + lastVisitTime: 'desc', + }, + take: 1, + select: { + resourceId: true, + resourceType: true, + }, + }); + + if (lastVisit) { + return { + resourceId: lastVisit.resourceId, + resourceType: lastVisit.resourceType as LastVisitResourceType, + }; + } + + return undefined; + } + async tableVisit(userId: string, baseId: string): Promise { const knex = this.knex; @@ -404,6 +431,7 @@ export class LastVisitService { resourceIcon: 'b.icon', resourceRole: 'c.role_name', spaceId: 's.id', + createBy: 'b.created_by', }) .from('user_last_visit as ulv') .join('base as b', function () { @@ -436,6 +464,7 @@ export class LastVisitService { resourceIcon: string; resourceRole: IRole; spaceId: string; + createBy: string; }[] >(query.toQuery()); @@ -449,6 +478,7 @@ export class LastVisitService { icon: result.resourceIcon, role: result.resourceRole, spaceId: result.spaceId, + createdBy: result.createBy, }, })); @@ -463,6 +493,8 @@ export class LastVisitService { params: IGetUserLastVisitRo ): Promise { switch (params.resourceType) { + case LastVisitResourceType.Space: + return this.spaceVisit(userId, params.parentResourceId); case LastVisitResourceType.Table: return this.tableVisit(userId, params.parentResourceId); case LastVisitResourceType.View: @@ -495,7 +527,6 @@ export class LastVisitService { resourceType: LastVisitResourceType.Base, resourceId, parentResourceId, - maxRecords: 10, }); return; } diff --git a/apps/nestjs-backend/src/types/i18n.generated.ts b/apps/nestjs-backend/src/types/i18n.generated.ts index 96bae35a36..d8a013b0d1 100644 --- a/apps/nestjs-backend/src/types/i18n.generated.ts +++ b/apps/nestjs-backend/src/types/i18n.generated.ts @@ -241,6 +241,7 @@ export type I18nTranslations = { "import": string; "expand": string; "deleteTip": string; + "select": string; }; "quickAction": { "title": string; @@ -846,6 +847,7 @@ export type I18nTranslations = { "resetTrashConfirm": string; "addToTrash": string; "description": string; + "spaceTrash": string; }; "pluginCenter": { "pluginUrlEmpty": string; @@ -2693,6 +2695,14 @@ export type I18nTranslations = { "title": string; "description": string; }; + "baseList": { + "allBases": string; + "owner": string; + "lastOpened": string; + "enter": string; + "noTables": string; + "empty": string; + }; }; "system": { "notFound": { diff --git a/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeTree.tsx b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeTree.tsx index cfaea49f9a..7853ee5e50 100644 --- a/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeTree.tsx +++ b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeTree.tsx @@ -118,10 +118,11 @@ type TreeMode = 'view' | 'edit'; interface IBaseNodeTreeProps { mode?: TreeMode; + emptyText?: string; } export const BaseNodeTree = (props: IBaseNodeTreeProps) => { - const { mode = 'edit' } = props; + const { mode = 'edit', emptyText } = props; const isEditMode = mode === 'edit'; const { t } = useTranslation(['common']); const baseId = useBaseId() as string; @@ -545,6 +546,27 @@ export const BaseNodeTree = (props: IBaseNodeTreeProps) => { ); }; + if (Object.keys(treeItems).length === 0) { + if (isLoading) { + return ( +
+ + + + + + +
+ ); + } else if (emptyText) { + return ( +
+

{emptyText}

+
+ ); + } + } + return ( <> {isEditMode && ( @@ -570,172 +592,155 @@ export const BaseNodeTree = (props: IBaseNodeTreeProps) => { )} - {isLoading && Object.keys(treeItems).length === 0 ? ( -
- - - - - - -
- ) : ( - - - - {tree.getItems().map((item) => { - const nodeId = item.getId(); - const node = item.getItemData(); - if (!node || Object.keys(node).length === 0) return null; - const { resourceType, resourceId } = node; - const name = getNodeName(node); - const isHighlighted = isEditMode && highlightedTableId === resourceId; - const isPinned = pinMap?.[resourceId]; - return ( - -
- -
- {editingNodeId === nodeId ? ( - { - if (e.key === 'Enter') { - const newVal = e.currentTarget.value; - if (newVal && newVal !== item.getItemName()) { - curdHooks.updateNode(nodeId, { name: newVal }); - } - setEditingNodeId(null); - } else if (e.key === 'Escape') { - setEditingNodeId(null); + + + + {tree.getItems().map((item) => { + const nodeId = item.getId(); + const node = item.getItemData(); + if (!node || Object.keys(node).length === 0) return null; + const { resourceType, resourceId } = node; + const name = getNodeName(node); + const isHighlighted = isEditMode && highlightedTableId === resourceId; + const isPinned = pinMap?.[resourceId]; + return ( + +
+ +
+ {editingNodeId === nodeId ? ( + { + if (e.key === 'Enter') { + const newVal = e.currentTarget.value; + if (newVal && newVal !== item.getItemName()) { + curdHooks.updateNode(nodeId, { name: newVal }); } - }} - onClick={(e) => { - e.stopPropagation(); - }} - onMouseDown={(e) => { - e.stopPropagation(); - }} - /> - ) : ( - <> - + setEditingNodeId(null); + } else if (e.key === 'Escape') { + setEditingNodeId(null); + } + }} + onClick={(e) => { + e.stopPropagation(); + }} + onMouseDown={(e) => { + e.stopPropagation(); + }} + /> + ) : ( + <> + +
+ {item.getItemName()} + {node.resourceType === BaseNodeResourceType.Workflow && + (node.resourceMeta as IBaseNodeWorkflowResourceMeta)?.isActive && ( + + )} +
+ { + // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
{ + e.stopPropagation(); + }} + className={cn('flex shrink-0 cursor-pointer items-center gap-2', { + 'w-0 group-hover:w-auto': !isPinned, + })} > - {item.getItemName()} - {node.resourceType === BaseNodeResourceType.Workflow && - (node.resourceMeta as IBaseNodeWorkflowResourceMeta)?.isActive && ( - +
+ {canCreateResource && ( + + + )} -
- { - // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -
{ - e.stopPropagation(); - }} - className={cn('flex shrink-0 cursor-pointer items-center gap-2', { - 'w-0 group-hover:w-auto': !isPinned, - })} - > -
- {canCreateResource && ( - - - - )} -
- + +
+ setEditingNodeId(nodeId)} + onDelete={async (permanent: boolean, confirm: boolean = true) => { + const titleMap = { + [BaseNodeResourceType.Folder]: t('common:noun.folder'), + [BaseNodeResourceType.Table]: t('common:noun.table'), + [BaseNodeResourceType.Dashboard]: t('common:noun.dashboard'), + [BaseNodeResourceType.Workflow]: t('common:noun.automation'), + [BaseNodeResourceType.App]: t('common:noun.app'), + }; + const result = !confirm + ? true + : await comfirmModal({ + title: `${t('common:actions.delete')} ${(titleMap[resourceType] ?? '').toLowerCase()}`, + description: t('common:actions.deleteTip', { + name, + }), + confirmText: t('common:actions.delete'), + cancelText: t('common:actions.cancel'), + confirmButtonVariant: 'destructive', + }); + if (result) { + await curdHooks.deleteNode(nodeId, permanent); + } + }} + onDuplicate={async (ro?: IDuplicateBaseNodeRo) => { + await curdHooks.duplicateNode(nodeId, { + name, + ...(ro ?? {}), + }); + }} /> -
- setEditingNodeId(nodeId)} - onDelete={async ( - permanent: boolean, - confirm: boolean = true - ) => { - const titleMap = { - [BaseNodeResourceType.Folder]: t('common:noun.folder'), - [BaseNodeResourceType.Table]: t('common:noun.table'), - [BaseNodeResourceType.Dashboard]: - t('common:noun.dashboard'), - [BaseNodeResourceType.Workflow]: - t('common:noun.automation'), - [BaseNodeResourceType.App]: t('common:noun.app'), - }; - const result = !confirm - ? true - : await comfirmModal({ - title: `${t('common:actions.delete')} ${(titleMap[resourceType] ?? '').toLowerCase()}`, - description: t('common:actions.deleteTip', { - name, - }), - confirmText: t('common:actions.delete'), - cancelText: t('common:actions.cancel'), - confirmButtonVariant: 'destructive', - }); - if (result) { - await curdHooks.deleteNode(nodeId, permanent); - } - }} - onDuplicate={async (ro?: IDuplicateBaseNodeRo) => { - await curdHooks.duplicateNode(nodeId, { - name, - ...(ro ?? {}), - }); - }} - /> -
- } - - )} -
- -
- - ); - })} - - - - - )} +
+ } + + )} +
+ +
+ + ); + })} + + + + ); }; diff --git a/apps/nextjs-app/src/features/app/blocks/base/hooks/index.ts b/apps/nextjs-app/src/features/app/blocks/base/hooks/index.ts new file mode 100644 index 0000000000..5757eb4318 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/base/hooks/index.ts @@ -0,0 +1 @@ +export * from './useLastVisitBase'; diff --git a/apps/nextjs-app/src/features/app/blocks/base/hooks/useLastVisitBase.ts b/apps/nextjs-app/src/features/app/blocks/base/hooks/useLastVisitBase.ts new file mode 100644 index 0000000000..a53b25dda5 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/base/hooks/useLastVisitBase.ts @@ -0,0 +1,39 @@ +import { useQuery } from '@tanstack/react-query'; + +import { getUserLastVisitListBase } from '@teable/openapi'; +import { ReactQueryKeys } from '@teable/sdk/config'; +import { keyBy } from 'lodash'; +import { useMemo } from 'react'; + +export const useLastVisitBase = () => { + const { data: recentlyBase } = useQuery({ + queryKey: ReactQueryKeys.recentlyBase(), + queryFn: () => getUserLastVisitListBase().then((res) => res.data), + }); + + return useMemo(() => { + if (!recentlyBase) { + return { + total: 0, + list: [], + }; + } + const { list: resourceList, total } = recentlyBase; + + const list = resourceList.map((item) => { + const base = item.resource; + return { + ...base, + lastVisitTime: item.lastVisitTime, + }; + }); + + const map = keyBy(list, 'id'); + + return { + total, + list, + map, + }; + }, [recentlyBase]); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/space/BaseItem.tsx b/apps/nextjs-app/src/features/app/blocks/space/BaseItem.tsx new file mode 100644 index 0000000000..17b57cb5b5 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/space/BaseItem.tsx @@ -0,0 +1,206 @@ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +/* eslint-disable jsx-a11y/click-events-have-key-events */ +import { hasPermission } from '@teable/core'; +import { + ChevronDown, + ChevronRight, + Database, + DraggableHandle, + MoreHorizontal, +} from '@teable/icons'; +import type { IGetBaseVo } from '@teable/openapi'; +import { PinType } from '@teable/openapi'; +import { useLanDayjs } from '@teable/sdk/hooks'; +import { Avatar, AvatarFallback, AvatarImage, Button, cn, Input } from '@teable/ui-lib/shadcn'; +import { ArrowRight } from 'lucide-react'; +import { useTranslation } from 'next-i18next'; +import type { FC } from 'react'; +import { useState, useRef, useEffect } from 'react'; +import { useClickAway } from 'react-use'; +import { spaceConfig } from '@/features/i18n/space.config'; +import { Emoji } from '../../components/emoji/Emoji'; +import { EmojiPicker } from '../../components/emoji/EmojiPicker'; +import { BaseActionTrigger } from './component/BaseActionTrigger'; +import { StarButton } from './space-side-bar/StarButton'; + +export interface IBaseItemProps { + base: IGetBaseVo; + lastVisitTime?: string; + className?: string; + isExpanded?: boolean; + onToggleExpand?: () => void; + onEnterBase?: () => void; + onUpdate?: (data: { name?: string; icon?: string }) => void; + onDelete?: (permanent?: boolean) => void; + dragHandleProps?: React.HTMLAttributes; +} + +export const BaseItem: FC = (props) => { + const { + base, + lastVisitTime, + className, + isExpanded = false, + onToggleExpand, + onEnterBase, + onUpdate, + onDelete, + dragHandleProps, + } = props; + const dayjs = useLanDayjs(); + const { t } = useTranslation(spaceConfig.i18nNamespaces); + const inputRef = useRef(null); + const [isEditing, setIsEditing] = useState(false); + const [editingValue, setEditingValue] = useState(''); + + const hasUpdatePermission = !base.restrictedAuthority && hasPermission(base.role, 'base|update'); + const hasDeletePermission = !base.restrictedAuthority && hasPermission(base.role, 'base|delete'); + const hasMovePermission = !base.restrictedAuthority && hasPermission(base.role, 'space|create'); + + const stopPropagation = (e: React.MouseEvent) => e.stopPropagation(); + + const startEditing = () => { + setIsEditing(true); + setEditingValue(base.name); + }; + + const finishEditing = () => { + if (!isEditing) return; + if (editingValue && editingValue !== base.name) { + onUpdate?.({ name: editingValue }); + } + setIsEditing(false); + setEditingValue(''); + }; + + useEffect(() => { + const timeout = setTimeout(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, 200); + return () => clearTimeout(timeout); + }, [isEditing]); + + useClickAway(inputRef, finishEditing); + + return ( +
+ {/* Drag Handle */} + {dragHandleProps && ( +
+ +
+ )} + {/* Name Column */} +
+ + +
hasUpdatePermission && stopPropagation(e)} + > + onUpdate?.({ icon })} + > + {base.icon ? : } + +
+ +
+ {isEditing ? ( + setEditingValue(e.target.value)} + onBlur={finishEditing} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === 'Enter') finishEditing(); + if (e.key === 'Escape') { + setIsEditing(false); + setEditingValue(''); + } + }} + /> + ) : ( + <> +

+ {base.name} +

+ + + )} +
+
+ + {/* Creator Column */} +
+ + + {base.createdUser?.name?.slice(0, 1)} + + {base.createdUser?.name} +
+ + {/* Last Opened Column */} +
+ {lastVisitTime ? dayjs(lastVisitTime).fromNow() : '-'} +
+ + {/* Actions Column */} +
+ + + + + +
+
+ ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/space/BaseList.tsx b/apps/nextjs-app/src/features/app/blocks/space/BaseList.tsx new file mode 100644 index 0000000000..41cfad82be --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/space/BaseList.tsx @@ -0,0 +1,142 @@ +import { useQueryClient, useMutation } from '@tanstack/react-query'; +import { deleteBase, permanentDeleteBase, updateBase, type IGetBaseVo } from '@teable/openapi'; +import { ReactQueryKeys } from '@teable/sdk/config'; +import { AnchorContext } from '@teable/sdk/context'; +import { Collapsible, CollapsibleContent, ScrollArea } from '@teable/ui-lib/shadcn'; +import { keyBy } from 'lodash'; +import { useRouter } from 'next/router'; +import { useTranslation } from 'next-i18next'; +import { useState, useMemo } from 'react'; +import { spaceConfig } from '@/features/i18n/space.config'; +import { useChatPanelStore } from '../../components/sidebar/useChatPanelStore'; +import { BaseNodeProvider } from '../base/base-node/BaseNodeProvider'; +import { BaseNodeTree } from '../base/base-side-bar/BaseNodeTree'; +import { useLastVisitBase } from '../base/hooks'; +import { BaseItem } from './BaseItem'; +import { useBaseList } from './useBaseList'; + +interface IBaseListProps { + baseIds: string[]; +} + +export const BaseList = (props: IBaseListProps) => { + const { baseIds } = props; + const { t } = useTranslation(spaceConfig.i18nNamespaces); + const router = useRouter(); + const queryClient = useQueryClient(); + const { open: openChatPanel } = useChatPanelStore(); + const [expandedBases, setExpandedBases] = useState>(new Set()); + + const allBaseList = useBaseList(); + const { map: lastVisitBaseMap = {} } = useLastVisitBase(); + + const allBaseMap = useMemo(() => { + return keyBy(allBaseList, 'id'); + }, [allBaseList]); + + const sortedList = useMemo(() => { + const withTime = baseIds.map((baseId) => { + const base = allBaseMap[baseId] || {}; + const lastVisitTime = lastVisitBaseMap[baseId]?.lastVisitTime; + + return { + ...base, + lastVisitTime, + }; + }); + + return withTime.sort((a, b) => { + const aTime = a.lastVisitTime + ? new Date(a.lastVisitTime).getTime() + : a.lastModifiedTime + ? new Date(a.lastModifiedTime).getTime() + : Number.NEGATIVE_INFINITY; + const bTime = b.lastVisitTime + ? new Date(b.lastVisitTime).getTime() + : b.lastModifiedTime + ? new Date(b.lastModifiedTime).getTime() + : Number.NEGATIVE_INFINITY; + return bTime - aTime; + }); + }, [baseIds, allBaseMap, lastVisitBaseMap]); + + const { mutate: updateBaseMutator } = useMutation({ + mutationFn: updateBase, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ReactQueryKeys.baseAll() }); + queryClient.invalidateQueries({ queryKey: ReactQueryKeys.recentlyBase() }); + }, + }); + + const { mutate: deleteBaseMutator } = useMutation({ + mutationFn: ({ baseId, permanent }: { baseId: string; permanent?: boolean }) => + permanent ? permanentDeleteBase(baseId) : deleteBase(baseId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ReactQueryKeys.baseAll() }); + queryClient.invalidateQueries({ queryKey: ReactQueryKeys.recentlyBase() }); + }, + }); + + const intoBase = (baseId: string) => { + openChatPanel(); + router.push({ pathname: '/base/[baseId]', query: { baseId } }); + }; + + const toggleExpanded = (baseId: string) => { + setExpandedBases((prev) => { + const next = new Set(prev); + next.has(baseId) ? next.delete(baseId) : next.add(baseId); + return next; + }); + }; + + const renderBaseRow = (base: IGetBaseVo, dragHandleProps?: React.HTMLAttributes) => ( + toggleExpanded(base.id)} + > + toggleExpanded(base.id)} + onEnterBase={() => intoBase(base.id)} + onUpdate={(data) => updateBaseMutator({ baseId: base.id, updateBaseRo: data })} + onDelete={(permanent) => deleteBaseMutator({ baseId: base.id, permanent })} + dragHandleProps={dragHandleProps} + /> + +
+ + + + + +
+
+
+ ); + + return ( + + {/* Header */} +
+
{t('space:baseList.allBases')}
+
{t('space:baseList.owner')}
+
{t('space:baseList.lastOpened')}
+
+
+ + {/* Rows */} + {
{sortedList.map((base) => renderBaseRow(base))}
} + + {sortedList.length === 0 && ( +
+ {t('space:baseList.empty')} +
+ )} + + ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/space/RecentlyBase.tsx b/apps/nextjs-app/src/features/app/blocks/space/RecentlyBase.tsx index 34e7760464..0ef1b206a7 100644 --- a/apps/nextjs-app/src/features/app/blocks/space/RecentlyBase.tsx +++ b/apps/nextjs-app/src/features/app/blocks/space/RecentlyBase.tsx @@ -13,7 +13,7 @@ import { } from '@teable/ui-lib/shadcn'; import { useTranslation } from 'next-i18next'; import { useMemo } from 'react'; -import { BaseCard } from './BaseCard'; +import { BaseList } from './BaseList'; export const RecentlyBase = () => { const { t } = useTranslation(['space']); @@ -23,6 +23,9 @@ export const RecentlyBase = () => { queryKey: ReactQueryKeys.recentlyBase(), queryFn: () => getUserLastVisitListBase().then((res) => res.data), }); + const recentlyBases = useMemo(() => { + return recentlyBase?.list || []; + }, [recentlyBase]); // Shared bases data const { data: sharedBases } = useQuery({ @@ -30,25 +33,9 @@ export const RecentlyBase = () => { queryFn: () => getSharedBase().then((res) => res.data), }); - const { data: spaceList } = useQuery({ - queryKey: ReactQueryKeys.spaceList(), - queryFn: () => getSpaceList().then((data) => data.data), - }); - - const spaceNameMap = useMemo(() => { - if (!spaceList) return {}; - return spaceList.reduce( - (acc, space) => { - acc[space.id] = space.name; - return acc; - }, - {} as Record - ); - }, [spaceList]); - // Don't render if neither recent nor shared bases exist if ( - (!recentlyBase?.list.length || recentlyBase?.list.length === 0) && + (!recentlyBases?.length || recentlyBases?.length === 0) && (!sharedBases?.length || sharedBases?.length === 0) ) { return null; @@ -77,20 +64,13 @@ export const RecentlyBase = () => { - {!recentlyBase?.list.length || recentlyBase?.list.length === 0 ? ( + {!recentlyBases?.length || recentlyBases?.length === 0 ? (
- No recently visited bases + {t('space:baseList.empty')}
) : ( -
- {recentlyBase?.list.map((item) => ( - - ))} +
+ item.resourceId)} />
)} @@ -101,15 +81,8 @@ export const RecentlyBase = () => { {t('space:sharedBase.empty')}
) : ( -
- {sharedBases?.map((base) => ( - - ))} +
+ base.id)} />
)} diff --git a/apps/nextjs-app/src/features/app/blocks/space/SharedBasePage.tsx b/apps/nextjs-app/src/features/app/blocks/space/SharedBasePage.tsx index 8bd9bc4915..474e39451e 100644 --- a/apps/nextjs-app/src/features/app/blocks/space/SharedBasePage.tsx +++ b/apps/nextjs-app/src/features/app/blocks/space/SharedBasePage.tsx @@ -1,52 +1,27 @@ import { useQuery } from '@tanstack/react-query'; -import { getSharedBase, getSpaceList } from '@teable/openapi'; +import { getSharedBase } from '@teable/openapi'; import { ReactQueryKeys } from '@teable/sdk/config'; import { useTranslation } from 'next-i18next'; -import { useMemo } from 'react'; import { spaceConfig } from '@/features/i18n/space.config'; -import { BaseCard } from './BaseCard'; +import { BaseList } from './BaseList'; export const SharedBasePage = () => { - const { data: bases } = useQuery({ + const { data: sharedBases } = useQuery({ queryKey: ReactQueryKeys.getSharedBase(), queryFn: () => getSharedBase().then((res) => res.data), }); const { t } = useTranslation(spaceConfig.i18nNamespaces); - - const { data: spaceList } = useQuery({ - queryKey: ReactQueryKeys.spaceList(), - queryFn: () => getSpaceList().then((data) => data.data), - }); - - const spaceNameMap = useMemo(() => { - if (!spaceList) return {}; - return spaceList.reduce( - (acc, space) => { - acc[space.id] = space.name; - return acc; - }, - {} as Record - ); - }, [spaceList]); - return ( -
-

{t('space:sharedBase.title')}

- {bases?.length === 0 && ( -

- {t('space:sharedBase.empty')} -

- )} -
- {bases?.map((base) => ( -
- -
- ))} +
+

{t('space:sharedBase.title')}

+
+ {sharedBases && sharedBases.length > 0 ? ( + base.id)} /> + ) : ( +

+ {t('space:sharedBase.empty')} +

+ )}
); diff --git a/apps/nextjs-app/src/features/app/blocks/space/SpaceCard.tsx b/apps/nextjs-app/src/features/app/blocks/space/SpaceCard.tsx index 4a68637b63..e4b129b39b 100644 --- a/apps/nextjs-app/src/features/app/blocks/space/SpaceCard.tsx +++ b/apps/nextjs-app/src/features/app/blocks/space/SpaceCard.tsx @@ -20,7 +20,7 @@ import { CollaboratorAvatars } from '../../components/space/CollaboratorAvatars' import { SpaceActionBar } from '../../components/space/SpaceActionBar'; import { SpaceRenaming } from '../../components/space/SpaceRenaming'; import { useIsCloud } from '../../hooks/useIsCloud'; -import { DraggableBaseGrid } from './DraggableBaseGrid'; +import { BaseList } from './BaseList'; import { StarButton } from './space-side-bar/StarButton'; interface ISpaceCard { @@ -140,7 +140,7 @@ export const SpaceCard: FC = (props) => { {bases?.length ? ( - + base.id)} /> ) : (
{t('space:spaceIsEmpty')} diff --git a/apps/nextjs-app/src/features/app/blocks/space/SpaceInnerPage.tsx b/apps/nextjs-app/src/features/app/blocks/space/SpaceInnerPage.tsx index 72b9bcaacb..9bdf726778 100644 --- a/apps/nextjs-app/src/features/app/blocks/space/SpaceInnerPage.tsx +++ b/apps/nextjs-app/src/features/app/blocks/space/SpaceInnerPage.tsx @@ -22,7 +22,7 @@ import { SpaceActionBar } from '../../components/space/SpaceActionBar'; import { SpaceRenaming } from '../../components/space/SpaceRenaming'; import { useIsCloud } from '../../hooks/useIsCloud'; import { useSetting } from '../../hooks/useSetting'; -import { DraggableBaseGrid } from './DraggableBaseGrid'; +import { BaseList } from './BaseList'; import { StarButton } from './space-side-bar/StarButton'; import { useBaseList } from './useBaseList'; @@ -113,9 +113,9 @@ export const SpaceInnerPage: React.FC = () => { return ( space && ( -
-
-
+
+
+
{ onSpaceSetting={onSpaceSetting} /> {basesInSpace?.length ? ( - - - +
+ base.id)} /> +
) : ( -
+
{ + const { data: spaceList } = useQuery({ + queryKey: ReactQueryKeys.spaceList(), + queryFn: () => getSpaceList().then((data) => data.data), + }); + + return { spaceList }; +}; diff --git a/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/PinList.tsx b/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/PinList.tsx index 77d507c0e4..fec730eb07 100644 --- a/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/PinList.tsx +++ b/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/PinList.tsx @@ -11,6 +11,8 @@ import { AccordionContent, AccordionItem, AccordionTrigger, + cn, + ScrollArea, } from '@teable/ui-lib/shadcn'; import { useTranslation } from 'next-i18next'; import { useLocalStorage } from 'react-use'; @@ -18,7 +20,8 @@ import { spaceConfig } from '@/features/i18n/space.config'; import { PinItem } from './PinItem'; import { StarButton } from './StarButton'; -export const PinList = () => { +export const PinList = (props: { className?: string }) => { + const { className } = props; const isHydrated = useIsHydrated(); const [pinListExpanded, setPinListExpanded] = useLocalStorage( LocalStorageKeys.PinListExpanded @@ -79,84 +82,89 @@ export const PinList = () => { { setPinListExpanded(value === 'pin-list'); }} > - - + +
{t('space:pin.pin')}
- -
- {pinListData?.length === 0 && ( -
- {t('space:pin.empty')} -
- )} - - id) ?? []} - overlayRender={(active) => { - const activePin = pinListData?.find((pin) => pin.id === active?.id); - if (!activePin) { - return
; - } - return ( -
- - + +
+ {pinListData?.length === 0 && ( +
+ {t('space:pin.empty')} +
+ )} + + id) ?? []} + overlayRender={(active) => { + const activePin = pinListData?.find((pin) => pin.id === active?.id); + if (!activePin) { + return
; + } + return ( +
+ + + + + } + /> +
+ ); + }} + > + {pinListData?.map((pin) => ( + + {({ setNodeRef, attributes, listeners, style }) => ( +
+
+ + + + + } /> - - - } - /> -
- ); - }} - > - {pinListData?.map((pin) => ( - - {({ setNodeRef, attributes, listeners, style }) => ( -
-
- - - - - } - /> +
-
- )} -
- ))} - - -
+ )} + + ))} +
+
+
+
diff --git a/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/SpaceInnerSideBar.tsx b/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/SpaceInnerSideBar.tsx new file mode 100644 index 0000000000..620c27792d --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/SpaceInnerSideBar.tsx @@ -0,0 +1,97 @@ +import { useQuery } from '@tanstack/react-query'; +import { hasPermission } from '@teable/core'; +import { Home, Plus, Settings, Trash2 } from '@teable/icons'; +import { getSpaceById } from '@teable/openapi'; +import { ReactQueryKeys } from '@teable/sdk/config'; +import { cn } from '@teable/ui-lib/shadcn'; +import { Button } from '@teable/ui-lib/shadcn/ui/button'; +import Link from 'next/link'; +import { useParams } from 'next/navigation'; +import { useRouter } from 'next/router'; +import { useTranslation } from 'next-i18next'; +import { CreateBaseModalTrigger } from '@/features/app/components/space/CreateBaseModal'; +import { spaceConfig } from '@/features/i18n/space.config'; +import { PinList } from './PinList'; + +export const SpaceInnerSideBar = (props: { isAdmin?: boolean | null }) => { + const { isAdmin } = props; + const router = useRouter(); + const { t } = useTranslation(spaceConfig.i18nNamespaces); + const { spaceId } = useParams<{ spaceId: string }>(); + + const { data: space } = useQuery({ + queryKey: ReactQueryKeys.space(spaceId), + queryFn: ({ queryKey }) => getSpaceById(queryKey[1]).then((res) => res.data), + enabled: !!spaceId, + }); + + const pageRoutes: { + href: string; + text: string; + Icon: React.FC<{ className?: string }>; + hidden?: boolean; + }[] = [ + { + href: `/space/${spaceId}`, + text: t('space:baseList.allBases'), + Icon: Home, + }, + { + href: `/space/${spaceId}/setting/general`, + text: t('space:spaceSetting.title'), + Icon: Settings, + hidden: !isAdmin, + }, + { + href: `/space/${spaceId}/trash`, + text: t('noun.trash'), + Icon: Trash2, + }, + ]; + + const canCreateSpace = space && hasPermission(space?.role, 'space|create'); + + return ( + <> +
+ {space && ( +
+ + + +
+ )} +
    + {pageRoutes.map(({ href, text, Icon, hidden }) => { + if (hidden) return null; + return ( +
  • + +
  • + ); + })} +
+
+
+ +
+ + ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/SpaceList.tsx b/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/SpaceList.tsx index ee3ea58aa5..92771ad520 100644 --- a/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/SpaceList.tsx +++ b/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/SpaceList.tsx @@ -116,8 +116,8 @@ export const SpaceList: FC = () => { }; return ( -
-
+
+
{!disallowSpaceCreation && ( @@ -166,7 +166,7 @@ export const SpaceList: FC = () => { )}
-
+
    {spaceList?.map((space) => (
  • @@ -223,10 +223,11 @@ export const SpaceList: FC = () => {
    - s.name) : [] + )} + value={spaceName} + onChange={(e) => setSpaceName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleCreateSpace(); + } + }} + /> +
    +
    + } + /> + + ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/trash/SpaceInnerTrashPage.tsx b/apps/nextjs-app/src/features/app/blocks/trash/SpaceInnerTrashPage.tsx new file mode 100644 index 0000000000..d812a5c1e5 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/trash/SpaceInnerTrashPage.tsx @@ -0,0 +1,249 @@ +import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import type { ColumnDef } from '@tanstack/react-table'; +import { MoreHorizontal, RefreshCcw, Trash2 } from '@teable/icons'; +import type { ITrashItemVo, ITrashVo } from '@teable/openapi'; +import { + getTrash, + ResourceType, + restoreTrash, + permanentDeleteBase, + PrincipalType, +} from '@teable/openapi'; +import { InfiniteTable } from '@teable/sdk/components'; +import { ReactQueryKeys } from '@teable/sdk/config'; +import { useIsHydrated } from '@teable/sdk/hooks'; +import { ConfirmDialog } from '@teable/ui-lib/base'; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@teable/ui-lib/shadcn'; +import { toast } from '@teable/ui-lib/shadcn/ui/sonner'; +import dayjs from 'dayjs'; +import { useParams } from 'next/navigation'; +import { useRouter } from 'next/router'; +import { useTranslation } from 'next-i18next'; +import { useCallback, useMemo, useState } from 'react'; +import { spaceConfig } from '@/features/i18n/space.config'; +import { Collaborator } from '../../components/collaborator-manage/components/Collaborator'; + +export const SpaceInnerTrashPage = () => { + const { spaceId } = useParams<{ spaceId: string }>(); + const router = useRouter(); + const isHydrated = useIsHydrated(); + const queryClient = useQueryClient(); + const { t } = useTranslation(spaceConfig.i18nNamespaces); + + const resourceType = ResourceType.Base; + + const [userMap, setUserMap] = useState({}); + const [resourceMap, setResourceMap] = useState({}); + const [nextCursor, setNextCursor] = useState(); + const [isConfirmVisible, setConfirmVisible] = useState(false); + const [deletingResource, setDeletingResource] = useState< + { resourceId: string; name: string } | undefined + >(); + + const queryFn = async () => { + const res = await getTrash({ spaceId, resourceType }); + const { trashItems, nextCursor } = res.data; + + setNextCursor(() => nextCursor); + setUserMap({ ...userMap, ...res.data.userMap }); + setResourceMap({ ...resourceMap, ...res.data.resourceMap }); + + return trashItems; + }; + + const { data, isFetching, isLoading, fetchNextPage } = useInfiniteQuery({ + queryKey: ReactQueryKeys.getSpaceTrash(resourceType), + queryFn, + refetchOnMount: 'always', + refetchOnWindowFocus: false, + getNextPageParam: () => nextCursor, + }); + + const { mutateAsync: mutateRestore } = useMutation({ + mutationFn: (props: { trashId: string }) => restoreTrash(props.trashId), + onSuccess: () => { + queryClient.invalidateQueries(ReactQueryKeys.spaceList()); + queryClient.invalidateQueries(ReactQueryKeys.getSpaceTrash(resourceType)); + toast.success(t('actions.restoreSucceed')); + }, + }); + + const { mutateAsync: mutatePermanentDeleteBase } = useMutation({ + mutationFn: (props: { baseId: string }) => permanentDeleteBase(props.baseId), + onSuccess: () => { + queryClient.invalidateQueries(ReactQueryKeys.getSpaceTrash(resourceType)); + toast.success(t('actions.deleteSucceed')); + }, + }); + + const allRows = useMemo( + () => (data ? (data.pages.flatMap((d) => d) as ITrashItemVo[]) : []), + [data] + ); + + const columns: ColumnDef[] = useMemo(() => { + const tableColumns: ColumnDef[] = [ + { + accessorKey: 'resourceId', + header: t('name'), + size: Number.MAX_SAFE_INTEGER, + minSize: 300, + cell: ({ row }) => { + const resourceId = row.getValue('resourceId'); + const resourceInfo = resourceMap[resourceId]; + + if (!resourceInfo) return null; + + const { name } = resourceInfo; + + if ('spaceId' in resourceInfo) { + const spaceId = resourceInfo.spaceId; + const spaceInfo = resourceMap[spaceId]; + + return ( +
    + {name} + +
    + ); + } + + return
    {name}
    ; + }, + }, + { + accessorKey: 'deletedBy', + header: t('trash.deletedBy'), + size: 220, + cell: ({ row }) => { + const createdBy = row.getValue('deletedBy'); + const user = userMap[createdBy]; + + if (!user) return null; + + const { name, avatar, email } = user; + + return ( + + ); + }, + }, + { + accessorKey: 'deletedTime', + header: t('trash.deletedTime'), + size: 220, + cell: ({ row }) => { + const deletedTime = row.getValue('deletedTime'); + const deletedDateStr = dayjs(deletedTime).format('YYYY/MM/DD HH:mm'); + return
    {deletedDateStr}
    ; + }, + }, + { + id: 'actions', + header: t('actions.title'), + size: 80, + cell: ({ row }) => { + const { id: trashId, resourceId } = row.original; + const resourceInfo = resourceMap[resourceId]; + + if (!resourceInfo) return null; + + return ( + + + + + + mutateRestore({ trashId })}> + + {t('actions.restore')} + + { + setConfirmVisible(true); + setDeletingResource({ + resourceId, + name: resourceInfo.name, + }); + }} + > + + {t('actions.permanentDelete')} + + + + ); + }, + }, + ]; + + return tableColumns; + }, [t, router, resourceMap, userMap, mutateRestore]); + + const fetchNextPageInner = useCallback(() => { + if (!isFetching && nextCursor) { + fetchNextPage(); + } + }, [fetchNextPage, isFetching, nextCursor]); + + if (!isHydrated || isLoading) return null; + + return ( +
    +
    +

    {t('noun.trash')}

    +
    + + setConfirmVisible(false)} + onConfirm={() => { + if (deletingResource == null) return; + const { resourceId } = deletingResource; + setConfirmVisible(false); + mutatePermanentDeleteBase({ + baseId: resourceId, + }); + }} + /> +
    + ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/trash/SpaceTrashPage.tsx b/apps/nextjs-app/src/features/app/blocks/trash/SpaceTrashPage.tsx index d5f070ce81..f664b94adb 100644 --- a/apps/nextjs-app/src/features/app/blocks/trash/SpaceTrashPage.tsx +++ b/apps/nextjs-app/src/features/app/blocks/trash/SpaceTrashPage.tsx @@ -7,7 +7,6 @@ import { getTrash, ResourceType, restoreTrash, - permanentDeleteBase, permanentDeleteSpace, PrincipalType, } from '@teable/openapi'; @@ -24,34 +23,29 @@ import { } from '@teable/ui-lib/shadcn'; import { toast } from '@teable/ui-lib/shadcn/ui/sonner'; import dayjs from 'dayjs'; -import { useRouter } from 'next/router'; import { useTranslation } from 'next-i18next'; import { useCallback, useMemo, useState } from 'react'; import { spaceConfig } from '@/features/i18n/space.config'; import { Collaborator } from '../../components/collaborator-manage/components/Collaborator'; +import { SpaceAvatar } from '../../components/space/SpaceAvatar'; export const SpaceTrashPage = () => { - const router = useRouter(); const isHydrated = useIsHydrated(); const queryClient = useQueryClient(); const { t } = useTranslation(spaceConfig.i18nNamespaces); - const [resourceType, setResourceType] = useState( - ResourceType.Space - ); + const resourceType = ResourceType.Space; + const [userMap, setUserMap] = useState({}); const [resourceMap, setResourceMap] = useState({}); const [nextCursor, setNextCursor] = useState(); const [isConfirmVisible, setConfirmVisible] = useState(false); const [deletingResource, setDeletingResource] = useState< - | { resourceId: string; resourceType: ResourceType.Space | ResourceType.Base; name: string } - | undefined + { resourceId: string; name: string } | undefined >(); - const queryFn = async ({ queryKey }: QueryFunctionContext) => { - const res = await getTrash({ - resourceType: queryKey[1] as ResourceType.Space | ResourceType.Base, - }); + const queryFn = async () => { + const res = await getTrash({ resourceType }); const { trashItems, nextCursor } = res.data; setNextCursor(() => nextCursor); @@ -86,14 +80,6 @@ export const SpaceTrashPage = () => { }, }); - const { mutateAsync: mutatePermanentDeleteBase } = useMutation({ - mutationFn: (props: { baseId: string }) => permanentDeleteBase(props.baseId), - onSuccess: () => { - queryClient.invalidateQueries(ReactQueryKeys.getSpaceTrash(resourceType)); - toast.success(t('actions.deleteSucceed')); - }, - }); - const allRows = useMemo( () => (data ? (data.pages.flatMap((d) => d) as ITrashItemVo[]) : []), [data] @@ -114,33 +100,12 @@ export const SpaceTrashPage = () => { const { name } = resourceInfo; - if ('spaceId' in resourceInfo) { - const spaceId = resourceInfo.spaceId; - const spaceInfo = resourceMap[spaceId]; - - return ( -
    - {name} - -
    - ); - } - - return
    {name}
    ; + return ( +
    + + {name} +
    + ); }, }, { @@ -201,7 +166,6 @@ export const SpaceTrashPage = () => { setConfirmVisible(true); setDeletingResource({ resourceId, - resourceType, name: resourceInfo.name, }); }} @@ -217,7 +181,7 @@ export const SpaceTrashPage = () => { ]; return tableColumns; - }, [t, router, resourceMap, userMap, resourceType, mutateRestore]); + }, [t, resourceMap, userMap, mutateRestore]); const fetchNextPageInner = useCallback(() => { if (!isFetching && nextCursor) { @@ -225,44 +189,12 @@ export const SpaceTrashPage = () => { } }, [fetchNextPage, isFetching, nextCursor]); - const handleResourceTypeChange = (value: ResourceType.Space | ResourceType.Base) => { - queryClient.invalidateQueries(ReactQueryKeys.getSpaceTrash(value)); - setResourceType(value); - }; - - const buttons = useMemo(() => { - return [ - { - value: ResourceType.Space, - label: t('noun.space'), - }, - { - value: ResourceType.Base, - label: t('noun.base'), - }, - ]; - }, [t]); - if (!isHydrated || isLoading) return null; return (

    {t('noun.trash')}

    -
    - {buttons.map(({ value, label }) => ( - - ))} -
    { onOpenChange={setConfirmVisible} title={t('trash.permanentDeleteTips', { name: deletingResource?.name, - resource: - deletingResource?.resourceType === ResourceType.Base ? t('noun.base') : t('noun.space'), + resource: t('noun.space'), })} cancelText={t('actions.cancel')} confirmText={t('actions.confirm')} onCancel={() => setConfirmVisible(false)} onConfirm={() => { if (deletingResource == null) return; - const { resourceId, resourceType } = deletingResource; + const { resourceId } = deletingResource; setConfirmVisible(false); - if (resourceType === ResourceType.Space) { - mutatePermanentDeleteSpace({ - spaceId: resourceId, - }); - return; - } - mutatePermanentDeleteBase({ - baseId: resourceId, + mutatePermanentDeleteSpace({ + spaceId: resourceId, }); }} /> diff --git a/apps/nextjs-app/src/features/app/components/space/SpaceAvatar.tsx b/apps/nextjs-app/src/features/app/components/space/SpaceAvatar.tsx new file mode 100644 index 0000000000..140ce6a0ec --- /dev/null +++ b/apps/nextjs-app/src/features/app/components/space/SpaceAvatar.tsx @@ -0,0 +1,45 @@ +import { Avatar, AvatarFallback, cn } from '@teable/ui-lib/shadcn'; + +const AVATAR_COLORS = [ + 'bg-red-400', + 'bg-orange-400', + 'bg-amber-400', + 'bg-yellow-400', + 'bg-lime-400', + 'bg-green-400', + 'bg-emerald-400', + 'bg-teal-400', + 'bg-cyan-400', + 'bg-sky-400', + 'bg-blue-400', + 'bg-indigo-400', + 'bg-violet-400', + 'bg-purple-400', + 'bg-fuchsia-400', +]; + +const getColorFromString = (str: string) => { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length]; +}; + +interface ISpaceAvatarProps { + name: string; + className?: string; +} + +export const SpaceAvatar = ({ name, className }: ISpaceAvatarProps) => { + const bgColor = getColorFromString(name); + const initial = name?.charAt(0).toUpperCase() || '?'; + + return ( + + + {initial} + + + ); +}; diff --git a/apps/nextjs-app/src/features/app/layouts/SharedBaseLayout.tsx b/apps/nextjs-app/src/features/app/layouts/SharedBaseLayout.tsx new file mode 100644 index 0000000000..d4a25c41fe --- /dev/null +++ b/apps/nextjs-app/src/features/app/layouts/SharedBaseLayout.tsx @@ -0,0 +1,74 @@ +import type { DehydratedState } from '@tanstack/react-query'; +import { Component, Database, Home, Users } from '@teable/icons'; +import type { IUser } from '@teable/sdk'; +import { NotificationProvider, SessionProvider } from '@teable/sdk'; +import { AppProvider } from '@teable/sdk/context'; +import Head from 'next/head'; +import { useRouter } from 'next/router'; +import { useTranslation } from 'next-i18next'; +import React, { Fragment, useMemo } from 'react'; +import { spaceConfig } from '@/features/i18n/space.config'; +import { Sidebar } from '../components/sidebar/Sidebar'; +import { SidebarContent } from '../components/sidebar/SidebarContent'; +import { SidebarHeaderLeft } from '../components/sidebar/SidebarHeaderLeft'; +import { SideBarFooter } from '../components/SideBarFooter'; +import { useSdkLocale } from '../hooks/useSdkLocale'; +import { AppLayout } from './AppLayout'; + +export const SharedBaseLayout: React.FC<{ + children: React.ReactNode; + user?: IUser; + dehydratedState?: DehydratedState; +}> = ({ children, user, dehydratedState }) => { + const sdkLocale = useSdkLocale(); + const { i18n } = useTranslation(); + const { t } = useTranslation(spaceConfig.i18nNamespaces); + const router = useRouter(); + const onBack = () => { + router.push({ pathname: '/space' }); + }; + + const routes = useMemo(() => { + return [ + { + Icon: Database, + label: t('space:sharedBase.title'), + route: `/space/shared-base`, + pathTo: `/space/shared-base`, + }, + ]; + }, [t]); + + return ( + + + {t('space:sharedBase.title')} + + + + +
    + } + onBack={onBack} + /> + } + > + +
    + +
    + +
    +
    + {children} +
    +
    +
    +
    +
    + ); +}; diff --git a/apps/nextjs-app/src/features/app/layouts/SpaceInnerLayout.tsx b/apps/nextjs-app/src/features/app/layouts/SpaceInnerLayout.tsx new file mode 100644 index 0000000000..3fe2cc3c89 --- /dev/null +++ b/apps/nextjs-app/src/features/app/layouts/SpaceInnerLayout.tsx @@ -0,0 +1,62 @@ +import type { DehydratedState } from '@tanstack/react-query'; +import { LastVisitResourceType, updateUserLastVisit } from '@teable/openapi'; +import type { IUser } from '@teable/sdk'; +import { NotificationProvider, SessionProvider } from '@teable/sdk'; +import { AppProvider } from '@teable/sdk/context'; +import { isString } from 'lodash'; +import { useParams } from 'next/navigation'; +import { useTranslation } from 'next-i18next'; +import React, { Fragment, useEffect } from 'react'; +import { LicenseExpiryBanner } from '@/features/app/components/LicenseExpiryBanner'; +import { AppLayout } from '@/features/app/layouts'; +import { SpaceInnerSideBar } from '../blocks/space/space-side-bar/SpaceInnerSideBar'; +import { SpaceSwitcher } from '../blocks/space/space-side-bar/SpaceSwitcher'; +import { Sidebar } from '../components/sidebar/Sidebar'; +import { SideBarFooter } from '../components/SideBarFooter'; +import { useSdkLocale } from '../hooks/useSdkLocale'; +import { SpacePageTitle } from './SpacePageTitle'; + +export const SpaceInnerLayout: React.FC<{ + children: React.ReactNode; + user?: IUser; + dehydratedState?: DehydratedState; +}> = ({ children, user, dehydratedState }) => { + const sdkLocale = useSdkLocale(); + const { i18n } = useTranslation(); + const { spaceId } = useParams(); + + useEffect(() => { + if (!spaceId || !isString(spaceId)) { + return; + } + updateUserLastVisit({ + resourceType: LastVisitResourceType.Space, + resourceId: spaceId, + parentResourceId: '', + }); + }, [spaceId]); + + return ( + + + + + + +
    + }> + +
    + +
    + +
    +
    + {children} +
    +
    +
    +
    +
    + ); +}; diff --git a/apps/nextjs-app/src/features/app/layouts/SpaceTrashLayout.tsx b/apps/nextjs-app/src/features/app/layouts/SpaceTrashLayout.tsx new file mode 100644 index 0000000000..e6324953dd --- /dev/null +++ b/apps/nextjs-app/src/features/app/layouts/SpaceTrashLayout.tsx @@ -0,0 +1,74 @@ +import type { DehydratedState } from '@tanstack/react-query'; +import { Component, Trash2 } from '@teable/icons'; +import type { IUser } from '@teable/sdk'; +import { NotificationProvider, SessionProvider } from '@teable/sdk'; +import { AppProvider } from '@teable/sdk/context'; +import Head from 'next/head'; +import { useRouter } from 'next/router'; +import { useTranslation } from 'next-i18next'; +import React, { Fragment, useMemo } from 'react'; +import { spaceConfig } from '@/features/i18n/space.config'; +import { Sidebar } from '../components/sidebar/Sidebar'; +import { SidebarContent } from '../components/sidebar/SidebarContent'; +import { SidebarHeaderLeft } from '../components/sidebar/SidebarHeaderLeft'; +import { SideBarFooter } from '../components/SideBarFooter'; +import { useSdkLocale } from '../hooks/useSdkLocale'; +import { AppLayout } from './AppLayout'; + +export const SpaceTrashLayout: React.FC<{ + children: React.ReactNode; + user?: IUser; + dehydratedState?: DehydratedState; +}> = ({ children, user, dehydratedState }) => { + const sdkLocale = useSdkLocale(); + const { i18n } = useTranslation(); + const { t } = useTranslation(spaceConfig.i18nNamespaces); + const router = useRouter(); + const onBack = () => { + router.push({ pathname: '/space' }); + }; + + const routes = useMemo(() => { + return [ + { + Icon: Trash2, + label: t('common:trash.spaceTrash'), + route: `/space/trash`, + pathTo: `/space/trash`, + }, + ]; + }, [t]); + + return ( + + + {t('common:trash.spaceTrash')} + + + + +
    + } + onBack={onBack} + /> + } + > + +
    + +
    + +
    +
    + {children} +
    +
    +
    +
    +
    + ); +}; diff --git a/apps/nextjs-app/src/pages/space/[spaceId].tsx b/apps/nextjs-app/src/pages/space/[spaceId].tsx index 46d635d862..4b12afb539 100644 --- a/apps/nextjs-app/src/pages/space/[spaceId].tsx +++ b/apps/nextjs-app/src/pages/space/[spaceId].tsx @@ -3,7 +3,7 @@ import { ReactQueryKeys } from '@teable/sdk'; import type { GetServerSideProps } from 'next'; import type { ReactElement } from 'react'; import { SpaceInnerPage } from '@/features/app/blocks/space'; -import { SpaceLayout } from '@/features/app/layouts/SpaceLayout'; +import { SpaceInnerLayout } from '@/features/app/layouts/SpaceInnerLayout'; import { spaceConfig } from '@/features/i18n/space.config'; import ensureLogin from '@/lib/ensureLogin'; import { getTranslationsProps } from '@/lib/i18n'; @@ -67,7 +67,7 @@ export const getServerSideProps: GetServerSideProps = withEnv( ); Node.getLayout = function getLayout(page: ReactElement, pageProps) { - return {page}; + return {page}; }; export default Node; diff --git a/apps/nextjs-app/src/pages/space/[spaceId]/trash.tsx b/apps/nextjs-app/src/pages/space/[spaceId]/trash.tsx new file mode 100644 index 0000000000..964389e438 --- /dev/null +++ b/apps/nextjs-app/src/pages/space/[spaceId]/trash.tsx @@ -0,0 +1,40 @@ +import { dehydrate, QueryClient } from '@tanstack/react-query'; +import { ReactQueryKeys } from '@teable/sdk/config'; +import type { GetServerSideProps } from 'next'; +import type { ReactElement } from 'react'; +import { SpaceInnerTrashPage } from '@/features/app/blocks/trash/SpaceInnerTrashPage'; +import { SpaceInnerLayout } from '@/features/app/layouts/SpaceInnerLayout'; +import { spaceConfig } from '@/features/i18n/space.config'; +import ensureLogin from '@/lib/ensureLogin'; +import { getTranslationsProps } from '@/lib/i18n'; +import type { NextPageWithLayout } from '@/lib/type'; +import withAuthSSR from '@/lib/withAuthSSR'; +import withEnv from '@/lib/withEnv'; + +const SpaceTrash: NextPageWithLayout = () => ; + +export const getServerSideProps: GetServerSideProps = withEnv( + ensureLogin( + withAuthSSR(async (context, ssrApi) => { + const queryClient = new QueryClient(); + + await queryClient.fetchQuery({ + queryKey: ReactQueryKeys.spaceList(), + queryFn: () => ssrApi.getSpaceList(), + }); + + return { + props: { + dehydratedState: dehydrate(queryClient), + ...(await getTranslationsProps(context, spaceConfig.i18nNamespaces)), + }, + }; + }) + ) +); + +SpaceTrash.getLayout = function getLayout(page: ReactElement, pageProps) { + return {page}; +}; + +export default SpaceTrash; diff --git a/apps/nextjs-app/src/pages/space/index.tsx b/apps/nextjs-app/src/pages/space/index.tsx index d541f21a5e..b05bca8175 100644 --- a/apps/nextjs-app/src/pages/space/index.tsx +++ b/apps/nextjs-app/src/pages/space/index.tsx @@ -1,4 +1,5 @@ import { dehydrate, QueryClient } from '@tanstack/react-query'; +import { LastVisitResourceType } from '@teable/openapi'; import { ReactQueryKeys } from '@teable/sdk/config'; import type { GetServerSideProps } from 'next'; import type { ReactElement } from 'react'; @@ -18,13 +19,29 @@ export const getServerSideProps: GetServerSideProps = withEnv( ensureLogin( withAuthSSR(async (context, ssrApi) => { const queryClient = new QueryClient(); - - await Promise.all([ + const [userLastVisitSpace, spaceList] = await Promise.all([ + ssrApi.getUserLastVisit(LastVisitResourceType.Space, ''), queryClient.fetchQuery({ queryKey: ReactQueryKeys.spaceList(), queryFn: () => ssrApi.getSpaceList(), }), + ]); + const spaceIds = spaceList.map((space) => space.id); + const spaceId = + userLastVisitSpace?.resourceId && spaceIds.includes(userLastVisitSpace?.resourceId) + ? userLastVisitSpace?.resourceId + : spaceIds[0]; + if (spaceId) { + return { + redirect: { + destination: `/space/${spaceId}`, + permanent: false, + }, + }; + } + + await Promise.all([ queryClient.fetchQuery({ queryKey: ReactQueryKeys.baseAll(), queryFn: () => ssrApi.getBaseList(), diff --git a/apps/nextjs-app/src/pages/space/shared-base.tsx b/apps/nextjs-app/src/pages/space/shared-base.tsx index 15f2d19a60..379a3f3683 100644 --- a/apps/nextjs-app/src/pages/space/shared-base.tsx +++ b/apps/nextjs-app/src/pages/space/shared-base.tsx @@ -3,7 +3,7 @@ import { ReactQueryKeys } from '@teable/sdk/config'; import type { GetServerSideProps } from 'next'; import type { ReactElement } from 'react'; import { SharedBasePage } from '@/features/app/blocks/space/SharedBasePage'; -import { SpaceLayout } from '@/features/app/layouts/SpaceLayout'; +import { SharedBaseLayout } from '@/features/app/layouts/SharedBaseLayout'; import { spaceConfig } from '@/features/i18n/space.config'; import ensureLogin from '@/lib/ensureLogin'; import { getTranslationsProps } from '@/lib/i18n'; @@ -40,6 +40,6 @@ export const getServerSideProps: GetServerSideProps = withEnv( ); Node.getLayout = function getLayout(page: ReactElement, pageProps) { - return {page}; + return {page}; }; export default Node; diff --git a/apps/nextjs-app/src/pages/space/trash.tsx b/apps/nextjs-app/src/pages/space/trash.tsx index 3084c9e248..792813bc4c 100644 --- a/apps/nextjs-app/src/pages/space/trash.tsx +++ b/apps/nextjs-app/src/pages/space/trash.tsx @@ -3,7 +3,7 @@ import { ReactQueryKeys } from '@teable/sdk/config'; import type { GetServerSideProps } from 'next'; import type { ReactElement } from 'react'; import { SpaceTrashPage } from '@/features/app/blocks/trash/SpaceTrashPage'; -import { SpaceLayout } from '@/features/app/layouts/SpaceLayout'; +import { SpaceTrashLayout } from '@/features/app/layouts/SpaceTrashLayout'; import { spaceConfig } from '@/features/i18n/space.config'; import ensureLogin from '@/lib/ensureLogin'; import { getTranslationsProps } from '@/lib/i18n'; @@ -34,7 +34,7 @@ export const getServerSideProps: GetServerSideProps = withEnv( ); SpaceTrash.getLayout = function getLayout(page: ReactElement, pageProps) { - return {page}; + return {page}; }; export default SpaceTrash; diff --git a/packages/common-i18n/src/locales/en/common.json b/packages/common-i18n/src/locales/en/common.json index bd9bdb552c..a36b8d17b0 100644 --- a/packages/common-i18n/src/locales/en/common.json +++ b/packages/common-i18n/src/locales/en/common.json @@ -52,7 +52,8 @@ "turnOn": "Turn on", "exit": "Exit", "next": "Next", - "previous": "Previous" + "previous": "Previous", + "select": "Select" }, "quickAction": { "title": "Quick actions...", @@ -644,6 +645,7 @@ } }, "trash": { + "spaceTrash": "Space trash", "type": "Type", "resetTrash": "Empty trash", "deletedBy": "Deleted by", diff --git a/packages/common-i18n/src/locales/en/space.json b/packages/common-i18n/src/locales/en/space.json index 301fd39afe..127364c380 100644 --- a/packages/common-i18n/src/locales/en/space.json +++ b/packages/common-i18n/src/locales/en/space.json @@ -101,5 +101,13 @@ "noSpaces": { "title": "Hi {{userName}}!", "description": "Create your first space to start your data collaboration journey." + }, + "baseList": { + "allBases": "All bases", + "owner": "Owner", + "lastOpened": "Last opened", + "enter": "Enter", + "noTables": "No tables", + "empty": "No bases yet" } } diff --git a/packages/common-i18n/src/locales/zh/common.json b/packages/common-i18n/src/locales/zh/common.json index 40f328ff12..416322ebec 100644 --- a/packages/common-i18n/src/locales/zh/common.json +++ b/packages/common-i18n/src/locales/zh/common.json @@ -52,7 +52,8 @@ "turnOn": "开启", "exit": "退出", "next": "下一页", - "previous": "上一页" + "previous": "上一页", + "select": "选择" }, "quickAction": { "title": "快捷搜索...", @@ -641,6 +642,7 @@ } }, "trash": { + "spaceTrash": "空间回收站", "type": "类型", "resetTrash": "清空回收站", "deletedBy": "操作者", diff --git a/packages/common-i18n/src/locales/zh/space.json b/packages/common-i18n/src/locales/zh/space.json index 031bedf596..ad08d2ee76 100644 --- a/packages/common-i18n/src/locales/zh/space.json +++ b/packages/common-i18n/src/locales/zh/space.json @@ -101,5 +101,13 @@ "noSpaces": { "title": "Hi {{userName}}!", "description": "创建您的第一个空间,开始您的数据协作之旅" + }, + "baseList": { + "allBases": "所有数据库", + "owner": "所有者", + "lastOpened": "最近打开", + "enter": "进入", + "noTables": "暂无表", + "empty": "暂无数据库" } } diff --git a/packages/openapi/src/base/get.ts b/packages/openapi/src/base/get.ts index 9d57518834..cde90b4259 100644 --- a/packages/openapi/src/base/get.ts +++ b/packages/openapi/src/base/get.ts @@ -16,6 +16,15 @@ export const getBaseItemSchema = z.object({ collaboratorType: z.enum(CollaboratorType).optional(), restrictedAuthority: z.boolean().optional(), enabledAuthority: z.boolean().optional(), + lastModifiedTime: z.string().nullable().optional(), + createdBy: z.string(), + createdUser: z + .object({ + id: z.string(), + name: z.string(), + avatar: z.string().nullable().optional(), + }) + .optional(), }); export const getBaseVoSchema = getBaseItemSchema; diff --git a/packages/openapi/src/trash/get.ts b/packages/openapi/src/trash/get.ts index 1c8712de73..016882e53a 100644 --- a/packages/openapi/src/trash/get.ts +++ b/packages/openapi/src/trash/get.ts @@ -72,6 +72,7 @@ export type IRecordSnapshotItemVo = z.infer; export type IResourceMapVo = z.infer; export const trashRoSchema = z.object({ + spaceId: z.string().startsWith(IdPrefix.Space).optional(), resourceType: z.enum([ResourceType.Space, ResourceType.Base]), }); diff --git a/packages/openapi/src/user/last-visit/get.ts b/packages/openapi/src/user/last-visit/get.ts index 0d436da451..cf4d6c09e0 100644 --- a/packages/openapi/src/user/last-visit/get.ts +++ b/packages/openapi/src/user/last-visit/get.ts @@ -6,6 +6,7 @@ import { z } from '../../zod'; export const GET_USER_LAST_VISIT = '/user/last-visit'; export enum LastVisitResourceType { + Space = 'space', Base = 'base', Table = 'table', View = 'view', diff --git a/packages/sdk/src/model/base.ts b/packages/sdk/src/model/base.ts index 08658f485e..7b9fc8121d 100644 --- a/packages/sdk/src/model/base.ts +++ b/packages/sdk/src/model/base.ts @@ -11,6 +11,7 @@ export class Base implements IGetBaseVo { collaboratorType?: CollaboratorType; restrictedAuthority?: boolean; enabledAuthority?: boolean; + createdBy: string; constructor(base: IGetBaseVo) { const { @@ -22,6 +23,7 @@ export class Base implements IGetBaseVo { collaboratorType, restrictedAuthority, enabledAuthority, + createdBy, } = base; this.id = id; this.name = name; @@ -31,6 +33,7 @@ export class Base implements IGetBaseVo { this.collaboratorType = collaboratorType; this.restrictedAuthority = restrictedAuthority; this.enabledAuthority = enabledAuthority; + this.createdBy = createdBy; } async createTable(tableRo?: ICreateTableRo) { diff --git a/packages/ui-lib/src/shadcn/ui/accordion.tsx b/packages/ui-lib/src/shadcn/ui/accordion.tsx index bfa59389d2..adba545dff 100644 --- a/packages/ui-lib/src/shadcn/ui/accordion.tsx +++ b/packages/ui-lib/src/shadcn/ui/accordion.tsx @@ -44,8 +44,10 @@ AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; const AccordionContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( + React.ComponentPropsWithoutRef & { + innerClassName?: string; + } +>(({ className, children, innerClassName, ...props }, ref) => ( -
    {children}
    +
    {children}
    )); AccordionContent.displayName = AccordionPrimitive.Content.displayName; diff --git a/packages/ui-lib/src/shadcn/ui/tree.tsx b/packages/ui-lib/src/shadcn/ui/tree.tsx index 1e11dcc45a..eaa38541b6 100644 --- a/packages/ui-lib/src/shadcn/ui/tree.tsx +++ b/packages/ui-lib/src/shadcn/ui/tree.tsx @@ -47,7 +47,7 @@ function Tree({ indent = 20, tree, className, ...props }: TreeProps) {
    @@ -89,7 +89,7 @@ function TreeItem({ data-slot="tree-item" style={mergedStyle} className={cn( - 'group z-10 ps-[var(--tree-padding)] outline-none select-none pb-0.5 last:pb-0 focus:z-20 data-[disabled]:pointer-events-none data-[disabled]:opacity-50', + 'group ps-[var(--tree-padding)] outline-none select-none pb-0.5 last:pb-0 focus:z-20 data-[disabled]:pointer-events-none data-[disabled]:opacity-50', className )} data-focus={typeof item.isFocused === 'function' ? item.isFocused() || false : undefined} From d8b0c8e5ab9128e6c1e296e70b90ee9c3fd054e4 Mon Sep 17 00:00:00 2001 From: Uno Date: Thu, 11 Dec 2025 10:52:35 +0800 Subject: [PATCH 02/19] feat: add lastModifiedTime and createdUser details to service responses --- .../nestjs-backend/src/features/base/base.service.ts | 1 + .../features/collaborator/collaborator.service.ts | 7 +++++++ .../src/features/space/space.service.ts | 1 + .../app/blocks/base/base-side-bar/BaseNodeTree.tsx | 2 +- .../src/features/app/blocks/space/BaseItem.tsx | 7 ++++++- .../src/features/app/blocks/space/BaseList.tsx | 12 +++++------- 6 files changed, 21 insertions(+), 9 deletions(-) diff --git a/apps/nestjs-backend/src/features/base/base.service.ts b/apps/nestjs-backend/src/features/base/base.service.ts index 88b86e756d..7e0d4f808d 100644 --- a/apps/nestjs-backend/src/features/base/base.service.ts +++ b/apps/nestjs-backend/src/features/base/base.service.ts @@ -140,6 +140,7 @@ export class BaseService { return { ...base, role, + lastModifiedTime: base.lastModifiedTime?.toISOString(), createdUser: { ...createUser, avatar: createUser?.avatar && getPublicFullStorageUrl(createUser.avatar), diff --git a/apps/nestjs-backend/src/features/collaborator/collaborator.service.ts b/apps/nestjs-backend/src/features/collaborator/collaborator.service.ts index 35df3e5b94..4c47fdee19 100644 --- a/apps/nestjs-backend/src/features/collaborator/collaborator.service.ts +++ b/apps/nestjs-backend/src/features/collaborator/collaborator.service.ts @@ -838,6 +838,13 @@ export class CollaboratorService { spaceId: base.spaceId, spaceName: base.space?.name, collaboratorType: CollaboratorType.Base, + lastModifiedTime: base.lastModifiedTime?.toISOString(), + createdBy: base.createdBy, + createdUser: { + id: base.createdBy, + name: base.createdBy, + avatar: base.createdBy ? getPublicFullStorageUrl(base.createdBy) : null, + }, })); } diff --git a/apps/nestjs-backend/src/features/space/space.service.ts b/apps/nestjs-backend/src/features/space/space.service.ts index 57b22c73c4..401fee78ec 100644 --- a/apps/nestjs-backend/src/features/space/space.service.ts +++ b/apps/nestjs-backend/src/features/space/space.service.ts @@ -291,6 +291,7 @@ export class SpaceService { return { ...base, role, + lastModifiedTime: base.lastModifiedTime?.toISOString(), createdUser: { ...createUser, avatar: createUser?.avatar && getPublicFullStorageUrl(createUser.avatar), diff --git a/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeTree.tsx b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeTree.tsx index 7853ee5e50..ca71b2038b 100644 --- a/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeTree.tsx +++ b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeTree.tsx @@ -560,7 +560,7 @@ export const BaseNodeTree = (props: IBaseNodeTreeProps) => { ); } else if (emptyText) { return ( -
    +

    {emptyText}

    ); diff --git a/apps/nextjs-app/src/features/app/blocks/space/BaseItem.tsx b/apps/nextjs-app/src/features/app/blocks/space/BaseItem.tsx index 17b57cb5b5..b93c533999 100644 --- a/apps/nextjs-app/src/features/app/blocks/space/BaseItem.tsx +++ b/apps/nextjs-app/src/features/app/blocks/space/BaseItem.tsx @@ -148,7 +148,12 @@ export const BaseItem: FC = (props) => { /> ) : ( <> -

    + {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */} +

    onToggleExpand?.()} + > {base.name}

    { dragHandleProps={dragHandleProps} /> -
    - - - - - -
    + + + + +
    ); From e4183de9843a0624a547c4ad2735bf9253f76cda Mon Sep 17 00:00:00 2001 From: Uno Date: Thu, 11 Dec 2025 11:49:15 +0800 Subject: [PATCH 03/19] refactor: update trash service and UI components --- .../src/features/trash/trash.service.ts | 13 ++- .../app/blocks/trash/SpaceInnerTrashPage.tsx | 103 +++++++----------- .../app/blocks/trash/SpaceTrashPage.tsx | 63 +++++------ packages/sdk/src/config/react-query-keys.ts | 3 +- 4 files changed, 75 insertions(+), 107 deletions(-) diff --git a/apps/nestjs-backend/src/features/trash/trash.service.ts b/apps/nestjs-backend/src/features/trash/trash.service.ts index 8b5d997303..88c1d38031 100644 --- a/apps/nestjs-backend/src/features/trash/trash.service.ts +++ b/apps/nestjs-backend/src/features/trash/trash.service.ts @@ -152,21 +152,24 @@ export class TrashService { private async getBaseTrash(spaceId?: string) { const { bases } = await this.getAuthorizedSpacesAndBases(); - const baseIds = bases.map((base) => base.id); - const spaceIds = spaceId ? [spaceId] : bases.map((base) => base.spaceId); + const authorizedBaseIds = bases.map((base) => base.id); + const authorizedBaseSpaceIds = bases.map((base) => base.spaceId); const baseIdMap = keyBy(bases, 'id'); const trashedSpaces = await this.prismaService.trash.findMany({ where: { resourceType: ResourceType.Space, - resourceId: { in: spaceIds }, + resourceId: { in: authorizedBaseSpaceIds }, }, select: { resourceId: true }, }); const list = await this.prismaService.trash.findMany({ where: { - parentId: { notIn: trashedSpaces.map((space) => space.resourceId) }, - resourceId: { in: baseIds }, + parentId: { + notIn: trashedSpaces.map((space) => space.resourceId), + in: spaceId ? [spaceId] : undefined, + }, + resourceId: { in: authorizedBaseIds }, resourceType: ResourceType.Base, }, }); diff --git a/apps/nextjs-app/src/features/app/blocks/trash/SpaceInnerTrashPage.tsx b/apps/nextjs-app/src/features/app/blocks/trash/SpaceInnerTrashPage.tsx index d812a5c1e5..609d0eab70 100644 --- a/apps/nextjs-app/src/features/app/blocks/trash/SpaceInnerTrashPage.tsx +++ b/apps/nextjs-app/src/features/app/blocks/trash/SpaceInnerTrashPage.tsx @@ -1,6 +1,6 @@ import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import type { ColumnDef } from '@tanstack/react-table'; -import { MoreHorizontal, RefreshCcw, Trash2 } from '@teable/icons'; +import { Database, RefreshCcw, Trash2 } from '@teable/icons'; import type { ITrashItemVo, ITrashVo } from '@teable/openapi'; import { getTrash, @@ -13,13 +13,7 @@ import { InfiniteTable } from '@teable/sdk/components'; import { ReactQueryKeys } from '@teable/sdk/config'; import { useIsHydrated } from '@teable/sdk/hooks'; import { ConfirmDialog } from '@teable/ui-lib/base'; -import { - Button, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@teable/ui-lib/shadcn'; +import { Button } from '@teable/ui-lib/shadcn'; import { toast } from '@teable/ui-lib/shadcn/ui/sonner'; import dayjs from 'dayjs'; import { useParams } from 'next/navigation'; @@ -58,7 +52,7 @@ export const SpaceInnerTrashPage = () => { }; const { data, isFetching, isLoading, fetchNextPage } = useInfiniteQuery({ - queryKey: ReactQueryKeys.getSpaceTrash(resourceType), + queryKey: ReactQueryKeys.getSpaceTrash(resourceType, spaceId), queryFn, refetchOnMount: 'always', refetchOnWindowFocus: false, @@ -69,7 +63,7 @@ export const SpaceInnerTrashPage = () => { mutationFn: (props: { trashId: string }) => restoreTrash(props.trashId), onSuccess: () => { queryClient.invalidateQueries(ReactQueryKeys.spaceList()); - queryClient.invalidateQueries(ReactQueryKeys.getSpaceTrash(resourceType)); + queryClient.invalidateQueries(ReactQueryKeys.getSpaceTrash(resourceType, spaceId)); toast.success(t('actions.restoreSucceed')); }, }); @@ -77,7 +71,7 @@ export const SpaceInnerTrashPage = () => { const { mutateAsync: mutatePermanentDeleteBase } = useMutation({ mutationFn: (props: { baseId: string }) => permanentDeleteBase(props.baseId), onSuccess: () => { - queryClient.invalidateQueries(ReactQueryKeys.getSpaceTrash(resourceType)); + queryClient.invalidateQueries(ReactQueryKeys.getSpaceTrash(resourceType, spaceId)); toast.success(t('actions.deleteSucceed')); }, }); @@ -99,36 +93,13 @@ export const SpaceInnerTrashPage = () => { const resourceInfo = resourceMap[resourceId]; if (!resourceInfo) return null; - const { name } = resourceInfo; - - if ('spaceId' in resourceInfo) { - const spaceId = resourceInfo.spaceId; - const spaceInfo = resourceMap[spaceId]; - - return ( -
    - {name} - -
    - ); - } - - return
    {name}
    ; + return ( +
    + + {name} +
    + ); }, }, { @@ -172,32 +143,32 @@ export const SpaceInnerTrashPage = () => { if (!resourceInfo) return null; return ( - - - - - - mutateRestore({ trashId })}> - - {t('actions.restore')} - - { - setConfirmVisible(true); - setDeletingResource({ - resourceId, - name: resourceInfo.name, - }); - }} - > - - {t('actions.permanentDelete')} - - - +
    + + +
    ); }, }, diff --git a/apps/nextjs-app/src/features/app/blocks/trash/SpaceTrashPage.tsx b/apps/nextjs-app/src/features/app/blocks/trash/SpaceTrashPage.tsx index f664b94adb..c391896828 100644 --- a/apps/nextjs-app/src/features/app/blocks/trash/SpaceTrashPage.tsx +++ b/apps/nextjs-app/src/features/app/blocks/trash/SpaceTrashPage.tsx @@ -1,7 +1,6 @@ -import type { QueryFunctionContext } from '@tanstack/react-query'; import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import type { ColumnDef } from '@tanstack/react-table'; -import { MoreHorizontal, RefreshCcw, Trash2 } from '@teable/icons'; +import { RefreshCcw, Trash2 } from '@teable/icons'; import type { ITrashItemVo, ITrashVo } from '@teable/openapi'; import { getTrash, @@ -14,13 +13,7 @@ import { InfiniteTable } from '@teable/sdk/components'; import { ReactQueryKeys } from '@teable/sdk/config'; import { useIsHydrated } from '@teable/sdk/hooks'; import { ConfirmDialog } from '@teable/ui-lib/base'; -import { - Button, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@teable/ui-lib/shadcn'; +import { Button } from '@teable/ui-lib/shadcn'; import { toast } from '@teable/ui-lib/shadcn/ui/sonner'; import dayjs from 'dayjs'; import { useTranslation } from 'next-i18next'; @@ -149,32 +142,32 @@ export const SpaceTrashPage = () => { if (!resourceInfo) return null; return ( - - - - - - mutateRestore({ trashId })}> - - {t('actions.restore')} - - { - setConfirmVisible(true); - setDeletingResource({ - resourceId, - name: resourceInfo.name, - }); - }} - > - - {t('actions.permanentDelete')} - - - +
    + + +
    ); }, }, diff --git a/packages/sdk/src/config/react-query-keys.ts b/packages/sdk/src/config/react-query-keys.ts index 10c2f25658..b2c8e8bf2f 100644 --- a/packages/sdk/src/config/react-query-keys.ts +++ b/packages/sdk/src/config/react-query-keys.ts @@ -174,7 +174,8 @@ export const ReactQueryKeys = { getSharedBase: () => ['shared-base-list'] as const, - getSpaceTrash: (resourceType: ResourceType) => ['space-trash', resourceType] as const, + getSpaceTrash: (resourceType: ResourceType, spaceId?: string) => + ['space-trash', resourceType, spaceId] as const, getTrashItems: (resourceId: string) => ['trash-items', resourceId] as const, From e8ae50084989aec1b788d30eec9e18cdf436edd6 Mon Sep 17 00:00:00 2001 From: Uno Date: Thu, 11 Dec 2025 11:50:16 +0800 Subject: [PATCH 04/19] refactor: remove unused router dependency --- .../src/features/app/blocks/trash/SpaceInnerTrashPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/nextjs-app/src/features/app/blocks/trash/SpaceInnerTrashPage.tsx b/apps/nextjs-app/src/features/app/blocks/trash/SpaceInnerTrashPage.tsx index 609d0eab70..641bec53f2 100644 --- a/apps/nextjs-app/src/features/app/blocks/trash/SpaceInnerTrashPage.tsx +++ b/apps/nextjs-app/src/features/app/blocks/trash/SpaceInnerTrashPage.tsx @@ -175,7 +175,7 @@ export const SpaceInnerTrashPage = () => { ]; return tableColumns; - }, [t, router, resourceMap, userMap, mutateRestore]); + }, [t, resourceMap, userMap, mutateRestore]); const fetchNextPageInner = useCallback(() => { if (!isFetching && nextCursor) { From 26725fc7309be79e3978e728edb7984c7b68d1b4 Mon Sep 17 00:00:00 2001 From: Uno Date: Thu, 11 Dec 2025 12:07:11 +0800 Subject: [PATCH 05/19] feat: enhance UI for shared bases and trash pages with improved layout and descriptions --- .../features/app/blocks/space/SharedBasePage.tsx | 9 +++++++-- .../app/blocks/trash/SpaceInnerTrashPage.tsx | 14 ++++++-------- .../features/app/blocks/trash/SpaceTrashPage.tsx | 14 ++++++-------- packages/common-i18n/src/locales/de/space.json | 6 +++++- packages/common-i18n/src/locales/en/space.json | 6 +++++- packages/common-i18n/src/locales/es/space.json | 6 +++++- packages/common-i18n/src/locales/fr/space.json | 9 +++++++++ packages/common-i18n/src/locales/it/space.json | 6 +++++- packages/common-i18n/src/locales/ja/space.json | 6 +++++- packages/common-i18n/src/locales/ru/space.json | 6 +++++- packages/common-i18n/src/locales/tr/space.json | 6 +++++- packages/common-i18n/src/locales/uk/space.json | 8 ++++++-- packages/common-i18n/src/locales/zh/space.json | 6 +++++- 13 files changed, 74 insertions(+), 28 deletions(-) diff --git a/apps/nextjs-app/src/features/app/blocks/space/SharedBasePage.tsx b/apps/nextjs-app/src/features/app/blocks/space/SharedBasePage.tsx index 474e39451e..d072e45fa1 100644 --- a/apps/nextjs-app/src/features/app/blocks/space/SharedBasePage.tsx +++ b/apps/nextjs-app/src/features/app/blocks/space/SharedBasePage.tsx @@ -12,8 +12,13 @@ export const SharedBasePage = () => { }); const { t } = useTranslation(spaceConfig.i18nNamespaces); return ( -
    -

    {t('space:sharedBase.title')}

    +
    +
    +

    {t('space:sharedBase.title')}

    +

    + {t('space:sharedBase.description')} +

    +
    {sharedBases && sharedBases.length > 0 ? ( base.id)} /> diff --git a/apps/nextjs-app/src/features/app/blocks/trash/SpaceInnerTrashPage.tsx b/apps/nextjs-app/src/features/app/blocks/trash/SpaceInnerTrashPage.tsx index 641bec53f2..093e53046e 100644 --- a/apps/nextjs-app/src/features/app/blocks/trash/SpaceInnerTrashPage.tsx +++ b/apps/nextjs-app/src/features/app/blocks/trash/SpaceInnerTrashPage.tsx @@ -186,16 +186,14 @@ export const SpaceInnerTrashPage = () => { if (!isHydrated || isLoading) return null; return ( -
    -
    +
    +

    {t('noun.trash')}

    +

    + {t('space:trash.baseDescription')} +

    - + { if (!isHydrated || isLoading) return null; return ( -
    -
    +
    +

    {t('noun.trash')}

    +

    + {t('space:trash.spaceDescription')} +

    - + ?", @@ -61,9 +61,13 @@ }, "sharedBase": { "title": "Спільні бази", - "description": "Тут з’являться бази, якими ви поділилися", + "description": "Всі бази, до яких мене запросили приєднатися", "empty": "Ще немає спільних баз" }, + "trash": { + "spaceDescription": "Всі видалені простори в цьому екземплярі", + "baseDescription": "Всі видалені бази в цьому просторі" + }, "integration": { "title": "Інтеграції", "description": "Керування інтеграціями вашого простору", diff --git a/packages/common-i18n/src/locales/zh/space.json b/packages/common-i18n/src/locales/zh/space.json index ad08d2ee76..53fdfc21a9 100644 --- a/packages/common-i18n/src/locales/zh/space.json +++ b/packages/common-i18n/src/locales/zh/space.json @@ -65,9 +65,13 @@ }, "sharedBase": { "title": "分享给我的", - "description": "与您分享的数据库将显示在这里", + "description": "所有邀请我加入的数据库", "empty": "暂无分享给我的数据库" }, + "trash": { + "spaceDescription": "该实例下所有已删除的空间", + "baseDescription": "该空间下所有已删除的数据库" + }, "integration": { "title": "集成", "description": "管理空间集成", From 1bf3f1a0d061601b4a125e1a7663086754809ba7 Mon Sep 17 00:00:00 2001 From: Uno Date: Thu, 11 Dec 2025 12:16:57 +0800 Subject: [PATCH 06/19] feat: add createdTime field to service responses and update sorting logic in BaseList component --- .../src/features/base/base.service.ts | 2 ++ .../collaborator/collaborator.service.ts | 1 + .../src/features/space/space.service.ts | 2 ++ .../features/app/blocks/space/BaseList.tsx | 35 +++++++++++++------ packages/openapi/src/base/get.ts | 1 + 5 files changed, 30 insertions(+), 11 deletions(-) diff --git a/apps/nestjs-backend/src/features/base/base.service.ts b/apps/nestjs-backend/src/features/base/base.service.ts index 7e0d4f808d..8799ed9622 100644 --- a/apps/nestjs-backend/src/features/base/base.service.ts +++ b/apps/nestjs-backend/src/features/base/base.service.ts @@ -105,6 +105,7 @@ export class BaseService { spaceId: true, icon: true, createdBy: true, + createdTime: true, lastModifiedTime: true, }, where: { @@ -141,6 +142,7 @@ export class BaseService { ...base, role, lastModifiedTime: base.lastModifiedTime?.toISOString(), + createdTime: base.createdTime?.toISOString(), createdUser: { ...createUser, avatar: createUser?.avatar && getPublicFullStorageUrl(createUser.avatar), diff --git a/apps/nestjs-backend/src/features/collaborator/collaborator.service.ts b/apps/nestjs-backend/src/features/collaborator/collaborator.service.ts index 4c47fdee19..0174c54d79 100644 --- a/apps/nestjs-backend/src/features/collaborator/collaborator.service.ts +++ b/apps/nestjs-backend/src/features/collaborator/collaborator.service.ts @@ -839,6 +839,7 @@ export class CollaboratorService { spaceName: base.space?.name, collaboratorType: CollaboratorType.Base, lastModifiedTime: base.lastModifiedTime?.toISOString(), + createdTime: base.createdTime?.toISOString(), createdBy: base.createdBy, createdUser: { id: base.createdBy, diff --git a/apps/nestjs-backend/src/features/space/space.service.ts b/apps/nestjs-backend/src/features/space/space.service.ts index 401fee78ec..b81e6e99b3 100644 --- a/apps/nestjs-backend/src/features/space/space.service.ts +++ b/apps/nestjs-backend/src/features/space/space.service.ts @@ -269,6 +269,7 @@ export class SpaceService { icon: true, createdBy: true, lastModifiedTime: true, + createdTime: true, }, where: { spaceId, @@ -292,6 +293,7 @@ export class SpaceService { ...base, role, lastModifiedTime: base.lastModifiedTime?.toISOString(), + createdTime: base.createdTime?.toISOString(), createdUser: { ...createUser, avatar: createUser?.avatar && getPublicFullStorageUrl(createUser.avatar), diff --git a/apps/nextjs-app/src/features/app/blocks/space/BaseList.tsx b/apps/nextjs-app/src/features/app/blocks/space/BaseList.tsx index 2216f0472c..7c09692a29 100644 --- a/apps/nextjs-app/src/features/app/blocks/space/BaseList.tsx +++ b/apps/nextjs-app/src/features/app/blocks/space/BaseList.tsx @@ -45,18 +45,31 @@ export const BaseList = (props: IBaseListProps) => { }; }); + /** + * 1. Both have lastVisitTime: compare by lastVisitTime (recent first) + * 2. One has lastVisitTime: prioritize the one with lastVisitTime + * 3. Both have lastModifiedTime: compare by lastModifiedTime (recent first) + * 4. One has lastModifiedTime: prioritize the one with lastModifiedTime + * 5. Finally, sort by createdTime (recent first) + */ return withTime.sort((a, b) => { - const aTime = a.lastVisitTime - ? new Date(a.lastVisitTime).getTime() - : a.lastModifiedTime - ? new Date(a.lastModifiedTime).getTime() - : Number.NEGATIVE_INFINITY; - const bTime = b.lastVisitTime - ? new Date(b.lastVisitTime).getTime() - : b.lastModifiedTime - ? new Date(b.lastModifiedTime).getTime() - : Number.NEGATIVE_INFINITY; - return bTime - aTime; + if (a.lastVisitTime && b.lastVisitTime) { + return new Date(b.lastVisitTime).getTime() - new Date(a.lastVisitTime).getTime(); + } + + if (a.lastVisitTime && !b.lastVisitTime) return -1; + if (!a.lastVisitTime && b.lastVisitTime) return 1; + + if (a.lastModifiedTime && b.lastModifiedTime) { + return new Date(b.lastModifiedTime).getTime() - new Date(a.lastModifiedTime).getTime(); + } + + if (a.lastModifiedTime && !b.lastModifiedTime) return -1; + if (!a.lastModifiedTime && b.lastModifiedTime) return 1; + + const aCreated = a.createdTime ? new Date(a.createdTime).getTime() : 0; + const bCreated = b.createdTime ? new Date(b.createdTime).getTime() : 0; + return bCreated - aCreated; }); }, [baseIds, allBaseMap, lastVisitBaseMap]); diff --git a/packages/openapi/src/base/get.ts b/packages/openapi/src/base/get.ts index cde90b4259..08f120c61b 100644 --- a/packages/openapi/src/base/get.ts +++ b/packages/openapi/src/base/get.ts @@ -17,6 +17,7 @@ export const getBaseItemSchema = z.object({ restrictedAuthority: z.boolean().optional(), enabledAuthority: z.boolean().optional(), lastModifiedTime: z.string().nullable().optional(), + createdTime: z.string().nullable().optional(), createdBy: z.string(), createdUser: z .object({ From bd1834b0174a0b12d1bb7c2a782f142b7f9bda9d Mon Sep 17 00:00:00 2001 From: Uno Date: Thu, 11 Dec 2025 12:36:31 +0800 Subject: [PATCH 07/19] fix: adjust layout in SpaceInnerPage component --- .../nextjs-app/src/features/app/blocks/space/SpaceInnerPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/nextjs-app/src/features/app/blocks/space/SpaceInnerPage.tsx b/apps/nextjs-app/src/features/app/blocks/space/SpaceInnerPage.tsx index 9bdf726778..645fb152cd 100644 --- a/apps/nextjs-app/src/features/app/blocks/space/SpaceInnerPage.tsx +++ b/apps/nextjs-app/src/features/app/blocks/space/SpaceInnerPage.tsx @@ -114,7 +114,7 @@ export const SpaceInnerPage: React.FC = () => { return ( space && (
    -
    +
    Date: Thu, 11 Dec 2025 12:36:53 +0800 Subject: [PATCH 08/19] feat: add new translation keys in multiple languages --- packages/common-i18n/src/locales/de/common.json | 4 +++- packages/common-i18n/src/locales/es/common.json | 4 +++- packages/common-i18n/src/locales/fr/common.json | 4 +++- packages/common-i18n/src/locales/it/common.json | 4 +++- packages/common-i18n/src/locales/ja/common.json | 4 +++- packages/common-i18n/src/locales/ru/common.json | 4 +++- packages/common-i18n/src/locales/tr/common.json | 4 +++- packages/common-i18n/src/locales/uk/common.json | 4 +++- 8 files changed, 24 insertions(+), 8 deletions(-) diff --git a/packages/common-i18n/src/locales/de/common.json b/packages/common-i18n/src/locales/de/common.json index ba91a8ee36..ed8273e0b4 100644 --- a/packages/common-i18n/src/locales/de/common.json +++ b/packages/common-i18n/src/locales/de/common.json @@ -47,7 +47,8 @@ "turnOn": "Einschalten", "exit": "Ausloggen", "next": "Nächste", - "previous": "Vorherige" + "previous": "Vorherige", + "select": "Auswählen" }, "quickAction": { "title": "Schnelle Aktionen...", @@ -500,6 +501,7 @@ } }, "trash": { + "spaceTrash": "Space Papierkorb", "type": "Typ", "resetTrash": "Papierkorb leeren", "deletedBy": "Gelöscht von", diff --git a/packages/common-i18n/src/locales/es/common.json b/packages/common-i18n/src/locales/es/common.json index 0df2bdaa26..02dbd59edc 100644 --- a/packages/common-i18n/src/locales/es/common.json +++ b/packages/common-i18n/src/locales/es/common.json @@ -47,7 +47,8 @@ "turnOn": "Activar", "exit": "Cerrar sesión", "next": "Siguiente", - "previous": "Anterior" + "previous": "Anterior", + "select": "Seleccionar" }, "quickAction": { "title": "Acciones Rápidas...", @@ -479,6 +480,7 @@ } }, "trash": { + "spaceTrash": "Papelera de espacio", "type": "Tipo", "resetTrash": "Vaciar papelera", "deletedBy": "Eliminado por", diff --git a/packages/common-i18n/src/locales/fr/common.json b/packages/common-i18n/src/locales/fr/common.json index e3196c616c..90a7e5eb80 100644 --- a/packages/common-i18n/src/locales/fr/common.json +++ b/packages/common-i18n/src/locales/fr/common.json @@ -40,7 +40,8 @@ "turnOn": "Activer", "exit": "Déconnexion", "next": "Suivant", - "previous": "Précédent" + "previous": "Précédent", + "select": "Sélectionner" }, "quickAction": { "title": "Actions rapides...", @@ -467,6 +468,7 @@ "showMore": "Voir plus" }, "trash": { + "spaceTrash": "Corbeille d'espace", "type": "Type", "resetTrash": "Vider", "deletedBy": "Supprimé par", diff --git a/packages/common-i18n/src/locales/it/common.json b/packages/common-i18n/src/locales/it/common.json index 2234e7a439..125966a21e 100644 --- a/packages/common-i18n/src/locales/it/common.json +++ b/packages/common-i18n/src/locales/it/common.json @@ -47,7 +47,8 @@ "turnOn": "Attiva", "exit": "Esci", "next": "Successivo", - "previous": "Precedente" + "previous": "Precedente", + "select": "Seleziona" }, "quickAction": { "title": "Azioni rapide...", @@ -496,6 +497,7 @@ } }, "trash": { + "spaceTrash": "Cestino dello spazio", "type": "Tipo", "resetTrash": "Svuota cestino", "deletedBy": "Eliminato da", diff --git a/packages/common-i18n/src/locales/ja/common.json b/packages/common-i18n/src/locales/ja/common.json index 62ebdf7fbd..fd33f82d9d 100644 --- a/packages/common-i18n/src/locales/ja/common.json +++ b/packages/common-i18n/src/locales/ja/common.json @@ -39,7 +39,8 @@ "turnOn": "有効にする", "exit": "ログアウト", "next": "次へ", - "previous": "前へ" + "previous": "前へ", + "select": "選択" }, "quickAction": { "title": "クイックアクション...", @@ -489,6 +490,7 @@ } }, "trash": { + "spaceTrash": "スペースのゴミ箱", "type": "タイプ", "resetTrash": "ゴミ箱を空にする", "deletedBy": "削除者", diff --git a/packages/common-i18n/src/locales/ru/common.json b/packages/common-i18n/src/locales/ru/common.json index 76084de8a5..4346c7f5e5 100644 --- a/packages/common-i18n/src/locales/ru/common.json +++ b/packages/common-i18n/src/locales/ru/common.json @@ -40,7 +40,8 @@ "turnOn": "Включить", "exit": "Выйти", "next": "Следующая", - "previous": "Предыдущая" + "previous": "Предыдущая", + "select": "Выбрать" }, "quickAction": { "title": "Быстрые действия...", @@ -490,6 +491,7 @@ } }, "trash": { + "spaceTrash": "Корзина пространства", "type": "Тип", "resetTrash": "Очистить корзину", "deletedBy": "Удалено", diff --git a/packages/common-i18n/src/locales/tr/common.json b/packages/common-i18n/src/locales/tr/common.json index f17c10c747..6dff84a2c0 100644 --- a/packages/common-i18n/src/locales/tr/common.json +++ b/packages/common-i18n/src/locales/tr/common.json @@ -43,7 +43,8 @@ "turnOn": "Aç", "exit": "Çıkış", "next": "Sonraki", - "previous": "Önceki" + "previous": "Önceki", + "select": "Seç" }, "quickAction": { "title": "Hızlı İşlemler...", @@ -494,6 +495,7 @@ } }, "trash": { + "spaceTrash": "Alan çöp kutusu", "type": "Tür", "resetTrash": "Çöp kutusunu boşalt", "deletedBy": "Silen", diff --git a/packages/common-i18n/src/locales/uk/common.json b/packages/common-i18n/src/locales/uk/common.json index 508da65c02..fc9aa0afd4 100644 --- a/packages/common-i18n/src/locales/uk/common.json +++ b/packages/common-i18n/src/locales/uk/common.json @@ -40,7 +40,8 @@ "turnOn": "Увімкнути", "exit": "Вийти", "next": "Наступна", - "previous": "Попередня" + "previous": "Попередня", + "select": "Вибрати" }, "quickAction": { "title": "Швидкі дії...", @@ -493,6 +494,7 @@ } }, "trash": { + "spaceTrash": "Кошик простору", "type": "Тип", "resetTrash": "Очистити кошик", "deletedBy": "Видалено", From 09e308439752c81be1d8b598f107ef955822ec1e Mon Sep 17 00:00:00 2001 From: Uno Date: Thu, 11 Dec 2025 14:19:36 +0800 Subject: [PATCH 09/19] fix: adjust padding in BaseItem component for improved layout --- apps/nextjs-app/src/features/app/blocks/space/BaseItem.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/nextjs-app/src/features/app/blocks/space/BaseItem.tsx b/apps/nextjs-app/src/features/app/blocks/space/BaseItem.tsx index b93c533999..c624738d59 100644 --- a/apps/nextjs-app/src/features/app/blocks/space/BaseItem.tsx +++ b/apps/nextjs-app/src/features/app/blocks/space/BaseItem.tsx @@ -97,7 +97,7 @@ export const BaseItem: FC = (props) => {
    )} {/* Name Column */} -
    +
    )} -
      +
        {pageRoutes.map(({ href, text, Icon, hidden }) => { if (hidden) return null; return ( -
      • +
      • @@ -161,8 +161,8 @@ export const SpaceSwitcher = () => { -
        -

        +

        +

        {t('space:allSpaces')} ({spaceList?.length || 0})

        { {t('common:noResult')} - + {spaceList?.map((space) => { const isSelected = space.id === currentSpaceId; const subscription = subscriptionMap.get(space.id); @@ -185,7 +185,7 @@ export const SpaceSwitcher = () => { key={space.id} value={space.name} onSelect={() => handleSelectSpace(space)} - className={cn('group flex items-center gap-2 mb-1 rounded-md h-9', { + className={cn('group flex items-center gap-2 rounded-md h-10', { 'bg-accent': isSelected, })} > @@ -209,7 +209,7 @@ export const SpaceSwitcher = () => { diff --git a/apps/nextjs-app/src/features/app/layouts/SpaceInnerLayout.tsx b/apps/nextjs-app/src/features/app/layouts/SpaceInnerLayout.tsx index 3fe2cc3c89..2dab2bcfc0 100644 --- a/apps/nextjs-app/src/features/app/layouts/SpaceInnerLayout.tsx +++ b/apps/nextjs-app/src/features/app/layouts/SpaceInnerLayout.tsx @@ -46,7 +46,7 @@ export const SpaceInnerLayout: React.FC<{
        }> -
        +
        From 1232966dd3751e6d708214add5fc612c7ad98812 Mon Sep 17 00:00:00 2001 From: Uno Date: Thu, 11 Dec 2025 17:17:45 +0800 Subject: [PATCH 14/19] refactor: improve layout and styling --- .../features/app/blocks/space/BaseItem.tsx | 39 +++++++++---------- .../features/app/blocks/space/BaseList.tsx | 8 ++-- .../app/blocks/space/SpaceInnerPage.tsx | 3 +- .../space/space-side-bar/SpaceSwitcher.tsx | 8 +++- .../app/components/space/SpaceRenaming.tsx | 7 ++-- 5 files changed, 34 insertions(+), 31 deletions(-) diff --git a/apps/nextjs-app/src/features/app/blocks/space/BaseItem.tsx b/apps/nextjs-app/src/features/app/blocks/space/BaseItem.tsx index 87ff523db1..bf310c7a2b 100644 --- a/apps/nextjs-app/src/features/app/blocks/space/BaseItem.tsx +++ b/apps/nextjs-app/src/features/app/blocks/space/BaseItem.tsx @@ -85,27 +85,26 @@ export const BaseItem: FC = (props) => { return (
        onToggleExpand?.()} > + {/* Name Column */}
        - -
        hasUpdatePermission && stopPropagation(e)} @@ -157,7 +156,7 @@ export const BaseItem: FC = (props) => {
        {/* Creator Column */} -
        +
        {base.createdUser?.name?.slice(0, 1)} @@ -166,13 +165,13 @@ export const BaseItem: FC = (props) => {
        {/* Last Opened Column */} -
        +
        {lastVisitTime ? dayjs(lastVisitTime).fromNow() : '-'}
        {/* Actions Column */}
        diff --git a/apps/nextjs-app/src/features/app/blocks/space/BaseList.tsx b/apps/nextjs-app/src/features/app/blocks/space/BaseList.tsx index 03d7573d40..88274a5712 100644 --- a/apps/nextjs-app/src/features/app/blocks/space/BaseList.tsx +++ b/apps/nextjs-app/src/features/app/blocks/space/BaseList.tsx @@ -112,7 +112,6 @@ export const BaseList = (props: IBaseListProps) => { toggleExpanded(base.id)} onEnterBase={() => intoBase(base.id)} @@ -133,10 +132,9 @@ export const BaseList = (props: IBaseListProps) => { {/* Header */}
        -
        {t('space:baseList.allBases')}
        -
        {t('space:baseList.owner')}
        -
        {t('space:baseList.lastOpened')}
        -
        +
        {t('space:baseList.allBases')}
        +
        {t('space:baseList.owner')}
        +
        {t('space:baseList.lastOpened')}
        {/* Rows */} diff --git a/apps/nextjs-app/src/features/app/blocks/space/SpaceInnerPage.tsx b/apps/nextjs-app/src/features/app/blocks/space/SpaceInnerPage.tsx index 7583194ece..00bf895f35 100644 --- a/apps/nextjs-app/src/features/app/blocks/space/SpaceInnerPage.tsx +++ b/apps/nextjs-app/src/features/app/blocks/space/SpaceInnerPage.tsx @@ -121,8 +121,9 @@ export const SpaceInnerPage: React.FC = () => { isRenaming={renaming} onChange={(e) => setSpaceName(e.target.value)} onBlur={(e) => toggleUpdateSpace(e)} + className="h-8" > -

        {space.name}

        +

        {space.name}

        {isCloud && ( diff --git a/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/SpaceSwitcher.tsx b/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/SpaceSwitcher.tsx index 3ed81216df..5e35e12df2 100644 --- a/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/SpaceSwitcher.tsx +++ b/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/SpaceSwitcher.tsx @@ -152,9 +152,13 @@ export const SpaceSwitcher = () => { <> - diff --git a/apps/nextjs-app/src/features/app/components/space/SpaceRenaming.tsx b/apps/nextjs-app/src/features/app/components/space/SpaceRenaming.tsx index 698013979d..3da3ed78de 100644 --- a/apps/nextjs-app/src/features/app/components/space/SpaceRenaming.tsx +++ b/apps/nextjs-app/src/features/app/components/space/SpaceRenaming.tsx @@ -1,7 +1,8 @@ -import { Input } from '@teable/ui-lib'; +import { cn, Input } from '@teable/ui-lib'; import React, { useEffect, useRef } from 'react'; interface SpaceRenamingProps { + className?: string; spaceName: string; isRenaming: boolean; children: React.ReactNode; @@ -10,7 +11,7 @@ interface SpaceRenamingProps { } export const SpaceRenaming: React.FC = (props) => { - const { spaceName, isRenaming, children, onChange, onBlur } = props; + const { spaceName, isRenaming, children, onChange, onBlur, className } = props; const inputRef = useRef(null); useEffect(() => { @@ -32,7 +33,7 @@ export const SpaceRenaming: React.FC = (props) => { {isRenaming ? ( Date: Thu, 11 Dec 2025 18:41:36 +0800 Subject: [PATCH 15/19] fix: adjust layout in BaseItem and BaseList components --- .../base/base-side-bar/BaseNodeTree.tsx | 23 ++++++++++++------- .../features/app/blocks/space/BaseItem.tsx | 2 +- .../features/app/blocks/space/BaseList.tsx | 19 +++++++++++---- .../space/space-side-bar/SpaceSwitcher.tsx | 8 ++----- .../app/components/sidebar/Sidebar.tsx | 7 +++++- packages/ui-lib/src/shadcn/ui/tree.tsx | 2 +- 6 files changed, 40 insertions(+), 21 deletions(-) diff --git a/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeTree.tsx b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeTree.tsx index 1f99a3a1ec..5528800da9 100644 --- a/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeTree.tsx +++ b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeTree.tsx @@ -119,6 +119,7 @@ type TreeMode = 'view' | 'edit'; interface IBaseNodeTreeProps { mode?: TreeMode; emptyText?: string; + skeleton?: React.ReactNode; } export const BaseNodeTree = (props: IBaseNodeTreeProps) => { @@ -549,14 +550,20 @@ export const BaseNodeTree = (props: IBaseNodeTreeProps) => { if (Object.keys(treeItems).length === 0) { if (isLoading) { return ( -
        - - - - - - -
        + <> + {props.skeleton ? ( + props.skeleton + ) : ( +
        + + + + + + +
        + )} + ); } else if (emptyText) { return ( diff --git a/apps/nextjs-app/src/features/app/blocks/space/BaseItem.tsx b/apps/nextjs-app/src/features/app/blocks/space/BaseItem.tsx index bf310c7a2b..ff6cf55b37 100644 --- a/apps/nextjs-app/src/features/app/blocks/space/BaseItem.tsx +++ b/apps/nextjs-app/src/features/app/blocks/space/BaseItem.tsx @@ -165,7 +165,7 @@ export const BaseItem: FC = (props) => {
        {/* Last Opened Column */} -
        +
        {lastVisitTime ? dayjs(lastVisitTime).fromNow() : '-'}
        diff --git a/apps/nextjs-app/src/features/app/blocks/space/BaseList.tsx b/apps/nextjs-app/src/features/app/blocks/space/BaseList.tsx index 88274a5712..5b5bc17b80 100644 --- a/apps/nextjs-app/src/features/app/blocks/space/BaseList.tsx +++ b/apps/nextjs-app/src/features/app/blocks/space/BaseList.tsx @@ -2,7 +2,7 @@ import { useQueryClient, useMutation } from '@tanstack/react-query'; import { deleteBase, permanentDeleteBase, updateBase, type IGetBaseVo } from '@teable/openapi'; import { ReactQueryKeys } from '@teable/sdk/config'; import { AnchorContext } from '@teable/sdk/context'; -import { Collapsible, CollapsibleContent, ScrollArea } from '@teable/ui-lib/shadcn'; +import { Collapsible, CollapsibleContent, ScrollArea, Skeleton } from '@teable/ui-lib/shadcn'; import { keyBy } from 'lodash'; import { useRouter } from 'next/router'; import { useTranslation } from 'next-i18next'; @@ -14,7 +14,6 @@ import { BaseNodeTree } from '../base/base-side-bar/BaseNodeTree'; import { useLastVisitBase } from '../base/hooks'; import { BaseItem } from './BaseItem'; import { useBaseList } from './useBaseList'; - interface IBaseListProps { baseIds: string[]; } @@ -121,7 +120,19 @@ export const BaseList = (props: IBaseListProps) => { - +
        + + + + +
        + } + /> +
        @@ -134,7 +145,7 @@ export const BaseList = (props: IBaseListProps) => {
        {t('space:baseList.allBases')}
        {t('space:baseList.owner')}
        -
        {t('space:baseList.lastOpened')}
        +
        {t('space:baseList.lastOpened')}
        {/* Rows */} diff --git a/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/SpaceSwitcher.tsx b/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/SpaceSwitcher.tsx index 5e35e12df2..81241272b3 100644 --- a/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/SpaceSwitcher.tsx +++ b/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/SpaceSwitcher.tsx @@ -152,11 +152,7 @@ export const SpaceSwitcher = () => { <> - diff --git a/apps/nextjs-app/src/features/app/components/collaborator-manage/space-inner/Collaborators.tsx b/apps/nextjs-app/src/features/app/components/collaborator-manage/space-inner/Collaborators.tsx index cbfaadcd14..0746ba257e 100644 --- a/apps/nextjs-app/src/features/app/components/collaborator-manage/space-inner/Collaborators.tsx +++ b/apps/nextjs-app/src/features/app/components/collaborator-manage/space-inner/Collaborators.tsx @@ -54,7 +54,10 @@ export const Collaborators: React.FC = (props) => { )}
        -

        +

        {item.type === PrincipalType.User ? item.userName : item.departmentName}

        {isBase && ( diff --git a/apps/nextjs-app/src/features/app/components/user/UserNav.tsx b/apps/nextjs-app/src/features/app/components/user/UserNav.tsx index 3ece2dc83a..bc04ba25d4 100644 --- a/apps/nextjs-app/src/features/app/components/user/UserNav.tsx +++ b/apps/nextjs-app/src/features/app/components/user/UserNav.tsx @@ -39,8 +39,12 @@ export const UserNav: React.FC = (props) => {
        -

        {user.name}

        -

        {user.email}

        +

        + {user.name} +

        +

        + {user.email} +

        From 4652ba81ef6115d0648a047e0327c0e4efad559c Mon Sep 17 00:00:00 2001 From: Uno Date: Thu, 11 Dec 2025 21:39:42 +0800 Subject: [PATCH 17/19] refactor: rename variables for clarity in user-related data handling across services --- .../src/features/base/base.service.ts | 10 +++++----- .../features/collaborator/collaborator.service.ts | 15 +++++++++++---- .../src/features/space/space.service.ts | 10 +++++----- .../src/features/app/blocks/space/BaseItem.tsx | 12 ++++-------- .../src/features/app/blocks/space/BaseList.tsx | 2 +- .../app/blocks/space/space-side-bar/PinList.tsx | 2 +- .../app/blocks/trash/SpaceInnerTrashPage.tsx | 2 -- .../src/features/app/layouts/SharedBaseLayout.tsx | 2 +- 8 files changed, 28 insertions(+), 27 deletions(-) diff --git a/apps/nestjs-backend/src/features/base/base.service.ts b/apps/nestjs-backend/src/features/base/base.service.ts index 8799ed9622..a347550822 100644 --- a/apps/nestjs-backend/src/features/base/base.service.ts +++ b/apps/nestjs-backend/src/features/base/base.service.ts @@ -129,23 +129,23 @@ export class BaseService { orderBy: [{ spaceId: 'asc' }, { order: 'asc' }], }); - const createUserList = await this.prismaService.user.findMany({ + const createdUserList = await this.prismaService.user.findMany({ where: { id: { in: baseList.map((base) => base.createdBy) } }, select: { id: true, name: true, avatar: true }, }); - const createUserMap = keyBy(createUserList, 'id'); + const createdUserMap = keyBy(createdUserList, 'id'); return baseList.map((base) => { const role = roleMap[base.id] || roleMap[base.spaceId]; - const createUser = createUserMap[base.createdBy]; + const createdUser = createdUserMap[base.createdBy]; return { ...base, role, lastModifiedTime: base.lastModifiedTime?.toISOString(), createdTime: base.createdTime?.toISOString(), createdUser: { - ...createUser, - avatar: createUser?.avatar && getPublicFullStorageUrl(createUser.avatar), + ...(createdUser ?? {}), + avatar: createdUser?.avatar && getPublicFullStorageUrl(createdUser.avatar), }, }; }); diff --git a/apps/nestjs-backend/src/features/collaborator/collaborator.service.ts b/apps/nestjs-backend/src/features/collaborator/collaborator.service.ts index 0174c54d79..3336084435 100644 --- a/apps/nestjs-backend/src/features/collaborator/collaborator.service.ts +++ b/apps/nestjs-backend/src/features/collaborator/collaborator.service.ts @@ -19,7 +19,7 @@ import type { } from '@teable/openapi'; import { CollaboratorType, PrincipalType } from '@teable/openapi'; import { Knex } from 'knex'; -import { difference, map } from 'lodash'; +import { difference, keyBy, map } from 'lodash'; import { InjectModel } from 'nest-knexjs'; import { ClsService } from 'nestjs-cls'; import { CustomHttpException } from '../../custom.exception'; @@ -830,6 +830,12 @@ export class CollaboratorService { }, }, }); + + const createdUserList = await this.prismaService.txClient().user.findMany({ + where: { id: { in: bases.map((base) => base.createdBy) } }, + select: { id: true, name: true, avatar: true }, + }); + const createdUserMap = keyBy(createdUserList, 'id'); return bases.map((base) => ({ id: base.id, name: base.name, @@ -842,9 +848,10 @@ export class CollaboratorService { createdTime: base.createdTime?.toISOString(), createdBy: base.createdBy, createdUser: { - id: base.createdBy, - name: base.createdBy, - avatar: base.createdBy ? getPublicFullStorageUrl(base.createdBy) : null, + ...(createdUserMap[base.createdBy] ?? {}), + avatar: + createdUserMap[base.createdBy]?.avatar && + getPublicFullStorageUrl(createdUserMap[base.createdBy]?.avatar ?? ''), }, })); } diff --git a/apps/nestjs-backend/src/features/space/space.service.ts b/apps/nestjs-backend/src/features/space/space.service.ts index b81e6e99b3..45970c658f 100644 --- a/apps/nestjs-backend/src/features/space/space.service.ts +++ b/apps/nestjs-backend/src/features/space/space.service.ts @@ -280,23 +280,23 @@ export class SpaceService { }, }); - const createUserList = await this.prismaService.user.findMany({ + const createdUserList = await this.prismaService.user.findMany({ where: { id: { in: baseList.map((base) => base.createdBy) } }, select: { id: true, name: true, avatar: true }, }); - const createUserMap = keyBy(createUserList, 'id'); + const createdUserMap = keyBy(createdUserList, 'id'); return baseList.map((base) => { const role = roleMap[base.id] || roleMap[base.spaceId]; - const createUser = createUserMap[base.createdBy]; + const createdUser = createdUserMap[base.createdBy]; return { ...base, role, lastModifiedTime: base.lastModifiedTime?.toISOString(), createdTime: base.createdTime?.toISOString(), createdUser: { - ...createUser, - avatar: createUser?.avatar && getPublicFullStorageUrl(createUser.avatar), + ...createdUser, + avatar: createdUser?.avatar && getPublicFullStorageUrl(createdUser.avatar), }, }; }); diff --git a/apps/nextjs-app/src/features/app/blocks/space/BaseItem.tsx b/apps/nextjs-app/src/features/app/blocks/space/BaseItem.tsx index ff6cf55b37..da6839a0eb 100644 --- a/apps/nextjs-app/src/features/app/blocks/space/BaseItem.tsx +++ b/apps/nextjs-app/src/features/app/blocks/space/BaseItem.tsx @@ -1,13 +1,7 @@ /* eslint-disable jsx-a11y/no-static-element-interactions */ /* eslint-disable jsx-a11y/click-events-have-key-events */ import { hasPermission } from '@teable/core'; -import { - ChevronDown, - ChevronRight, - Database, - DraggableHandle, - MoreHorizontal, -} from '@teable/icons'; +import { ChevronDown, ChevronRight, Database, MoreHorizontal } from '@teable/icons'; import type { IGetBaseVo } from '@teable/openapi'; import { PinType } from '@teable/openapi'; import { useLanDayjs } from '@teable/sdk/hooks'; @@ -161,7 +155,9 @@ export const BaseItem: FC = (props) => { {base.createdUser?.name?.slice(0, 1)} - {base.createdUser?.name} + + {base.createdUser?.name} +
        {/* Last Opened Column */} diff --git a/apps/nextjs-app/src/features/app/blocks/space/BaseList.tsx b/apps/nextjs-app/src/features/app/blocks/space/BaseList.tsx index 5b5bc17b80..0e7e2d08df 100644 --- a/apps/nextjs-app/src/features/app/blocks/space/BaseList.tsx +++ b/apps/nextjs-app/src/features/app/blocks/space/BaseList.tsx @@ -123,7 +123,7 @@ export const BaseList = (props: IBaseListProps) => {
        diff --git a/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/PinList.tsx b/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/PinList.tsx index 8fee51520c..d169f9d1ef 100644 --- a/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/PinList.tsx +++ b/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/PinList.tsx @@ -100,7 +100,7 @@ export const PinList = (props: { className?: string }) => { innerClassName="flex min-h-0 flex-1 flex-col" > -
        +
        {pinListData?.length === 0 && (
        {t('space:pin.empty')} diff --git a/apps/nextjs-app/src/features/app/blocks/trash/SpaceInnerTrashPage.tsx b/apps/nextjs-app/src/features/app/blocks/trash/SpaceInnerTrashPage.tsx index 585731217b..188d5ca327 100644 --- a/apps/nextjs-app/src/features/app/blocks/trash/SpaceInnerTrashPage.tsx +++ b/apps/nextjs-app/src/features/app/blocks/trash/SpaceInnerTrashPage.tsx @@ -18,7 +18,6 @@ import { toast } from '@teable/ui-lib/shadcn/ui/sonner'; import dayjs from 'dayjs'; import { IterationCcwIcon } from 'lucide-react'; import { useParams } from 'next/navigation'; -import { useRouter } from 'next/router'; import { useTranslation } from 'next-i18next'; import { useCallback, useMemo, useState } from 'react'; import { spaceConfig } from '@/features/i18n/space.config'; @@ -26,7 +25,6 @@ import { Collaborator } from '../../components/collaborator-manage/components/Co export const SpaceInnerTrashPage = () => { const { spaceId } = useParams<{ spaceId: string }>(); - const router = useRouter(); const isHydrated = useIsHydrated(); const queryClient = useQueryClient(); const { t } = useTranslation(spaceConfig.i18nNamespaces); diff --git a/apps/nextjs-app/src/features/app/layouts/SharedBaseLayout.tsx b/apps/nextjs-app/src/features/app/layouts/SharedBaseLayout.tsx index d4a25c41fe..5dffd4e747 100644 --- a/apps/nextjs-app/src/features/app/layouts/SharedBaseLayout.tsx +++ b/apps/nextjs-app/src/features/app/layouts/SharedBaseLayout.tsx @@ -1,5 +1,5 @@ import type { DehydratedState } from '@tanstack/react-query'; -import { Component, Database, Home, Users } from '@teable/icons'; +import { Component, Database } from '@teable/icons'; import type { IUser } from '@teable/sdk'; import { NotificationProvider, SessionProvider } from '@teable/sdk'; import { AppProvider } from '@teable/sdk/context'; From 085ca6f8f9b4c886f65a13969f7872b169b0a246 Mon Sep 17 00:00:00 2001 From: Uno Date: Thu, 11 Dec 2025 21:55:02 +0800 Subject: [PATCH 18/19] refactor: initialize treeItems from cache to prevent empty state flash on remount --- .../blocks/base/base-node/hooks/useBaseNode.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/nextjs-app/src/features/app/blocks/base/base-node/hooks/useBaseNode.ts b/apps/nextjs-app/src/features/app/blocks/base/base-node/hooks/useBaseNode.ts index 272539b953..74638e000d 100644 --- a/apps/nextjs-app/src/features/app/blocks/base/base-node/hooks/useBaseNode.ts +++ b/apps/nextjs-app/src/features/app/blocks/base/base-node/hooks/useBaseNode.ts @@ -1,6 +1,6 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { getBaseNodeChannel } from '@teable/core'; -import type { IBaseNodeVo } from '@teable/openapi'; +import type { IBaseNodeTreeVo, IBaseNodeVo } from '@teable/openapi'; import { getBaseNodeTree } from '@teable/openapi'; import { ReactQueryKeys } from '@teable/sdk/config'; import { useConnection } from '@teable/sdk/hooks'; @@ -16,10 +16,21 @@ export const useBaseNode = (baseId: string) => { const channel = getBaseNodeChannel(baseId); const presence = connection?.getPresence(channel); const [nodes, setNodes] = useState([]); - const [treeItems, setTreeItems] = useState>({}); const [shouldInvalidate, setShouldInvalidate] = useState(0); const queryClient = useQueryClient(); + + // Initialize treeItems from cache to avoid flash of empty state on remount + const [treeItems, setTreeItems] = useState>(() => { + const cachedData = queryClient.getQueryData( + ReactQueryKeys.baseNodeTree(baseId) + ); + if (cachedData?.nodes && cachedData.nodes.length > 0) { + return buildTreeItems(cachedData.nodes); + } + return {}; + }); + const { data: queryData, isLoading } = useQuery({ queryKey: ReactQueryKeys.baseNodeTree(baseId), queryFn: ({ queryKey }) => getBaseNodeTree(queryKey[1]).then((res) => res.data), From e4bfcefbb80bed9b4437efa733bee3c279536b25 Mon Sep 17 00:00:00 2001 From: Uno Date: Thu, 11 Dec 2025 22:37:54 +0800 Subject: [PATCH 19/19] refactor: optimize BaseList sorting logic to handle null values and improve readability --- .../features/app/blocks/space/BaseList.tsx | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/apps/nextjs-app/src/features/app/blocks/space/BaseList.tsx b/apps/nextjs-app/src/features/app/blocks/space/BaseList.tsx index 0e7e2d08df..345895170b 100644 --- a/apps/nextjs-app/src/features/app/blocks/space/BaseList.tsx +++ b/apps/nextjs-app/src/features/app/blocks/space/BaseList.tsx @@ -34,15 +34,18 @@ export const BaseList = (props: IBaseListProps) => { }, [allBaseList]); const sortedList = useMemo(() => { - const withTime = baseIds.map((baseId) => { - const base = allBaseMap[baseId] || {}; - const lastVisitTime = lastVisitBaseMap[baseId]?.lastVisitTime; - - return { - ...base, - lastVisitTime, - }; - }); + const withTime = baseIds + .map((baseId) => { + const base = allBaseMap[baseId]; + if (!base) return null; + const lastVisitTime = lastVisitBaseMap[baseId]?.lastVisitTime; + + return { + ...base, + lastVisitTime, + }; + }) + .filter((item) => item !== null) as (IGetBaseVo & { lastVisitTime?: string })[]; /** * 1. Both have lastVisitTime: compare by lastVisitTime (recent first)