diff --git a/README.md b/README.md index 3e1166921..1fbc962cb 100644 --- a/README.md +++ b/README.md @@ -78,8 +78,8 @@ Visualize and interact with data in various ways best suited for their specific - Grid View: The default view of the table, which displays data in a spreadsheet-like format. - Form View: Input data in a form format, which is useful for collecting data. - Kanban View: Displays data in a Kanban board, which is a visual representation of data in columns and cards. +- Gallery View: Displays data in a gallery format, which is useful for displaying images and other media. - Calendar View: Displays data in a calendar format, which is useful for tracking dates and events. (coming soon) -- Gallery View: Displays data in a gallery format, which is useful for displaying images and other media. (coming soon) - Gantt View: Displays data in a Gantt chart, which is useful for tracking project schedules. (coming soon) - Timeline View: Displays data in a timeline format, which is useful for tracking events over time. (coming soon) diff --git a/apps/nestjs-backend/src/features/aggregation/aggregation.service.ts b/apps/nestjs-backend/src/features/aggregation/aggregation.service.ts index a68c57987..361c45024 100644 --- a/apps/nestjs-backend/src/features/aggregation/aggregation.service.ts +++ b/apps/nestjs-backend/src/features/aggregation/aggregation.service.ts @@ -284,7 +284,7 @@ export class AggregationService { where: { tableId, ...(withView?.viewId ? { id: withView.viewId } : {}), - type: { in: [ViewType.Grid, ViewType.Gantt, ViewType.Kanban] }, + type: { in: [ViewType.Grid, ViewType.Gantt, ViewType.Kanban, ViewType.Gallery] }, deletedTime: null, }, }) diff --git a/apps/nestjs-backend/src/features/share/share.service.ts b/apps/nestjs-backend/src/features/share/share.service.ts index c4ce71de1..6e5006f7d 100644 --- a/apps/nestjs-backend/src/features/share/share.service.ts +++ b/apps/nestjs-backend/src/features/share/share.service.ts @@ -351,7 +351,7 @@ export class ShareService { return this.getViewAllCollaborators(shareInfo); } - // only form and kanban view can get all records + // only form, kanban and plugin view can get all collaborators if ([ViewType.Form, ViewType.Kanban, ViewType.Plugin].includes(view.type)) { return this.getViewAllCollaborators(shareInfo); } diff --git a/apps/nestjs-backend/src/features/view/model/factory.ts b/apps/nestjs-backend/src/features/view/model/factory.ts index 422600887..4d399b90b 100644 --- a/apps/nestjs-backend/src/features/view/model/factory.ts +++ b/apps/nestjs-backend/src/features/view/model/factory.ts @@ -3,6 +3,7 @@ import { assertNever, ViewType } from '@teable/core'; import type { View } from '@teable/db-main-prisma'; import { plainToInstance } from 'class-transformer'; import { FormViewDto } from './form-view.dto'; +import { GalleryViewDto } from './gallery-view.dto'; import { GridViewDto } from './grid-view.dto'; import { KanbanViewDto } from './kanban-view.dto'; import { PluginViewDto } from './plugin-view.dto'; @@ -15,11 +16,12 @@ export function createViewInstanceByRaw(viewRaw: View) { return plainToInstance(GridViewDto, viewVo); case ViewType.Kanban: return plainToInstance(KanbanViewDto, viewVo); + case ViewType.Gallery: + return plainToInstance(GalleryViewDto, viewVo); case ViewType.Form: return plainToInstance(FormViewDto, viewVo); case ViewType.Plugin: return plainToInstance(PluginViewDto, viewVo); - case ViewType.Gallery: case ViewType.Gantt: case ViewType.Calendar: throw new Error('did not implement yet'); diff --git a/apps/nestjs-backend/src/features/view/model/gallery-view.dto.ts b/apps/nestjs-backend/src/features/view/model/gallery-view.dto.ts new file mode 100644 index 000000000..11cd56ea4 --- /dev/null +++ b/apps/nestjs-backend/src/features/view/model/gallery-view.dto.ts @@ -0,0 +1,8 @@ +import type { IShareViewMeta } from '@teable/core'; +import { GalleryViewCore } from '@teable/core'; + +export class GalleryViewDto extends GalleryViewCore { + defaultShareMeta: IShareViewMeta = { + includeRecords: true, + }; +} diff --git a/apps/nestjs-backend/src/features/view/view.service.ts b/apps/nestjs-backend/src/features/view/view.service.ts index fa720cf78..c2851f399 100644 --- a/apps/nestjs-backend/src/features/view/view.service.ts +++ b/apps/nestjs-backend/src/features/view/view.service.ts @@ -139,7 +139,7 @@ export class ViewService implements IReadonlyAdapterService { // create view compensation data const innerViewRo = { ...viewRo }; // primary field set visible default - if (viewRo.type === ViewType.Kanban) { + if ([ViewType.Kanban, ViewType.Gallery].includes(viewRo.type)) { const primaryField = await this.prismaService.txClient().field.findFirstOrThrow({ where: { tableId, isPrimary: true, deletedTime: null }, select: { id: true }, diff --git a/apps/nestjs-backend/src/utils/is-not-hidden-field.ts b/apps/nestjs-backend/src/utils/is-not-hidden-field.ts index d80831c43..1af08b66f 100644 --- a/apps/nestjs-backend/src/utils/is-not-hidden-field.ts +++ b/apps/nestjs-backend/src/utils/is-not-hidden-field.ts @@ -1,4 +1,4 @@ -import type { IKanbanViewOptions, IViewVo } from '@teable/core'; +import type { IGalleryViewOptions, IKanbanViewOptions, IViewVo } from '@teable/core'; import { ViewType } from '@teable/core'; export const isNotHiddenField = ( @@ -16,6 +16,13 @@ export const isNotHiddenField = ( ); } + if (viewType === ViewType.Gallery) { + const { coverFieldId } = (options ?? {}) as IGalleryViewOptions; + return ( + fieldId === coverFieldId || Boolean((columnMeta[fieldId] as { visible?: boolean })?.visible) + ); + } + if ([ViewType.Form].includes(viewType)) { return Boolean((columnMeta[fieldId] as { visible?: boolean })?.visible); } diff --git a/apps/nestjs-backend/test/data-helpers/caces/view-default-share-meta.ts b/apps/nestjs-backend/test/data-helpers/caces/view-default-share-meta.ts index 065eb68b4..6fd4a2ce3 100644 --- a/apps/nestjs-backend/test/data-helpers/caces/view-default-share-meta.ts +++ b/apps/nestjs-backend/test/data-helpers/caces/view-default-share-meta.ts @@ -19,6 +19,12 @@ export const VIEW_DEFAULT_SHARE_META: { includeRecords: true, }, }, + { + viewType: ViewType.Gallery, + defaultShareMeta: { + includeRecords: true, + }, + }, { viewType: ViewType.Grid, defaultShareMeta: { diff --git a/apps/nextjs-app/package.json b/apps/nextjs-app/package.json index f03099670..6342c90cc 100644 --- a/apps/nextjs-app/package.json +++ b/apps/nextjs-app/package.json @@ -115,6 +115,7 @@ "@tailwindcss/container-queries": "0.1.1", "@tanstack/react-query": "4.36.1", "@tanstack/react-table": "8.11.7", + "@tanstack/react-virtual": "3.2.0", "@teable/common-i18n": "workspace:^", "@teable/core": "workspace:^", "@teable/icons": "workspace:^", diff --git a/apps/nextjs-app/src/features/app/blocks/share/view/ShareView.tsx b/apps/nextjs-app/src/features/app/blocks/share/view/ShareView.tsx index a2d830fa4..f7c8b8bd6 100644 --- a/apps/nextjs-app/src/features/app/blocks/share/view/ShareView.tsx +++ b/apps/nextjs-app/src/features/app/blocks/share/view/ShareView.tsx @@ -2,6 +2,7 @@ import { ViewType } from '@teable/core'; import { ShareViewContext } from '@teable/sdk/context'; import { useContext } from 'react'; import { FormView } from './component/form/FormView'; +import { GalleryView } from './component/gallery/GalleryView'; import { GridView } from './component/grid/GridView'; import { KanbanView } from './component/kanban/KanbanView'; import { PluginView } from './component/plugin/SharePluginView'; @@ -18,6 +19,8 @@ export const ShareView = () => { return ; case ViewType.Kanban: return ; + case ViewType.Gallery: + return ; case ViewType.Plugin: return ; default: diff --git a/apps/nextjs-app/src/features/app/blocks/share/view/component/gallery/GalleryView.tsx b/apps/nextjs-app/src/features/app/blocks/share/view/component/gallery/GalleryView.tsx new file mode 100644 index 000000000..7a989adb9 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/share/view/component/gallery/GalleryView.tsx @@ -0,0 +1,47 @@ +/* eslint-disable @next/next/no-html-link-for-pages */ +import { TeableNew } from '@teable/icons'; +import { RecordProvider, RowCountProvider, ShareViewContext } from '@teable/sdk/context'; +import { SearchProvider } from '@teable/sdk/context/query'; +import { useIsHydrated } from '@teable/sdk/hooks'; +import { cn } from '@teable/ui-lib/shadcn'; +import { useRouter } from 'next/router'; +import { useContext } from 'react'; +import { GalleryProvider } from '@/features/app/blocks/view/gallery/context'; +import { GalleryViewBase } from '@/features/app/blocks/view/gallery/GalleryViewBase'; +import { GalleryToolbar } from './toolbar'; + +export const GalleryView = () => { + const { view } = useContext(ShareViewContext); + const isHydrated = useIsHydrated(); + const { + query: { hideToolBar, embed }, + } = useRouter(); + + return ( +
+ {!embed && ( +
+

{view?.name}

+ + +

Teable

+
+
+ )} +
+ + + + {!hideToolBar && } + +
+ {isHydrated && } +
+
+
+
+
+
+
+ ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/share/view/component/gallery/toolbar/Toolbar.tsx b/apps/nextjs-app/src/features/app/blocks/share/view/component/gallery/toolbar/Toolbar.tsx new file mode 100644 index 000000000..0f077ef53 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/share/view/component/gallery/toolbar/Toolbar.tsx @@ -0,0 +1,58 @@ +import { ArrowUpDown, Filter as FilterIcon } from '@teable/icons'; +import type { GalleryView } from '@teable/sdk'; +import { useView } from '@teable/sdk/hooks/use-view'; +import { cn } from '@teable/ui-lib/shadcn'; +import { useToolbarChange } from '@/features/app/blocks/view/hooks/useToolbarChange'; +import { SearchButton } from '@/features/app/blocks/view/search/SearchButton'; +import { ToolBarButton } from '@/features/app/blocks/view/tool-bar/ToolBarButton'; +import { Sort } from '../../grid/toolbar/Sort'; +import { ShareViewFilter } from '../../share-view-filter'; + +export const GalleryToolbar: React.FC<{ disabled?: boolean }> = (props) => { + const { disabled } = props; + const view = useView() as GalleryView | undefined; + const { onFilterChange, onSortChange } = useToolbarChange(); + + if (!view) return null; + + return ( +
+ + {(text, isActive) => ( + + + + )} + + + {(text: string, isActive) => ( + + + + )} + +
+ +
+
+ ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/share/view/component/gallery/toolbar/index.ts b/apps/nextjs-app/src/features/app/blocks/share/view/component/gallery/toolbar/index.ts new file mode 100644 index 000000000..7c6430332 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/share/view/component/gallery/toolbar/index.ts @@ -0,0 +1 @@ +export * from './Toolbar'; diff --git a/apps/nextjs-app/src/features/app/blocks/table/table-header/AddView.tsx b/apps/nextjs-app/src/features/app/blocks/table/table-header/AddView.tsx index 5e511287f..e4c1b6130 100644 --- a/apps/nextjs-app/src/features/app/blocks/table/table-header/AddView.tsx +++ b/apps/nextjs-app/src/features/app/blocks/table/table-header/AddView.tsx @@ -34,6 +34,11 @@ export const AddView: React.FC = () => { type: ViewType.Grid, Icon: VIEW_ICON_MAP[ViewType.Grid], }, + { + name: t('view.category.gallery'), + type: ViewType.Gallery, + Icon: VIEW_ICON_MAP[ViewType.Gallery], + }, { name: t('view.category.kanban'), type: ViewType.Kanban, diff --git a/apps/nextjs-app/src/features/app/blocks/view/View.tsx b/apps/nextjs-app/src/features/app/blocks/view/View.tsx index f906f0434..30f3a1202 100644 --- a/apps/nextjs-app/src/features/app/blocks/view/View.tsx +++ b/apps/nextjs-app/src/features/app/blocks/view/View.tsx @@ -1,6 +1,7 @@ import { ViewType } from '@teable/core'; import { useView } from '@teable/sdk'; import { FormView } from './form/FormView'; +import { GalleryView } from './gallery/GalleryView'; import { GridView } from './grid/GridView'; import { KanbanView } from './kanban/KanbanView'; import { PluginView } from './plugin/PluginView'; @@ -18,6 +19,8 @@ export const View = (props: IViewBaseProps) => { return ; case ViewType.Kanban: return ; + case ViewType.Gallery: + return ; case ViewType.Plugin: return ; default: diff --git a/apps/nextjs-app/src/features/app/blocks/view/constant.ts b/apps/nextjs-app/src/features/app/blocks/view/constant.ts index ef0a01d91..b5e1b8e9e 100644 --- a/apps/nextjs-app/src/features/app/blocks/view/constant.ts +++ b/apps/nextjs-app/src/features/app/blocks/view/constant.ts @@ -1,11 +1,17 @@ import { ViewType } from '@teable/core'; -import { Sheet, ClipboardList as Form, Kanban, Component } from '@teable/icons'; +import { + Sheet, + ClipboardList as Form, + LayoutGrid as Gallery, + Kanban, + Component, +} from '@teable/icons'; export const VIEW_ICON_MAP = { [ViewType.Grid]: Sheet, [ViewType.Gantt]: Sheet, [ViewType.Kanban]: Kanban, - [ViewType.Gallery]: Sheet, + [ViewType.Gallery]: Gallery, [ViewType.Calendar]: Sheet, [ViewType.Form]: Form, [ViewType.Plugin]: Component, diff --git a/apps/nextjs-app/src/features/app/blocks/view/gallery/GalleryView.tsx b/apps/nextjs-app/src/features/app/blocks/view/gallery/GalleryView.tsx new file mode 100644 index 000000000..257c697e7 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/view/gallery/GalleryView.tsx @@ -0,0 +1,23 @@ +import { RecordProvider, RowCountProvider } from '@teable/sdk/context'; +import { SearchProvider } from '@teable/sdk/context/query'; +import { useIsHydrated } from '@teable/sdk/hooks'; +import { GalleryToolBar } from '../tool-bar/GalleryToolBar'; +import { GalleryProvider } from './context'; +import { GalleryViewBase } from './GalleryViewBase'; + +export const GalleryView = () => { + const isHydrated = useIsHydrated(); + + return ( + + + + + +
{isHydrated && }
+
+
+
+
+ ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/view/gallery/GalleryViewBase.tsx b/apps/nextjs-app/src/features/app/blocks/view/gallery/GalleryViewBase.tsx new file mode 100644 index 000000000..1a8e8fcee --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/view/gallery/GalleryViewBase.tsx @@ -0,0 +1,187 @@ +import type { DragStartEvent, DragEndEvent } from '@dnd-kit/core'; +import { + DndContext, + PointerSensor, + useSensor, + useSensors, + DragOverlay, + closestCenter, +} from '@dnd-kit/core'; +import { SortableContext, arrayMove, rectSortingStrategy } from '@dnd-kit/sortable'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { FieldKeyType } from '@teable/core'; +import { useRecords, useRowCount, useTableId, useViewId } from '@teable/sdk/hooks'; +import { Record } from '@teable/sdk/model'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Card } from './components/Card'; +import { SortableItem } from './components/SortableItem'; +import { useGallery } from './hooks'; +import { calculateColumns, getCardHeight } from './utils'; + +const DEFAULT_TAKE = 200; + +export const GalleryViewBase = () => { + const { recordQuery, displayFields, coverField, isFieldNameHidden } = useGallery(); + const tableId = useTableId() as string; + const viewId = useViewId() as string; + const rowCount = useRowCount() ?? 0; + + const [skip, setSkip] = useState(0); + const [activeId, setActiveId] = useState(null); + const parentRef = useRef(null); + const skipIndexRef = useRef(skip); + const [columnsPerRow, setColumnsPerRow] = useState(4); + + const query = useMemo(() => { + return { + ...recordQuery, + skip, + take: DEFAULT_TAKE, + }; + }, [recordQuery, skip]); + + const { records } = useRecords(query); + + const [cards, setCards] = useState(records); + + const virtualizer = useVirtualizer({ + count: Math.ceil(rowCount / columnsPerRow), + getScrollElement: () => parentRef.current, + estimateSize: () => getCardHeight(displayFields, Boolean(coverField), isFieldNameHidden), + overscan: 5, + }); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }) + ); + + const updateGridColumns = useCallback(() => { + if (!parentRef.current) return; + const containerWidth = parentRef.current.offsetWidth; + setColumnsPerRow(calculateColumns(containerWidth)); + }, []); + + useEffect(() => { + virtualizer.measure(); + }, [displayFields, coverField, isFieldNameHidden, virtualizer]); + + useEffect(() => { + const container = parentRef.current; + if (!container) return; + + const resizeObserver = new ResizeObserver(() => { + updateGridColumns(); + }); + + resizeObserver.observe(container); + updateGridColumns(); + + return () => { + resizeObserver.disconnect(); + }; + }, [updateGridColumns]); + + useEffect(() => { + if (!records.length) return; + + setCards((prev) => { + const merged = [...prev]; + records.forEach((record, index) => { + merged[skipIndexRef.current + index] = record; + }); + return merged; + }); + }, [records]); + + useEffect(() => { + if (!virtualizer.range) return; + const { endIndex } = virtualizer.range; + const actualIndex = endIndex * columnsPerRow; + const newSkip = Math.floor(actualIndex / DEFAULT_TAKE) * DEFAULT_TAKE; + + if (newSkip >= rowCount) return; + + skipIndexRef.current = newSkip; + setSkip(newSkip); + }, [columnsPerRow, rowCount, virtualizer.range]); + + const handleDragStart = (event: DragStartEvent) => { + setActiveId(event.active.id as string); + }; + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + + if (active.id !== over?.id) { + const oldIndex = cards.findIndex((item) => item.id === active.id); + const newIndex = cards.findIndex((item) => item.id === over?.id); + const newCards = arrayMove(cards, oldIndex, newIndex); + + setCards(newCards); + + if (oldIndex != null && newIndex != null && oldIndex !== newIndex) { + Record.updateRecord(tableId, active.id as string, { + fieldKeyType: FieldKeyType.Id, + record: { fields: {} }, + order: { + viewId, + anchorId: over?.id as string, + position: oldIndex > newIndex ? 'before' : 'after', + }, + }); + } + } + }; + + return ( + +
+ +
+ {virtualizer.getVirtualItems().map((virtualRow) => ( +
+ {Array.from({ length: columnsPerRow }).map((_, i) => { + const card = cards[virtualRow.index * columnsPerRow + i]; + return card ? ( + + + + ) : ( +
+ ); + })} +
+ ))} +
+ +
+ + {activeId ? c?.id === activeId)!} /> : null} + + + ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/view/gallery/components/Card.tsx b/apps/nextjs-app/src/features/app/blocks/view/gallery/components/Card.tsx new file mode 100644 index 000000000..4ca309694 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/view/gallery/components/Card.tsx @@ -0,0 +1,199 @@ +/* eslint-disable jsx-a11y/no-static-element-interactions,jsx-a11y/click-events-have-key-events */ +import type { IAttachmentCellValue } from '@teable/core'; +import { FieldKeyType } from '@teable/core'; +import { ArrowDown, ArrowUp, Image, Maximize2, Trash } from '@teable/icons'; +import type { IRecordInsertOrderRo } from '@teable/openapi'; +import { createRecords, deleteRecord } from '@teable/openapi'; +import { CellValue, getFileCover } from '@teable/sdk/components'; +import { useFieldStaticGetter, useTableId, useViewId } from '@teable/sdk/hooks'; +import type { Record } from '@teable/sdk/model'; +import { + Carousel, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from '@teable/ui-lib/shadcn'; +import { Fragment, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { tableConfig } from '@/features/i18n/table.config'; +import { useGallery } from '../hooks'; +import { CARD_COVER_HEIGHT, CARD_STYLE } from '../utils'; + +interface IKanbanCardProps { + card: Record; +} + +export const Card = (props: IKanbanCardProps) => { + const { card } = props; + const tableId = useTableId(); + const viewId = useViewId(); + const getFieldStatic = useFieldStaticGetter(); + const { t } = useTranslation(tableConfig.i18nNamespaces); + const { + coverField, + primaryField, + displayFields, + permission, + isCoverFit, + isFieldNameHidden, + setExpandRecordId, + } = useGallery(); + + const { cardCreatable, cardDeletable } = permission; + const coverFieldId = coverField?.id; + const coverCellValue = card.getCellValue(coverFieldId as string) as + | IAttachmentCellValue + | undefined; + + const titleComponent = useMemo(() => { + if (primaryField == null) return t('untitled'); + const value = card.getCellValue(primaryField.id); + if (value == null) return t('untitled'); + return ; + }, [card, primaryField, t]); + + const onExpand = () => { + setExpandRecordId(card.id); + }; + + const onDelete = () => { + if (tableId == null) return; + deleteRecord(tableId, card.id); + }; + + const onInsert = async (position: IRecordInsertOrderRo['position']) => { + if (tableId == null || viewId == null) return; + const res = await createRecords(tableId, { + fieldKeyType: FieldKeyType.Id, + records: [{ fields: {} }], + order: { + viewId, + anchorId: card.id, + position, + }, + }); + const record = res.data.records[0]; + + if (record != null) { + setExpandRecordId(record.id); + } + }; + + return ( + + +
+ {coverFieldId && ( + + {coverCellValue?.length ? ( + + + {coverCellValue.map(({ id, mimetype, presignedUrl, lgThumbnailUrl }) => { + const url = lgThumbnailUrl ?? getFileCover(mimetype, presignedUrl); + return ( + + card cover + + ); + })} + + e.stopPropagation()} /> + e.stopPropagation()} /> + + ) : ( +
+ +
+ )} +
+ )} +
+
+ {titleComponent} +
+ {displayFields.map((field) => { + const { id: fieldId, name, type, isLookup } = field; + const { Icon } = getFieldStatic(type, isLookup); + const cellValue = card.getCellValue(fieldId); + + if (cellValue == null) return null; + + return ( +
+ {!isFieldNameHidden && ( +
+ + {name} +
+ )} + +
+ ); + })} +
+
+
+ + {cardCreatable && ( + <> + onInsert('before')}> + + {t('table:kanban.cardMenu.insertCardAbove')} + + onInsert('after')}> + + {t('table:kanban.cardMenu.insertCardBelow')} + + + + )} + + + {t('table:kanban.cardMenu.expandCard')} + + {cardDeletable && ( + <> + + + + {t('table:kanban.cardMenu.deleteCard')} + + + )} + +
+ ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/view/gallery/components/SortableItem.tsx b/apps/nextjs-app/src/features/app/blocks/view/gallery/components/SortableItem.tsx new file mode 100644 index 000000000..891fd5e2f --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/view/gallery/components/SortableItem.tsx @@ -0,0 +1,33 @@ +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; + +interface SortableProps { + id: string; + children: React.ReactNode; +} + +export const SortableItem = (props: SortableProps) => { + const { id, children } = props; + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + flex: 1, + }; + + return ( +
+ {children} +
+ ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/view/gallery/context/GalleryContext.ts b/apps/nextjs-app/src/features/app/blocks/view/gallery/context/GalleryContext.ts new file mode 100644 index 000000000..0a907221b --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/view/gallery/context/GalleryContext.ts @@ -0,0 +1,18 @@ +import type { IGetRecordsRo } from '@teable/openapi'; +import type { AttachmentField, IFieldInstance } from '@teable/sdk/model'; +import type { Dispatch, SetStateAction } from 'react'; +import { createContext } from 'react'; +import type { IGalleryPermission } from '../type'; + +export interface IGalleryContext { + recordQuery?: Pick; + coverField?: AttachmentField; + isCoverFit?: boolean; + isFieldNameHidden?: boolean; + permission: IGalleryPermission; + primaryField: IFieldInstance; + displayFields: IFieldInstance[]; + setExpandRecordId: Dispatch>; +} + +export const GalleryContext = createContext(null!); diff --git a/apps/nextjs-app/src/features/app/blocks/view/gallery/context/GalleryProvider.tsx b/apps/nextjs-app/src/features/app/blocks/view/gallery/context/GalleryProvider.tsx new file mode 100644 index 000000000..afecc1be5 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/view/gallery/context/GalleryProvider.tsx @@ -0,0 +1,96 @@ +import { FieldType } from '@teable/core'; +import { ExpandRecorder } from '@teable/sdk/components'; +import { ShareViewContext } from '@teable/sdk/context'; +import { useTableId, useView, useFields, useTablePermission } from '@teable/sdk/hooks'; +import type { AttachmentField, GalleryView, IFieldInstance } from '@teable/sdk/model'; +import { useContext, useMemo, useState, type ReactNode } from 'react'; +import { GalleryContext } from './GalleryContext'; + +export const GalleryProvider = ({ children }: { children: ReactNode }) => { + const tableId = useTableId(); + const view = useView() as GalleryView | undefined; + const { shareId } = useContext(ShareViewContext) ?? {}; + const { sort, filter } = view ?? {}; + const permission = useTablePermission(); + const fields = useFields(); + const allFields = useFields({ withHidden: true, withDenied: true }); + const { coverFieldId, isCoverFit, isFieldNameHidden } = view?.options ?? {}; + const [expandRecordId, setExpandRecordId] = useState(); + + const recordQuery = useMemo(() => { + if (!shareId || (!sort && !filter)) return; + + return { + orderBy: sort?.sortObjs, + filter: filter, + }; + }, [shareId, sort, filter]); + + const galleryPermission = useMemo(() => { + return { + cardCreatable: Boolean(permission['record|create']), + cardEditable: Boolean(permission['record|update']), + cardDeletable: Boolean(permission['record|delete']), + cardDraggable: Boolean(permission['record|update'] && permission['view|update']), + }; + }, [permission]); + + const coverField = useMemo(() => { + if (!coverFieldId) return; + return allFields.find( + ({ id, type }) => id === coverFieldId && type === FieldType.Attachment + ) as AttachmentField | undefined; + }, [coverFieldId, allFields]); + + const { primaryField, displayFields } = useMemo(() => { + let primaryField: IFieldInstance | null = null; + const displayFields = fields.filter((f) => { + if (f.isPrimary) { + primaryField = f; + return false; + } + return true; + }); + + return { + primaryField: primaryField as unknown as IFieldInstance, + displayFields, + }; + }, [fields]); + + const value = useMemo(() => { + return { + recordQuery, + isCoverFit, + isFieldNameHidden, + permission: galleryPermission, + coverField, + primaryField, + displayFields, + setExpandRecordId, + }; + }, [ + recordQuery, + isCoverFit, + isFieldNameHidden, + galleryPermission, + coverField, + primaryField, + displayFields, + setExpandRecordId, + ]); + + return ( + + {primaryField && children} + {tableId && ( + setExpandRecordId(undefined)} + /> + )} + + ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/view/gallery/context/index.ts b/apps/nextjs-app/src/features/app/blocks/view/gallery/context/index.ts new file mode 100644 index 000000000..803ceb3b5 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/view/gallery/context/index.ts @@ -0,0 +1,2 @@ +export * from './GalleryContext'; +export * from './GalleryProvider'; diff --git a/apps/nextjs-app/src/features/app/blocks/view/gallery/hooks/index.ts b/apps/nextjs-app/src/features/app/blocks/view/gallery/hooks/index.ts new file mode 100644 index 000000000..ef0e3a404 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/view/gallery/hooks/index.ts @@ -0,0 +1 @@ +export * from './useGallery'; diff --git a/apps/nextjs-app/src/features/app/blocks/view/gallery/hooks/useGallery.ts b/apps/nextjs-app/src/features/app/blocks/view/gallery/hooks/useGallery.ts new file mode 100644 index 000000000..c0650b554 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/view/gallery/hooks/useGallery.ts @@ -0,0 +1,6 @@ +import { useContext } from 'react'; +import { GalleryContext } from '../context'; + +export const useGallery = () => { + return useContext(GalleryContext); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/view/gallery/type.ts b/apps/nextjs-app/src/features/app/blocks/view/gallery/type.ts new file mode 100644 index 000000000..f2232f570 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/view/gallery/type.ts @@ -0,0 +1,6 @@ +export interface IGalleryPermission { + cardCreatable: boolean; + cardEditable: boolean; + cardDeletable: boolean; + cardDraggable: boolean; +} diff --git a/apps/nextjs-app/src/features/app/blocks/view/gallery/utils/card.ts b/apps/nextjs-app/src/features/app/blocks/view/gallery/utils/card.ts new file mode 100644 index 000000000..88f757aa1 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/view/gallery/utils/card.ts @@ -0,0 +1,50 @@ +import { FieldType } from '@teable/core'; +import type { IFieldInstance } from '@teable/sdk/model'; + +export const CARD_STYLE = { + titleHeight: 32, + cardPaddingBottom: 16, + contentPadding: 8, + itemGap: 8, + itemInnerGap: 4, + itemTitleHeight: 16, +}; + +export const DEFAULT_FIELD_HEIGHT = 20; + +export const CARD_COVER_HEIGHT = 180; + +export const LONG_TEXT_FIELD_DISPLAY_ROWS = 4; + +export const FIELD_HEIGHT_MAP: { [key in FieldType]?: number } = { + [FieldType.Attachment]: 28, + [FieldType.SingleSelect]: 20, + [FieldType.MultipleSelect]: 20, + [FieldType.Link]: 20, + [FieldType.User]: 24, + [FieldType.CreatedBy]: 24, + [FieldType.LastModifiedBy]: 24, + [FieldType.Rating]: 16, +}; + +const { titleHeight, contentPadding, cardPaddingBottom, itemGap, itemInnerGap, itemTitleHeight } = + CARD_STYLE; + +export const getCardHeight = ( + fields: IFieldInstance[], + hasCover?: boolean, + isFieldNameHidden?: boolean +) => { + const fieldCount = fields.length; + const staticFieldNameSpace = isFieldNameHidden ? 0 : itemInnerGap + itemTitleHeight; + let staticHeight = + titleHeight + + contentPadding * 2 + + (itemGap + staticFieldNameSpace) * fieldCount + + cardPaddingBottom; + staticHeight = hasCover ? staticHeight + CARD_COVER_HEIGHT : staticHeight; + const dynamicHeight = fields.reduce((prev, { type }) => { + return prev + (FIELD_HEIGHT_MAP[type] || DEFAULT_FIELD_HEIGHT); + }, 0); + return staticHeight + dynamicHeight; +}; diff --git a/apps/nextjs-app/src/features/app/blocks/view/gallery/utils/columns.ts b/apps/nextjs-app/src/features/app/blocks/view/gallery/utils/columns.ts new file mode 100644 index 000000000..32259241c --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/view/gallery/utils/columns.ts @@ -0,0 +1,14 @@ +export const BREAKPOINTS = { + sm: { width: 640, columns: 2 }, + md: { width: 768, columns: 3 }, + lg: { width: 1024, columns: 4 }, + xl: { width: 1280, columns: 5 }, + '2xl': { width: 1536, columns: 6 }, +} as const; + +export const calculateColumns = (width: number) => { + const breakpoint = Object.values(BREAKPOINTS) + .reverse() + .find((bp) => width >= bp.width); + return breakpoint?.columns ?? 1; +}; diff --git a/apps/nextjs-app/src/features/app/blocks/view/gallery/utils/index.ts b/apps/nextjs-app/src/features/app/blocks/view/gallery/utils/index.ts new file mode 100644 index 000000000..61e89e45e --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/view/gallery/utils/index.ts @@ -0,0 +1,2 @@ +export * from './card'; +export * from './columns'; diff --git a/apps/nextjs-app/src/features/app/blocks/view/kanban/components/KanbanCard.tsx b/apps/nextjs-app/src/features/app/blocks/view/kanban/components/KanbanCard.tsx index 6e43b4706..2a44a474d 100644 --- a/apps/nextjs-app/src/features/app/blocks/view/kanban/components/KanbanCard.tsx +++ b/apps/nextjs-app/src/features/app/blocks/view/kanban/components/KanbanCard.tsx @@ -63,7 +63,7 @@ export const KanbanCard = (props: IKanbanCardProps) => { if (primaryField == null) return t('untitled'); const value = card.getCellValue(primaryField.id); if (value == null) return t('untitled'); - return ; + return ; }, [card, primaryField, t]); const onExpand = () => { @@ -160,7 +160,7 @@ export const KanbanCard = (props: IKanbanCardProps) => { {name}
)} - + ); })} diff --git a/apps/nextjs-app/src/features/app/blocks/view/kanban/components/KanbanStack.tsx b/apps/nextjs-app/src/features/app/blocks/view/kanban/components/KanbanStack.tsx index 06cc60fe9..4a23641a2 100644 --- a/apps/nextjs-app/src/features/app/blocks/view/kanban/components/KanbanStack.tsx +++ b/apps/nextjs-app/src/features/app/blocks/view/kanban/components/KanbanStack.tsx @@ -4,7 +4,7 @@ import type { IFilter } from '@teable/core'; import { and, mergeFilter } from '@teable/core'; import { useRecords } from '@teable/sdk/hooks'; import type { Record } from '@teable/sdk/model'; -import { forwardRef, useEffect, useMemo, useRef, useState } from 'react'; +import { forwardRef, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useMeasure } from 'react-use'; import type { ListRange, VirtuosoHandle } from 'react-virtuoso'; @@ -56,7 +56,6 @@ export const KanbanStack = forwardRef((props, const { t } = useTranslation(tableConfig.i18nNamespaces); const { stackField, permission, recordQuery } = useKanban() as Required; const [skipIndex, setSkipIndex] = useState(0); - const skipIndexRef = useRef(skipIndex); const [ref, { height }] = useMeasure(); const cardCount = cards.length; @@ -98,7 +97,6 @@ export const KanbanStack = forwardRef((props, const willSkipIndex = Math.max(0, Math.floor(startIndex / LOAD_COUNT) * LOAD_COUNT); if (willSkipIndex !== skipIndex) { setSkipIndex(willSkipIndex); - skipIndexRef.current = willSkipIndex; } }; diff --git a/apps/nextjs-app/src/features/app/blocks/view/tool-bar/GalleryToolBar.tsx b/apps/nextjs-app/src/features/app/blocks/view/tool-bar/GalleryToolBar.tsx new file mode 100644 index 000000000..0cb751472 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/view/tool-bar/GalleryToolBar.tsx @@ -0,0 +1,16 @@ +import { useTablePermission } from '@teable/sdk/hooks'; +import { GalleryViewOperators } from './components'; +import { Others } from './Others'; + +export const GalleryToolBar: React.FC = () => { + const permission = useTablePermission(); + + return ( +
+
+ + +
+
+ ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/view/tool-bar/components/GalleryViewOperators.tsx b/apps/nextjs-app/src/features/app/blocks/view/tool-bar/components/GalleryViewOperators.tsx new file mode 100644 index 000000000..810008e5e --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/view/tool-bar/components/GalleryViewOperators.tsx @@ -0,0 +1,143 @@ +import { ArrowUpDown, Filter as FilterIcon, Share2, Settings, Plus } from '@teable/icons'; +import type { GalleryView } from '@teable/sdk'; +import { + Sort, + ViewFilter, + VisibleFields, + useTablePermission, + CreateRecordModal, +} from '@teable/sdk'; +import { useView } from '@teable/sdk/hooks/use-view'; +import { Button, Label, Switch, cn } from '@teable/ui-lib/shadcn'; +import { useTranslation } from 'next-i18next'; +import { GUIDE_VIEW_FILTERING, GUIDE_VIEW_SORTING } from '@/components/Guide'; +import { tableConfig } from '@/features/i18n/table.config'; +import { useToolbarChange } from '../../hooks/useToolbarChange'; +import { ToolBarButton } from '../ToolBarButton'; +import { CoverFieldSelect } from './CoverFieldSelect'; +import { UndoRedoButtons } from './UndoRedoButtons'; + +export const GalleryViewOperators: React.FC<{ disabled?: boolean }> = (props) => { + const { disabled } = props; + const view = useView() as GalleryView | undefined; + const permission = useTablePermission(); + const { t } = useTranslation(tableConfig.i18nNamespaces); + const { onFilterChange, onSortChange } = useToolbarChange(); + + const { coverFieldId, isCoverFit, isFieldNameHidden } = view?.options ?? {}; + + const onCoverFieldChange = (fieldId: string | null) => { + view?.updateOption({ coverFieldId: fieldId }); + }; + + const onCoverFitChange = (checked: boolean) => { + view?.updateOption({ isCoverFit: checked }); + }; + + const onFieldNameHiddenChange = (checked: boolean) => { + view?.updateOption({ isFieldNameHidden: checked }); + }; + + if (!view) return null; + + return ( +
+ +
+ + + +
+ + +
+ + +
+ + } + > + {(_text, _isActive) => ( + + + + )} +
+ + + {t('table:toolbar.viewFilterInShare')} +
+ ) + } + > + {(text, isActive) => ( + + + + )} + + + {(text: string, isActive) => ( + + + + )} + +
+ ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/view/tool-bar/components/index.ts b/apps/nextjs-app/src/features/app/blocks/view/tool-bar/components/index.ts index aecbcbf3b..d9b5016e8 100644 --- a/apps/nextjs-app/src/features/app/blocks/view/tool-bar/components/index.ts +++ b/apps/nextjs-app/src/features/app/blocks/view/tool-bar/components/index.ts @@ -1,2 +1,3 @@ export * from './GridViewOperators'; export * from './KanbanViewOperators'; +export * from './GalleryViewOperators'; diff --git a/packages/common-i18n/src/locales/en/common.json b/packages/common-i18n/src/locales/en/common.json index a6d8e354c..92edb8b4c 100644 --- a/packages/common-i18n/src/locales/en/common.json +++ b/packages/common-i18n/src/locales/en/common.json @@ -158,7 +158,7 @@ "createTableTooltipTitle": "Create a table", "createTableTooltipContent": "Tables are designed to efficiently handle diverse datasets, offering a versatile display of information through various data types.", "createViewTooltipTitle": "Create a view", - "createViewTooltipContent": "Currently, users can create Grid and Form views, with Gallery, Kanban, and Calendar views planned for future releases.

This variety equips users with a comprehensive toolkit for various data management tasks.", + "createViewTooltipContent": "Currently, users can create Grid, Gallery, Kanban and Form views, with Calendar views planned for future releases.

This variety equips users with a comprehensive toolkit for various data management tasks.", "viewFilteringTooltipTitle": "Filtering records", "viewFilteringTooltipContent": "One of the core features of views is the ability to filter out records from a view according to the conditions you set.

When a record is filtered out based on a condition, it is not deleted—it's just hidden from the particular view you're using to look at your table.", "viewSortingTooltipTitle": "Sorting records", diff --git a/packages/common-i18n/src/locales/en/table.json b/packages/common-i18n/src/locales/en/table.json index ba74e1055..7af2e45ea 100644 --- a/packages/common-i18n/src/locales/en/table.json +++ b/packages/common-i18n/src/locales/en/table.json @@ -450,7 +450,8 @@ "category": { "table": "Grid View", "form": "Form View", - "kanban": "Kanban View" + "kanban": "Kanban View", + "gallery": "Gallery View" }, "crash": { "title": "Crash!", diff --git a/packages/common-i18n/src/locales/fr/common.json b/packages/common-i18n/src/locales/fr/common.json index f3aee5228..4dee21a8a 100644 --- a/packages/common-i18n/src/locales/fr/common.json +++ b/packages/common-i18n/src/locales/fr/common.json @@ -154,7 +154,7 @@ "createTableTooltipTitle": "Créer une table", "createTableTooltipContent": "Les tables sont conçues pour gérer efficacement des ensembles de données divers, offrant un affichage polyvalent des informations à travers différents types de données.", "createViewTooltipTitle": "Créer une vue", - "createViewTooltipContent": "Actuellement, les utilisateurs peuvent créer des vues en Grille et en Formulaire, avec des vues Galerie, Kanban et Calendrier prévues pour les futures versions.

Cette variété fournit aux utilisateurs un ensemble complet d'outils pour diverses tâches de gestion des données.", + "createViewTooltipContent": "Actuellement, les utilisateurs peuvent créer des vues en Grille, Galerie, Kanban et Formulaire, avec des vues Calendrier prévues pour les futures versions.

Cette variété fournit aux utilisateurs un ensemble complet d'outils pour diverses tâches de gestion des données.", "viewFilteringTooltipTitle": "Filtrage des enregistrements", "viewFilteringTooltipContent": "L'une des fonctionnalités principales des vues est la capacité à filtrer les enregistrements d'une vue selon les conditions que vous définissez.

Lorsqu'un enregistrement est filtré en fonction d'une condition, il n'est pas supprimé—il est juste masqué de la vue particulière que vous utilisez pour consulter votre table.", "viewSortingTooltipTitle": "Triage des enregistrements", diff --git a/packages/common-i18n/src/locales/fr/table.json b/packages/common-i18n/src/locales/fr/table.json index 50b2968f2..a979f5f81 100644 --- a/packages/common-i18n/src/locales/fr/table.json +++ b/packages/common-i18n/src/locales/fr/table.json @@ -438,7 +438,8 @@ "category": { "table": "Vue en grille", "form": "Vue en formulaire", - "kanban": "Vue Kanban" + "kanban": "Vue Kanban", + "gallery": "Vue en galerie" }, "crash": { "title": "Plantage !", diff --git a/packages/common-i18n/src/locales/ja/common.json b/packages/common-i18n/src/locales/ja/common.json index 07a779dc6..fd18a5a95 100644 --- a/packages/common-i18n/src/locales/ja/common.json +++ b/packages/common-i18n/src/locales/ja/common.json @@ -152,7 +152,7 @@ "createTableTooltipTitle": "テーブルの作成", "createTableTooltipContent": "テーブルは、多様なデータセットを効率的に処理できるように設計されており、さまざまなデータ型による情報を多様に表示できます。", "createViewTooltipTitle": "ビューの作成", - "createViewTooltipContent": "現在、ユーザーはグリッドビューとフォームビューを作成できます。ギャラリー ビュー、カンバン ビュー、カレンダー ビューは将来のリリースで追加される予定です。

この多様性により、ユーザーはさまざまなデータ管理タスクに対応する包括的なツールキットを利用できるようになります。", + "createViewTooltipContent": "現在、ユーザーはグリッド、ギャラリー、カンバン、およびフォームビューを作成できます。カレンダービューは将来のリリースで追加される予定です。

この多様性により、ユーザーはさまざまなデータ管理タスクに対応する包括的なツールキットを利用できるようになります。", "viewFilteringTooltipTitle": "レコードのフィルタリング", "viewFilteringTooltipContent": "ビューのコア機能の一つは、設定した条件に従ってビューからレコードをフィルター処理できることです。

条件に基づいてレコードがフィルター処理されても、そのレコードは削除されず、テーブルを表示するために使用している特定のビューから非表示になるだけです。", "viewSortingTooltipTitle": "レコードのソート", diff --git a/packages/common-i18n/src/locales/ja/table.json b/packages/common-i18n/src/locales/ja/table.json index 5419c44ce..f194d3afd 100644 --- a/packages/common-i18n/src/locales/ja/table.json +++ b/packages/common-i18n/src/locales/ja/table.json @@ -437,7 +437,8 @@ "category": { "table": "グリッドビュー", "form": "フォームビュー", - "kanban": "カンバンビュー" + "kanban": "カンバンビュー", + "gallery": "ギャラリービュー" }, "crash": { "title": "クラッシュしました!", diff --git a/packages/common-i18n/src/locales/ru/common.json b/packages/common-i18n/src/locales/ru/common.json index 8243b9ff9..0a15d6c10 100644 --- a/packages/common-i18n/src/locales/ru/common.json +++ b/packages/common-i18n/src/locales/ru/common.json @@ -154,7 +154,7 @@ "createTableTooltipTitle": "Создать таблицу", "createTableTooltipContent": "Таблицы предназначены для эффективной обработки различных наборов данных, предлагая универсальное представление информации через различные типы данных.", "createViewTooltipTitle": "Создать представление", - "createViewTooltipContent": "В настоящее время пользователи могут создавать представления Сетка и Форма, а представления Галерея, Канбан и Календарь будут добавлены в будущих версиях.

Этот набор инструментов обеспечивает пользователям все необходимое для различных задач управления данными.", + "createViewTooltipContent": "В настоящее время пользователи могут создавать представления Сетка, Галерея, Канбан и Форма, а представления Календарь будут добавлены в будущих версиях.

Этот набор инструментов обеспечивает пользователям все необходимое для различных задач управления данными.", "viewFilteringTooltipTitle": "Фильтрация записей", "viewFilteringTooltipContent": "Одной из основных функций представлений является возможность фильтрации записей по заданным условиям.

Когда запись фильтруется, она не удаляется — она просто скрывается из данного представления.", "viewSortingTooltipTitle": "Сортировка записей", diff --git a/packages/common-i18n/src/locales/ru/table.json b/packages/common-i18n/src/locales/ru/table.json index 4bb673f79..9f4b18bdb 100644 --- a/packages/common-i18n/src/locales/ru/table.json +++ b/packages/common-i18n/src/locales/ru/table.json @@ -438,7 +438,8 @@ "category": { "table": "Табличный вид", "form": "Вид формы", - "kanban": "Канбан-вид" + "kanban": "Канбан-вид", + "gallery": "Галерея вид" }, "crash": { "title": "Сбой!", diff --git a/packages/common-i18n/src/locales/zh/common.json b/packages/common-i18n/src/locales/zh/common.json index 7353abda0..3b2f0e82b 100644 --- a/packages/common-i18n/src/locales/zh/common.json +++ b/packages/common-i18n/src/locales/zh/common.json @@ -158,7 +158,7 @@ "createTableTooltipTitle": "创建表格", "createTableTooltipContent": "表格是一种结构化数据存储方式,用于存储应用内相关数据的集合。", "createViewTooltipTitle": "创建视图", - "createViewTooltipContent": "目前,用户可以创建表格和表单视图,未来的版本计划还包括相册、看板和日历视图。

这种多样性为用户提供了一个全面的工具,用于处理各种数据管理任务。", + "createViewTooltipContent": "目前,用户可以创建表格、画册、看板和表单视图,未来的版本计划还包括日历视图。

这种多样性为用户提供了一个全面的工具,用于处理各种数据管理任务。", "viewFilteringTooltipTitle": "记录筛选", "viewFilteringTooltipContent": "视图的核心功能之一是能够根据您设置的条件从视图中过滤掉记录。

当根据条件过滤掉记录时,它不会被删除,它只是在您用于查看表的特定视图中隐藏。", "viewSortingTooltipTitle": "记录排序", diff --git a/packages/common-i18n/src/locales/zh/table.json b/packages/common-i18n/src/locales/zh/table.json index 350c90d2c..8c3000561 100644 --- a/packages/common-i18n/src/locales/zh/table.json +++ b/packages/common-i18n/src/locales/zh/table.json @@ -449,7 +449,8 @@ "category": { "table": "表格视图", "form": "表单视图", - "kanban": "看板视图" + "kanban": "看板视图", + "gallery": "画册视图" }, "crash": { "title": "页面崩溃!", diff --git a/packages/core/src/models/field/derivate/date.field.ts b/packages/core/src/models/field/derivate/date.field.ts index 05ff9fc63..e1c6a0b83 100644 --- a/packages/core/src/models/field/derivate/date.field.ts +++ b/packages/core/src/models/field/derivate/date.field.ts @@ -119,4 +119,11 @@ export class DateFieldCore extends FieldCore { } return dataFieldCellValueSchema.nullable().safeParse(cellValue); } + + validateCellValueLoose(cellValue: unknown) { + if (this.isMultipleCellValue) { + return z.array(z.string()).nonempty().nullable().safeParse(cellValue); + } + return z.string().nullable().safeParse(cellValue); + } } diff --git a/packages/core/src/models/view/column-meta.schema.ts b/packages/core/src/models/view/column-meta.schema.ts index d3699fb44..b6ade2a7e 100644 --- a/packages/core/src/models/view/column-meta.schema.ts +++ b/packages/core/src/models/view/column-meta.schema.ts @@ -18,6 +18,8 @@ export type IGridColumnMeta = z.infer; export type IKanbanColumnMeta = z.infer; +export type IGalleryColumnMeta = z.infer; + export type IFormColumnMeta = z.infer; export type IPluginColumnMeta = z.infer; @@ -59,7 +61,15 @@ export const gridColumnSchema = columnSchemaBase.merge( export const kanbanColumnSchema = columnSchemaBase.merge( z.object({ visible: z.boolean().optional().openapi({ - description: 'If column visible in the view.', + description: 'If column visible in the kanban view.', + }), + }) +); + +export const galleryColumnSchema = columnSchemaBase.merge( + z.object({ + visible: z.boolean().optional().openapi({ + description: 'If column visible in the gallery view.', }), }) ); @@ -86,6 +96,7 @@ export const pluginColumnSchema = columnSchemaBase.merge( export const columnSchema = z.union([ gridColumnSchema, kanbanColumnSchema, + galleryColumnSchema, formColumnSchema, pluginColumnSchema, ]); @@ -102,6 +113,11 @@ export const kanbanColumnMetaSchema = z.record( kanbanColumnSchema ); +export const galleryColumnMetaSchema = z.record( + z.string().startsWith(IdPrefix.Field), + galleryColumnSchema +); + export const formColumnMetaSchema = z.record( z.string().startsWith(IdPrefix.Field), formColumnSchema diff --git a/packages/core/src/models/view/derivate/gallery.view.ts b/packages/core/src/models/view/derivate/gallery.view.ts new file mode 100644 index 000000000..0712a29c5 --- /dev/null +++ b/packages/core/src/models/view/derivate/gallery.view.ts @@ -0,0 +1,35 @@ +import z from 'zod'; +import type { IGalleryColumnMeta } from '../column-meta.schema'; +import type { ViewType } from '../constant'; +import { ViewCore } from '../view'; +import type { IViewVo } from '../view.schema'; + +export interface IGalleryView extends IViewVo { + type: ViewType.Gallery; + options: IGalleryViewOptions; +} + +export type IGalleryViewOptions = z.infer; + +export const galleryViewOptionSchema = z + .object({ + coverFieldId: z.string().optional().nullable().openapi({ + description: + 'The cover field id is a designated attachment field id, the contents of which appear at the top of each gallery card.', + }), + isCoverFit: z.boolean().optional().openapi({ + description: 'If true, cover images are resized to fit gallery cards.', + }), + isFieldNameHidden: z.boolean().optional().openapi({ + description: 'If true, hides field name in the gallery cards.', + }), + }) + .strict(); + +export class GalleryViewCore extends ViewCore { + type!: ViewType.Gallery; + + options!: IGalleryViewOptions; + + columnMeta!: IGalleryColumnMeta; +} diff --git a/packages/core/src/models/view/derivate/index.ts b/packages/core/src/models/view/derivate/index.ts index 99f4780db..c2fa0fc31 100644 --- a/packages/core/src/models/view/derivate/index.ts +++ b/packages/core/src/models/view/derivate/index.ts @@ -1,4 +1,5 @@ export * from './grid.view'; export * from './kanban.view'; +export * from './gallery.view'; export * from './form.view'; export * from './plugin.view'; diff --git a/packages/core/src/models/view/option.schema.ts b/packages/core/src/models/view/option.schema.ts index d1aac246e..9909c4622 100644 --- a/packages/core/src/models/view/option.schema.ts +++ b/packages/core/src/models/view/option.schema.ts @@ -1,11 +1,17 @@ import z from 'zod'; import { ViewType } from './constant'; -import { kanbanViewOptionSchema, gridViewOptionSchema, formViewOptionSchema } from './derivate'; +import { + kanbanViewOptionSchema, + gridViewOptionSchema, + formViewOptionSchema, + galleryViewOptionSchema, +} from './derivate'; import { pluginViewOptionSchema } from './derivate/plugin.view'; export const viewOptionsSchema = z.union([ gridViewOptionSchema, kanbanViewOptionSchema, + galleryViewOptionSchema, formViewOptionSchema, pluginViewOptionSchema, ]); @@ -20,6 +26,9 @@ export const validateOptionsType = (type: ViewType, optionsString: IViewOptions) case ViewType.Kanban: kanbanViewOptionSchema.parse(optionsString); break; + case ViewType.Gallery: + galleryViewOptionSchema.parse(optionsString); + break; case ViewType.Form: formViewOptionSchema.parse(optionsString); break; diff --git a/packages/core/src/models/view/view.schema.ts b/packages/core/src/models/view/view.schema.ts index 63457c066..131ce592f 100644 --- a/packages/core/src/models/view/view.schema.ts +++ b/packages/core/src/models/view/view.schema.ts @@ -4,6 +4,7 @@ import { columnMetaSchema } from './column-meta.schema'; import { ViewType } from './constant'; import { formViewOptionSchema, + galleryViewOptionSchema, gridViewOptionSchema, kanbanViewOptionSchema, pluginViewOptionSchema, @@ -73,6 +74,7 @@ export const viewRoSchema = viewVoSchema const optionsSchemaMap = { [ViewType.Form]: formViewOptionSchema, [ViewType.Kanban]: kanbanViewOptionSchema, + [ViewType.Gallery]: galleryViewOptionSchema, [ViewType.Grid]: gridViewOptionSchema, [ViewType.Plugin]: pluginViewOptionSchema, } as const; diff --git a/packages/icons/src/components/LayoutGrid.tsx b/packages/icons/src/components/LayoutGrid.tsx new file mode 100644 index 000000000..7dafe1be7 --- /dev/null +++ b/packages/icons/src/components/LayoutGrid.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; +import type { SVGProps } from 'react'; +const LayoutGrid = (props: SVGProps) => ( + + + +); +export default LayoutGrid; diff --git a/packages/icons/src/index.ts b/packages/icons/src/index.ts index 24d91b605..0a9c4fd5c 100644 --- a/packages/icons/src/index.ts +++ b/packages/icons/src/index.ts @@ -81,6 +81,7 @@ export { default as Inbox } from './components/Inbox'; export { default as Kanban } from './components/Kanban'; export { default as Key } from './components/Key'; export { default as Layers } from './components/Layers'; +export { default as LayoutGrid } from './components/LayoutGrid'; export { default as LayoutList } from './components/LayoutList'; export { default as LayoutTemplate } from './components/LayoutTemplate'; export { default as License } from './components/License'; diff --git a/packages/sdk/src/components/cell-value-editor/CellEditor.tsx b/packages/sdk/src/components/cell-value-editor/CellEditor.tsx index 7f785b969..53553a078 100644 --- a/packages/sdk/src/components/cell-value-editor/CellEditor.tsx +++ b/packages/sdk/src/components/cell-value-editor/CellEditor.tsx @@ -16,7 +16,6 @@ export const CellEditor = (props: ICellValueEditor) => { diff --git a/packages/sdk/src/components/cell-value/CellValue.tsx b/packages/sdk/src/components/cell-value/CellValue.tsx index 1d79fbad2..b73f374f5 100644 --- a/packages/sdk/src/components/cell-value/CellValue.tsx +++ b/packages/sdk/src/components/cell-value/CellValue.tsx @@ -28,19 +28,19 @@ interface ICellValueContainer extends ICellValue { } export const CellValue = (props: ICellValueContainer) => { - const { field, value, maxWidth, maxLine, className, itemClassName, formatImageUrl } = props; + const { field, value, maxWidth, ellipsis, className, itemClassName, formatImageUrl } = props; const { type, options, cellValueType } = field; switch (type) { case FieldType.LongText: { - return ; + return ; } case FieldType.SingleLineText: { return ( ); @@ -51,17 +51,23 @@ export const CellValue = (props: ICellValueContainer) => { value={value as number} formatting={options.formatting as INumberFormatting} className={className} + ellipsis={ellipsis} /> ); } case FieldType.AutoNumber: { - return ; + return ; } case FieldType.Date: case FieldType.CreatedTime: case FieldType.LastModifiedTime: { return ( - + ); } case FieldType.SingleSelect: @@ -73,6 +79,7 @@ export const CellValue = (props: ICellValueContainer) => { className={className} itemClassName={itemClassName} maxWidth={maxWidth} + ellipsis={ellipsis} /> ); } diff --git a/packages/sdk/src/components/cell-value/cell-date/CellDate.tsx b/packages/sdk/src/components/cell-value/cell-date/CellDate.tsx index abe7d30de..59835f635 100644 --- a/packages/sdk/src/components/cell-value/cell-date/CellDate.tsx +++ b/packages/sdk/src/components/cell-value/cell-date/CellDate.tsx @@ -11,7 +11,7 @@ interface ICellDate extends ICellValue { } export const CellDate = (props: ICellDate) => { - const { value, formatting, maxLine, className, style } = props; + const { value, formatting, ellipsis, className, style } = props; const displayValue = useMemo(() => { if (value == null) return ''; @@ -22,8 +22,8 @@ export const CellDate = (props: ICellDate) => { return ( ); diff --git a/packages/sdk/src/components/cell-value/cell-number/CellNumber.tsx b/packages/sdk/src/components/cell-value/cell-number/CellNumber.tsx index dfd9fcd74..ed37aa5df 100644 --- a/packages/sdk/src/components/cell-value/cell-number/CellNumber.tsx +++ b/packages/sdk/src/components/cell-value/cell-number/CellNumber.tsx @@ -10,7 +10,7 @@ interface ICellNumber extends ICellValue { } export const CellNumber = (props: ICellNumber) => { - const { value, formatting, maxLine, className, style } = props; + const { value, formatting, ellipsis, className, style } = props; const displayValue = useMemo(() => { if (value == null) return; @@ -26,8 +26,8 @@ export const CellNumber = (props: ICellNumber) => { return ( ); diff --git a/packages/sdk/src/components/cell-value/cell-select/CellSelect.tsx b/packages/sdk/src/components/cell-value/cell-select/CellSelect.tsx index 5d04cdaa6..edfe6a5de 100644 --- a/packages/sdk/src/components/cell-value/cell-select/CellSelect.tsx +++ b/packages/sdk/src/components/cell-value/cell-select/CellSelect.tsx @@ -24,11 +24,12 @@ export interface ISelectOption { interface ICellSelect extends ICellValue { options?: ISelectOption[] | null; + ellipsis?: boolean; itemClassName?: string; } export const CellSelect = (props: ICellSelect) => { - const { value, options, className, style, itemClassName } = props; + const { value, options, className, style, ellipsis, itemClassName } = props; const innerValue = useMemo(() => { if (value == null || Array.isArray(value)) return value; @@ -40,7 +41,14 @@ export const CellSelect = (props: ICellSelect) => { }, [options]); return ( -
+
{innerValue?.map((itemVal) => { const option = optionMap[itemVal]; if (option == null) return null; diff --git a/packages/sdk/src/components/cell-value/cell-select/SelectTag.tsx b/packages/sdk/src/components/cell-value/cell-select/SelectTag.tsx index 8ec0475ba..903bc4d56 100644 --- a/packages/sdk/src/components/cell-value/cell-select/SelectTag.tsx +++ b/packages/sdk/src/components/cell-value/cell-select/SelectTag.tsx @@ -13,7 +13,7 @@ export const SelectTag: React.FC> = (props) return (
{ } export const CellText = (props: ICellText) => { - const { value, className, style, maxLine = 1, displayType } = props; + const { value, className, style, ellipsis, displayType } = props; const onJump = () => { if (!displayType || !value) return; @@ -19,9 +19,9 @@ export const CellText = (props: ICellText) => { return ( { - const { text = '', maxLine = 1, className, tooltipClassName, onClick } = props; + const { text = '', ellipsis = false, className, tooltipClassName, onClick } = props; const [isOverflow, setOverflow] = useState(false); const contentRef = useRef(null); const checkOverflow = useCallback(() => { - if (contentRef.current) { + if (contentRef.current && ellipsis) { const element = contentRef.current; const lineHeight = parseInt(window.getComputedStyle(element).lineHeight); - const maxHeight = lineHeight * maxLine; - const isOverflow = element.scrollHeight > maxHeight; + const isOverflow = element.scrollHeight > lineHeight; setOverflow(isOverflow); } - }, [maxLine]); + }, [ellipsis]); useEffect(() => { const observer = new ResizeObserver(checkOverflow); @@ -54,15 +53,11 @@ export const OverflowTooltip = (props: IOverflowTooltipProps) => { const Content = (
99999 ? 99999 : maxLine, - WebkitBoxOrient: 'vertical', - wordBreak: 'break-all', - whiteSpace: 'pre-wrap', + className={cn(className, 'overflow-hidden whitespace-pre-wrap break-all line-clamp-6')} + onClick={(e) => { + e.stopPropagation(); + onClick?.(); }} - onClick={onClick} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { onClick?.(); @@ -70,19 +65,36 @@ export const OverflowTooltip = (props: IOverflowTooltipProps) => { }} role={onClick ? 'button' : undefined} tabIndex={onClick ? 0 : undefined} + title={text} > {text}
); - if (!isOverflow) { + if (!ellipsis || !isOverflow) { return Content; } return ( - {Content} + { + e.stopPropagation(); + onClick?.(); + }} + className="w-full text-left" + > +
+ {text} +
+

{text}

diff --git a/packages/sdk/src/components/cell-value/type.ts b/packages/sdk/src/components/cell-value/type.ts index 537714675..572f1a3c1 100644 --- a/packages/sdk/src/components/cell-value/type.ts +++ b/packages/sdk/src/components/cell-value/type.ts @@ -3,5 +3,5 @@ export interface ICellValue { className?: string; style?: React.CSSProperties; maxWidth?: number; - maxLine?: number; + ellipsis?: boolean; } diff --git a/packages/sdk/src/components/expand-record/RecordHistory.tsx b/packages/sdk/src/components/expand-record/RecordHistory.tsx index 6ec6fcc93..6285017f5 100644 --- a/packages/sdk/src/components/expand-record/RecordHistory.tsx +++ b/packages/sdk/src/components/expand-record/RecordHistory.tsx @@ -103,7 +103,7 @@ export const RecordHistory = (props: IRecordHistoryProps) => { return (
- +
); }, @@ -120,7 +120,6 @@ export const RecordHistory = (props: IRecordHistoryProps) => { ) : ( @@ -154,7 +153,6 @@ export const RecordHistory = (props: IRecordHistoryProps) => { ) : ( diff --git a/packages/sdk/src/components/grid-enhancements/hooks/use-grid-group-collection.ts b/packages/sdk/src/components/grid-enhancements/hooks/use-grid-group-collection.ts index c9c154d1b..cc8baad7c 100644 --- a/packages/sdk/src/components/grid-enhancements/hooks/use-grid-group-collection.ts +++ b/packages/sdk/src/components/grid-enhancements/hooks/use-grid-group-collection.ts @@ -4,7 +4,7 @@ import { LRUCache } from 'lru-cache'; import { useCallback, useMemo } from 'react'; import { useTranslation } from '../../../context/app/i18n/useTranslation'; import { useFields, useView } from '../../../hooks'; -import type { IFieldInstance } from '../../../model'; +import type { DateField, IFieldInstance } from '../../../model'; import { getFileCover, isSystemFileIcon } from '../../editor'; import { GRID_DEFAULT } from '../../grid/configs'; import type { IGridColumn } from '../../grid/interface'; @@ -51,11 +51,13 @@ const useGenerateGroupCellFn = () => { const { id: fieldId, type, isMultipleCellValue: isMultiple, cellValueType } = field; const emptyStr = '(Empty)'; - const validateCellValue = field.validateCellValue(_cellValue); - const cellValue = + const validateCellValue = field.cellValueType === CellValueType.DateTime - ? _cellValue - : ((validateCellValue.success ? validateCellValue.data : undefined) as unknown); + ? (field as DateField).validateCellValueLoose(_cellValue) + : field.validateCellValue(_cellValue); + const cellValue = ( + validateCellValue.success ? validateCellValue.data : undefined + ) as unknown; if (cellValue == null) { return { diff --git a/packages/sdk/src/components/grid-enhancements/hooks/use-grid-selection.ts b/packages/sdk/src/components/grid-enhancements/hooks/use-grid-selection.ts index 6b6679925..ca3a6981a 100644 --- a/packages/sdk/src/components/grid-enhancements/hooks/use-grid-selection.ts +++ b/packages/sdk/src/components/grid-enhancements/hooks/use-grid-selection.ts @@ -67,9 +67,11 @@ export const useGridSelection = (props: IUseGridSelectionProps) => { }); } - setActiveCell(undefined); - setSelection(emptySelection); - gridRef.current?.setSelection(emptySelection); + if (isDeleted) { + setActiveCell(undefined); + setSelection(emptySelection); + gridRef.current?.setSelection(emptySelection); + } }, }); diff --git a/packages/sdk/src/hooks/use-fields.ts b/packages/sdk/src/hooks/use-fields.ts index 14aec1c6d..92fa7214a 100644 --- a/packages/sdk/src/hooks/use-fields.ts +++ b/packages/sdk/src/hooks/use-fields.ts @@ -27,10 +27,11 @@ export function useFields(options: { withHidden?: boolean; withDenied?: boolean if (withHidden) { return true; } - if (viewType === ViewType.Form) { - return columnMeta?.[id]?.visible; - } - if (viewType === ViewType.Kanban) { + if ( + viewType === ViewType.Form || + viewType === ViewType.Kanban || + viewType === ViewType.Gallery + ) { return columnMeta?.[id]?.visible; } return !columnMeta?.[id]?.hidden; diff --git a/packages/sdk/src/model/view/factory.ts b/packages/sdk/src/model/view/factory.ts index 145bec3d9..39eea3493 100644 --- a/packages/sdk/src/model/view/factory.ts +++ b/packages/sdk/src/model/view/factory.ts @@ -3,6 +3,7 @@ import { assertNever, ViewType } from '@teable/core'; import { plainToInstance } from 'class-transformer'; import type { Doc } from 'sharedb/lib/client'; import { FormView } from './form.view'; +import { GalleryView } from './gallery.view'; import { GridView } from './grid.view'; import { KanbanView } from './kanban.view'; import { PluginView } from './plugin.view'; @@ -16,10 +17,11 @@ export function createViewInstance(view: IViewVo, doc?: Doc) { return plainToInstance(KanbanView, view); case ViewType.Form: return plainToInstance(FormView, view); + case ViewType.Gallery: + return plainToInstance(GalleryView, view); case ViewType.Plugin: return plainToInstance(PluginView, view); case ViewType.Calendar: - case ViewType.Gallery: case ViewType.Gantt: throw new Error('did not implement yet'); default: diff --git a/packages/sdk/src/model/view/gallery.view.ts b/packages/sdk/src/model/view/gallery.view.ts new file mode 100644 index 000000000..f0b0fe1a1 --- /dev/null +++ b/packages/sdk/src/model/view/gallery.view.ts @@ -0,0 +1,13 @@ +import { GalleryViewCore } from '@teable/core'; +import { updateViewOptions } from '@teable/openapi'; +import { Mixin } from 'ts-mixer'; +import { requestWrap } from '../../utils/requestWrap'; +import { View } from './view'; + +export class GalleryView extends Mixin(GalleryViewCore, View) { + async updateOption({ coverFieldId, isCoverFit, isFieldNameHidden }: GalleryView['options']) { + return await requestWrap(updateViewOptions)(this.tableId, this.id, { + options: { coverFieldId, isCoverFit, isFieldNameHidden }, + }); + } +} diff --git a/packages/sdk/src/model/view/index.ts b/packages/sdk/src/model/view/index.ts index 19a264c8e..acc37da76 100644 --- a/packages/sdk/src/model/view/index.ts +++ b/packages/sdk/src/model/view/index.ts @@ -1,5 +1,6 @@ export * from './factory'; export * from './grid.view'; export * from './kanban.view'; +export * from './gallery.view'; export * from './form.view'; export * from './view'; diff --git a/packages/ui-lib/src/base/file/preview/FilePreviewContent.tsx b/packages/ui-lib/src/base/file/preview/FilePreviewContent.tsx index 9dc8a7b43..81763c216 100644 --- a/packages/ui-lib/src/base/file/preview/FilePreviewContent.tsx +++ b/packages/ui-lib/src/base/file/preview/FilePreviewContent.tsx @@ -46,6 +46,7 @@ export const FilePreviewContent = (props: { container?: HTMLElement | null }) => onMouseDown={(e) => { e.stopPropagation(); }} + onClick={(e) => e.stopPropagation()} onKeyDown={(e) => { if (e.key === 'Escape') { closePreview(); diff --git a/packages/ui-lib/src/base/file/preview/FilePreviewItem.tsx b/packages/ui-lib/src/base/file/preview/FilePreviewItem.tsx index 02a36631a..4b934b0ac 100644 --- a/packages/ui-lib/src/base/file/preview/FilePreviewItem.tsx +++ b/packages/ui-lib/src/base/file/preview/FilePreviewItem.tsx @@ -43,7 +43,10 @@ export const FilePreviewItem = (props: IFilePreviewItem) => { e.preventDefault(); } }} - onClick={() => openPreview(fileIdRef.current)} + onClick={(e) => { + e.stopPropagation(); + openPreview(fileIdRef.current); + }} > {children}
diff --git a/plugins/next-env.d.ts b/plugins/next-env.d.ts index 4f11a03dc..40c3d6809 100644 --- a/plugins/next-env.d.ts +++ b/plugins/next-env.d.ts @@ -2,4 +2,4 @@ /// // NOTE: This file should not be edited -// see https://nextjs.org/docs/basic-features/typescript for more information. +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6372f9f3c..a71d422cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -577,6 +577,9 @@ importers: '@tanstack/react-table': specifier: 8.11.7 version: 8.11.7(react-dom@18.3.1)(react@18.3.1) + '@tanstack/react-virtual': + specifier: 3.2.0 + version: 3.2.0(react-dom@18.3.1)(react@18.3.1) '@teable/common-i18n': specifier: workspace:^ version: link:../../packages/common-i18n