diff --git a/apps/nestjs-backend/src/features/base/base.service.ts b/apps/nestjs-backend/src/features/base/base.service.ts index 00a70f697e..a347550822 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,9 @@ export class BaseService { order: true, spaceId: true, icon: true, + createdBy: true, + createdTime: true, + lastModifiedTime: true, }, where: { deletedTime: null, @@ -122,9 +128,26 @@ export class BaseService { }, orderBy: [{ spaceId: 'asc' }, { order: 'asc' }], }); + + const createdUserList = await this.prismaService.user.findMany({ + where: { id: { in: baseList.map((base) => base.createdBy) } }, + select: { id: true, name: true, avatar: true }, + }); + const createdUserMap = keyBy(createdUserList, 'id'); + return baseList.map((base) => { const role = roleMap[base.id] || roleMap[base.spaceId]; - return { ...base, role }; + const createdUser = createdUserMap[base.createdBy]; + return { + ...base, + role, + lastModifiedTime: base.lastModifiedTime?.toISOString(), + createdTime: base.createdTime?.toISOString(), + createdUser: { + ...(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 35df3e5b94..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, @@ -838,6 +844,15 @@ export class CollaboratorService { spaceId: base.spaceId, spaceName: base.space?.name, collaboratorType: CollaboratorType.Base, + lastModifiedTime: base.lastModifiedTime?.toISOString(), + createdTime: base.createdTime?.toISOString(), + createdBy: base.createdBy, + createdUser: { + ...(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 9c7d14dbc9..45970c658f 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,9 @@ export class SpaceService { order: true, spaceId: true, icon: true, + createdBy: true, + lastModifiedTime: true, + createdTime: true, }, where: { spaceId, @@ -277,9 +280,25 @@ export class SpaceService { }, }); + const createdUserList = await this.prismaService.user.findMany({ + where: { id: { in: baseList.map((base) => base.createdBy) } }, + select: { id: true, name: true, avatar: true }, + }); + const createdUserMap = keyBy(createdUserList, 'id'); + return baseList.map((base) => { const role = roleMap[base.id] || roleMap[base.spaceId]; - return { ...base, role }; + const createdUser = createdUserMap[base.createdBy]; + return { + ...base, + role, + lastModifiedTime: base.lastModifiedTime?.toISOString(), + createdTime: base.createdTime?.toISOString(), + createdUser: { + ...createdUser, + avatar: createdUser?.avatar && getPublicFullStorageUrl(createdUser.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..88c1d38031 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,23 +150,26 @@ 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 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/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..1a16efcdfc 100644 --- a/apps/nestjs-backend/src/types/i18n.generated.ts +++ b/apps/nestjs-backend/src/types/i18n.generated.ts @@ -236,6 +236,7 @@ export type I18nTranslations = { "exit": string; "next": string; "previous": string; + "select": string; "continue": string; "export": string; "import": string; @@ -837,6 +838,7 @@ export type I18nTranslations = { }; }; "trash": { + "spaceTrash": string; "type": string; "resetTrash": string; "deletedBy": string; @@ -2657,6 +2659,10 @@ export type I18nTranslations = { "description": string; "empty": string; }; + "trash": { + "spaceDescription": string; + "baseDescription": string; + }; "integration": { "title": string; "description": string; @@ -2693,6 +2699,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-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), diff --git a/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeMore.tsx b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeMore.tsx index c884f8b1ab..31bc2719d0 100644 --- a/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeMore.tsx +++ b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeMore.tsx @@ -64,6 +64,8 @@ interface ICommonOperationProps extends IBaseNodeMoreProps { const CommonOperation = (props: ICommonOperationProps) => { const { + open, + setOpen, onRename, onDuplicate, onDelete, @@ -82,7 +84,7 @@ const CommonOperation = (props: ICommonOperationProps) => { return ( <> - +
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..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 @@ -118,10 +118,12 @@ type TreeMode = 'view' | 'edit'; interface IBaseNodeTreeProps { mode?: TreeMode; + emptyText?: string; + skeleton?: React.ReactNode; } 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 +547,33 @@ export const BaseNodeTree = (props: IBaseNodeTreeProps) => { ); }; + if (Object.keys(treeItems).length === 0) { + if (isLoading) { + return ( + <> + {props.skeleton ? ( + props.skeleton + ) : ( +
+ + + + + + +
+ )} + + ); + } else if (emptyText) { + return ( +
+

{emptyText}

+
+ ); + } + } + return ( <> {isEditMode && ( @@ -570,172 +599,162 @@ 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(); + }} + /> + ) : ( + <> + +
+ { + setEditingNodeId(nodeId); + }} + > + {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..da6839a0eb --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/space/BaseItem.tsx @@ -0,0 +1,196 @@ +/* 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, 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; +} + +export const BaseItem: FC = (props) => { + const { + base, + lastVisitTime, + className, + isExpanded = false, + onToggleExpand, + onEnterBase, + onUpdate, + onDelete, + } = 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 ( +
onToggleExpand?.()} + > + + {/* 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..345895170b --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/space/BaseList.tsx @@ -0,0 +1,164 @@ +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, Skeleton } 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]; + 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) + * 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) => { + 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]); + + 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) => ( + 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 })} + /> + + + +
+ + + + +
+ } + /> +
+ + + + + ); + + 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..d072e45fa1 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,32 @@ 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')} +

+
+

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

+

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

- )} -
- {bases?.map((base) => ( -
- -
- ))} +
+
+ {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..1e1ce88088 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,16 +113,17 @@ export const SpaceInnerPage: React.FC = () => { return ( space && ( -
-
-
+
+
+
setSpaceName(e.target.value)} onBlur={(e) => toggleUpdateSpace(e)} + className="h-8" > -

{space.name}

+

{space.name}

{isCloud && ( @@ -139,7 +140,7 @@ export const SpaceInnerPage: React.FC = () => { )}
{ onRename={() => setRenaming(true)} onSpaceSetting={onSpaceSetting} /> - {basesInSpace?.length ? ( - - - - ) : ( -
- No roles available -
-

- {t('space:emptySpaceTitle')} -

-

{t('space:spaceIsEmpty')}

-
-
- )}
-
- deleteSpaceMutator(space.id)} - onPermanentDelete={() => permanentDeleteSpaceMutator(space.id)} - onRename={() => setRenaming(true)} - onSpaceSetting={onSpaceSetting} - /> - -
- -
-
+
+
+ {basesInSpace?.length ? ( + base.id)} /> + ) : ( +
+ No roles available +
+

+ {t('space:emptySpaceTitle')} +

+

{t('space:spaceIsEmpty')}

+
+
+ )} +
+ +
+ +
+ +
+
+
) diff --git a/apps/nextjs-app/src/features/app/blocks/space/hooks/index.ts b/apps/nextjs-app/src/features/app/blocks/space/hooks/index.ts new file mode 100644 index 0000000000..df3476632c --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/space/hooks/index.ts @@ -0,0 +1 @@ +export * from './useSpaceList'; diff --git a/apps/nextjs-app/src/features/app/blocks/space/hooks/useSpaceList.ts b/apps/nextjs-app/src/features/app/blocks/space/hooks/useSpaceList.ts new file mode 100644 index 0000000000..8c294ba3f4 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/space/hooks/useSpaceList.ts @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query'; +import { getSpaceList } from '@teable/openapi'; +import { ReactQueryKeys } from '@teable/sdk/config'; + +export const useSpaceList = () => { + 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..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 @@ -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..ca73b7aab8 --- /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/space/space-side-bar/StarButton.tsx b/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/StarButton.tsx index 7241b2a259..45ac7c80f2 100644 --- a/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/StarButton.tsx +++ b/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/StarButton.tsx @@ -55,7 +55,7 @@ export const StarButton = (props: IStarButtonProps) => { > { + const { spaceId } = useParams<{ spaceId: string }>(); + 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, spaceId), + 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, spaceId)); + toast.success(t('actions.restoreSucceed')); + }, + }); + + const { mutateAsync: mutatePermanentDeleteBase } = useMutation({ + mutationFn: (props: { baseId: string }) => permanentDeleteBase(props.baseId), + onSuccess: () => { + queryClient.invalidateQueries(ReactQueryKeys.getSpaceTrash(resourceType, spaceId)); + 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; + 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 ( +
    + + +
    + ); + }, + }, + ]; + + return tableColumns; + }, [t, resourceMap, userMap, mutateRestore]); + + const fetchNextPageInner = useCallback(() => { + if (!isFetching && nextCursor) { + fetchNextPage(); + } + }, [fetchNextPage, isFetching, nextCursor]); + + if (!isHydrated || isLoading) return null; + + return ( +
    +
    +

    {t('noun.trash')}

    +

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

    +
    + + 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..43a9cfc8ec 100644 --- a/apps/nextjs-app/src/features/app/blocks/trash/SpaceTrashPage.tsx +++ b/apps/nextjs-app/src/features/app/blocks/trash/SpaceTrashPage.tsx @@ -1,13 +1,11 @@ -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 { Trash2 } from '@teable/icons'; import type { ITrashItemVo, ITrashVo } from '@teable/openapi'; import { getTrash, ResourceType, restoreTrash, - permanentDeleteBase, permanentDeleteSpace, PrincipalType, } from '@teable/openapi'; @@ -15,43 +13,33 @@ 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 { useRouter } from 'next/router'; +import { IterationCcwIcon } from 'lucide-react'; 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 +74,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 +94,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} +
    + ); }, }, { @@ -184,40 +143,39 @@ export const SpaceTrashPage = () => { if (!resourceInfo) return null; return ( - - - - - - mutateRestore({ trashId })}> - - {t('actions.restore')} - - { - setConfirmVisible(true); - setDeletingResource({ - resourceId, - resourceType, - name: resourceInfo.name, - }); - }} - > - - {t('actions.permanentDelete')} - - - +
    + + +
    ); }, }, ]; return tableColumns; - }, [t, router, resourceMap, userMap, resourceType, mutateRestore]); + }, [t, resourceMap, userMap, mutateRestore]); const fetchNextPageInner = useCallback(() => { if (!isFetching && nextCursor) { @@ -225,74 +183,33 @@ 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 }) => ( - - ))} -
    +

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

    - + 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/SideBarFooter.tsx b/apps/nextjs-app/src/features/app/components/SideBarFooter.tsx index fb5258a8d2..7f39908969 100644 --- a/apps/nextjs-app/src/features/app/components/SideBarFooter.tsx +++ b/apps/nextjs-app/src/features/app/components/SideBarFooter.tsx @@ -22,7 +22,9 @@ export const SideBarFooter: React.FC = () => { className="w-full justify-start py-1.5 pl-2 text-sm font-normal" > - {user.name} +

    + {user.name} +

    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/sidebar/Sidebar.tsx b/apps/nextjs-app/src/features/app/components/sidebar/Sidebar.tsx index 212f7f3d53..ee0179c4fb 100644 --- a/apps/nextjs-app/src/features/app/components/sidebar/Sidebar.tsx +++ b/apps/nextjs-app/src/features/app/components/sidebar/Sidebar.tsx @@ -4,6 +4,7 @@ import { Button, cn } from '@teable/ui-lib'; import type { FC, PropsWithChildren, ReactNode } from 'react'; import { useEffect, useState } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; +import { useMedia } from 'react-use'; import { SIDE_BAR_WIDTH } from '../toggle-side-bar/constant'; import { HoverWrapper } from '../toggle-side-bar/HoverWrapper'; import { SheetWrapper } from '../toggle-side-bar/SheetWrapper'; @@ -20,7 +21,7 @@ export const Sidebar: FC> = (props) => { const { headerLeft, children, className } = props; const isMobile = useIsMobile(); const [leftVisible, setLeftVisible] = useState(true); - + const isLargeScreen = useMedia('(min-width: 1024px)'); const { setVisible } = useSidebarStore(); const { status } = useChatPanelStore(); @@ -35,6 +36,10 @@ export const Sidebar: FC> = (props) => { setVisible(leftVisible); }, [leftVisible, setVisible]); + useEffect(() => { + setLeftVisible(isLargeScreen); + }, [isLargeScreen]); + return ( <> {isMobile ? ( diff --git a/apps/nextjs-app/src/features/app/components/sidebar/SidebarHeader.tsx b/apps/nextjs-app/src/features/app/components/sidebar/SidebarHeader.tsx index c5d068e9b4..8e676d45b2 100644 --- a/apps/nextjs-app/src/features/app/components/sidebar/SidebarHeader.tsx +++ b/apps/nextjs-app/src/features/app/components/sidebar/SidebarHeader.tsx @@ -13,7 +13,7 @@ export const SidebarHeader = (prop: ISidebarHeaderProps) => { const { t } = useTranslation('common'); return ( -
    +
    {headerLeft} {onExpand && ( diff --git a/apps/nextjs-app/src/features/app/components/space/SpaceActionBar.tsx b/apps/nextjs-app/src/features/app/components/space/SpaceActionBar.tsx index d44e52f580..7083da7cd3 100644 --- a/apps/nextjs-app/src/features/app/components/space/SpaceActionBar.tsx +++ b/apps/nextjs-app/src/features/app/components/space/SpaceActionBar.tsx @@ -80,8 +80,8 @@ export const SpaceActionBar: React.FC = (props) => { onSpaceSetting={onSpaceSetting} onImportBase={() => setImportBaseOpen(true)} > - 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/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 ? ( = (props) => {
    -

    {user.name}

    -

    {user.email}

    +

    + {user.name} +

    +

    + {user.email} +

    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..5dffd4e747 --- /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 } 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..2dab2bcfc0 --- /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/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/de/space.json b/packages/common-i18n/src/locales/de/space.json index 3efc5beb0e..8a05a333e6 100644 --- a/packages/common-i18n/src/locales/de/space.json +++ b/packages/common-i18n/src/locales/de/space.json @@ -61,9 +61,13 @@ }, "sharedBase": { "title": "Geteilte Bases", - "description": "Die mit Ihnen geteilten Bases werden hier erscheinen", + "description": "Alle Bases, zu denen ich eingeladen wurde", "empty": "Noch keine geteilten Bases" }, + "trash": { + "spaceDescription": "Alle gelöschten Spaces in dieser Instanz", + "baseDescription": "Alle gelöschten Bases in diesem Space" + }, "integration": { "title": "Integrationen", "description": "Verwalten Sie die Integrationen Ihres Spaces", 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..b5068d72a5 100644 --- a/packages/common-i18n/src/locales/en/space.json +++ b/packages/common-i18n/src/locales/en/space.json @@ -65,9 +65,13 @@ }, "sharedBase": { "title": "Shared with me", - "description": "Bases shared with me will appear here", + "description": "All the bases that invited me to join", "empty": "No bases shared with me yet" }, + "trash": { + "spaceDescription": "All deleted spaces under this instance", + "baseDescription": "All deleted bases under this space" + }, "integration": { "title": "Integrations", "description": "Manage integrations of your space", @@ -101,5 +105,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/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/es/space.json b/packages/common-i18n/src/locales/es/space.json index bf4a020857..eaa26e635b 100644 --- a/packages/common-i18n/src/locales/es/space.json +++ b/packages/common-i18n/src/locales/es/space.json @@ -61,9 +61,13 @@ }, "sharedBase": { "title": "Bases compartidas", - "description": "Las bases compartidas contigo aparecerán aquí", + "description": "Todas las bases a las que me invitaron a unirme", "empty": "Aún no hay bases compartidas" }, + "trash": { + "spaceDescription": "Todos los espacios eliminados en esta instancia", + "baseDescription": "Todas las bases eliminadas en este espacio" + }, "integration": { "title": "Integración", "description": "Administrar integraciones de su espacio", 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/fr/space.json b/packages/common-i18n/src/locales/fr/space.json index 9a0143ee1a..8a73ec1d58 100644 --- a/packages/common-i18n/src/locales/fr/space.json +++ b/packages/common-i18n/src/locales/fr/space.json @@ -59,6 +59,15 @@ "permanentDeleteWarning": "Cette action supprimera définitivement toutes les ressources et données de l'espace actuel. Veuillez procéder avec prudence !", "confirmInputLabel": "Veuillez taper DELETE pour confirmer la suppression" }, + "sharedBase": { + "title": "Partagé avec moi", + "description": "Toutes les bases auxquelles j'ai été invité à rejoindre", + "empty": "Aucune base partagée avec moi pour le moment" + }, + "trash": { + "spaceDescription": "Tous les espaces supprimés dans cette instance", + "baseDescription": "Toutes les bases supprimées dans cet espace" + }, "integration": { "title": "Intégrations", "description": "Gérer les intégrations de votre espace", 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/it/space.json b/packages/common-i18n/src/locales/it/space.json index 49c66ecca3..b492374452 100644 --- a/packages/common-i18n/src/locales/it/space.json +++ b/packages/common-i18n/src/locales/it/space.json @@ -61,7 +61,11 @@ }, "sharedBase": { "title": "Basi condivise", - "description": "Le basi condivise con te appariranno qui", + "description": "Tutte le basi a cui sono stato invitato a partecipare", "empty": "Nessuna base condivisa ancora" + }, + "trash": { + "spaceDescription": "Tutti gli spazi eliminati in questa istanza", + "baseDescription": "Tutte le basi eliminate in questo spazio" } } 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/ja/space.json b/packages/common-i18n/src/locales/ja/space.json index 026fb9e6af..816564b637 100644 --- a/packages/common-i18n/src/locales/ja/space.json +++ b/packages/common-i18n/src/locales/ja/space.json @@ -61,9 +61,13 @@ }, "sharedBase": { "title": "共有ベース", - "description": "共有されたベースはここに表示されます", + "description": "参加を招待されたすべてのベース", "empty": "共有ベースはまだありません" }, + "trash": { + "spaceDescription": "このインスタンス内のすべての削除されたスペース", + "baseDescription": "このスペース内のすべての削除されたベース" + }, "integration": { "title": "インテグレーション", "description": "空間のインテグレーションを管理", 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/ru/space.json b/packages/common-i18n/src/locales/ru/space.json index dfd4b65ad5..82c17c3512 100644 --- a/packages/common-i18n/src/locales/ru/space.json +++ b/packages/common-i18n/src/locales/ru/space.json @@ -61,9 +61,13 @@ }, "sharedBase": { "title": "Общие базы", - "description": "Базы, которыми с вами поделились, будут отображаться здесь", + "description": "Все базы, в которые меня пригласили", "empty": "Общих баз пока нет" }, + "trash": { + "spaceDescription": "Все удалённые пространства в этом экземпляре", + "baseDescription": "Все удалённые базы в этом пространстве" + }, "integration": { "title": "Интеграции", "description": "Управление интеграциями вашего пространства", 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/tr/space.json b/packages/common-i18n/src/locales/tr/space.json index 7260743d5d..6fbb5e18ee 100644 --- a/packages/common-i18n/src/locales/tr/space.json +++ b/packages/common-i18n/src/locales/tr/space.json @@ -61,9 +61,13 @@ }, "sharedBase": { "title": "Paylaşılan veritabanları", - "description": "Sizinle paylaşılan veritabanları burada görünecek", + "description": "Katılmaya davet edildiğim tüm veritabanları", "empty": "Henüz paylaşılan veritabanı yok" }, + "trash": { + "spaceDescription": "Bu örnekteki tüm silinen alanlar", + "baseDescription": "Bu alandaki tüm silinen veritabanları" + }, "integration": { "title": "Integrasyonlar", "description": "Yerleştirilen integreasyonları burada görünecek", 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": "Видалено", diff --git a/packages/common-i18n/src/locales/uk/space.json b/packages/common-i18n/src/locales/uk/space.json index deb1e4782d..bee2188dac 100644 --- a/packages/common-i18n/src/locales/uk/space.json +++ b/packages/common-i18n/src/locales/uk/space.json @@ -39,7 +39,7 @@ "add": "Додати до закріплення", "remove": "Видалити з закріплених", "pin": "Закріпити", - "empty": "Тут з’являться ваші закріплені бази та простори" + "empty": "Тут з'являться ваші закріплені бази та простори" }, "tip": { "delete": "Ви впевнені, що хочете видалити <0/>?", @@ -61,9 +61,13 @@ }, "sharedBase": { "title": "Спільні бази", - "description": "Тут з’являться бази, якими ви поділилися", + "description": "Всі бази, до яких мене запросили приєднатися", "empty": "Ще немає спільних баз" }, + "trash": { + "spaceDescription": "Всі видалені простори в цьому екземплярі", + "baseDescription": "Всі видалені бази в цьому просторі" + }, "integration": { "title": "Інтеграції", "description": "Керування інтеграціями вашого простору", 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..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": "管理空间集成", @@ -101,5 +105,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..08f120c61b 100644 --- a/packages/openapi/src/base/get.ts +++ b/packages/openapi/src/base/get.ts @@ -16,6 +16,16 @@ export const getBaseItemSchema = z.object({ collaboratorType: z.enum(CollaboratorType).optional(), restrictedAuthority: z.boolean().optional(), enabledAuthority: z.boolean().optional(), + lastModifiedTime: z.string().nullable().optional(), + createdTime: 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/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, 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..7ef381a884 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} @@ -145,7 +145,7 @@ function TreeItemLabel({ data-drag-target={isDragTarget ? 'true' : undefined} data-search-match={isSearchMatch ? 'true' : undefined} className={cn( - 'flex items-center gap-1 rounded-md border border-transparent bg-background px-2 py-1 text-sm transition-colors hover:bg-accent group-focus-visible:ring-[3px] group-focus-visible:ring-ring/50 [&_svg]:pointer-events-none [&_svg]:shrink-0', + 'flex items-center gap-1 rounded-md border border-transparent px-2 py-1 text-sm transition-colors hover:bg-accent group-focus-visible:ring-[3px] group-focus-visible:ring-ring/50 [&_svg]:pointer-events-none [&_svg]:shrink-0', !isFolder && 'ps-7', isDragTarget && 'border-dashed border-foreground bg-foreground/[0.06]', isSearchMatch && 'bg-blue-400/20',